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:
@@ -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);
|
||||
Reference in New Issue
Block a user