mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Adding support for events & shared state in durable workflows.
This commit is contained in:
@@ -312,11 +312,8 @@ public static class ServiceCollectionExtensions
|
||||
Dictionary<string, ExecutorBinding> executorBindings = workflow.ReflectExecutors();
|
||||
List<ActivityRegistrationInfo> activities = [];
|
||||
|
||||
// Filter out AI agents and subworkflows - they are not registered as activities.
|
||||
// AI agents use Durable Entities for stateful execution, and subworkflows are
|
||||
// registered as separate orchestrations via BuildWorkflowRegistrationRecursive.
|
||||
foreach (KeyValuePair<string, ExecutorBinding> entry in executorBindings
|
||||
.Where(e => e.Value is not AIAgentBinding and not SubworkflowBinding))
|
||||
.Where(e => IsActivityBinding(e.Value)))
|
||||
{
|
||||
string executorName = WorkflowNamingHelper.GetExecutorName(entry.Key);
|
||||
string activityName = WorkflowNamingHelper.ToOrchestrationFunctionName(executorName);
|
||||
@@ -330,6 +327,15 @@ public static class ServiceCollectionExtensions
|
||||
return new WorkflowRegistrationInfo(orchestrationName, activities);
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
private static bool IsActivityBinding(ExecutorBinding binding)
|
||||
=> binding is not AIAgentBinding
|
||||
and not SubworkflowBinding;
|
||||
|
||||
private static async Task<string> RunWorkflowOrchestrationAsync(
|
||||
TaskOrchestrationContext context,
|
||||
DurableWorkflowInput<object> workflowInput,
|
||||
|
||||
@@ -11,11 +11,20 @@ namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
/// A workflow context for durable activity execution.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Some of the methods are returning default for this version. Those method will be updated with real implementations in follow up PRs.
|
||||
/// State is passed in from the orchestration and updates are collected for return.
|
||||
/// Events emitted during execution are collected and returned to the orchestration
|
||||
/// as part of the activity output for streaming to callers.
|
||||
/// </remarks>
|
||||
[DebuggerDisplay("Executor = {_executor.Id}, StateEntries = {_initialState.Count}")]
|
||||
internal sealed class DurableActivityContext : IWorkflowContext
|
||||
{
|
||||
/// <summary>
|
||||
/// The default scope name used when no explicit scope is specified.
|
||||
/// Scopes partition shared state into logical namespaces so that different
|
||||
/// parts of a workflow can manage their state keys independently.
|
||||
/// </summary>
|
||||
private const string DefaultScopeName = "__default__";
|
||||
|
||||
private readonly Dictionary<string, string> _initialState;
|
||||
private readonly Executor _executor;
|
||||
|
||||
@@ -33,12 +42,40 @@ internal sealed class DurableActivityContext : IWorkflowContext
|
||||
/// <summary>
|
||||
/// Gets the messages sent during activity execution via <see cref="SendMessageAsync"/>.
|
||||
/// </summary>
|
||||
internal List<SentMessageInfo> SentMessages { get; } = [];
|
||||
internal List<TypedPayload> SentMessages { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the events that were added during activity execution.
|
||||
/// </summary>
|
||||
internal List<WorkflowEvent> Events { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the state updates made during activity execution.
|
||||
/// </summary>
|
||||
internal Dictionary<string, string?> StateUpdates { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the scopes that were cleared during activity execution.
|
||||
/// </summary>
|
||||
internal HashSet<string> ClearedScopes { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the executor requested a workflow halt.
|
||||
/// </summary>
|
||||
internal bool HaltRequested { get; private set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ValueTask AddEventAsync(
|
||||
WorkflowEvent workflowEvent,
|
||||
CancellationToken cancellationToken = default) => default;
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (workflowEvent is not null)
|
||||
{
|
||||
this.Events.Add(workflowEvent);
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing workflow message types registered at startup.")]
|
||||
@@ -51,10 +88,10 @@ internal sealed class DurableActivityContext : IWorkflowContext
|
||||
if (message is not null)
|
||||
{
|
||||
Type messageType = message.GetType();
|
||||
this.SentMessages.Add(new SentMessageInfo
|
||||
this.SentMessages.Add(new TypedPayload
|
||||
{
|
||||
Message = JsonSerializer.Serialize(message, messageType),
|
||||
TypeName = messageType.FullName ?? messageType.Name
|
||||
Data = JsonSerializer.Serialize(message, messageType, DurableSerialization.Options),
|
||||
TypeName = messageType.AssemblyQualifiedName
|
||||
});
|
||||
}
|
||||
|
||||
@@ -64,44 +101,193 @@ internal sealed class DurableActivityContext : IWorkflowContext
|
||||
/// <inheritdoc/>
|
||||
public ValueTask YieldOutputAsync(
|
||||
object output,
|
||||
CancellationToken cancellationToken = default) => default;
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (output is not null)
|
||||
{
|
||||
Type outputType = output.GetType();
|
||||
if (!this._executor.CanOutput(outputType))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot output object of type {outputType.Name}. " +
|
||||
$"Expecting one of [{string.Join(", ", this._executor.OutputTypes)}].");
|
||||
}
|
||||
|
||||
this.Events.Add(new WorkflowOutputEvent(output, this._executor.Id));
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ValueTask RequestHaltAsync() => default;
|
||||
public ValueTask RequestHaltAsync()
|
||||
{
|
||||
this.HaltRequested = true;
|
||||
this.Events.Add(new DurableHaltRequestedEvent(this._executor.Id));
|
||||
return default;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ValueTask<T?> ReadStateAsync<T>(
|
||||
string key,
|
||||
string? scopeName = null,
|
||||
CancellationToken cancellationToken = default) => default;
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
string scopeKey = GetScopeKey(scopeName, key);
|
||||
string normalizedScope = scopeName ?? DefaultScopeName;
|
||||
bool scopeCleared = this.ClearedScopes.Contains(normalizedScope);
|
||||
|
||||
// Local updates take priority over initial state.
|
||||
if (this.StateUpdates.TryGetValue(scopeKey, out string? updated))
|
||||
{
|
||||
return DeserializeStateAsync<T>(updated);
|
||||
}
|
||||
|
||||
// If scope was cleared, ignore initial state
|
||||
if (scopeCleared)
|
||||
{
|
||||
return ValueTask.FromResult<T?>(default);
|
||||
}
|
||||
|
||||
// Fall back to initial state passed from orchestration
|
||||
if (this._initialState.TryGetValue(scopeKey, out string? initial))
|
||||
{
|
||||
return DeserializeStateAsync<T>(initial);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult<T?>(default);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ValueTask<T> ReadOrInitStateAsync<T>(
|
||||
public async ValueTask<T> ReadOrInitStateAsync<T>(
|
||||
string key,
|
||||
Func<T> initialStateFactory,
|
||||
string? scopeName = null,
|
||||
CancellationToken cancellationToken = default) => default;
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
T? value = await this.ReadStateAsync<T>(key, scopeName, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (value is not null)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
T initialValue = initialStateFactory();
|
||||
await this.QueueStateUpdateAsync(key, initialValue, scopeName, cancellationToken).ConfigureAwait(false);
|
||||
return initialValue;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ValueTask<HashSet<string>> ReadStateKeysAsync(
|
||||
string? scopeName = null,
|
||||
CancellationToken cancellationToken = default) => default;
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
string scopePrefix = GetScopePrefix(scopeName);
|
||||
int scopePrefixLength = scopePrefix.Length;
|
||||
HashSet<string> keys = new(StringComparer.Ordinal);
|
||||
|
||||
bool scopeCleared = scopeName is null
|
||||
? this.ClearedScopes.Contains(DefaultScopeName)
|
||||
: this.ClearedScopes.Contains(scopeName);
|
||||
|
||||
// Start with keys from initial state (skip if scope was cleared)
|
||||
if (!scopeCleared)
|
||||
{
|
||||
foreach (string stateKey in this._initialState.Keys)
|
||||
{
|
||||
if (stateKey.StartsWith(scopePrefix, StringComparison.Ordinal))
|
||||
{
|
||||
keys.Add(stateKey[scopePrefixLength..]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge local updates: add if non-null, remove if null (deleted)
|
||||
foreach (KeyValuePair<string, string?> update in this.StateUpdates)
|
||||
{
|
||||
if (!update.Key.StartsWith(scopePrefix, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string key = update.Key[scopePrefixLength..];
|
||||
if (update.Value is not null)
|
||||
{
|
||||
keys.Add(key);
|
||||
}
|
||||
else
|
||||
{
|
||||
keys.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(keys);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ValueTask QueueStateUpdateAsync<T>(
|
||||
string key,
|
||||
T? value,
|
||||
string? scopeName = null,
|
||||
CancellationToken cancellationToken = default) => default;
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
string scopeKey = GetScopeKey(scopeName, key);
|
||||
this.StateUpdates[scopeKey] = value is null ? null : SerializeState(value);
|
||||
return default;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ValueTask QueueClearScopeAsync(
|
||||
string? scopeName = null,
|
||||
CancellationToken cancellationToken = default) => default;
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
this.ClearedScopes.Add(scopeName ?? DefaultScopeName);
|
||||
|
||||
// Remove any pending updates in this scope (snapshot keys to allow removal during iteration)
|
||||
string scopePrefix = GetScopePrefix(scopeName);
|
||||
foreach (string key in this.StateUpdates.Keys.ToArray())
|
||||
{
|
||||
if (key.StartsWith(scopePrefix, StringComparison.Ordinal))
|
||||
{
|
||||
this.StateUpdates.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlyDictionary<string, string>? TraceContext => null;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool ConcurrentRunsEnabled => false;
|
||||
|
||||
private static string GetScopeKey(string? scopeName, string key)
|
||||
=> $"{GetScopePrefix(scopeName)}{key}";
|
||||
|
||||
/// <summary>
|
||||
/// Returns the key prefix for the given scope. Scopes partition shared state
|
||||
/// into logical namespaces, allowing different workflow executors to manage
|
||||
/// their state keys independently. When no scope is specified, the
|
||||
/// <see cref="DefaultScopeName"/> is used.
|
||||
/// </summary>
|
||||
private static string GetScopePrefix(string? scopeName)
|
||||
=> scopeName is null ? $"{DefaultScopeName}:" : $"{scopeName}:";
|
||||
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing workflow state types.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing workflow state types.")]
|
||||
private static string SerializeState<T>(T value)
|
||||
=> JsonSerializer.Serialize(value, DurableSerialization.Options);
|
||||
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow state types.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow state types.")]
|
||||
private static ValueTask<T?> DeserializeStateAsync<T>(string? json)
|
||||
{
|
||||
if (json is null)
|
||||
{
|
||||
return ValueTask.FromResult<T?>(default);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(JsonSerializer.Deserialize<T>(json, DurableSerialization.Options));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,15 +15,6 @@ namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Workflow and executor types are registered at startup.")]
|
||||
internal static class DurableActivityExecutor
|
||||
{
|
||||
/// <summary>
|
||||
/// Shared JSON options that match the DurableDataConverter settings.
|
||||
/// </summary>
|
||||
private static readonly JsonSerializerOptions s_jsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Executes an activity using the provided executor binding.
|
||||
/// </summary>
|
||||
@@ -68,16 +59,31 @@ internal static class DurableActivityExecutor
|
||||
DurableActivityOutput output = new()
|
||||
{
|
||||
Result = SerializeResult(result),
|
||||
SentMessages = context.SentMessages.ConvertAll(m => new SentMessageInfo
|
||||
{
|
||||
Message = m.Message,
|
||||
TypeName = m.TypeName
|
||||
})
|
||||
StateUpdates = context.StateUpdates,
|
||||
ClearedScopes = [.. context.ClearedScopes],
|
||||
Events = context.Events.ConvertAll(SerializeEvent),
|
||||
SentMessages = context.SentMessages,
|
||||
HaltRequested = context.HaltRequested
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(output, DurableWorkflowJsonContext.Default.DurableActivityOutput);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes a workflow event with type information for proper deserialization.
|
||||
/// </summary>
|
||||
private static string SerializeEvent(WorkflowEvent evt)
|
||||
{
|
||||
Type eventType = evt.GetType();
|
||||
TypedPayload wrapper = new()
|
||||
{
|
||||
TypeName = eventType.AssemblyQualifiedName,
|
||||
Data = JsonSerializer.Serialize(evt, eventType, DurableSerialization.Options)
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(wrapper, DurableWorkflowJsonContext.Default.TypedPayload);
|
||||
}
|
||||
|
||||
private static string SerializeResult(object? result)
|
||||
{
|
||||
if (result is null)
|
||||
@@ -90,7 +96,7 @@ internal static class DurableActivityExecutor
|
||||
return str;
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(result, result.GetType(), s_jsonOptions);
|
||||
return JsonSerializer.Serialize(result, result.GetType(), DurableSerialization.Options);
|
||||
}
|
||||
|
||||
private static DurableActivityInput? TryDeserializeActivityInput(string input)
|
||||
@@ -112,7 +118,7 @@ internal static class DurableActivityExecutor
|
||||
return input;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize(input, targetType, s_jsonOptions)
|
||||
return JsonSerializer.Deserialize(input, targetType, DurableSerialization.Options)
|
||||
?? throw new InvalidOperationException($"Failed to deserialize input to type '{targetType.Name}'.");
|
||||
}
|
||||
|
||||
|
||||
@@ -3,17 +3,37 @@
|
||||
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Output payload from activity execution, containing the result and other metadata.
|
||||
/// Output payload from activity execution, containing the result, state updates, and emitted events.
|
||||
/// </summary>
|
||||
internal sealed class DurableActivityOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the serialized result of the activity.
|
||||
/// Gets or sets the executor result.
|
||||
/// </summary>
|
||||
public string? Result { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the collection of messages that have been sent.
|
||||
/// Gets or sets the state updates (scope-prefixed key to value; null indicates deletion).
|
||||
/// </summary>
|
||||
public List<SentMessageInfo> SentMessages { get; set; } = [];
|
||||
public Dictionary<string, string?> StateUpdates { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the scope names that were cleared.
|
||||
/// </summary>
|
||||
public List<string> ClearedScopes { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the workflow events emitted during execution.
|
||||
/// </summary>
|
||||
public List<string> Events { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the typed messages sent to downstream executors.
|
||||
/// </summary>
|
||||
public List<TypedPayload> SentMessages { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the executor requested a workflow halt.
|
||||
/// </summary>
|
||||
public bool HaltRequested { get; set; }
|
||||
}
|
||||
|
||||
@@ -31,12 +31,14 @@ internal static class DurableExecutorDispatcher
|
||||
/// <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="logger">The logger for tracing.</param>
|
||||
/// <returns>The result from the executor.</returns>
|
||||
internal static async Task<string> DispatchAsync(
|
||||
TaskOrchestrationContext context,
|
||||
WorkflowExecutorInfo executorInfo,
|
||||
DurableMessageEnvelope envelope,
|
||||
Dictionary<string, string> sharedState,
|
||||
ILogger logger)
|
||||
{
|
||||
logger.LogDispatchingExecutor(executorInfo.ExecutorId, executorInfo.IsAgenticExecutor);
|
||||
@@ -46,14 +48,15 @@ internal static class DurableExecutorDispatcher
|
||||
return await ExecuteAgentAsync(context, executorInfo, logger, envelope.Message).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
return await ExecuteActivityAsync(context, executorInfo, envelope.Message, envelope.InputTypeName).ConfigureAwait(true);
|
||||
return await ExecuteActivityAsync(context, executorInfo, envelope.Message, envelope.InputTypeName, sharedState).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
private static async Task<string> ExecuteActivityAsync(
|
||||
TaskOrchestrationContext context,
|
||||
WorkflowExecutorInfo executorInfo,
|
||||
string input,
|
||||
string? inputTypeName)
|
||||
string? inputTypeName,
|
||||
Dictionary<string, string> sharedState)
|
||||
{
|
||||
string executorName = WorkflowNamingHelper.GetExecutorName(executorInfo.ExecutorId);
|
||||
string activityName = WorkflowNamingHelper.ToOrchestrationFunctionName(executorName);
|
||||
@@ -61,7 +64,8 @@ internal static class DurableExecutorDispatcher
|
||||
DurableActivityInput activityInput = new()
|
||||
{
|
||||
Input = input,
|
||||
InputTypeName = inputTypeName
|
||||
InputTypeName = inputTypeName,
|
||||
State = sharedState
|
||||
};
|
||||
|
||||
string serializedInput = JsonSerializer.Serialize(activityInput, DurableWorkflowJsonContext.Default.DurableActivityInput);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when an executor requests the workflow to halt via <see cref="IWorkflowContext.RequestHaltAsync"/>.
|
||||
/// </summary>
|
||||
public sealed class DurableHaltRequestedEvent : WorkflowEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DurableHaltRequestedEvent"/> class.
|
||||
/// </summary>
|
||||
/// <param name="executorId">The ID of the executor that requested the halt.</param>
|
||||
public DurableHaltRequestedEvent(string executorId) : base($"Halt requested by {executorId}")
|
||||
{
|
||||
this.ExecutorId = executorId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ID of the executor that requested the halt.
|
||||
/// </summary>
|
||||
public string ExecutorId { get; }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the execution status of a durable workflow run.
|
||||
/// </summary>
|
||||
public enum DurableRunStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// The orchestration instance was not found.
|
||||
/// </summary>
|
||||
NotFound,
|
||||
|
||||
/// <summary>
|
||||
/// The orchestration is pending and has not started.
|
||||
/// </summary>
|
||||
Pending,
|
||||
|
||||
/// <summary>
|
||||
/// The orchestration is currently running.
|
||||
/// </summary>
|
||||
Running,
|
||||
|
||||
/// <summary>
|
||||
/// The orchestration completed successfully.
|
||||
/// </summary>
|
||||
Completed,
|
||||
|
||||
/// <summary>
|
||||
/// The orchestration failed with an error.
|
||||
/// </summary>
|
||||
Failed,
|
||||
|
||||
/// <summary>
|
||||
/// The orchestration was terminated.
|
||||
/// </summary>
|
||||
Terminated,
|
||||
|
||||
/// <summary>
|
||||
/// The orchestration is suspended.
|
||||
/// </summary>
|
||||
Suspended,
|
||||
|
||||
/// <summary>
|
||||
/// The orchestration status is unknown.
|
||||
/// </summary>
|
||||
Unknown
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Shared serialization options for user-defined workflow types that are not known at compile time
|
||||
/// and therefore cannot use the source-generated <see cref="DurableWorkflowJsonContext"/>.
|
||||
/// </summary>
|
||||
internal static class DurableSerialization
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the shared <see cref="JsonSerializerOptions"/> for workflow serialization
|
||||
/// with camelCase naming and case-insensitive deserialization.
|
||||
/// </summary>
|
||||
internal static JsonSerializerOptions Options { get; } = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.DurableTask.Client;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a durable workflow run that supports streaming workflow events as they occur.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[DebuggerDisplay("{WorkflowName} ({RunId})")]
|
||||
internal sealed class DurableStreamingWorkflowRun : IStreamingWorkflowRun
|
||||
{
|
||||
private readonly DurableTaskClient _client;
|
||||
private readonly Workflow _workflow;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DurableStreamingWorkflowRun"/> class.
|
||||
/// </summary>
|
||||
/// <param name="client">The durable task client for orchestration operations.</param>
|
||||
/// <param name="instanceId">The unique instance ID for this orchestration run.</param>
|
||||
/// <param name="workflow">The workflow being executed.</param>
|
||||
internal DurableStreamingWorkflowRun(DurableTaskClient client, string instanceId, Workflow workflow)
|
||||
{
|
||||
this._client = client;
|
||||
this.RunId = instanceId;
|
||||
this._workflow = workflow;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string RunId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the workflow being executed.
|
||||
/// </summary>
|
||||
public string WorkflowName => this._workflow.Name ?? string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current execution status of the workflow run.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
||||
/// <returns>The current status of the durable run.</returns>
|
||||
public async ValueTask<DurableRunStatus> GetStatusAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
OrchestrationMetadata? metadata = await this._client.GetInstanceAsync(
|
||||
this.RunId,
|
||||
getInputsAndOutputs: false,
|
||||
cancellation: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (metadata is null)
|
||||
{
|
||||
return DurableRunStatus.NotFound;
|
||||
}
|
||||
|
||||
return metadata.RuntimeStatus switch
|
||||
{
|
||||
OrchestrationRuntimeStatus.Pending => DurableRunStatus.Pending,
|
||||
OrchestrationRuntimeStatus.Running => DurableRunStatus.Running,
|
||||
OrchestrationRuntimeStatus.Completed => DurableRunStatus.Completed,
|
||||
OrchestrationRuntimeStatus.Failed => DurableRunStatus.Failed,
|
||||
OrchestrationRuntimeStatus.Terminated => DurableRunStatus.Terminated,
|
||||
OrchestrationRuntimeStatus.Suspended => DurableRunStatus.Suspended,
|
||||
_ => DurableRunStatus.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IAsyncEnumerable<WorkflowEvent> WatchStreamAsync(CancellationToken cancellationToken = default)
|
||||
=> this.WatchStreamAsync(pollingInterval: null, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously streams workflow events as they occur during workflow execution.
|
||||
/// </summary>
|
||||
/// <param name="pollingInterval">The interval between status checks. Defaults to 100ms.</param>
|
||||
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
||||
/// <returns>An asynchronous stream of <see cref="WorkflowEvent"/> objects.</returns>
|
||||
private async IAsyncEnumerable<WorkflowEvent> WatchStreamAsync(
|
||||
TimeSpan? pollingInterval,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
TimeSpan interval = pollingInterval ?? TimeSpan.FromMilliseconds(100);
|
||||
|
||||
// Track how many events we've already read from custom status
|
||||
int lastReadEventIndex = 0;
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
OrchestrationMetadata? metadata = await this._client.GetInstanceAsync(
|
||||
this.RunId,
|
||||
getInputsAndOutputs: true,
|
||||
cancellation: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (metadata is null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
// Always drain any unread events from custom 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)
|
||||
{
|
||||
DurableWorkflowCustomStatus? customStatus = TryParseCustomStatus(metadata.SerializedCustomStatus);
|
||||
if (customStatus is not null)
|
||||
{
|
||||
(List<WorkflowEvent> events, lastReadEventIndex) = DrainNewEvents(customStatus.Events, lastReadEventIndex);
|
||||
foreach (WorkflowEvent evt in events)
|
||||
{
|
||||
yield return evt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check terminal states after draining events from custom status
|
||||
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Completed)
|
||||
{
|
||||
// The framework clears custom status on completion, so events may be in
|
||||
// SerializedOutput as a DurableWorkflowResult wrapper.
|
||||
DurableWorkflowResult? outputResult = TryParseWorkflowResult(metadata.SerializedOutput);
|
||||
if (outputResult is not null)
|
||||
{
|
||||
(List<WorkflowEvent> events, _) = DrainNewEvents(outputResult.Events, lastReadEventIndex);
|
||||
foreach (WorkflowEvent evt in events)
|
||||
{
|
||||
yield return evt;
|
||||
}
|
||||
|
||||
yield return new DurableWorkflowCompletedEvent(outputResult.Result);
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return new DurableWorkflowCompletedEvent(metadata.SerializedOutput);
|
||||
}
|
||||
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Failed)
|
||||
{
|
||||
string errorMessage = metadata.FailureDetails?.ErrorMessage ?? "Workflow execution failed.";
|
||||
yield return new DurableWorkflowFailedEvent(errorMessage);
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Terminated)
|
||||
{
|
||||
yield return new DurableWorkflowFailedEvent("Workflow was terminated.");
|
||||
yield break;
|
||||
}
|
||||
|
||||
await Task.Delay(interval, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the workflow to complete and returns the result.
|
||||
/// </summary>
|
||||
/// <typeparam name="TResult">The expected result type.</typeparam>
|
||||
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
||||
/// <returns>The result of the workflow execution.</returns>
|
||||
public async ValueTask<TResult?> WaitForCompletionAsync<TResult>(CancellationToken cancellationToken = default)
|
||||
{
|
||||
OrchestrationMetadata metadata = await this._client.WaitForInstanceCompletionAsync(
|
||||
this.RunId,
|
||||
getInputsAndOutputs: true,
|
||||
cancellation: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Completed)
|
||||
{
|
||||
return ExtractResult<TResult>(metadata.SerializedOutput);
|
||||
}
|
||||
|
||||
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Failed)
|
||||
{
|
||||
string errorMessage = metadata.FailureDetails?.ErrorMessage ?? "Workflow execution failed.";
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Workflow ended with unexpected status: {metadata.RuntimeStatus}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes and returns any events beyond <paramref name="lastReadIndex"/> from the list.
|
||||
/// </summary>
|
||||
private static (List<WorkflowEvent> Events, int UpdatedIndex) DrainNewEvents(List<string> serializedEvents, int lastReadIndex)
|
||||
{
|
||||
List<WorkflowEvent> events = [];
|
||||
while (lastReadIndex < serializedEvents.Count)
|
||||
{
|
||||
string serializedEvent = serializedEvents[lastReadIndex];
|
||||
lastReadIndex++;
|
||||
|
||||
WorkflowEvent? workflowEvent = TryDeserializeEvent(serializedEvent);
|
||||
if (workflowEvent is not null)
|
||||
{
|
||||
events.Add(workflowEvent);
|
||||
}
|
||||
}
|
||||
|
||||
return (events, lastReadIndex);
|
||||
}
|
||||
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow custom status.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow custom status.")]
|
||||
private static DurableWorkflowCustomStatus? TryParseCustomStatus(string serializedStatus)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize(serializedStatus, DurableWorkflowJsonContext.Default.DurableWorkflowCustomStatus);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to parse the orchestration output as a <see cref="DurableWorkflowResult"/> wrapper.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The orchestration wraps its output in a <see cref="DurableWorkflowResult"/> to include
|
||||
/// accumulated events alongside the result. The Durable Task framework's <c>DataConverter</c>
|
||||
/// serializes the string output with an extra layer of JSON encoding, so we first unwrap that.
|
||||
/// </remarks>
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow result wrapper.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow result wrapper.")]
|
||||
private static DurableWorkflowResult? TryParseWorkflowResult(string? serializedOutput)
|
||||
{
|
||||
if (serializedOutput is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// The DurableDataConverter wraps string results in JSON quotes, so
|
||||
// SerializedOutput is a JSON-encoded string like "\"{ ... }\"".
|
||||
// We need to unwrap the outer JSON string first.
|
||||
string? innerJson = JsonSerializer.Deserialize<string>(serializedOutput);
|
||||
if (innerJson is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize(innerJson, DurableWorkflowJsonContext.Default.DurableWorkflowResult);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a typed result from the orchestration output, unwrapping the
|
||||
/// <see cref="DurableWorkflowResult"/> wrapper if present.
|
||||
/// </summary>
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow result.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow result.")]
|
||||
internal static TResult? ExtractResult<TResult>(string? serializedOutput)
|
||||
{
|
||||
DurableWorkflowResult? workflowResult = TryParseWorkflowResult(serializedOutput);
|
||||
string? resultJson = workflowResult?.Result;
|
||||
|
||||
if (resultJson is null)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
if (typeof(TResult) == typeof(string))
|
||||
{
|
||||
return (TResult)(object)resultJson;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<TResult>(resultJson);
|
||||
}
|
||||
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow event types.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow event types.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2057", Justification = "Event types are registered at startup.")]
|
||||
private static WorkflowEvent? TryDeserializeEvent(string serializedEvent)
|
||||
{
|
||||
try
|
||||
{
|
||||
TypedPayload? wrapper = JsonSerializer.Deserialize(
|
||||
serializedEvent,
|
||||
DurableWorkflowJsonContext.Default.TypedPayload);
|
||||
|
||||
if (wrapper?.TypeName is not null && wrapper.Data is not null)
|
||||
{
|
||||
Type? eventType = Type.GetType(wrapper.TypeName);
|
||||
if (eventType is not null)
|
||||
{
|
||||
return DeserializeEventByType(eventType, wrapper.Data);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow event types.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow event types.")]
|
||||
private static WorkflowEvent? DeserializeEventByType(Type eventType, string json)
|
||||
{
|
||||
// Types with internal constructors need manual deserialization
|
||||
if (eventType == typeof(ExecutorInvokedEvent)
|
||||
|| eventType == typeof(ExecutorCompletedEvent)
|
||||
|| eventType == typeof(WorkflowOutputEvent))
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse(json);
|
||||
JsonElement root = doc.RootElement;
|
||||
|
||||
if (eventType == typeof(ExecutorInvokedEvent))
|
||||
{
|
||||
string executorId = root.GetProperty("executorId").GetString() ?? string.Empty;
|
||||
JsonElement? data = GetDataProperty(root);
|
||||
return new ExecutorInvokedEvent(executorId, data!);
|
||||
}
|
||||
|
||||
if (eventType == typeof(ExecutorCompletedEvent))
|
||||
{
|
||||
string executorId = root.GetProperty("executorId").GetString() ?? string.Empty;
|
||||
JsonElement? data = GetDataProperty(root);
|
||||
return new ExecutorCompletedEvent(executorId, data);
|
||||
}
|
||||
|
||||
// WorkflowOutputEvent
|
||||
string sourceId = root.GetProperty("sourceId").GetString() ?? string.Empty;
|
||||
object? outputData = GetDataProperty(root);
|
||||
return new WorkflowOutputEvent(outputData!, sourceId);
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize(json, eventType, DurableSerialization.Options) as WorkflowEvent;
|
||||
}
|
||||
|
||||
private static JsonElement? GetDataProperty(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("data", out JsonElement dataElement))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return dataElement.ValueKind == JsonValueKind.Null ? null : dataElement.Clone();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.DurableTask;
|
||||
@@ -58,4 +58,30 @@ internal sealed class DurableWorkflowClient : IWorkflowClient
|
||||
string? runId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> this.RunAsync<string>(workflow, input, runId, cancellationToken);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask<IStreamingWorkflowRun> StreamAsync<TInput>(
|
||||
Workflow workflow,
|
||||
TInput input,
|
||||
string? runId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
where TInput : notnull
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workflow);
|
||||
|
||||
if (string.IsNullOrEmpty(workflow.Name))
|
||||
{
|
||||
throw new ArgumentException("Workflow must have a valid Name property.", nameof(workflow));
|
||||
}
|
||||
|
||||
DurableWorkflowInput<TInput> workflowInput = new() { Input = input };
|
||||
|
||||
string instanceId = await this._client.ScheduleNewOrchestrationInstanceAsync(
|
||||
orchestratorName: WorkflowNamingHelper.ToOrchestrationFunctionName(workflow.Name),
|
||||
input: workflowInput,
|
||||
options: runId is not null ? new StartOrchestrationOptions(runId) : null,
|
||||
cancellation: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new DurableStreamingWorkflowRun(this._client, instanceId, workflow);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a durable workflow completes successfully.
|
||||
/// </summary>
|
||||
[DebuggerDisplay("Completed: {Result}")]
|
||||
public sealed class DurableWorkflowCompletedEvent : WorkflowEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DurableWorkflowCompletedEvent"/> class.
|
||||
/// </summary>
|
||||
/// <param name="result">The serialized result of the workflow.</param>
|
||||
public DurableWorkflowCompletedEvent(string? result) : base(result)
|
||||
{
|
||||
this.Result = result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the serialized result of the workflow.
|
||||
/// </summary>
|
||||
public string? Result { get; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// 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; } = [];
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a durable workflow fails.
|
||||
/// </summary>
|
||||
[DebuggerDisplay("Failed: {ErrorMessage}")]
|
||||
public sealed class DurableWorkflowFailedEvent : WorkflowEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DurableWorkflowFailedEvent"/> class.
|
||||
/// </summary>
|
||||
/// <param name="errorMessage">The error message describing the failure.</param>
|
||||
public DurableWorkflowFailedEvent(string errorMessage) : base(errorMessage)
|
||||
{
|
||||
this.ErrorMessage = errorMessage;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the error message describing the failure.
|
||||
/// </summary>
|
||||
public string ErrorMessage { get; }
|
||||
}
|
||||
@@ -14,8 +14,9 @@ namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description><see cref="DurableActivityInput"/>: Activity input wrapper with state</description></item>
|
||||
/// <item><description><see cref="DurableActivityOutput"/>: Activity output wrapper with results and events</description></item>
|
||||
/// <item><description><see cref="SentMessageInfo"/>: Messages sent via SendMessageAsync</description></item>
|
||||
/// <item><description><see cref="DurableActivityOutput"/>: Activity 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>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Note: User-defined executor input/output types still use reflection-based serialization
|
||||
@@ -28,7 +29,11 @@ namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
|
||||
[JsonSerializable(typeof(DurableActivityInput))]
|
||||
[JsonSerializable(typeof(DurableActivityOutput))]
|
||||
[JsonSerializable(typeof(SentMessageInfo))]
|
||||
[JsonSerializable(typeof(List<SentMessageInfo>))]
|
||||
[JsonSerializable(typeof(TypedPayload))]
|
||||
[JsonSerializable(typeof(List<TypedPayload>))]
|
||||
[JsonSerializable(typeof(DurableWorkflowCustomStatus))]
|
||||
[JsonSerializable(typeof(DurableWorkflowResult))]
|
||||
[JsonSerializable(typeof(List<string>))]
|
||||
[JsonSerializable(typeof(Dictionary<string, string>))]
|
||||
[JsonSerializable(typeof(Dictionary<string, string?>))]
|
||||
internal partial class DurableWorkflowJsonContext : JsonSerializerContext;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps the orchestration output to include both the workflow result and accumulated events.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The Durable Task framework clears <c>SerializedCustomStatus</c> when an orchestration
|
||||
/// completes. To ensure streaming clients can retrieve events even after completion,
|
||||
/// the accumulated events are embedded in the orchestration output alongside the result.
|
||||
/// </remarks>
|
||||
internal sealed class DurableWorkflowResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the serialized result of the workflow execution.
|
||||
/// </summary>
|
||||
public string? Result { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the serialized workflow events emitted during execution.
|
||||
/// </summary>
|
||||
public List<string> Events { get; set; } = [];
|
||||
}
|
||||
@@ -55,7 +55,7 @@ internal sealed class DurableWorkflowRun : IAwaitableWorkflowRun
|
||||
|
||||
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Completed)
|
||||
{
|
||||
return metadata.ReadOutputAs<TResult>();
|
||||
return DurableStreamingWorkflowRun.ExtractResult<TResult>(metadata.SerializedOutput);
|
||||
}
|
||||
|
||||
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Failed)
|
||||
|
||||
@@ -171,25 +171,43 @@ internal sealed class DurableWorkflowRunner
|
||||
logger.LogSuperstepExecutors(superstep, string.Join(", ", executorInputs.Select(e => e.ExecutorId)));
|
||||
}
|
||||
|
||||
string[] results = await DispatchExecutorsInParallelAsync(context, executorInputs, logger).ConfigureAwait(true);
|
||||
string[] results = await DispatchExecutorsInParallelAsync(context, executorInputs, state.SharedState, logger).ConfigureAwait(true);
|
||||
|
||||
ProcessSuperstepResults(executorInputs, results, state, logger);
|
||||
bool haltRequested = ProcessSuperstepResults(executorInputs, results, state, context, logger);
|
||||
|
||||
if (haltRequested)
|
||||
{
|
||||
logger.LogWorkflowCompleted();
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if we've reached the limit and still have work remaining
|
||||
if (superstep == MaxSupersteps)
|
||||
int remainingExecutors = CountRemainingExecutors(state.MessageQueues);
|
||||
if (superstep == MaxSupersteps && remainingExecutors > 0)
|
||||
{
|
||||
int remainingExecutors = CountRemainingExecutors(state.MessageQueues);
|
||||
if (remainingExecutors > 0)
|
||||
{
|
||||
logger.LogWorkflowMaxSuperstepsExceeded(context.InstanceId, MaxSupersteps, remainingExecutors);
|
||||
}
|
||||
logger.LogWorkflowMaxSuperstepsExceeded(context.InstanceId, MaxSupersteps, remainingExecutors);
|
||||
}
|
||||
}
|
||||
|
||||
// Publish final events for live streaming (skip during replay)
|
||||
if (!context.IsReplaying)
|
||||
{
|
||||
PublishEventsToCustomStatus(context, state);
|
||||
}
|
||||
|
||||
string finalResult = GetFinalResult(state.LastResults);
|
||||
logger.LogWorkflowCompleted();
|
||||
|
||||
return finalResult;
|
||||
// Return wrapper with both result and events so streaming clients can
|
||||
// retrieve events from SerializedOutput after the orchestration completes
|
||||
// (SerializedCustomStatus is cleared by the framework on completion).
|
||||
DurableWorkflowResult workflowResult = new()
|
||||
{
|
||||
Result = finalResult,
|
||||
Events = state.AccumulatedEvents
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(workflowResult, DurableWorkflowJsonContext.Default.DurableWorkflowResult);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -203,10 +221,11 @@ internal sealed class DurableWorkflowRunner
|
||||
private static async Task<string[]> DispatchExecutorsInParallelAsync(
|
||||
TaskOrchestrationContext context,
|
||||
List<ExecutorInput> executorInputs,
|
||||
Dictionary<string, string> sharedState,
|
||||
ILogger logger)
|
||||
{
|
||||
Task<string>[] dispatchTasks = executorInputs
|
||||
.Select(input => DurableExecutorDispatcher.DispatchAsync(context, input.Info, input.Envelope, logger))
|
||||
.Select(input => DurableExecutorDispatcher.DispatchAsync(context, input.Info, input.Envelope, sharedState, logger))
|
||||
.ToArray();
|
||||
|
||||
return await Task.WhenAll(dispatchTasks).ConfigureAwait(true);
|
||||
@@ -242,6 +261,16 @@ internal sealed class DurableWorkflowRunner
|
||||
public Dictionary<string, Queue<DurableMessageEnvelope>> MessageQueues { get; } = [];
|
||||
|
||||
public Dictionary<string, string> LastResults { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Shared state dictionary across supersteps (scope-prefixed key -> serialized value).
|
||||
/// </summary>
|
||||
public Dictionary<string, string> SharedState { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Accumulated workflow events for custom status (streaming consumption).
|
||||
/// </summary>
|
||||
public List<string> AccumulatedEvents { get; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -322,22 +351,128 @@ internal sealed class DurableWorkflowRunner
|
||||
/// <summary>
|
||||
/// Processes results from a superstep, updating state and routing messages to successors.
|
||||
/// </summary>
|
||||
private static void ProcessSuperstepResults(
|
||||
/// <returns><c>true</c> if a halt was requested by any executor; otherwise, <c>false</c>.</returns>
|
||||
private static bool ProcessSuperstepResults(
|
||||
List<ExecutorInput> inputs,
|
||||
string[] rawResults,
|
||||
SuperstepState state,
|
||||
TaskOrchestrationContext context,
|
||||
ILogger logger)
|
||||
{
|
||||
bool haltRequested = false;
|
||||
|
||||
for (int i = 0; i < inputs.Count; i++)
|
||||
{
|
||||
string executorId = inputs[i].ExecutorId;
|
||||
(string result, List<SentMessageInfo> sentMessages) = ParseActivityResult(rawResults[i]);
|
||||
ExecutorResultInfo resultInfo = ParseActivityResult(rawResults[i]);
|
||||
|
||||
logger.LogExecutorResultReceived(executorId, result.Length, sentMessages.Count);
|
||||
logger.LogExecutorResultReceived(executorId, resultInfo.Result.Length, resultInfo.SentMessages.Count);
|
||||
|
||||
state.LastResults[executorId] = result;
|
||||
RouteOutputToSuccessors(executorId, result, sentMessages, state, logger);
|
||||
state.LastResults[executorId] = resultInfo.Result;
|
||||
|
||||
// Merge state updates from activity into shared state
|
||||
MergeStateUpdates(state, resultInfo.StateUpdates, resultInfo.ClearedScopes);
|
||||
|
||||
// Accumulate events for custom status (streaming)
|
||||
state.AccumulatedEvents.AddRange(resultInfo.Events);
|
||||
|
||||
// Check for halt request
|
||||
haltRequested |= resultInfo.HaltRequested;
|
||||
|
||||
// Publish events for live streaming (skip during replay)
|
||||
if (!context.IsReplaying)
|
||||
{
|
||||
PublishEventsToCustomStatus(context, state);
|
||||
}
|
||||
|
||||
RouteOutputToSuccessors(executorId, resultInfo.Result, resultInfo.SentMessages, state, logger);
|
||||
}
|
||||
|
||||
return haltRequested;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges state updates from an executor into the shared state.
|
||||
/// </summary>
|
||||
private static void MergeStateUpdates(
|
||||
SuperstepState state,
|
||||
Dictionary<string, string?> stateUpdates,
|
||||
List<string> clearedScopes)
|
||||
{
|
||||
Dictionary<string, string> shared = state.SharedState;
|
||||
|
||||
ApplyClearedScopes(shared, clearedScopes);
|
||||
|
||||
// Apply individual state updates
|
||||
foreach ((string key, string? value) in stateUpdates)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
shared.Remove(key);
|
||||
}
|
||||
else
|
||||
{
|
||||
shared[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes all keys belonging to the specified scopes from the shared state dictionary.
|
||||
/// </summary>
|
||||
private static void ApplyClearedScopes(Dictionary<string, string> shared, List<string> clearedScopes)
|
||||
{
|
||||
if (clearedScopes.Count == 0 || shared.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
List<string> keysToRemove = [];
|
||||
|
||||
foreach (string clearedScope in clearedScopes)
|
||||
{
|
||||
string scopePrefix = string.Concat(clearedScope, ":");
|
||||
keysToRemove.Clear();
|
||||
|
||||
foreach (string key in shared.Keys)
|
||||
{
|
||||
if (key.StartsWith(scopePrefix, StringComparison.Ordinal))
|
||||
{
|
||||
keysToRemove.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (string key in keysToRemove)
|
||||
{
|
||||
shared.Remove(key);
|
||||
}
|
||||
|
||||
if (shared.Count == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes accumulated workflow events to the orchestration'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
|
||||
/// 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)
|
||||
{
|
||||
DurableWorkflowCustomStatus customStatus = new()
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -346,16 +481,16 @@ internal sealed class DurableWorkflowRunner
|
||||
private static void RouteOutputToSuccessors(
|
||||
string executorId,
|
||||
string result,
|
||||
List<SentMessageInfo> sentMessages,
|
||||
List<TypedPayload> sentMessages,
|
||||
SuperstepState state,
|
||||
ILogger logger)
|
||||
{
|
||||
if (sentMessages.Count > 0)
|
||||
{
|
||||
// Only route messages that have content
|
||||
foreach (SentMessageInfo message in sentMessages.Where(m => !string.IsNullOrEmpty(m.Message)))
|
||||
foreach (TypedPayload message in sentMessages.Where(m => !string.IsNullOrEmpty(m.Data)))
|
||||
{
|
||||
state.EdgeMap.RouteMessage(executorId, message.Message!, message.TypeName, state.MessageQueues, logger);
|
||||
state.EdgeMap.RouteMessage(executorId, message.Data!, message.TypeName, state.MessageQueues, logger);
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -406,13 +541,25 @@ internal sealed class DurableWorkflowRunner
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the raw activity result to extract the result string and any sent messages.
|
||||
/// Output from an executor invocation, including its result,
|
||||
/// messages, state updates, and emitted workflow events.
|
||||
/// </summary>
|
||||
private static (string Result, List<SentMessageInfo> SentMessages) ParseActivityResult(string rawResult)
|
||||
private sealed record ExecutorResultInfo(
|
||||
string Result,
|
||||
List<TypedPayload> SentMessages,
|
||||
Dictionary<string, string?> StateUpdates,
|
||||
List<string> ClearedScopes,
|
||||
List<string> Events,
|
||||
bool HaltRequested);
|
||||
|
||||
/// <summary>
|
||||
/// Parses the raw activity result to extract result, messages, events, and state updates.
|
||||
/// </summary>
|
||||
private static ExecutorResultInfo ParseActivityResult(string rawResult)
|
||||
{
|
||||
if (string.IsNullOrEmpty(rawResult))
|
||||
{
|
||||
return (rawResult, []);
|
||||
return new ExecutorResultInfo(rawResult, [], [], [], [], false);
|
||||
}
|
||||
|
||||
try
|
||||
@@ -423,14 +570,20 @@ internal sealed class DurableWorkflowRunner
|
||||
|
||||
if (output is null || !HasMeaningfulContent(output))
|
||||
{
|
||||
return (rawResult, []);
|
||||
return new ExecutorResultInfo(rawResult, [], [], [], [], false);
|
||||
}
|
||||
|
||||
return (output.Result ?? string.Empty, output.SentMessages);
|
||||
return new ExecutorResultInfo(
|
||||
output.Result ?? string.Empty,
|
||||
output.SentMessages,
|
||||
output.StateUpdates,
|
||||
output.ClearedScopes,
|
||||
output.Events,
|
||||
output.HaltRequested);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return (rawResult, []);
|
||||
return new ExecutorResultInfo(rawResult, [], [], [], [], false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,6 +596,11 @@ internal sealed class DurableWorkflowRunner
|
||||
/// </remarks>
|
||||
private static bool HasMeaningfulContent(DurableActivityOutput output)
|
||||
{
|
||||
return output.Result is not null || output.SentMessages.Count > 0;
|
||||
return output.Result is not null
|
||||
|| output.SentMessages.Count > 0
|
||||
|| output.Events.Count > 0
|
||||
|| output.StateUpdates.Count > 0
|
||||
|| output.ClearedScopes.Count > 0
|
||||
|| output.HaltRequested;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a workflow run that supports streaming workflow events as they occur.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This interface defines the contract for streaming workflow runs in durable execution
|
||||
/// environments. Implementations provide real-time access to workflow events.
|
||||
/// </remarks>
|
||||
public interface IStreamingWorkflowRun
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the unique identifier for the run.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This identifier can be provided at the start of the run, or auto-generated.
|
||||
/// For durable runs, this corresponds to the orchestration instance ID.
|
||||
/// </remarks>
|
||||
string RunId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously streams workflow events as they occur during workflow execution.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method yields <see cref="WorkflowEvent"/> instances in real time as the workflow
|
||||
/// progresses. The stream completes when the workflow completes, fails, or is terminated.
|
||||
/// Events are delivered in the order they are raised.
|
||||
/// </remarks>
|
||||
/// <param name="cancellationToken">
|
||||
/// A <see cref="CancellationToken"/> that can be used to cancel the streaming operation.
|
||||
/// If cancellation is requested, the stream will end and no further events will be yielded.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// An asynchronous stream of <see cref="WorkflowEvent"/> objects representing significant
|
||||
/// workflow state changes.
|
||||
/// </returns>
|
||||
IAsyncEnumerable<WorkflowEvent> WatchStreamAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -38,4 +38,20 @@ public interface IWorkflowClient
|
||||
string input,
|
||||
string? runId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Starts a workflow and returns a streaming handle to watch events in real-time.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of the input to the workflow.</typeparam>
|
||||
/// <param name="workflow">The workflow to execute.</param>
|
||||
/// <param name="input">The input to pass to the workflow's starting executor.</param>
|
||||
/// <param name="runId">Optional identifier for the run. If not provided, a new ID will be generated.</param>
|
||||
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
||||
/// <returns>An <see cref="IStreamingWorkflowRun"/> that can be used to stream workflow events.</returns>
|
||||
ValueTask<IStreamingWorkflowRun> StreamAsync<TInput>(
|
||||
Workflow workflow,
|
||||
TInput input,
|
||||
string? runId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
where TInput : notnull;
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Information about a message sent via <see cref="IWorkflowContext.SendMessageAsync"/>.
|
||||
/// </summary>
|
||||
internal sealed class SentMessageInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the serialized message content.
|
||||
/// </summary>
|
||||
public string? Message { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the full type name of the message.
|
||||
/// </summary>
|
||||
public string? TypeName { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Pairs a JSON-serialized payload with its assembly-qualified type name
|
||||
/// for type-safe deserialization across activity boundaries.
|
||||
/// </summary>
|
||||
internal sealed class TypedPayload
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the assembly-qualified type name of the payload.
|
||||
/// </summary>
|
||||
public string? TypeName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the serialized payload data as JSON.
|
||||
/// </summary>
|
||||
public string? Data { get; set; }
|
||||
}
|
||||
@@ -48,9 +48,7 @@ internal static class BuiltInFunctions
|
||||
|
||||
if (string.IsNullOrEmpty(inputMessage))
|
||||
{
|
||||
HttpResponseData errorResponse = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await errorResponse.WriteStringAsync("Workflow input cannot be empty.");
|
||||
return errorResponse;
|
||||
return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Workflow input cannot be empty.");
|
||||
}
|
||||
|
||||
DurableWorkflowInput<string> orchestrationInput = new() { Input = inputMessage };
|
||||
|
||||
Reference in New Issue
Block a user