Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)}";
Expand Down Expand Up @@ -62,6 +64,11 @@ public static async Task<HttpResponseData> 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;
Expand Down Expand Up @@ -304,15 +311,7 @@ public static async Task<HttpResponseData> 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<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);

Expand Down Expand Up @@ -428,25 +427,114 @@ await agentProxy.RunAsync(
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>
/// <param name="req">The HTTP request data.</param>
/// <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);
Expand Down Expand Up @@ -479,10 +567,7 @@ private static async Task<HttpResponseData> CreateSuccessResponseAsync(
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);
Expand Down Expand Up @@ -511,10 +596,7 @@ private static async Task<HttpResponseData> CreateAcceptedResponseAsync(
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);
Expand All @@ -528,6 +610,34 @@ private static async Task<HttpResponseData> CreateAcceptedResponseAsync(
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
Expand Down Expand Up @@ -591,6 +701,19 @@ private sealed record WorkflowRespondRequest(
[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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Comment thread
kshyju marked this conversation as resolved.
Comment thread
kshyju marked this conversation as resolved.

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());
});
}

Expand Down
Loading