mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: [Feature Branch] Add Human In the Loop support for durable workflows (#4358)
* Add Azure Functions HITL workflow sample Add 06_WorkflowHITL Azure Functions sample demonstrating Human-in-the-Loop workflow support with HTTP endpoints for status checking and approval responses. The sample includes: - ExpenseReimbursement workflow with RequestPort for manager approval - Custom HTTP endpoint to check workflow status and pending approvals - Custom HTTP endpoint to send approval responses via RaiseEventAsync - demo.http file with step-by-step interaction examples * PR feedback fixes * Minor comment cleanup * Minor comment clReverted the `!context.IsReplaying` guards on `PendingEvents.Add`/`RemoveAll` and `SetCustomStatus` in `ExecuteRequestPortAsync`. The guards broke fan-out scenarios where parallel RequestPorts need to be discoverable after replay. `SetCustomStatus` is idempotent metadata that doesn't affect replay determinism.eanup * fix for PR feedback * PR feedback updates * Improvements to samples * Improvements to README * Update samples to use parallel request ports. * Unit tests * Introduce local variables to improve readability of Workflows.Workflows access patter * Use GitHub-style callouts and add PowerShell command variants in HITL sample README
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
@@ -9,7 +9,7 @@ namespace Microsoft.Agents.AI.DurableTask;
|
||||
/// Provides configuration options for durable agents and workflows.
|
||||
/// </summary>
|
||||
[DebuggerDisplay("Workflows = {Workflows.Workflows.Count}, Agents = {Agents.AgentCount}")]
|
||||
public sealed class DurableOptions
|
||||
public class DurableOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DurableOptions"/> class.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -211,4 +211,20 @@ internal static partial class Logs
|
||||
this ILogger logger,
|
||||
string source,
|
||||
string sink);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 112,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Workflow waiting for external input at RequestPort '{RequestPortId}'")]
|
||||
public static partial void LogWaitingForExternalEvent(
|
||||
this ILogger logger,
|
||||
string requestPortId);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 113,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Received external event for RequestPort '{RequestPortId}'")]
|
||||
public static partial void LogReceivedExternalEvent(
|
||||
this ILogger logger,
|
||||
string requestPortId);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
@@ -234,11 +234,12 @@ public static class ServiceCollectionExtensions
|
||||
HashSet<string> registeredActivities = [];
|
||||
HashSet<string> registeredOrchestrations = [];
|
||||
|
||||
foreach (Workflow workflow in durableOptions.Workflows.Workflows.Values.ToList())
|
||||
DurableWorkflowOptions workflowOptions = durableOptions.Workflows;
|
||||
foreach (Workflow workflow in workflowOptions.Workflows.Values.ToList())
|
||||
{
|
||||
BuildWorkflowRegistrationRecursive(
|
||||
workflow,
|
||||
durableOptions.Workflows,
|
||||
workflowOptions,
|
||||
registrations,
|
||||
registeredActivities,
|
||||
registeredOrchestrations);
|
||||
@@ -329,12 +330,14 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> for bindings that should be registered as Durable Task activities.
|
||||
/// <see cref="AIAgentBinding"/> (Durable Entities) and <see cref="SubworkflowBinding"/> (sub-orchestrations)
|
||||
/// use specialized dispatch and are excluded.
|
||||
/// <see cref="AIAgentBinding"/> (Durable Entities), <see cref="SubworkflowBinding"/> (sub-orchestrations),
|
||||
/// and <see cref="RequestPortBinding"/> (human-in-the-loop via external events) use specialized dispatch
|
||||
/// and are excluded.
|
||||
/// </summary>
|
||||
private static bool IsActivityBinding(ExecutorBinding binding)
|
||||
=> binding is not AIAgentBinding
|
||||
and not SubworkflowBinding;
|
||||
and not SubworkflowBinding
|
||||
and not RequestPortBinding;
|
||||
|
||||
private static async Task<DurableWorkflowResult> RunWorkflowOrchestrationAsync(
|
||||
TaskOrchestrationContext context,
|
||||
|
||||
@@ -111,18 +111,38 @@ internal static class DurableActivityExecutor
|
||||
}
|
||||
}
|
||||
|
||||
private static object DeserializeInput(string input, Type targetType)
|
||||
internal static object DeserializeInput(string input, Type targetType)
|
||||
{
|
||||
if (targetType == typeof(string))
|
||||
{
|
||||
return input;
|
||||
}
|
||||
|
||||
// Fan-in aggregation serializes results as a JSON array of strings (e.g., ["{...}", "{...}"]).
|
||||
// When the target type is a non-string array, deserialize each element individually.
|
||||
if (targetType.IsArray && targetType != typeof(string[]))
|
||||
{
|
||||
Type elementType = targetType.GetElementType()!;
|
||||
string[]? stringArray = JsonSerializer.Deserialize<string[]>(input, DurableSerialization.Options);
|
||||
if (stringArray is not null)
|
||||
{
|
||||
Array result = Array.CreateInstance(elementType, stringArray.Length);
|
||||
for (int i = 0; i < stringArray.Length; i++)
|
||||
{
|
||||
object element = JsonSerializer.Deserialize(stringArray[i], elementType, DurableSerialization.Options)
|
||||
?? throw new InvalidOperationException($"Failed to deserialize element {i} to type '{elementType.Name}'.");
|
||||
result.SetValue(element, i);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize(input, targetType, DurableSerialization.Options)
|
||||
?? throw new InvalidOperationException($"Failed to deserialize input to type '{targetType.Name}'.");
|
||||
}
|
||||
|
||||
private static Type ResolveInputType(string? inputTypeName, ISet<Type> supportedTypes)
|
||||
internal static Type ResolveInputType(string? inputTypeName, ISet<Type> supportedTypes)
|
||||
{
|
||||
if (string.IsNullOrEmpty(inputTypeName))
|
||||
{
|
||||
@@ -141,10 +161,13 @@ internal static class DurableActivityExecutor
|
||||
|
||||
Type? loadedType = Type.GetType(inputTypeName);
|
||||
|
||||
// Fall back if type is string but executor doesn't support string
|
||||
if (loadedType == typeof(string) && !supportedTypes.Contains(typeof(string)))
|
||||
// Fall back if type is string or string[] but executor doesn't support it
|
||||
if (loadedType is not null && !supportedTypes.Contains(loadedType))
|
||||
{
|
||||
return supportedTypes.FirstOrDefault() ?? typeof(string);
|
||||
if (loadedType == typeof(string) || loadedType == typeof(string[]))
|
||||
{
|
||||
return supportedTypes.FirstOrDefault() ?? typeof(string);
|
||||
}
|
||||
}
|
||||
|
||||
return loadedType ?? supportedTypes.FirstOrDefault() ?? typeof(string);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// ConfigureAwait Usage in Orchestration Code:
|
||||
// This file uses ConfigureAwait(true) because it runs within orchestration context.
|
||||
@@ -8,31 +8,34 @@
|
||||
// Using ConfigureAwait(false) here could cause non-deterministic behavior during replay.
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.DurableTask;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Dispatches workflow executors to activities, AI agents, or sub-orchestrations.
|
||||
/// Dispatches workflow executors to activities, AI agents, sub-orchestrations, or external events (human-in-the-loop).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Called during the dispatch phase of each superstep by
|
||||
/// <c>DurableWorkflowRunner.DispatchExecutorsInParallelAsync</c>. For each executor that has
|
||||
/// pending input, this dispatcher determines whether the executor is an AI agent (stateful,
|
||||
/// backed by Durable Entities), a sub-workflow (dispatched as a sub-orchestration), or a
|
||||
/// regular activity, and invokes the appropriate Durable Task API.
|
||||
/// backed by Durable Entities), a request port (human-in-the-loop, backed by external events),
|
||||
/// a sub-workflow (dispatched as a sub-orchestration), or a regular activity, and invokes the
|
||||
/// appropriate Durable Task API.
|
||||
/// The serialised string result is returned to the runner for the routing phase.
|
||||
/// </remarks>
|
||||
internal static class DurableExecutorDispatcher
|
||||
{
|
||||
/// <summary>
|
||||
/// Dispatches an executor based on its type (activity, AI agent, or sub-workflow).
|
||||
/// Dispatches an executor based on its type (activity, AI agent, request port, or sub-workflow).
|
||||
/// </summary>
|
||||
/// <param name="context">The task orchestration context.</param>
|
||||
/// <param name="executorInfo">Information about the executor to dispatch.</param>
|
||||
/// <param name="envelope">The message envelope containing input and type information.</param>
|
||||
/// <param name="sharedState">The shared state dictionary to pass to the executor.</param>
|
||||
/// <param name="liveStatus">The live workflow status used to publish events and pending request port state.</param>
|
||||
/// <param name="logger">The logger for tracing.</param>
|
||||
/// <returns>The result from the executor.</returns>
|
||||
internal static async Task<string> DispatchAsync(
|
||||
@@ -40,10 +43,16 @@ internal static class DurableExecutorDispatcher
|
||||
WorkflowExecutorInfo executorInfo,
|
||||
DurableMessageEnvelope envelope,
|
||||
Dictionary<string, string> sharedState,
|
||||
DurableWorkflowLiveStatus liveStatus,
|
||||
ILogger logger)
|
||||
{
|
||||
logger.LogDispatchingExecutor(executorInfo.ExecutorId, executorInfo.IsAgenticExecutor);
|
||||
|
||||
if (executorInfo.IsRequestPortExecutor)
|
||||
{
|
||||
return await ExecuteRequestPortAsync(context, executorInfo, envelope.Message, liveStatus, logger).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
if (executorInfo.IsAgenticExecutor)
|
||||
{
|
||||
return await ExecuteAgentAsync(context, executorInfo, logger, envelope.Message).ConfigureAwait(true);
|
||||
@@ -79,6 +88,47 @@ internal static class DurableExecutorDispatcher
|
||||
return await context.CallActivityAsync<string>(activityName, serializedInput).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a request port executor by waiting for an external event (human-in-the-loop).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When the workflow reaches a <see cref="RequestPort"/> executor, the orchestration publishes
|
||||
/// the pending request to <see cref="DurableWorkflowLiveStatus"/> and waits for an external actor
|
||||
/// (e.g., a UI or API) to raise the corresponding event via
|
||||
/// <see cref="IStreamingWorkflowRun.SendResponseAsync{TResponse}(DurableWorkflowWaitingForInputEvent, TResponse, CancellationToken)"/>.
|
||||
/// Multiple RequestPorts may be dispatched in parallel during a fan-out superstep.
|
||||
/// Each adds its pending request to <see cref="DurableWorkflowLiveStatus.PendingEvents"/>.
|
||||
/// The wait has no built-in timeout; for time-limited approvals, callers can combine
|
||||
/// <c>context.CreateTimer</c> with <c>Task.WhenAny</c> in a wrapper executor.
|
||||
/// </remarks>
|
||||
private static async Task<string> ExecuteRequestPortAsync(
|
||||
TaskOrchestrationContext context,
|
||||
WorkflowExecutorInfo executorInfo,
|
||||
string input,
|
||||
DurableWorkflowLiveStatus liveStatus,
|
||||
ILogger logger)
|
||||
{
|
||||
RequestPort requestPort = executorInfo.RequestPort!;
|
||||
string eventName = requestPort.Id;
|
||||
|
||||
logger.LogWaitingForExternalEvent(eventName);
|
||||
|
||||
// Publish pending request so external clients can discover what input is needed
|
||||
liveStatus.PendingEvents.Add(new PendingRequestPortStatus(EventName: eventName, Input: input));
|
||||
context.SetCustomStatus(liveStatus);
|
||||
|
||||
// Wait until the external actor raises the event
|
||||
string response = await context.WaitForExternalEvent<string>(eventName).ConfigureAwait(true);
|
||||
|
||||
// Remove this pending request after receiving the response
|
||||
liveStatus.PendingEvents.RemoveAll(p => p.EventName == eventName);
|
||||
context.SetCustomStatus(liveStatus.Events.Count > 0 || liveStatus.PendingEvents.Count > 0 ? liveStatus : null);
|
||||
|
||||
logger.LogReceivedExternalEvent(eventName);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes an AI agent executor through Durable Entities.
|
||||
/// </summary>
|
||||
|
||||
+76
-22
@@ -14,15 +14,24 @@ namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
/// Represents a durable workflow run that supports streaming workflow events as they occur.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Events are detected by monitoring the orchestration's custom status at regular intervals.
|
||||
/// When executors emit events via <see cref="IWorkflowContext.AddEventAsync"/> or
|
||||
/// <see cref="IWorkflowContext.YieldOutputAsync"/>, they are written to the orchestration's
|
||||
/// custom status and picked up by this streaming run.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When the workflow reaches a <see cref="RequestPort"/> executor, a <see cref="DurableWorkflowWaitingForInputEvent"/>
|
||||
/// is yielded containing the request data. The caller should then call
|
||||
/// <see cref="SendResponseAsync{TResponse}(DurableWorkflowWaitingForInputEvent, TResponse, CancellationToken)"/>
|
||||
/// to provide the response and resume the workflow.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[DebuggerDisplay("{WorkflowName} ({RunId})")]
|
||||
internal sealed class DurableStreamingWorkflowRun : IStreamingWorkflowRun
|
||||
{
|
||||
private readonly DurableTaskClient _client;
|
||||
private readonly Dictionary<string, RequestPort> _requestPorts;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DurableStreamingWorkflowRun"/> class.
|
||||
@@ -35,6 +44,7 @@ internal sealed class DurableStreamingWorkflowRun : IStreamingWorkflowRun
|
||||
this._client = client;
|
||||
this.RunId = instanceId;
|
||||
this.WorkflowName = workflow.Name ?? string.Empty;
|
||||
this._requestPorts = ExtractRequestPorts(workflow);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -92,9 +102,12 @@ internal sealed class DurableStreamingWorkflowRun : IStreamingWorkflowRun
|
||||
TimeSpan maxInterval = TimeSpan.FromSeconds(2);
|
||||
TimeSpan currentInterval = minInterval;
|
||||
|
||||
// Track how many events we've already read from custom status
|
||||
// Track how many events we've already read from the durable workflow status
|
||||
int lastReadEventIndex = 0;
|
||||
|
||||
// Track which pending events we've already yielded to avoid duplicates
|
||||
HashSet<string> yieldedPendingEvents = [];
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Poll with getInputsAndOutputs: true because SerializedCustomStatus
|
||||
@@ -111,26 +124,54 @@ internal sealed class DurableStreamingWorkflowRun : IStreamingWorkflowRun
|
||||
|
||||
bool hasNewEvents = false;
|
||||
|
||||
// Always drain any unread events from custom status before checking terminal states.
|
||||
// Always drain any unread events from the durable workflow status before checking terminal states.
|
||||
// The orchestration may complete before the next poll, so events would be lost if we
|
||||
// check terminal status first.
|
||||
if (metadata.SerializedCustomStatus is not null)
|
||||
{
|
||||
if (TryParseCustomStatus(metadata.SerializedCustomStatus, out DurableWorkflowCustomStatus customStatus))
|
||||
if (DurableWorkflowLiveStatus.TryParse(metadata.SerializedCustomStatus, out DurableWorkflowLiveStatus liveStatus))
|
||||
{
|
||||
(List<WorkflowEvent> events, lastReadEventIndex) = DrainNewEvents(customStatus.Events, lastReadEventIndex);
|
||||
(List<WorkflowEvent> events, lastReadEventIndex) = DrainNewEvents(liveStatus.Events, lastReadEventIndex);
|
||||
foreach (WorkflowEvent evt in events)
|
||||
{
|
||||
hasNewEvents = true;
|
||||
yield return evt;
|
||||
}
|
||||
|
||||
// Yield a DurableWorkflowWaitingForInputEvent for each new pending request port
|
||||
foreach (PendingRequestPortStatus pending in liveStatus.PendingEvents)
|
||||
{
|
||||
if (yieldedPendingEvents.Add(pending.EventName))
|
||||
{
|
||||
if (!this._requestPorts.TryGetValue(pending.EventName, out RequestPort? matchingPort))
|
||||
{
|
||||
// RequestPort may not exist in the current workflow definition (e.g., during rolling deployments).
|
||||
continue;
|
||||
}
|
||||
|
||||
hasNewEvents = true;
|
||||
yield return new DurableWorkflowWaitingForInputEvent(
|
||||
pending.Input,
|
||||
matchingPort);
|
||||
}
|
||||
}
|
||||
|
||||
// Sync tracking with current pending events so re-used RequestPort names can be yielded again
|
||||
if (liveStatus.PendingEvents.Count == 0)
|
||||
{
|
||||
yieldedPendingEvents.Clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
yieldedPendingEvents.IntersectWith(liveStatus.PendingEvents.Select(p => p.EventName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check terminal states after draining events from custom status
|
||||
// Check terminal states after draining events from the durable workflow status
|
||||
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Completed)
|
||||
{
|
||||
// The framework clears custom status on completion, so events may be in
|
||||
// The framework clears the durable workflow status on completion, so events may be in
|
||||
// SerializedOutput as a DurableWorkflowResult wrapper.
|
||||
if (TryParseWorkflowResult(metadata.SerializedOutput, out DurableWorkflowResult? outputResult))
|
||||
{
|
||||
@@ -183,6 +224,28 @@ internal sealed class DurableStreamingWorkflowRun : IStreamingWorkflowRun
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a response to a <see cref="DurableWorkflowWaitingForInputEvent"/> to resume the workflow.
|
||||
/// </summary>
|
||||
/// <typeparam name="TResponse">The type of the response data.</typeparam>
|
||||
/// <param name="requestEvent">The request event to respond to.</param>
|
||||
/// <param name="response">The response data to send.</param>
|
||||
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
||||
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing workflow types provided by the caller.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing workflow types provided by the caller.")]
|
||||
public async ValueTask SendResponseAsync<TResponse>(DurableWorkflowWaitingForInputEvent requestEvent, TResponse response, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(requestEvent);
|
||||
|
||||
string serializedResponse = JsonSerializer.Serialize(response, DurableSerialization.Options);
|
||||
await this._client.RaiseEventAsync(
|
||||
this.RunId,
|
||||
requestEvent.RequestPort.Id,
|
||||
serializedResponse,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the workflow to complete and returns the result.
|
||||
/// </summary>
|
||||
@@ -242,22 +305,6 @@ internal sealed class DurableStreamingWorkflowRun : IStreamingWorkflowRun
|
||||
return (events, lastReadIndex);
|
||||
}
|
||||
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow custom status.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow custom status.")]
|
||||
private static bool TryParseCustomStatus(string serializedStatus, out DurableWorkflowCustomStatus result)
|
||||
{
|
||||
try
|
||||
{
|
||||
result = JsonSerializer.Deserialize(serializedStatus, DurableWorkflowJsonContext.Default.DurableWorkflowCustomStatus)!;
|
||||
return result is not null;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
result = default!;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to parse the orchestration output as a <see cref="DurableWorkflowResult"/> wrapper.
|
||||
/// </summary>
|
||||
@@ -395,4 +442,11 @@ internal sealed class DurableStreamingWorkflowRun : IStreamingWorkflowRun
|
||||
|
||||
return dataElement.ValueKind == JsonValueKind.Null ? null : dataElement.Clone();
|
||||
}
|
||||
|
||||
private static Dictionary<string, RequestPort> ExtractRequestPorts(Workflow workflow)
|
||||
{
|
||||
return WorkflowAnalyzer.GetExecutorsFromWorkflowInOrder(workflow)
|
||||
.Where(e => e.RequestPort is not null)
|
||||
.ToDictionary(e => e.RequestPort!.Id, e => e.RequestPort!);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the custom status written by the orchestration for streaming consumption.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The Durable Task framework exposes <c>SerializedCustomStatus</c> on orchestration metadata,
|
||||
/// which is the only orchestration state readable by external clients while the orchestration
|
||||
/// is still running. The orchestrator writes this object via <c>SetCustomStatus</c> after each
|
||||
/// superstep so that <see cref="DurableStreamingWorkflowRun"/> can poll for new events.
|
||||
/// On orchestration completion the framework clears custom status, so events are also
|
||||
/// embedded in the output via <see cref="DurableWorkflowResult"/>.
|
||||
/// </remarks>
|
||||
internal sealed class DurableWorkflowCustomStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the serialized workflow events emitted so far.
|
||||
/// </summary>
|
||||
public List<string> Events { get; set; } = [];
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
/// <item><description><see cref="DurableActivityInput"/>: Activity input wrapper with state</description></item>
|
||||
/// <item><description><see cref="DurableExecutorOutput"/>: Executor output wrapper with results, events, and state updates</description></item>
|
||||
/// <item><description><see cref="TypedPayload"/>: Serialized payload wrapper with type info (events and messages)</description></item>
|
||||
/// <item><description><see cref="DurableWorkflowCustomStatus"/>: Custom status for streaming consumption</description></item>
|
||||
/// <item><description><see cref="DurableWorkflowLiveStatus"/>: Live status payload (streaming events and pending request ports)</description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Note: User-defined executor input/output types still use reflection-based serialization
|
||||
@@ -31,8 +31,10 @@ namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
[JsonSerializable(typeof(DurableExecutorOutput))]
|
||||
[JsonSerializable(typeof(TypedPayload))]
|
||||
[JsonSerializable(typeof(List<TypedPayload>))]
|
||||
[JsonSerializable(typeof(DurableWorkflowCustomStatus))]
|
||||
[JsonSerializable(typeof(DurableWorkflowLiveStatus))]
|
||||
[JsonSerializable(typeof(DurableWorkflowResult))]
|
||||
[JsonSerializable(typeof(PendingRequestPortStatus))]
|
||||
[JsonSerializable(typeof(List<PendingRequestPortStatus>))]
|
||||
[JsonSerializable(typeof(List<string>))]
|
||||
[JsonSerializable(typeof(Dictionary<string, string>))]
|
||||
[JsonSerializable(typeof(Dictionary<string, string?>))]
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Live status payload written to the orchestration via <c>SetCustomStatus</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is the only orchestration state readable by external clients while the workflow
|
||||
/// is still running. It is written after each superstep so that
|
||||
/// <see cref="DurableStreamingWorkflowRun"/> can poll for new events.
|
||||
/// On completion the framework clears it, so events are also
|
||||
/// embedded in the output via <see cref="DurableWorkflowResult"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When the workflow is paused at one or more <see cref="RequestPort"/> nodes,
|
||||
/// <see cref="PendingEvents"/> contains the request data for each.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class DurableWorkflowLiveStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the pending request ports the workflow is waiting on. Empty when no input is needed.
|
||||
/// </summary>
|
||||
public List<PendingRequestPortStatus> PendingEvents { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the serialized workflow events emitted so far.
|
||||
/// </summary>
|
||||
public List<string> Events { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to deserialize a serialized custom status string into a <see cref="DurableWorkflowLiveStatus"/>.
|
||||
/// </summary>
|
||||
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing durable workflow status.")]
|
||||
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing durable workflow status.")]
|
||||
internal static bool TryParse(string? serializedStatus, out DurableWorkflowLiveStatus result)
|
||||
{
|
||||
if (serializedStatus is null)
|
||||
{
|
||||
result = default!;
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
result = System.Text.Json.JsonSerializer.Deserialize<DurableWorkflowLiveStatus>(serializedStatus, DurableSerialization.Options)!;
|
||||
return result is not null;
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
result = default!;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
@@ -12,7 +12,6 @@ namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
public sealed class DurableWorkflowOptions
|
||||
{
|
||||
private readonly Dictionary<string, Workflow> _workflows = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly DurableOptions? _parentOptions;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DurableWorkflowOptions"/> class.
|
||||
@@ -20,9 +19,14 @@ public sealed class DurableWorkflowOptions
|
||||
/// <param name="parentOptions">Optional parent options container for accessing related configuration.</param>
|
||||
internal DurableWorkflowOptions(DurableOptions? parentOptions = null)
|
||||
{
|
||||
this._parentOptions = parentOptions;
|
||||
this.ParentOptions = parentOptions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the parent <see cref="DurableOptions"/> container, if available.
|
||||
/// </summary>
|
||||
internal DurableOptions? ParentOptions { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the collection of workflows available in the current context, keyed by their unique names.
|
||||
/// </summary>
|
||||
@@ -77,7 +81,7 @@ public sealed class DurableWorkflowOptions
|
||||
/// </summary>
|
||||
private void RegisterWorkflowExecutors(Workflow workflow)
|
||||
{
|
||||
DurableAgentsOptions? agentOptions = this._parentOptions?.Agents;
|
||||
DurableAgentsOptions? agentOptions = this.ParentOptions?.Agents;
|
||||
|
||||
foreach ((string executorId, ExecutorBinding binding) in workflow.ReflectExecutors())
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// ConfigureAwait Usage in Orchestration Code:
|
||||
// This file uses ConfigureAwait(true) because it runs within orchestration context.
|
||||
@@ -173,7 +173,7 @@ internal sealed class DurableWorkflowRunner
|
||||
logger.LogSuperstepExecutors(superstep, string.Join(", ", executorInputs.Select(e => e.ExecutorId)));
|
||||
}
|
||||
|
||||
string[] results = await DispatchExecutorsInParallelAsync(context, executorInputs, state.SharedState, logger).ConfigureAwait(true);
|
||||
string[] results = await DispatchExecutorsInParallelAsync(context, executorInputs, state, logger).ConfigureAwait(true);
|
||||
|
||||
haltRequested = ProcessSuperstepResults(executorInputs, results, state, context, logger);
|
||||
|
||||
@@ -193,7 +193,7 @@ internal sealed class DurableWorkflowRunner
|
||||
// Publish final events for live streaming (skip during replay)
|
||||
if (!context.IsReplaying)
|
||||
{
|
||||
PublishEventsToCustomStatus(context, state);
|
||||
PublishEventsToLiveStatus(context, state);
|
||||
}
|
||||
|
||||
string finalResult = GetFinalResult(state.LastResults);
|
||||
@@ -226,11 +226,11 @@ internal sealed class DurableWorkflowRunner
|
||||
private static async Task<string[]> DispatchExecutorsInParallelAsync(
|
||||
TaskOrchestrationContext context,
|
||||
List<ExecutorInput> executorInputs,
|
||||
Dictionary<string, string> sharedState,
|
||||
SuperstepState state,
|
||||
ILogger logger)
|
||||
{
|
||||
Task<string>[] dispatchTasks = executorInputs
|
||||
.Select(input => DurableExecutorDispatcher.DispatchAsync(context, input.Info, input.Envelope, sharedState, logger))
|
||||
.Select(input => DurableExecutorDispatcher.DispatchAsync(context, input.Info, input.Envelope, state.SharedState, state.LiveStatus, logger))
|
||||
.ToArray();
|
||||
|
||||
return await Task.WhenAll(dispatchTasks).ConfigureAwait(true);
|
||||
@@ -273,9 +273,14 @@ internal sealed class DurableWorkflowRunner
|
||||
public Dictionary<string, string> SharedState { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Accumulated workflow events for custom status (streaming consumption).
|
||||
/// Accumulated workflow events for the durable workflow status (streaming consumption).
|
||||
/// </summary>
|
||||
public List<string> AccumulatedEvents { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Workflow status published via <c>SetCustomStatus</c> so external clients can poll for streaming events and pending HITL requests.
|
||||
/// </summary>
|
||||
public DurableWorkflowLiveStatus LiveStatus { get; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -378,7 +383,7 @@ internal sealed class DurableWorkflowRunner
|
||||
// Merge state updates from activity into shared state
|
||||
MergeStateUpdates(state, resultInfo.StateUpdates, resultInfo.ClearedScopes);
|
||||
|
||||
// Accumulate events for custom status (streaming)
|
||||
// Accumulate events for the durable workflow status (streaming)
|
||||
state.AccumulatedEvents.AddRange(resultInfo.Events);
|
||||
|
||||
// Check for halt request
|
||||
@@ -387,7 +392,7 @@ internal sealed class DurableWorkflowRunner
|
||||
// Publish events for live streaming (skip during replay)
|
||||
if (!context.IsReplaying)
|
||||
{
|
||||
PublishEventsToCustomStatus(context, state);
|
||||
PublishEventsToLiveStatus(context, state);
|
||||
}
|
||||
|
||||
RouteOutputToSuccessors(executorId, resultInfo.Result, resultInfo.SentMessages, state, logger);
|
||||
@@ -464,24 +469,23 @@ internal sealed class DurableWorkflowRunner
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes accumulated workflow events to the orchestration's custom status,
|
||||
/// Publishes accumulated workflow events to the durable workflow's custom status,
|
||||
/// making them available to <see cref="DurableStreamingWorkflowRun"/> for live streaming.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Custom status is the only orchestration metadata readable by external clients while
|
||||
/// Custom status is the only orchestration state readable by external clients while
|
||||
/// the orchestration is still running. It is cleared by the framework on completion,
|
||||
/// so events are also included in <see cref="DurableWorkflowResult"/> for final retrieval.
|
||||
/// </remarks>
|
||||
private static void PublishEventsToCustomStatus(TaskOrchestrationContext context, SuperstepState state)
|
||||
private static void PublishEventsToLiveStatus(
|
||||
TaskOrchestrationContext context,
|
||||
SuperstepState state)
|
||||
{
|
||||
DurableWorkflowCustomStatus customStatus = new()
|
||||
{
|
||||
Events = state.AccumulatedEvents
|
||||
};
|
||||
state.LiveStatus.Events = state.AccumulatedEvents;
|
||||
|
||||
// Pass the object directly — the framework's DataConverter handles serialization.
|
||||
// Pre-serializing would cause double-serialization (string wrapped in JSON quotes).
|
||||
context.SetCustomStatus(customStatus);
|
||||
context.SetCustomStatus(state.LiveStatus);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when the durable workflow is waiting for external input at a <see cref="RequestPort"/>.
|
||||
/// </summary>
|
||||
/// <param name="Input">The serialized input data that was passed to the RequestPort.</param>
|
||||
/// <param name="RequestPort">The request port definition.</param>
|
||||
[DebuggerDisplay("RequestPort = {RequestPort.Id}")]
|
||||
public sealed class DurableWorkflowWaitingForInputEvent(
|
||||
string Input,
|
||||
RequestPort RequestPort) : WorkflowEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the serialized input data that was passed to the RequestPort.
|
||||
/// </summary>
|
||||
public string Input { get; } = Input;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the request port definition.
|
||||
/// </summary>
|
||||
public RequestPort RequestPort { get; } = RequestPort;
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to deserialize the input data to the specified type.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type to deserialize to.</typeparam>
|
||||
/// <returns>The deserialized input.</returns>
|
||||
/// <exception cref="JsonException">Thrown when the input cannot be deserialized to the specified type.</exception>
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow types provided by the caller.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow types provided by the caller.")]
|
||||
public T? GetInputAs<T>()
|
||||
{
|
||||
return JsonSerializer.Deserialize<T>(this.Input, DurableSerialization.Options);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
@@ -39,4 +39,17 @@ public interface IStreamingWorkflowRun
|
||||
/// workflow state changes.
|
||||
/// </returns>
|
||||
IAsyncEnumerable<WorkflowEvent> WatchStreamAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Sends a response to a <see cref="DurableWorkflowWaitingForInputEvent"/> to resume the workflow.
|
||||
/// </summary>
|
||||
/// <typeparam name="TResponse">The type of the response data.</typeparam>
|
||||
/// <param name="requestEvent">The request event to respond to.</param>
|
||||
/// <param name="response">The response data to send.</param>
|
||||
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
||||
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
|
||||
ValueTask SendResponseAsync<TResponse>(
|
||||
DurableWorkflowWaitingForInputEvent requestEvent,
|
||||
TResponse response,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a RequestPort the workflow is paused at, waiting for a response.
|
||||
/// </summary>
|
||||
/// <param name="EventName">The RequestPort ID identifying which input is needed.</param>
|
||||
/// <param name="Input">The serialized request data passed to the RequestPort.</param>
|
||||
internal sealed record PendingRequestPortStatus(
|
||||
string EventName,
|
||||
string Input);
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Context.Features;
|
||||
@@ -85,6 +85,34 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.GetWorkflowStatusHttpFunctionEntryPoint)
|
||||
{
|
||||
if (httpRequestData == null)
|
||||
{
|
||||
throw new InvalidOperationException($"HTTP request data binding is missing for the invocation {context.InvocationId}.");
|
||||
}
|
||||
|
||||
context.GetInvocationResult().Value = await BuiltInFunctions.GetWorkflowStatusAsync(
|
||||
httpRequestData,
|
||||
durableTaskClient,
|
||||
context);
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RespondToWorkflowHttpFunctionEntryPoint)
|
||||
{
|
||||
if (httpRequestData == null)
|
||||
{
|
||||
throw new InvalidOperationException($"HTTP request data binding is missing for the invocation {context.InvocationId}.");
|
||||
}
|
||||
|
||||
context.GetInvocationResult().Value = await BuiltInFunctions.RespondToWorkflowAsync(
|
||||
httpRequestData,
|
||||
durableTaskClient,
|
||||
context);
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint)
|
||||
{
|
||||
if (encodedEntityRequest is null)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
@@ -26,6 +27,8 @@ internal static class BuiltInFunctions
|
||||
internal static readonly string RunWorkflowOrchestrationHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunWorkflowOrchestrationHttpTriggerAsync)}";
|
||||
internal static readonly string RunWorkflowOrchestrationFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunWorkflowOrchestration)}";
|
||||
internal static readonly string InvokeWorkflowActivityFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(InvokeWorkflowActivityAsync)}";
|
||||
internal static readonly string GetWorkflowStatusHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(GetWorkflowStatusAsync)}";
|
||||
internal static readonly string RespondToWorkflowHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RespondToWorkflowAsync)}";
|
||||
|
||||
#pragma warning disable IL3000 // Avoid accessing Assembly file path when publishing as a single file - Azure Functions does not use single-file publishing
|
||||
internal static readonly string ScriptFile = Path.GetFileName(typeof(BuiltInFunctions).Assembly.Location);
|
||||
@@ -63,6 +66,122 @@ internal static class BuiltInFunctions
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the workflow status including any pending HITL requests.
|
||||
/// The run ID is extracted from the route parameter <c>{runId}</c>.
|
||||
/// </summary>
|
||||
public static async Task<HttpResponseData> GetWorkflowStatusAsync(
|
||||
[HttpTrigger] HttpRequestData req,
|
||||
[DurableClient] DurableTaskClient client,
|
||||
FunctionContext context)
|
||||
{
|
||||
string? runId = context.BindingContext.BindingData.TryGetValue("runId", out object? value) ? value?.ToString() : null;
|
||||
if (string.IsNullOrEmpty(runId))
|
||||
{
|
||||
return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Run ID is required.");
|
||||
}
|
||||
|
||||
OrchestrationMetadata? metadata = await client.GetInstanceAsync(runId, getInputsAndOutputs: true);
|
||||
if (metadata is null)
|
||||
{
|
||||
return await CreateErrorResponseAsync(req, context, HttpStatusCode.NotFound, $"Workflow run '{runId}' not found.");
|
||||
}
|
||||
|
||||
// Parse HITL inputs the workflow is waiting for from the durable workflow status
|
||||
List<PendingRequestPortStatus>? waitingForInput = null;
|
||||
if (DurableWorkflowLiveStatus.TryParse(metadata.SerializedCustomStatus, out DurableWorkflowLiveStatus liveStatus)
|
||||
&& liveStatus.PendingEvents.Count > 0)
|
||||
{
|
||||
waitingForInput = liveStatus.PendingEvents;
|
||||
}
|
||||
|
||||
HttpResponseData response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new
|
||||
{
|
||||
runId,
|
||||
status = metadata.RuntimeStatus.ToString(),
|
||||
waitingForInput = waitingForInput?.Select(p => new { eventName = p.EventName, input = JsonDocument.Parse(p.Input).RootElement })
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a response to a pending RequestPort, resuming the workflow.
|
||||
/// Expects a JSON body: <c>{ "eventName": "...", "response": { ... } }</c>.
|
||||
/// </summary>
|
||||
public static async Task<HttpResponseData> RespondToWorkflowAsync(
|
||||
[HttpTrigger] HttpRequestData req,
|
||||
[DurableClient] DurableTaskClient client,
|
||||
FunctionContext context)
|
||||
{
|
||||
string? runId = context.BindingContext.BindingData.TryGetValue("runId", out object? value) ? value?.ToString() : null;
|
||||
if (string.IsNullOrEmpty(runId))
|
||||
{
|
||||
return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Run ID is required.");
|
||||
}
|
||||
|
||||
WorkflowRespondRequest? request;
|
||||
try
|
||||
{
|
||||
request = await req.ReadFromJsonAsync<WorkflowRespondRequest>(context.CancellationToken);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Request body is not valid JSON.");
|
||||
}
|
||||
|
||||
if (request is null || string.IsNullOrEmpty(request.EventName)
|
||||
|| request.Response.ValueKind == JsonValueKind.Undefined)
|
||||
{
|
||||
return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Body must contain a non-empty 'eventName' and a 'response' property.");
|
||||
}
|
||||
|
||||
// Verify the orchestration exists and is in a valid state
|
||||
OrchestrationMetadata? metadata = await client.GetInstanceAsync(runId, getInputsAndOutputs: true);
|
||||
if (metadata is null)
|
||||
{
|
||||
return await CreateErrorResponseAsync(req, context, HttpStatusCode.NotFound, $"Workflow run '{runId}' not found.");
|
||||
}
|
||||
|
||||
if (metadata.RuntimeStatus is OrchestrationRuntimeStatus.Completed
|
||||
or OrchestrationRuntimeStatus.Failed
|
||||
or OrchestrationRuntimeStatus.Terminated)
|
||||
{
|
||||
return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest,
|
||||
$"Workflow run '{runId}' is in terminal state '{metadata.RuntimeStatus}'.");
|
||||
}
|
||||
|
||||
// Verify the workflow is waiting for the specified event.
|
||||
// If status can't be parsed (e.g., not yet set during early execution), allow the event through —
|
||||
// Durable Task safely queues it until the orchestration reaches WaitForExternalEvent.
|
||||
bool eventValidated = false;
|
||||
if (DurableWorkflowLiveStatus.TryParse(metadata.SerializedCustomStatus, out DurableWorkflowLiveStatus liveStatus))
|
||||
{
|
||||
if (!liveStatus.PendingEvents.Exists(p => string.Equals(p.EventName, request.EventName, StringComparison.Ordinal)))
|
||||
{
|
||||
return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest,
|
||||
$"Workflow is not waiting for event '{request.EventName}'.");
|
||||
}
|
||||
|
||||
eventValidated = true;
|
||||
}
|
||||
|
||||
// Raise the external event to unblock the orchestration's WaitForExternalEvent call
|
||||
await client.RaiseEventAsync(runId, request.EventName, request.Response.GetRawText());
|
||||
|
||||
HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted);
|
||||
await response.WriteAsJsonAsync(new
|
||||
{
|
||||
message = eventValidated
|
||||
? "Response sent to workflow."
|
||||
: "Response sent to workflow. Event could not be validated against pending requests.",
|
||||
runId,
|
||||
eventName = request.EventName,
|
||||
validated = eventValidated,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a workflow activity by looking up the registered executor and delegating to it.
|
||||
/// The executor name is derived from the activity function name via <see cref="WorkflowNamingHelper"/>.
|
||||
@@ -413,6 +532,15 @@ internal static class BuiltInFunctions
|
||||
[property: JsonPropertyName("status")] int Status,
|
||||
[property: JsonPropertyName("thread_id")] string ThreadId);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a request to respond to a pending RequestPort in a workflow.
|
||||
/// </summary>
|
||||
/// <param name="EventName">The name of the event to raise (the RequestPort ID).</param>
|
||||
/// <param name="Response">The response payload to send to the workflow.</param>
|
||||
private sealed record WorkflowRespondRequest(
|
||||
[property: JsonPropertyName("eventName")] string? EventName,
|
||||
[property: JsonPropertyName("response")] JsonElement Response);
|
||||
|
||||
/// <summary>
|
||||
/// A service provider that combines the original service provider with an additional DurableTaskClient instance.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata;
|
||||
@@ -38,8 +38,9 @@ internal static class FunctionMetadataFactory
|
||||
/// <param name="name">The base name used to derive the HTTP function name.</param>
|
||||
/// <param name="route">The HTTP route for the trigger.</param>
|
||||
/// <param name="entryPoint">The entry point method for the HTTP trigger.</param>
|
||||
/// <param name="methods">The allowed HTTP methods as a JSON array fragment (e.g., <c>"\"get\""</c>). Defaults to POST.</param>
|
||||
/// <returns>A <see cref="DefaultFunctionMetadata"/> configured for an HTTP trigger.</returns>
|
||||
internal static DefaultFunctionMetadata CreateHttpTrigger(string name, string route, string entryPoint)
|
||||
internal static DefaultFunctionMetadata CreateHttpTrigger(string name, string route, string entryPoint, string methods = "\"post\"")
|
||||
{
|
||||
return new DefaultFunctionMetadata()
|
||||
{
|
||||
@@ -47,7 +48,7 @@ internal static class FunctionMetadataFactory
|
||||
Language = "dotnet-isolated",
|
||||
RawBindings =
|
||||
[
|
||||
$"{{\"name\":\"req\",\"type\":\"httpTrigger\",\"direction\":\"In\",\"authLevel\":\"function\",\"methods\": [\"post\"],\"route\":\"{route}\"}}",
|
||||
$"{{\"name\":\"req\",\"type\":\"httpTrigger\",\"direction\":\"In\",\"authLevel\":\"function\",\"methods\": [{methods}],\"route\":\"{route}\"}}",
|
||||
"{\"name\":\"$return\",\"type\":\"http\",\"direction\":\"Out\"}",
|
||||
"{\"name\":\"client\",\"type\":\"durableClient\",\"direction\":\"In\"}"
|
||||
],
|
||||
|
||||
+11
-8
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
@@ -62,10 +62,10 @@ public static class FunctionsApplicationBuilderExtensions
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
|
||||
builder.Services.ConfigureDurableOptions(configure);
|
||||
// Ensure FunctionsDurableOptions is registered BEFORE the core extension creates a plain DurableOptions
|
||||
FunctionsDurableOptions sharedOptions = GetOrCreateSharedOptions(builder.Services);
|
||||
|
||||
// Read the shared options to check if workflows were added
|
||||
DurableOptions sharedOptions = GetOrCreateSharedOptions(builder.Services);
|
||||
builder.Services.ConfigureDurableOptions(configure);
|
||||
|
||||
if (sharedOptions.Workflows.Workflows.Count > 0)
|
||||
{
|
||||
@@ -105,7 +105,9 @@ public static class FunctionsApplicationBuilderExtensions
|
||||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentEntityFunctionEntryPoint, StringComparison.Ordinal) ||
|
||||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowOrchestrationHttpFunctionEntryPoint, StringComparison.Ordinal) ||
|
||||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowOrchestrationFunctionEntryPoint, StringComparison.Ordinal) ||
|
||||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint, StringComparison.Ordinal)
|
||||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint, StringComparison.Ordinal) ||
|
||||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.GetWorkflowStatusHttpFunctionEntryPoint, StringComparison.Ordinal) ||
|
||||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RespondToWorkflowHttpFunctionEntryPoint, StringComparison.Ordinal)
|
||||
);
|
||||
builder.Services.TryAddSingleton<BuiltInFunctionExecutor>();
|
||||
}
|
||||
@@ -113,17 +115,18 @@ public static class FunctionsApplicationBuilderExtensions
|
||||
/// <summary>
|
||||
/// Gets or creates a shared <see cref="DurableOptions"/> instance from the service collection.
|
||||
/// </summary>
|
||||
private static DurableOptions GetOrCreateSharedOptions(IServiceCollection services)
|
||||
private static FunctionsDurableOptions GetOrCreateSharedOptions(IServiceCollection services)
|
||||
{
|
||||
ServiceDescriptor? existingDescriptor = services.FirstOrDefault(
|
||||
d => d.ServiceType == typeof(DurableOptions) && d.ImplementationInstance is not null);
|
||||
|
||||
if (existingDescriptor?.ImplementationInstance is DurableOptions existing)
|
||||
if (existingDescriptor?.ImplementationInstance is FunctionsDurableOptions existing)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
DurableOptions options = new();
|
||||
FunctionsDurableOptions options = new();
|
||||
services.AddSingleton<DurableOptions>(options);
|
||||
services.AddSingleton(options);
|
||||
return options;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
|
||||
/// <summary>
|
||||
/// Provides Azure Functions–specific configuration for durable workflows.
|
||||
/// </summary>
|
||||
internal sealed class FunctionsDurableOptions : DurableOptions
|
||||
{
|
||||
private readonly HashSet<string> _statusEndpointWorkflows = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Enables the status HTTP endpoint for the specified workflow.
|
||||
/// </summary>
|
||||
internal void EnableStatusEndpoint(string workflowName)
|
||||
{
|
||||
this._statusEndpointWorkflows.Add(workflowName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether the status endpoint is enabled for the specified workflow.
|
||||
/// </summary>
|
||||
internal bool IsStatusEndpointEnabled(string workflowName)
|
||||
{
|
||||
return this._statusEndpointWorkflows.Contains(workflowName);
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for <see cref="DurableWorkflowOptions"/> to configure Azure Functions HTTP trigger options.
|
||||
/// </summary>
|
||||
public static class DurableWorkflowOptionsExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds a workflow and optionally exposes a status HTTP endpoint for querying pending HITL requests.
|
||||
/// </summary>
|
||||
/// <param name="options">The workflow options to add the workflow to.</param>
|
||||
/// <param name="workflow">The workflow instance to add.</param>
|
||||
/// <param name="exposeStatusEndpoint">If <see langword="true"/>, a GET endpoint is generated at <c>workflows/{name}/status/{runId}</c>.</param>
|
||||
public static void AddWorkflow(this DurableWorkflowOptions options, Workflow workflow, bool exposeStatusEndpoint)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
options.AddWorkflow(workflow);
|
||||
|
||||
if (exposeStatusEndpoint && options.ParentOptions is FunctionsDurableOptions functionsOptions)
|
||||
{
|
||||
functionsOptions.EnableStatusEndpoint(workflow.Name!);
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
-6
@@ -25,18 +25,20 @@ namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
internal sealed class DurableWorkflowsFunctionMetadataTransformer : IFunctionMetadataTransformer
|
||||
{
|
||||
private readonly ILogger<DurableWorkflowsFunctionMetadataTransformer> _logger;
|
||||
private readonly DurableWorkflowOptions _options;
|
||||
private readonly FunctionsDurableOptions _options;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DurableWorkflowsFunctionMetadataTransformer"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger instance for diagnostic output.</param>
|
||||
/// <param name="durableOptions">The durable options containing workflow configurations.</param>
|
||||
public DurableWorkflowsFunctionMetadataTransformer(ILogger<DurableWorkflowsFunctionMetadataTransformer> logger, DurableOptions durableOptions)
|
||||
public DurableWorkflowsFunctionMetadataTransformer(
|
||||
ILogger<DurableWorkflowsFunctionMetadataTransformer> logger,
|
||||
FunctionsDurableOptions durableOptions)
|
||||
{
|
||||
this._logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
ArgumentNullException.ThrowIfNull(durableOptions);
|
||||
this._options = durableOptions.Workflows;
|
||||
this._options = durableOptions;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -51,7 +53,8 @@ internal sealed class DurableWorkflowsFunctionMetadataTransformer : IFunctionMet
|
||||
// Track registered function names to avoid duplicates when workflows share executors.
|
||||
HashSet<string> registeredFunctions = [];
|
||||
|
||||
foreach (var workflow in this._options.Workflows)
|
||||
DurableWorkflowOptions workflowOptions = this._options.Workflows;
|
||||
foreach (var workflow in workflowOptions.Workflows)
|
||||
{
|
||||
string httpFunctionName = $"{BuiltInFunctions.HttpPrefix}{workflow.Key}";
|
||||
|
||||
@@ -80,12 +83,42 @@ internal sealed class DurableWorkflowsFunctionMetadataTransformer : IFunctionMet
|
||||
BuiltInFunctions.RunWorkflowOrchestrationHttpFunctionEntryPoint));
|
||||
}
|
||||
|
||||
// Register a status endpoint if opted in via AddWorkflow(exposeStatusEndpoint: true).
|
||||
if (this._options.IsStatusEndpointEnabled(workflow.Key))
|
||||
{
|
||||
string statusFunctionName = $"{BuiltInFunctions.HttpPrefix}{workflow.Key}-status";
|
||||
if (registeredFunctions.Add(statusFunctionName))
|
||||
{
|
||||
this._logger.LogRegisteringWorkflowTrigger(workflow.Key, statusFunctionName, "http-status");
|
||||
original.Add(FunctionMetadataFactory.CreateHttpTrigger(
|
||||
$"{workflow.Key}-status",
|
||||
$"workflows/{workflow.Key}/status/{{runId}}",
|
||||
BuiltInFunctions.GetWorkflowStatusHttpFunctionEntryPoint,
|
||||
methods: "\"get\""));
|
||||
}
|
||||
}
|
||||
|
||||
// Register a respond endpoint when the workflow contains RequestPort nodes.
|
||||
bool hasRequestPorts = workflow.Value.ReflectExecutors().Values.Any(b => b is RequestPortBinding);
|
||||
if (hasRequestPorts)
|
||||
{
|
||||
string respondFunctionName = $"{BuiltInFunctions.HttpPrefix}{workflow.Key}-respond";
|
||||
if (registeredFunctions.Add(respondFunctionName))
|
||||
{
|
||||
this._logger.LogRegisteringWorkflowTrigger(workflow.Key, respondFunctionName, "http-respond");
|
||||
original.Add(FunctionMetadataFactory.CreateHttpTrigger(
|
||||
$"{workflow.Key}-respond",
|
||||
$"workflows/{workflow.Key}/respond/{{runId}}",
|
||||
BuiltInFunctions.RespondToWorkflowHttpFunctionEntryPoint));
|
||||
}
|
||||
}
|
||||
|
||||
// Register activity or entity functions for each executor in the workflow.
|
||||
// ReflectExecutors() returns all executors across the graph; no need to manually traverse edges.
|
||||
foreach (KeyValuePair<string, ExecutorBinding> entry in workflow.Value.ReflectExecutors())
|
||||
{
|
||||
// Sub-workflow bindings are handled as separate orchestrations, not activities.
|
||||
if (entry.Value is SubworkflowBinding)
|
||||
// Sub-workflow and RequestPort bindings use specialized dispatch, not activities.
|
||||
if (entry.Value is SubworkflowBinding or RequestPortBinding)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user