From 61c28ebda64031dd8439aed3a05385f0cafa778c Mon Sep 17 00:00:00 2001 From: Shyju Krishnankutty Date: Thu, 16 Apr 2026 17:19:21 -0700 Subject: [PATCH] Adding support for "wait for response" when invoking workflow http endpoint. --- .../01_SequentialWorkflow/README.md | 47 +++++++ .../01_SequentialWorkflow/demo.http | 22 +++ .../BuiltInFunctions.cs | 127 +++++++++++++++--- .../CHANGELOG.md | 1 + .../WorkflowSamplesValidation.cs | 16 +++ 5 files changed, 192 insertions(+), 21 deletions(-) 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..4e2a98dc7a 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", + "status": "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..9da94f8522 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 @@ 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? 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,62 @@ internal static class BuiltInFunctions 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.InternalServerError, + $"Workflow orchestration '{instanceId}' returned no metadata.", acceptsJson); + } + + if (metadata.RuntimeStatus is OrchestrationRuntimeStatus.Failed) + { + string errorMessage = metadata.FailureDetails?.ErrorMessage ?? "Unknown error"; + return await CreateErrorResponseAsync(req, context, HttpStatusCode.InternalServerError, + $"Workflow orchestration '{instanceId}' failed: {errorMessage}", acceptsJson); + } + + 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 = result is not null + ? JsonDocument.Parse(result).RootElement.Clone() + : null; + + await response.WriteAsJsonAsync(new WorkflowRunSuccessResponse(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 +490,18 @@ internal static class BuiltInFunctions /// 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 +534,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? 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 +563,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? 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 +577,31 @@ internal static class BuiltInFunctions 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 includes an Accept: application/json header. + /// + private static bool AcceptsJson(HttpRequestData req) + { + return req.Headers.TryGetValues("Accept", out IEnumerable? acceptValues) && + acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase); + } + private static string GetAgentName(FunctionContext context) { // Check if the function name starts with the HttpPrefix @@ -591,6 +665,17 @@ internal static class BuiltInFunctions [property: JsonPropertyName("eventName")] string? EventName, [property: JsonPropertyName("response")] JsonElement Response); + /// + /// Represents a successful workflow run response when waiting for completion. + /// + /// The orchestration run ID. + /// The orchestration runtime status. + /// The workflow result as a JSON element so POCOs serialize as nested objects rather than escaped strings. + private sealed record WorkflowRunSuccessResponse( + [property: JsonPropertyName("runId")] string RunId, + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("result")] JsonElement? Result); + /// /// 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..24b02ca7a5 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] +- Added `x-ms-wait-for-response` header support for workflow HTTP triggers to optionally wait for completion and return the result - 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..435627a169 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs @@ -125,6 +125,22 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) : }, 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); }); }