diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/README.md b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/README.md index 384fd358a7..4f455b3dec 100644 --- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/README.md +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/README.md @@ -65,6 +65,53 @@ Workflow orchestration started for CancelOrder. Orchestration runId: abc123def45 > > If not provided, a unique run ID is auto-generated. +### Wait for the Workflow Result + +By default, the HTTP endpoint returns `202 Accepted` immediately with the run ID. If you want to wait for the workflow to complete and get the result in the response, add the `x-ms-wait-for-response: true` header: + +Bash (Linux/macOS/WSL): + +```bash +curl -X POST http://localhost:7071/api/workflows/CancelOrder/run \ + -H "Content-Type: text/plain" \ + -H "x-ms-wait-for-response: true" \ + -d "12345" +``` + +PowerShell: + +```powershell +Invoke-RestMethod -Method Post ` + -Uri http://localhost:7071/api/workflows/CancelOrder/run ` + -ContentType text/plain ` + -Headers @{ "x-ms-wait-for-response" = "true" } ` + -Body "12345" +``` + +The response will contain the workflow result as plain text (200 OK): + +```text +Cancellation email sent for order 12345 to jerry@example.com. +``` + +To get the result as JSON, also include the `Accept: application/json` header: + +```bash +curl -X POST http://localhost:7071/api/workflows/CancelOrder/run \ + -H "Content-Type: text/plain" \ + -H "x-ms-wait-for-response: true" \ + -H "Accept: application/json" \ + -d "12345" +``` + +```json +{ + "runId": "abc123def456", + "workflowStatus": "Completed", + "result": "Cancellation email sent for order 12345 to jerry@example.com." +} +``` + In the function app logs, you will see the sequential execution of each executor: ```text diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/demo.http b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/demo.http index 8366216a6c..fb9793f449 100644 --- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/demo.http +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/demo.http @@ -7,6 +7,21 @@ Content-Type: text/plain 12345 +### Cancel an order and wait for the result +POST {{authority}}/api/workflows/CancelOrder/run +Content-Type: text/plain +x-ms-wait-for-response: true + +12345 + +### Cancel an order and wait for the result (JSON response) +POST {{authority}}/api/workflows/CancelOrder/run +Content-Type: text/plain +Accept: application/json +x-ms-wait-for-response: true + +12345 + ### Cancel an order with a custom run ID POST {{authority}}/api/workflows/CancelOrder/run?runId=my-custom-id-123 Content-Type: text/plain @@ -19,6 +34,13 @@ Content-Type: text/plain 12345 +### Get order status and wait for the result +POST {{authority}}/api/workflows/OrderStatus/run +Content-Type: text/plain +x-ms-wait-for-response: true + +12345 + ### Batch cancel orders with a complex JSON input POST {{authority}}/api/workflows/BatchCancelOrders/run Content-Type: application/json diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs index e6c94347a1..376f2fa2ca 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs @@ -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 @@ public static async Task RunWorkflowOrchestrationHttpTriggerAs 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 @@ public static async Task RunAgentHttpAsync( } // Check if we should wait for response (default is true) - bool waitForResponse = true; - if (req.Headers.TryGetValues("x-ms-wait-for-response", out IEnumerable? 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 @@ await agentProxy.RunAsync( return metadata.ReadOutputAs()?.Result; } + /// + /// Waits for a workflow orchestration to complete and returns an appropriate HTTP response. + /// + private static async Task 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()?.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(); + 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; + } + /// /// Creates an error response with the specified status code and error message. /// @@ -435,18 +523,18 @@ await agentProxy.RunAsync( /// The function context. /// The HTTP status code. /// The error message. + /// Optional pre-computed value indicating whether the client accepts JSON. When , the value is determined from the request's Accept header. /// The HTTP response data containing the error. private static async Task 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? 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 @@ private static async Task CreateSuccessResponseAsync( HttpResponseData response = req.CreateResponse(statusCode); response.Headers.Add("x-ms-thread-id", sessionId); - bool acceptsJson = req.Headers.TryGetValues("Accept", out IEnumerable? 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 @@ private static async Task CreateAcceptedResponseAsync( HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted); response.Headers.Add("x-ms-thread-id", sessionId); - bool acceptsJson = req.Headers.TryGetValues("Accept", out IEnumerable? 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 @@ private static async Task CreateAcceptedResponseAsync( return response; } + /// + /// Returns when the caller has requested waiting for the workflow/agent to complete, + /// as indicated by the x-ms-wait-for-response header. Falls back to + /// when the header is absent or not a valid boolean. + /// + private static bool ShouldWaitForResponse(HttpRequestData req, bool defaultValue) + { + if (req.Headers.TryGetValues(WaitForResponseHeaderName, out IEnumerable? values) && + bool.TryParse(values.FirstOrDefault(), out bool parsed)) + { + return parsed; + } + + return defaultValue; + } + + /// + /// Returns when the request accepts the application/json media type. + /// + private static bool AcceptsJson(HttpRequestData req) + { + return req.Headers.TryGetValues("Accept", out IEnumerable? 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 @@ private sealed record WorkflowRespondRequest( [property: JsonPropertyName("eventName")] string? EventName, [property: JsonPropertyName("response")] JsonElement Response); + /// + /// Represents a workflow run response when waiting for completion. + /// + /// The orchestration run ID. + /// The orchestration runtime status (e.g., "Completed", "Failed"). + /// The workflow result as a JSON element so POCOs serialize as nested objects rather than escaped strings. + /// An optional error message when the workflow has failed. + 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); + /// /// A service provider that combines the original service provider with an additional DurableTaskClient instance. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md index 2c188757d5..f8f59c89d1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md @@ -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)) diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs index a7f2f51156..2eba009c67 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Reflection; using System.Text; +using System.Text.Json; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using ModelContextProtocol.Client; @@ -125,6 +126,45 @@ await this.WaitForConditionAsync( }, message: "OrderStatus workflow completed", timeout: s_orchestrationTimeout); + + // Test the CancelOrder workflow with x-ms-wait-for-response header + this._outputHelper.WriteLine("Starting CancelOrder workflow with x-ms-wait-for-response: true..."); + + using HttpRequestMessage waitRequest = new(HttpMethod.Post, cancelOrderUri); + waitRequest.Content = new StringContent("55555", Encoding.UTF8, "text/plain"); + waitRequest.Headers.Add("x-ms-wait-for-response", "true"); + using HttpResponseMessage waitResponse = await s_sharedHttpClient.SendAsync(waitRequest); + + Assert.True(waitResponse.IsSuccessStatusCode, $"CancelOrder wait-for-response request failed with status: {waitResponse.StatusCode}"); + string waitResponseText = await waitResponse.Content.ReadAsStringAsync(); + this._outputHelper.WriteLine($"CancelOrder wait-for-response result: {waitResponseText}"); + + // The response should contain the workflow result (not just "started for CancelOrder") + Assert.DoesNotContain("Workflow orchestration started", waitResponseText); + Assert.Contains("55555", waitResponseText); + + // Test the wait-for-response with Accept: application/json header + this._outputHelper.WriteLine("Starting CancelOrder workflow with x-ms-wait-for-response and Accept: application/json..."); + + using HttpRequestMessage jsonWaitRequest = new(HttpMethod.Post, cancelOrderUri); + jsonWaitRequest.Content = new StringContent("77777", Encoding.UTF8, "text/plain"); + jsonWaitRequest.Headers.Add("x-ms-wait-for-response", "true"); + jsonWaitRequest.Headers.Add("Accept", "application/json"); + + using CancellationTokenSource jsonWaitCts = new(s_orchestrationTimeout); + using HttpResponseMessage jsonWaitResponse = await s_sharedHttpClient.SendAsync(jsonWaitRequest, jsonWaitCts.Token); + + Assert.True(jsonWaitResponse.IsSuccessStatusCode, $"CancelOrder JSON wait-for-response request failed with status: {jsonWaitResponse.StatusCode}"); + string jsonWaitResponseText = await jsonWaitResponse.Content.ReadAsStringAsync(); + this._outputHelper.WriteLine($"CancelOrder JSON wait-for-response result: {jsonWaitResponseText}"); + + using JsonDocument jsonDoc = JsonDocument.Parse(jsonWaitResponseText); + JsonElement root = jsonDoc.RootElement; + Assert.True(root.TryGetProperty("runId", out _), "JSON response missing 'runId' property"); + Assert.True(root.TryGetProperty("workflowStatus", out JsonElement statusEl), "JSON response missing 'workflowStatus' property"); + Assert.Equal("Completed", statusEl.GetString()); + Assert.True(root.TryGetProperty("result", out JsonElement resultEl), "JSON response missing 'result' property"); + Assert.Contains("77777", resultEl.GetString()); }); }