Adding support for events & shared state in durable workflows.

This commit is contained in:
Shyju Krishnankutty
2026-02-17 14:41:11 -08:00
parent b62b1f2191
commit 8ffe7e6092
32 changed files with 2128 additions and 96 deletions
@@ -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 };