mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into feature-harness
This commit is contained in:
+1
@@ -34,6 +34,7 @@
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="ModelContextProtocol" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -21,6 +21,8 @@ internal static class BuiltInFunctions
|
||||
internal const string HttpPrefix = "http-";
|
||||
internal const string McpToolPrefix = "mcptool-";
|
||||
|
||||
private const string WaitForResponseHeaderName = "x-ms-wait-for-response";
|
||||
|
||||
internal static readonly string RunAgentHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunAgentHttpAsync)}";
|
||||
internal static readonly string RunAgentEntityFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(InvokeAgentAsync)}";
|
||||
internal static readonly string RunAgentMcpToolFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunMcpToolAsync)}";
|
||||
@@ -62,6 +64,11 @@ internal static class BuiltInFunctions
|
||||
StartOrchestrationOptions? options = instanceId is not null ? new StartOrchestrationOptions(instanceId) : null;
|
||||
string resolvedInstanceId = await client.ScheduleNewOrchestrationInstanceAsync(orchestrationFunctionName, orchestrationInput, options);
|
||||
|
||||
if (ShouldWaitForResponse(req, defaultValue: false))
|
||||
{
|
||||
return await WaitForWorkflowCompletionAsync(req, client, context, resolvedInstanceId);
|
||||
}
|
||||
|
||||
HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted);
|
||||
await response.WriteStringAsync($"Workflow orchestration started for {workflowName}. Orchestration runId: {resolvedInstanceId}");
|
||||
return response;
|
||||
@@ -304,15 +311,7 @@ internal static class BuiltInFunctions
|
||||
}
|
||||
|
||||
// Check if we should wait for response (default is true)
|
||||
bool waitForResponse = true;
|
||||
if (req.Headers.TryGetValues("x-ms-wait-for-response", out IEnumerable<string>? waitForResponseValues))
|
||||
{
|
||||
string? waitForResponseValue = waitForResponseValues.FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(waitForResponseValue) && bool.TryParse(waitForResponseValue, out bool parsedValue))
|
||||
{
|
||||
waitForResponse = parsedValue;
|
||||
}
|
||||
}
|
||||
bool waitForResponse = ShouldWaitForResponse(req, defaultValue: true);
|
||||
|
||||
AIAgent agentProxy = client.AsDurableAgentProxy(context, agentName);
|
||||
|
||||
@@ -428,6 +427,95 @@ internal static class BuiltInFunctions
|
||||
return metadata.ReadOutputAs<DurableWorkflowResult>()?.Result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for a workflow orchestration to complete and returns an appropriate HTTP response.
|
||||
/// </summary>
|
||||
private static async Task<HttpResponseData> WaitForWorkflowCompletionAsync(
|
||||
HttpRequestData req,
|
||||
DurableTaskClient client,
|
||||
FunctionContext context,
|
||||
string instanceId)
|
||||
{
|
||||
bool acceptsJson = AcceptsJson(req);
|
||||
|
||||
OrchestrationMetadata? metadata = await client.WaitForInstanceCompletionAsync(
|
||||
instanceId,
|
||||
getInputsAndOutputs: true,
|
||||
cancellation: context.CancellationToken);
|
||||
|
||||
if (metadata is null)
|
||||
{
|
||||
return await CreateErrorResponseAsync(req, context, HttpStatusCode.NotFound,
|
||||
$"No workflow orchestration with ID '{instanceId}' was found.", acceptsJson);
|
||||
}
|
||||
|
||||
if (metadata.RuntimeStatus is OrchestrationRuntimeStatus.Failed)
|
||||
{
|
||||
string errorMessage = metadata.FailureDetails?.ErrorMessage ?? "Unknown error";
|
||||
HttpResponseData failedResponse = req.CreateResponse(HttpStatusCode.OK);
|
||||
|
||||
if (acceptsJson)
|
||||
{
|
||||
await failedResponse.WriteAsJsonAsync(
|
||||
new WorkflowRunResponse(instanceId, metadata.RuntimeStatus.ToString(), Result: null, Error: errorMessage),
|
||||
context.CancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
failedResponse.Headers.Add("Content-Type", "text/plain");
|
||||
await failedResponse.WriteStringAsync(errorMessage, context.CancellationToken);
|
||||
}
|
||||
|
||||
return failedResponse;
|
||||
}
|
||||
|
||||
if (metadata.RuntimeStatus is not OrchestrationRuntimeStatus.Completed)
|
||||
{
|
||||
return await CreateErrorResponseAsync(req, context, HttpStatusCode.InternalServerError,
|
||||
$"Workflow orchestration '{instanceId}' ended with unexpected status '{metadata.RuntimeStatus}'.", acceptsJson);
|
||||
}
|
||||
|
||||
string? result = metadata.ReadOutputAs<DurableWorkflowResult>()?.Result;
|
||||
|
||||
HttpResponseData response = req.CreateResponse(HttpStatusCode.OK);
|
||||
|
||||
if (acceptsJson)
|
||||
{
|
||||
JsonElement? resultElement = null;
|
||||
if (!string.IsNullOrEmpty(result))
|
||||
{
|
||||
try
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse(result);
|
||||
resultElement = doc.RootElement.Clone();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Result is a plain string (not valid JSON) — serialize it as a JSON string element.
|
||||
var buffer = new System.Buffers.ArrayBufferWriter<byte>();
|
||||
using (var writer = new Utf8JsonWriter(buffer))
|
||||
{
|
||||
writer.WriteStringValue(result);
|
||||
}
|
||||
|
||||
using JsonDocument fallbackDoc = JsonDocument.Parse(buffer.WrittenMemory);
|
||||
resultElement = fallbackDoc.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
|
||||
await response.WriteAsJsonAsync(
|
||||
new WorkflowRunResponse(instanceId, metadata.RuntimeStatus.ToString(), resultElement),
|
||||
context.CancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
response.Headers.Add("Content-Type", "text/plain");
|
||||
await response.WriteStringAsync(result ?? string.Empty, context.CancellationToken);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error response with the specified status code and error message.
|
||||
/// </summary>
|
||||
@@ -435,18 +523,18 @@ internal static class BuiltInFunctions
|
||||
/// <param name="context">The function context.</param>
|
||||
/// <param name="statusCode">The HTTP status code.</param>
|
||||
/// <param name="errorMessage">The error message.</param>
|
||||
/// <param name="acceptsJson">Optional pre-computed value indicating whether the client accepts JSON. When <see langword="null"/>, the value is determined from the request's <c>Accept</c> header.</param>
|
||||
/// <returns>The HTTP response data containing the error.</returns>
|
||||
private static async Task<HttpResponseData> CreateErrorResponseAsync(
|
||||
HttpRequestData req,
|
||||
FunctionContext context,
|
||||
HttpStatusCode statusCode,
|
||||
string errorMessage)
|
||||
string errorMessage,
|
||||
bool? acceptsJson = null)
|
||||
{
|
||||
HttpResponseData response = req.CreateResponse(statusCode);
|
||||
bool acceptsJson = req.Headers.TryGetValues("Accept", out IEnumerable<string>? acceptValues) &&
|
||||
acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (acceptsJson)
|
||||
if (acceptsJson ?? AcceptsJson(req))
|
||||
{
|
||||
ErrorResponse errorResponse = new((int)statusCode, errorMessage);
|
||||
await response.WriteAsJsonAsync(errorResponse, context.CancellationToken);
|
||||
@@ -479,10 +567,7 @@ internal static class BuiltInFunctions
|
||||
HttpResponseData response = req.CreateResponse(statusCode);
|
||||
response.Headers.Add("x-ms-thread-id", sessionId);
|
||||
|
||||
bool acceptsJson = req.Headers.TryGetValues("Accept", out IEnumerable<string>? acceptValues) &&
|
||||
acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (acceptsJson)
|
||||
if (AcceptsJson(req))
|
||||
{
|
||||
AgentRunSuccessResponse successResponse = new((int)statusCode, sessionId, agentResponse);
|
||||
await response.WriteAsJsonAsync(successResponse, context.CancellationToken);
|
||||
@@ -511,10 +596,7 @@ internal static class BuiltInFunctions
|
||||
HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted);
|
||||
response.Headers.Add("x-ms-thread-id", sessionId);
|
||||
|
||||
bool acceptsJson = req.Headers.TryGetValues("Accept", out IEnumerable<string>? acceptValues) &&
|
||||
acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (acceptsJson)
|
||||
if (AcceptsJson(req))
|
||||
{
|
||||
AgentRunAcceptedResponse acceptedResponse = new((int)HttpStatusCode.Accepted, sessionId);
|
||||
await response.WriteAsJsonAsync(acceptedResponse, context.CancellationToken);
|
||||
@@ -528,6 +610,34 @@ internal static class BuiltInFunctions
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> when the caller has requested waiting for the workflow/agent to complete,
|
||||
/// as indicated by the <c>x-ms-wait-for-response</c> header. Falls back to <paramref name="defaultValue"/>
|
||||
/// when the header is absent or not a valid boolean.
|
||||
/// </summary>
|
||||
private static bool ShouldWaitForResponse(HttpRequestData req, bool defaultValue)
|
||||
{
|
||||
if (req.Headers.TryGetValues(WaitForResponseHeaderName, out IEnumerable<string>? values) &&
|
||||
bool.TryParse(values.FirstOrDefault(), out bool parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> when the request accepts the <c>application/json</c> media type.
|
||||
/// </summary>
|
||||
private static bool AcceptsJson(HttpRequestData req)
|
||||
{
|
||||
return req.Headers.TryGetValues("Accept", out IEnumerable<string>? acceptValues) &&
|
||||
acceptValues
|
||||
.SelectMany(v => v.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
.Select(v => v.Split(';', 2)[0].Trim())
|
||||
.Contains("application/json", StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string GetAgentName(FunctionContext context)
|
||||
{
|
||||
// Check if the function name starts with the HttpPrefix
|
||||
@@ -591,6 +701,19 @@ internal static class BuiltInFunctions
|
||||
[property: JsonPropertyName("eventName")] string? EventName,
|
||||
[property: JsonPropertyName("response")] JsonElement Response);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a workflow run response when waiting for completion.
|
||||
/// </summary>
|
||||
/// <param name="RunId">The orchestration run ID.</param>
|
||||
/// <param name="WorkflowStatus">The orchestration runtime status (e.g., "Completed", "Failed").</param>
|
||||
/// <param name="Result">The workflow result as a JSON element so POCOs serialize as nested objects rather than escaped strings.</param>
|
||||
/// <param name="Error">An optional error message when the workflow has failed.</param>
|
||||
private sealed record WorkflowRunResponse(
|
||||
[property: JsonPropertyName("runId")] string RunId,
|
||||
[property: JsonPropertyName("workflowStatus")] string WorkflowStatus,
|
||||
[property: JsonPropertyName("result")] JsonElement? Result,
|
||||
[property: JsonPropertyName("error"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Error = null);
|
||||
|
||||
/// <summary>
|
||||
/// A service provider that combines the original service provider with an additional DurableTaskClient instance.
|
||||
/// </summary>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Support returning workflow results from HTTP trigger endpoint ([#5321](https://github.com/microsoft/agent-framework/pull/5321))
|
||||
- Added MCP tool trigger support for durable workflows ([#4768](https://github.com/microsoft/agent-framework/pull/4768))
|
||||
- Added Azure Functions hosting support for durable workflows ([#4436](https://github.com/microsoft/agent-framework/pull/4436))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user