.NET: Add durable workflow support (#4436)

* .NET: [Feature Branch] Add basic durable workflow support (#3648)

* Add basic durable workflow support.

* PR feedback fixes

* Add conditional edge sample.

* PR feedback fixes.

* Minor cleanup.

* Minor cleanup

* Minor formatting improvements.

* Improve comments/documentation on the execution flow.

* .NET: [Feature Branch] Add Azure Functions hosting support for durable workflows (#3935)

* Adding azure functions workflow support.

* - PR feedback fixes.
- Add example to demonstrate complex Object as payload.

* rename instanceId to runId.

* Use custom ITaskOrchestrator to run orchestrator function.

* .NET: [Feature Branch] Adding support for events & shared state in durable workflows (#4020)

* Adding support for events & shared state in durable workflows.

* PR feedback fixes

* PR feedback fixes.

* Add YieldOutputAsync calls to 05_WorkflowEvents sample executors

The integration test asserts that WorkflowOutputEvent is found in the
stream, but the sample executors only used AddEventAsync for custom
events and never called YieldOutputAsync. Since WorkflowOutputEvent is
only emitted via explicit YieldOutputAsync calls, the assertion would
fail. Added YieldOutputAsync to each executor to match the test
expectation and demonstrate the API in the sample.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix deserialization to use shared serializer options.

* PR feedback updates.

* Sample cleanup

* PR feedback fixes

* Addressing PR review feedback for DurableStreamingWorkflowRun

   - Use -1 instead of 0 for taskId in TaskFailedException when task ID is not relevant.
   - Add [NotNullWhen(true)] to TryParseWorkflowResult out parameter following .NET TryXXX conventions.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* .NET: [Feature Branch]  Add nested sub-workflow support for durable workflows (#4190)

* .NET: [Feature Branch] Add nested sub-workflow support for durable workflows

* fix readme path

* Switch Orchestration output from string to DurableWorkflowResult.

* PR feedback fixes

* Minor cleanup based on PR feedback.

* .NET: [Feature Branch] Add Human In the Loop support for durable workflows (#4358)

* Add Azure Functions HITL workflow sample

Add 06_WorkflowHITL Azure Functions sample demonstrating Human-in-the-Loop
workflow support with HTTP endpoints for status checking and approval responses.

The sample includes:
- ExpenseReimbursement workflow with RequestPort for manager approval
- Custom HTTP endpoint to check workflow status and pending approvals
- Custom HTTP endpoint to send approval responses via RaiseEventAsync
- demo.http file with step-by-step interaction examples

* PR feedback fixes

* Minor comment cleanup

* Minor comment clReverted the `!context.IsReplaying` guards on `PendingEvents.Add`/`RemoveAll` and `SetCustomStatus` in `ExecuteRequestPortAsync`. The guards broke fan-out scenarios where parallel RequestPorts      need to be discoverable after replay. `SetCustomStatus` is idempotent metadata that doesn't affect replay determinism.eanup

* fix  for PR feedback

* PR feedback updates

* Improvements to samples

* Improvements to README

* Update samples to use parallel request ports.

* Unit tests

* Introduce local variables to improve readability of Workflows.Workflows access patter

* Use GitHub-style callouts and add PowerShell command variants in HITL sample README

* Add changelog entries for durable workflow support (#4436)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Bump Microsoft.DurableTask.Worker to 1.19.1 to fix version downgrade

Microsoft.Azure.Functions.Worker.Extensions.DurableTask 1.13.1 requires
Microsoft.DurableTask.Worker >= 1.19.1 via its transitive dependency on
Microsoft.DurableTask.Worker.Grpc 1.19.1.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix broken markdown links in durable workflow sample READMEs

- Create Workflow/README.md with environment setup docs
- Fix ../README.md -> ../../README.md in ConsoleApps 01, 02, 03, 08
- Fix SubWorkflows relative path (3 levels -> 4 levels up)
- Fix dead Durable Task Scheduler URL

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix build errors from main merge: Throw conflict, ExecuteAsync rename, GetNewSessionAsync rename

- Remove InjectSharedThrow from DurableTask csproj (uses Workflows' internal Throw via InternalsVisibleTo)
- Update ExecuteAsync -> ExecuteCoreAsync with WorkflowTelemetryContext.Disabled
- Update GetNewSessionAsync -> CreateSessionAsync

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Move durable workflow samples to 04-hosting/DurableWorkflows

Aligns with main branch sample reorganization where durable samples
live under 04-hosting/ (alongside DurableAgents/).

- Move samples/Durable/Workflow/ -> samples/04-hosting/DurableWorkflows/
- Add Directory.Build.props matching DurableAgents pattern
- Update slnx project paths
- Update integration test sample paths
- Update README cd paths and cross-references

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix build errors: remove duplicate base class members, update renamed APIs

- Remove duplicate OutputLog, WriteInputAsync, CreateTestTimeoutCts, etc. from
  ConsoleAppSamplesValidation (already in SamplesValidationBase)
- Update AddFanInEdge -> AddFanInBarrierEdge in workflow samples
- Update GetNewSessionAsync -> CreateSessionAsync in workflow samples
- Update SourceId -> ExecutorId (obsolete) in workflow samples

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix dotnet format issues: add UTF-8 BOM and remove unused using

- Add UTF-8 BOM to 20 .cs files across DurableTask, AzureFunctions,
  unit tests, and workflow samples
- Remove unnecessary using directive in 07_SubWorkflows/Executors.cs

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix typo PaymentProcesser -> PaymentProcessor and garbled arrows in README

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix GetExecutorName to handle agent names with underscores

Split on last underscore instead of first, and validate that the
suffix is a 32-char hex string (sanitized GUID) before stripping it.
This prevents truncation of agent names like 'my_agent' when the
executor ID is 'my_agent_<guid>'.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Align DurableTask.Client.AzureManaged to 1.19.1

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Bump DurableTask and Azure Functions extension package versions

- DurableTask.* packages: 1.19.1 -> 1.22.0
- Functions.Worker.Extensions.DurableTask: 1.13.1 -> 1.16.0
- Functions.Worker.Extensions.DurableTask.AzureManaged: 1.0.1 -> 1.5.0 (telemetry bug fix)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Bump DurableTask SDK packages to 1.22.0

- DurableTask.Client: 1.19.1 -> 1.22.0
- DurableTask.Client.AzureManaged: 1.19.1 -> 1.22.0
- DurableTask.Worker: 1.19.1 -> 1.22.0
- DurableTask.Worker.AzureManaged: 1.19.1 -> 1.22.0
- Azure Functions extensions kept at original versions (1.13.1/1.0.1) due to
  host-side DurableTask.Core 3.7.0 incompatibility with newer extensions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Update Microsoft.Azure.Functions.Worker.Extensions.DurableTask to "1.16.0"

* Add the local.settings.json files to the sample which were previously ignored. This aligns with our other samples.

* Increase timeout for tests as CI has them failing transiently.

* increaset timeout value for azure functions integration tests.

* Add YieldsOutput(string) to workflow shared state sample executors

ValidateOrder and EnrichOrder call YieldOutputAsync with string messages,
but only their TOutput (OrderDetails) was in the allowed yield types.
This caused TargetInvocationException in the WorkflowSharedState sample
validation integration test.

* Downgrade the durable packages to 1.18.0

* Downgrading Worker.Extensions.DurableTask to 1.12.1

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Shyju Krishnankutty
2026-03-16 16:00:50 -07:00
committed by GitHub
Unverified
parent 0fdcfd0f4c
commit cbcdb2d29e
123 changed files with 12611 additions and 603 deletions
@@ -2,7 +2,36 @@
## [Unreleased]
### Changed
- Added support for durable workflows ([#4436](https://github.com/microsoft/agent-framework/pull/4436))
## v1.0.0-preview.260219.1
- [BREAKING] Changed ChatHistory and AIContext Providers to have pipeline semantics ([#3806](https://github.com/microsoft/agent-framework/pull/3806))
- Marked all `RunAsync<T>` overloads as `new`, added missing ones, and added support for primitives and arrays ([#3803](https://github.com/microsoft/agent-framework/pull/3803))
- Improve session cast error message quality and consistency ([#3973](https://github.com/microsoft/agent-framework/pull/3973))
## v1.0.0-preview.260212.1
- [BREAKING] Changed AIAgent.SerializeSession to AIAgent.SerializeSessionAsync ([#3879](https://github.com/microsoft/agent-framework/pull/3879))
## v1.0.0-preview.260209.1
- [BREAKING] Introduce Core method pattern for Session management methods on AIAgent ([#3699](https://github.com/microsoft/agent-framework/pull/3699))
## v1.0.0-preview.260205.1
- [BREAKING] Moved AgentSession.Serialize to AIAgent.SerializeSession ([#3650](https://github.com/microsoft/agent-framework/pull/3650))
- [BREAKING] Renamed serializedSession parameter to serializedState on DeserializeSessionAsync for consistency ([#3681](https://github.com/microsoft/agent-framework/pull/3681))
## v1.0.0-preview.260127.1
- [BREAKING] Renamed AgentThread to AgentSession ([#3430](https://github.com/microsoft/agent-framework/pull/3430))
## v1.0.0-preview.260108.1
- [BREAKING] Removed AgentThreadMetadata and used AgentSessionId directly instead ([#3067](https://github.com/microsoft/agent-framework/pull/3067))
## v1.0.0-preview.251219.1
- Filter empty `AIContent` from durable agent state responses ([#4670](https://github.com/microsoft/agent-framework/pull/4670))
@@ -12,15 +41,6 @@
- Added TTL configuration for durable agent entities ([#2679](https://github.com/microsoft/agent-framework/pull/2679))
- Switch to new "Run" method name ([#2843](https://github.com/microsoft/agent-framework/pull/2843))
- Removed AgentThreadMetadata and used AgentSessionId directly instead ([#3067](https://github.com/microsoft/agent-framework/pull/3067));
- Renamed AgentThread to AgentSession ([#3430](https://github.com/microsoft/agent-framework/pull/3430))
- Moved AgentSession.Serialize to AIAgent.SerializeSession ([#3650](https://github.com/microsoft/agent-framework/pull/3650))
- Renamed serializedSession parameter to serializedState on DeserializeSessionAsync for consistency ([#3681](https://github.com/microsoft/agent-framework/pull/3681))
- Introduce Core method pattern for Session management methods on AIAgent ([#3699](https://github.com/microsoft/agent-framework/pull/3699))
- Changed AIAgent.SerializeSession to AIAgent.SerializeSessionAsync ([#3879](https://github.com/microsoft/agent-framework/pull/3879))
- Changed ChatHistory and AIContext Providers to have pipeline semantics ([#3806](https://github.com/microsoft/agent-framework/pull/3806))
- Marked all `RunAsync<T>` overloads as `new`, added missing ones, and added support for primitives and arrays ([#3803](https://github.com/microsoft/agent-framework/pull/3803))
- Improve session cast error message quality and consistency ([#3973](https://github.com/microsoft/agent-framework/pull/3973))
NOTE: Some of the above changes may have been part of earlier releases not mentioned in this file.
@@ -141,4 +141,15 @@ public sealed class DurableAgentsOptions
{
return this._agentTimeToLive.TryGetValue(agentName, out TimeSpan? ttl) ? ttl : this.DefaultTimeToLive;
}
/// <summary>
/// Determines whether an agent with the specified name is registered.
/// </summary>
/// <param name="agentName">The name of the agent to locate. Cannot be null.</param>
/// <returns>true if an agent with the specified name is registered; otherwise, false.</returns>
internal bool ContainsAgent(string agentName)
{
ArgumentNullException.ThrowIfNull(agentName);
return this._agentFactories.ContainsKey(agentName);
}
}
@@ -0,0 +1,66 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using Microsoft.Agents.AI.DurableTask.State;
using Microsoft.DurableTask;
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// Custom data converter for durable agents and workflows that ensures proper JSON serialization.
/// </summary>
/// <remarks>
/// This converter handles special cases like <see cref="DurableAgentState"/> using source-generated
/// JSON contexts for AOT compatibility, and falls back to reflection-based serialization for other types.
/// </remarks>
internal sealed class DurableDataConverter : DataConverter
{
private static readonly JsonSerializerOptions s_options = new(DurableAgentJsonUtilities.DefaultOptions)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Fallback uses reflection when metadata unavailable.")]
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Fallback uses reflection when metadata unavailable.")]
public override object? Deserialize(string? data, Type targetType)
{
if (data is null)
{
return null;
}
if (targetType == typeof(DurableAgentState))
{
return JsonSerializer.Deserialize(data, DurableAgentStateJsonContext.Default.DurableAgentState);
}
JsonTypeInfo? typeInfo = s_options.GetTypeInfo(targetType);
return typeInfo is not null
? JsonSerializer.Deserialize(data, typeInfo)
: JsonSerializer.Deserialize(data, targetType, s_options);
}
[return: NotNullIfNotNull(nameof(value))]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Fallback uses reflection when metadata unavailable.")]
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Fallback uses reflection when metadata unavailable.")]
public override string? Serialize(object? value)
{
if (value is null)
{
return null;
}
if (value is DurableAgentState durableAgentState)
{
return JsonSerializer.Serialize(durableAgentState, DurableAgentStateJsonContext.Default.DurableAgentState);
}
JsonTypeInfo? typeInfo = s_options.GetTypeInfo(value.GetType());
return typeInfo is not null
? JsonSerializer.Serialize(value, typeInfo)
: JsonSerializer.Serialize(value, s_options);
}
}
@@ -0,0 +1,31 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using Microsoft.Agents.AI.DurableTask.Workflows;
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// Provides configuration options for durable agents and workflows.
/// </summary>
[DebuggerDisplay("Workflows = {Workflows.Workflows.Count}, Agents = {Agents.AgentCount}")]
public class DurableOptions
{
/// <summary>
/// Initializes a new instance of the <see cref="DurableOptions"/> class.
/// </summary>
internal DurableOptions()
{
this.Workflows = new DurableWorkflowOptions(this);
}
/// <summary>
/// Gets the configuration options for durable agents.
/// </summary>
public DurableAgentsOptions Agents { get; } = new();
/// <summary>
/// Gets the configuration options for durable workflows.
/// </summary>
public DurableWorkflowOptions Workflows { get; }
}
@@ -0,0 +1,34 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// Marker class used to track whether core durable task services have been registered.
/// </summary>
/// <remarks>
/// <para>
/// <b>Problem it solves:</b> Users may call configuration methods multiple times:
/// <code>
/// services.ConfigureDurableOptions(...); // 1st call - registers agent A
/// services.ConfigureDurableOptions(...); // 2nd call - registers workflow X
/// services.ConfigureDurableOptions(...); // 3rd call - registers agent B and workflow Y
/// </code>
/// Each call invokes <c>EnsureDurableServicesRegistered</c>. Without this marker, core services like
/// <c>AddDurableTaskWorker</c> and <c>AddDurableTaskClient</c> would be registered multiple times,
/// causing runtime errors or unexpected behavior.
/// </para>
/// <para>
/// <b>How it works:</b>
/// <list type="number">
/// <item><description>First call: No marker in services → register marker + all core services</description></item>
/// <item><description>Subsequent calls: Marker exists → early return, skip core service registration</description></item>
/// </list>
/// </para>
/// <para>
/// <b>Why not use TryAddSingleton for everything?</b>
/// While <c>TryAddSingleton</c> prevents duplicate simple service registrations, it doesn't work for
/// complex registrations like <c>AddDurableTaskWorker</c> which have side effects and configure
/// internal builders. The marker pattern provides a clean, explicit guard for the entire registration block.
/// </para>
/// </remarks>
internal sealed class DurableServicesMarker;
@@ -100,4 +100,131 @@ internal static partial class Logs
public static partial void LogTTLExpirationTimeCleared(
this ILogger logger,
AgentSessionId sessionId);
// Durable workflow logs (EventIds 100-199)
[LoggerMessage(
EventId = 100,
Level = LogLevel.Information,
Message = "Starting workflow '{WorkflowName}' with instance '{InstanceId}'")]
public static partial void LogWorkflowStarting(
this ILogger logger,
string workflowName,
string instanceId);
[LoggerMessage(
EventId = 101,
Level = LogLevel.Information,
Message = "Superstep {Step}: {Count} active executor(s)")]
public static partial void LogSuperstepStarting(
this ILogger logger,
int step,
int count);
[LoggerMessage(
EventId = 102,
Level = LogLevel.Debug,
Message = "Superstep {Step} executors: [{Executors}]")]
public static partial void LogSuperstepExecutors(
this ILogger logger,
int step,
string executors);
[LoggerMessage(
EventId = 103,
Level = LogLevel.Information,
Message = "Workflow completed")]
public static partial void LogWorkflowCompleted(
this ILogger logger);
[LoggerMessage(
EventId = 104,
Level = LogLevel.Warning,
Message = "Workflow '{InstanceId}' terminated early: reached maximum superstep limit ({MaxSupersteps}) with {RemainingExecutors} executor(s) still queued")]
public static partial void LogWorkflowMaxSuperstepsExceeded(
this ILogger logger,
string instanceId,
int maxSupersteps,
int remainingExecutors);
[LoggerMessage(
EventId = 105,
Level = LogLevel.Debug,
Message = "Fan-In executor {ExecutorId}: aggregated {Count} messages from [{Sources}]")]
public static partial void LogFanInAggregated(
this ILogger logger,
string executorId,
int count,
string sources);
[LoggerMessage(
EventId = 106,
Level = LogLevel.Debug,
Message = "Executor '{ExecutorId}' returned result (length: {Length}, messages: {MessageCount})")]
public static partial void LogExecutorResultReceived(
this ILogger logger,
string executorId,
int length,
int messageCount);
[LoggerMessage(
EventId = 107,
Level = LogLevel.Debug,
Message = "Dispatching executor '{ExecutorId}' (agentic: {IsAgentic})")]
public static partial void LogDispatchingExecutor(
this ILogger logger,
string executorId,
bool isAgentic);
[LoggerMessage(
EventId = 108,
Level = LogLevel.Warning,
Message = "Agent '{AgentName}' not found")]
public static partial void LogAgentNotFound(
this ILogger logger,
string agentName);
[LoggerMessage(
EventId = 109,
Level = LogLevel.Debug,
Message = "Edge {Source} -> {Sink}: condition returned false, skipping")]
public static partial void LogEdgeConditionFalse(
this ILogger logger,
string source,
string sink);
[LoggerMessage(
EventId = 110,
Level = LogLevel.Warning,
Message = "Failed to evaluate condition for edge {Source} -> {Sink}, skipping")]
public static partial void LogEdgeConditionEvaluationFailed(
this ILogger logger,
Exception ex,
string source,
string sink);
[LoggerMessage(
EventId = 111,
Level = LogLevel.Debug,
Message = "Edge {Source} -> {Sink}: routing message")]
public static partial void LogEdgeRoutingMessage(
this ILogger logger,
string source,
string sink);
[LoggerMessage(
EventId = 112,
Level = LogLevel.Information,
Message = "Workflow waiting for external input at RequestPort '{RequestPortId}'")]
public static partial void LogWaitingForExternalEvent(
this ILogger logger,
string requestPortId);
[LoggerMessage(
EventId = 113,
Level = LogLevel.Information,
Message = "Received external event for RequestPort '{RequestPortId}'")]
public static partial void LogReceivedExternalEvent(
this ILogger logger,
string requestPortId);
}
@@ -17,7 +17,6 @@
</PropertyGroup>
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectSharedStructuredOutput>true</InjectSharedStructuredOutput>
</PropertyGroup>
@@ -28,6 +27,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
@@ -1,18 +1,18 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using Microsoft.Agents.AI.DurableTask.State;
using Microsoft.Agents.AI.DurableTask.Workflows;
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask;
using Microsoft.DurableTask.Client;
using Microsoft.DurableTask.Worker;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// Agent-specific extension methods for the <see cref="IServiceCollection"/> class.
/// Extension methods for configuring durable agents and workflows with dependency injection.
/// </summary>
public static class ServiceCollectionExtensions
{
@@ -30,77 +30,331 @@ public static class ServiceCollectionExtensions
}
/// <summary>
/// Configures the Durable Agents services via the service collection.
/// Configures durable agents, automatically registering agent entities.
/// </summary>
/// <remarks>
/// <para>
/// This method provides an agent-focused configuration experience.
/// If you need to configure both agents and workflows, consider using
/// <see cref="ConfigureDurableOptions"/> instead.
/// </para>
/// <para>
/// Multiple calls to this method are supported and configurations are composed additively.
/// </para>
/// </remarks>
/// <param name="services">The service collection.</param>
/// <param name="configure">A delegate to configure the durable agents.</param>
/// <param name="workerBuilder">A delegate to configure the Durable Task worker.</param>
/// <param name="clientBuilder">A delegate to configure the Durable Task client.</param>
/// <returns>The service collection.</returns>
/// <param name="workerBuilder">Optional delegate to configure the Durable Task worker.</param>
/// <param name="clientBuilder">Optional delegate to configure the Durable Task client.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection ConfigureDurableAgents(
this IServiceCollection services,
Action<DurableAgentsOptions> configure,
Action<IDurableTaskWorkerBuilder>? workerBuilder = null,
Action<IDurableTaskClientBuilder>? clientBuilder = null)
{
return services.ConfigureDurableOptions(
options => configure(options.Agents),
workerBuilder,
clientBuilder);
}
/// <summary>
/// Configures durable workflows, automatically registering orchestrations and activities.
/// </summary>
/// <remarks>
/// <para>
/// This method provides a workflow-focused configuration experience.
/// If you need to configure both agents and workflows, consider using
/// <see cref="ConfigureDurableOptions"/> instead.
/// </para>
/// <para>
/// Multiple calls to this method are supported and configurations are composed additively.
/// </para>
/// </remarks>
/// <param name="services">The service collection to configure.</param>
/// <param name="configure">A delegate to configure the workflow options.</param>
/// <param name="workerBuilder">Optional delegate to configure the durable task worker.</param>
/// <param name="clientBuilder">Optional delegate to configure the durable task client.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection ConfigureDurableWorkflows(
this IServiceCollection services,
Action<DurableWorkflowOptions> configure,
Action<IDurableTaskWorkerBuilder>? workerBuilder = null,
Action<IDurableTaskClientBuilder>? clientBuilder = null)
{
return services.ConfigureDurableOptions(
options => configure(options.Workflows),
workerBuilder,
clientBuilder);
}
/// <summary>
/// Configures durable agents and workflows, automatically registering orchestrations, activities, and agent entities.
/// </summary>
/// <remarks>
/// <para>
/// This is the recommended entry point for configuring durable functionality. It provides unified configuration
/// for both agents and workflows through a single <see cref="DurableOptions"/> instance, ensuring agents
/// referenced in workflows are automatically registered.
/// </para>
/// <para>
/// Multiple calls to this method (or to <see cref="ConfigureDurableAgents"/>
/// and <see cref="ConfigureDurableWorkflows"/>) are supported and configurations are composed additively.
/// </para>
/// </remarks>
/// <param name="services">The service collection to configure.</param>
/// <param name="configure">A delegate to configure the durable options for both agents and workflows.</param>
/// <param name="workerBuilder">Optional delegate to configure the durable task worker.</param>
/// <param name="clientBuilder">Optional delegate to configure the durable task client.</param>
/// <returns>The service collection for chaining.</returns>
/// <example>
/// <code>
/// services.ConfigureDurableOptions(options =>
/// {
/// // Register agents not part of workflows
/// options.Agents.AddAIAgent(standaloneAgent);
///
/// // Register workflows - agents in workflows are auto-registered
/// options.Workflows.AddWorkflow(myWorkflow);
/// },
/// workerBuilder: builder => builder.UseDurableTaskScheduler(connectionString),
/// clientBuilder: builder => builder.UseDurableTaskScheduler(connectionString));
/// </code>
/// </example>
public static IServiceCollection ConfigureDurableOptions(
this IServiceCollection services,
Action<DurableOptions> configure,
Action<IDurableTaskWorkerBuilder>? workerBuilder = null,
Action<IDurableTaskClientBuilder>? clientBuilder = null)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
DurableAgentsOptions options = services.ConfigureDurableAgents(configure);
// Get or create the shared DurableOptions instance for configuration
DurableOptions sharedOptions = GetOrCreateSharedOptions(services);
// A worker is required to run the agent entities
services.AddDurableTaskWorker(builder =>
{
workerBuilder?.Invoke(builder);
// Apply the configuration immediately to capture agent names for keyed service registration
configure(sharedOptions);
builder.AddTasks(registry =>
{
foreach (string name in options.GetAgentFactories().Keys)
{
registry.AddEntity<AgentEntity>(AgentSessionId.ToEntityName(name));
}
});
});
// Register keyed services for any new agents
RegisterAgentKeyedServices(services, sharedOptions);
// The client is needed to send notifications to the agent entities from non-orchestrator code
if (clientBuilder != null)
{
services.AddDurableTaskClient(clientBuilder);
}
services.AddSingleton<IDurableAgentClient, DefaultDurableAgentClient>();
// Register core services only once
EnsureDurableServicesRegistered(services, sharedOptions, workerBuilder, clientBuilder);
return services;
}
// This is internal because it's also used by Microsoft.Azure.Functions.DurableAgents, which is a friend assembly project.
internal static DurableAgentsOptions ConfigureDurableAgents(
this IServiceCollection services,
Action<DurableAgentsOptions> configure)
private static DurableOptions GetOrCreateSharedOptions(IServiceCollection services)
{
DurableAgentsOptions options = new();
configure(options);
// Look for an existing DurableOptions registration
ServiceDescriptor? existingDescriptor = services.FirstOrDefault(
d => d.ServiceType == typeof(DurableOptions) && d.ImplementationInstance is not null);
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> agents = options.GetAgentFactories();
// The agent dictionary contains the real agent factories, which is used by the agent entities.
services.AddSingleton(agents);
// Register the options so AgentEntity can access TTL configuration
services.AddSingleton(options);
// The keyed services are used to resolve durable agent *proxy* instances for external clients.
foreach (var factory in agents)
if (existingDescriptor?.ImplementationInstance is DurableOptions existing)
{
services.AddKeyedSingleton(factory.Key, (sp, _) => factory.Value(sp).AsDurableAgentProxy(sp));
return existing;
}
// A custom data converter is needed because the default chat client uses camel case for JSON properties,
// which is not the default behavior for the Durable Task SDK.
services.AddSingleton<DataConverter, DefaultDataConverter>();
// Create a new shared options instance
DurableOptions options = new();
services.AddSingleton(options);
return options;
}
private static void RegisterAgentKeyedServices(IServiceCollection services, DurableOptions options)
{
foreach (KeyValuePair<string, Func<IServiceProvider, AIAgent>> factory in options.Agents.GetAgentFactories())
{
// Only add if not already registered (to support multiple Configure* calls)
if (!services.Any(d => d.ServiceType == typeof(AIAgent) && d.IsKeyedService && Equals(d.ServiceKey, factory.Key)))
{
services.AddKeyedSingleton(factory.Key, (sp, _) => factory.Value(sp).AsDurableAgentProxy(sp));
}
}
}
/// <summary>
/// Ensures that the core durable services are registered only once, regardless of how many
/// times the configuration methods are called.
/// </summary>
private static void EnsureDurableServicesRegistered(
IServiceCollection services,
DurableOptions sharedOptions,
Action<IDurableTaskWorkerBuilder>? workerBuilder,
Action<IDurableTaskClientBuilder>? clientBuilder)
{
// Use a marker to ensure we only register core services once
if (services.Any(d => d.ServiceType == typeof(DurableServicesMarker)))
{
return;
}
services.AddSingleton<DurableServicesMarker>();
services.TryAddSingleton<DurableWorkflowRunner>();
// Configure Durable Task Worker - capture sharedOptions reference in closure.
// The options object is populated by all Configure* calls before the worker starts.
if (workerBuilder is not null)
{
services.AddDurableTaskWorker(builder =>
{
workerBuilder?.Invoke(builder);
builder.AddTasks(registry => RegisterTasksFromOptions(registry, sharedOptions));
});
}
// Configure Durable Task Client
if (clientBuilder is not null)
{
services.AddDurableTaskClient(clientBuilder);
services.TryAddSingleton<IWorkflowClient, DurableWorkflowClient>();
services.TryAddSingleton<IDurableAgentClient, DefaultDurableAgentClient>();
}
// Register workflow and agent services
services.TryAddSingleton<DataConverter, DurableDataConverter>();
// Register agent factories resolver - returns factories from the shared options
services.TryAddSingleton(
sp => sp.GetRequiredService<DurableOptions>().Agents.GetAgentFactories());
// Register DurableAgentsOptions resolver
services.TryAddSingleton(sp => sp.GetRequiredService<DurableOptions>().Agents);
}
private static void RegisterTasksFromOptions(DurableTaskRegistry registry, DurableOptions durableOptions)
{
// Build registrations for all workflows including sub-workflows
List<WorkflowRegistrationInfo> registrations = [];
HashSet<string> registeredActivities = [];
HashSet<string> registeredOrchestrations = [];
DurableWorkflowOptions workflowOptions = durableOptions.Workflows;
foreach (Workflow workflow in workflowOptions.Workflows.Values.ToList())
{
BuildWorkflowRegistrationRecursive(
workflow,
workflowOptions,
registrations,
registeredActivities,
registeredOrchestrations);
}
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> agentFactories =
durableOptions.Agents.GetAgentFactories();
// Register orchestrations and activities
foreach (WorkflowRegistrationInfo registration in registrations)
{
// Register with DurableWorkflowInput<object> - the DataConverter handles serialization/deserialization
registry.AddOrchestratorFunc<DurableWorkflowInput<object>, DurableWorkflowResult>(
registration.OrchestrationName,
(context, input) => RunWorkflowOrchestrationAsync(context, input, durableOptions));
foreach (ActivityRegistrationInfo activity in registration.Activities)
{
ExecutorBinding binding = activity.Binding;
registry.AddActivityFunc<string, string>(
activity.ActivityName,
(context, input) => DurableActivityExecutor.ExecuteAsync(binding, input));
}
}
// Register agent entities
foreach (string agentName in agentFactories.Keys)
{
registry.AddEntity<AgentEntity>(AgentSessionId.ToEntityName(agentName));
}
}
private static void BuildWorkflowRegistrationRecursive(
Workflow workflow,
DurableWorkflowOptions workflowOptions,
List<WorkflowRegistrationInfo> registrations,
HashSet<string> registeredActivities,
HashSet<string> registeredOrchestrations)
{
string orchestrationName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflow.Name!);
if (!registeredOrchestrations.Add(orchestrationName))
{
return;
}
registrations.Add(BuildWorkflowRegistration(workflow, registeredActivities));
// Process subworkflows recursively to register them as separate orchestrations
foreach (SubworkflowBinding subworkflowBinding in workflow.ReflectExecutors()
.Select(e => e.Value)
.OfType<SubworkflowBinding>())
{
Workflow subWorkflow = subworkflowBinding.WorkflowInstance;
workflowOptions.AddWorkflow(subWorkflow);
BuildWorkflowRegistrationRecursive(
subWorkflow,
workflowOptions,
registrations,
registeredActivities,
registeredOrchestrations);
}
}
private static WorkflowRegistrationInfo BuildWorkflowRegistration(
Workflow workflow,
HashSet<string> registeredActivities)
{
string orchestrationName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflow.Name!);
Dictionary<string, ExecutorBinding> executorBindings = workflow.ReflectExecutors();
List<ActivityRegistrationInfo> activities = [];
foreach (KeyValuePair<string, ExecutorBinding> entry in executorBindings
.Where(e => IsActivityBinding(e.Value)))
{
string executorName = WorkflowNamingHelper.GetExecutorName(entry.Key);
string activityName = WorkflowNamingHelper.ToOrchestrationFunctionName(executorName);
if (registeredActivities.Add(activityName))
{
activities.Add(new ActivityRegistrationInfo(activityName, entry.Value));
}
}
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), <see cref="SubworkflowBinding"/> (sub-orchestrations),
/// and <see cref="RequestPortBinding"/> (human-in-the-loop via external events) use specialized dispatch
/// and are excluded.
/// </summary>
private static bool IsActivityBinding(ExecutorBinding binding)
=> binding is not AIAgentBinding
and not SubworkflowBinding
and not RequestPortBinding;
private static async Task<DurableWorkflowResult> RunWorkflowOrchestrationAsync(
TaskOrchestrationContext context,
DurableWorkflowInput<object> workflowInput,
DurableOptions durableOptions)
{
ILogger logger = context.CreateReplaySafeLogger("DurableWorkflow");
DurableWorkflowRunner runner = new(durableOptions);
// ConfigureAwait(true) is required in orchestration code for deterministic replay.
return await runner.RunWorkflowOrchestrationAsync(context, workflowInput, logger).ConfigureAwait(true);
}
private sealed record WorkflowRegistrationInfo(string OrchestrationName, List<ActivityRegistrationInfo> Activities);
private sealed record ActivityRegistrationInfo(string ActivityName, ExecutorBinding Binding);
/// <summary>
/// Validates that an agent with the specified name has been registered.
/// </summary>
@@ -124,63 +378,4 @@ public static class ServiceCollectionExtensions
throw new AgentNotRegisteredException(agentName);
}
}
private sealed class DefaultDataConverter : DataConverter
{
// Use durable agent options (web defaults + camel case by default) with case-insensitive matching.
// We clone to apply naming/casing tweaks while retaining source-generated metadata where available.
private static readonly JsonSerializerOptions s_options = new(DurableAgentJsonUtilities.DefaultOptions)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Fallback path uses reflection when metadata unavailable.")]
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050", Justification = "Fallback path uses reflection when metadata unavailable.")]
public override object? Deserialize(string? data, Type targetType)
{
if (data is null)
{
return null;
}
if (targetType == typeof(DurableAgentState))
{
return JsonSerializer.Deserialize(data, DurableAgentStateJsonContext.Default.DurableAgentState);
}
JsonTypeInfo? typeInfo = s_options.GetTypeInfo(targetType);
if (typeInfo is JsonTypeInfo typedInfo)
{
return JsonSerializer.Deserialize(data, typedInfo);
}
// Fallback (may trigger trimming/AOT warnings for unsupported dynamic types).
return JsonSerializer.Deserialize(data, targetType, s_options);
}
[return: NotNullIfNotNull(nameof(value))]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Fallback path uses reflection when metadata unavailable.")]
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050", Justification = "Fallback path uses reflection when metadata unavailable.")]
public override string? Serialize(object? value)
{
if (value is null)
{
return null;
}
if (value is DurableAgentState durableAgentState)
{
return JsonSerializer.Serialize(durableAgentState, DurableAgentStateJsonContext.Default.DurableAgentState);
}
JsonTypeInfo? typeInfo = s_options.GetTypeInfo(value.GetType());
if (typeInfo is JsonTypeInfo typedInfo)
{
return JsonSerializer.Serialize(value, typedInfo);
}
return JsonSerializer.Serialize(value, s_options);
}
}
}
@@ -0,0 +1,177 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Agents.AI.Workflows.Observability;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Executes workflow activities by invoking executor bindings and handling serialization.
/// </summary>
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Workflow and executor types are registered at startup.")]
[UnconditionalSuppressMessage("Trimming", "IL2057", Justification = "Workflow and executor types are registered at startup.")]
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Workflow and executor types are registered at startup.")]
internal static class DurableActivityExecutor
{
/// <summary>
/// Executes an activity using the provided executor binding.
/// </summary>
/// <param name="binding">The executor binding to invoke.</param>
/// <param name="input">The serialized input string.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>The serialized activity output.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="binding"/> is null.</exception>
/// <exception cref="InvalidOperationException">Thrown when the executor factory is not configured.</exception>
internal static async Task<string> ExecuteAsync(
ExecutorBinding binding,
string input,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(binding);
if (binding.FactoryAsync is null)
{
throw new InvalidOperationException($"Executor binding for '{binding.Id}' does not have a factory configured.");
}
DurableActivityInput? inputWithState = TryDeserializeActivityInput(input);
string executorInput = inputWithState?.Input ?? input;
Dictionary<string, string> sharedState = inputWithState?.State ?? [];
Executor executor = await binding.FactoryAsync(binding.Id).ConfigureAwait(false);
Type inputType = ResolveInputType(inputWithState?.InputTypeName, executor.InputTypes);
object typedInput = DeserializeInput(executorInput, inputType);
DurableWorkflowContext workflowContext = new(sharedState, executor);
object? result = await executor.ExecuteCoreAsync(
typedInput,
new TypeId(inputType),
workflowContext,
WorkflowTelemetryContext.Disabled,
cancellationToken).ConfigureAwait(false);
return SerializeActivityOutput(result, workflowContext);
}
private static string SerializeActivityOutput(object? result, DurableWorkflowContext context)
{
DurableExecutorOutput output = new()
{
Result = SerializeResult(result),
StateUpdates = context.StateUpdates,
ClearedScopes = [.. context.ClearedScopes],
Events = context.OutboundEvents.ConvertAll(SerializeEvent),
SentMessages = context.SentMessages,
HaltRequested = context.HaltRequested
};
return JsonSerializer.Serialize(output, DurableWorkflowJsonContext.Default.DurableExecutorOutput);
}
/// <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)
{
return string.Empty;
}
if (result is string str)
{
return str;
}
return JsonSerializer.Serialize(result, result.GetType(), DurableSerialization.Options);
}
private static DurableActivityInput? TryDeserializeActivityInput(string input)
{
try
{
return JsonSerializer.Deserialize(input, DurableWorkflowJsonContext.Default.DurableActivityInput);
}
catch (JsonException)
{
return null;
}
}
internal static object DeserializeInput(string input, Type targetType)
{
if (targetType == typeof(string))
{
return input;
}
// Fan-in aggregation serializes results as a JSON array of strings (e.g., ["{...}", "{...}"]).
// When the target type is a non-string array, deserialize each element individually.
if (targetType.IsArray && targetType != typeof(string[]))
{
Type elementType = targetType.GetElementType()!;
string[]? stringArray = JsonSerializer.Deserialize<string[]>(input, DurableSerialization.Options);
if (stringArray is not null)
{
Array result = Array.CreateInstance(elementType, stringArray.Length);
for (int i = 0; i < stringArray.Length; i++)
{
object element = JsonSerializer.Deserialize(stringArray[i], elementType, DurableSerialization.Options)
?? throw new InvalidOperationException($"Failed to deserialize element {i} to type '{elementType.Name}'.");
result.SetValue(element, i);
}
return result;
}
}
return JsonSerializer.Deserialize(input, targetType, DurableSerialization.Options)
?? throw new InvalidOperationException($"Failed to deserialize input to type '{targetType.Name}'.");
}
internal static Type ResolveInputType(string? inputTypeName, ISet<Type> supportedTypes)
{
if (string.IsNullOrEmpty(inputTypeName))
{
return supportedTypes.FirstOrDefault() ?? typeof(string);
}
Type? matchedType = supportedTypes.FirstOrDefault(t =>
t.AssemblyQualifiedName == inputTypeName ||
t.FullName == inputTypeName ||
t.Name == inputTypeName);
if (matchedType is not null)
{
return matchedType;
}
Type? loadedType = Type.GetType(inputTypeName);
// Fall back if type is string or string[] but executor doesn't support it
if (loadedType is not null && !supportedTypes.Contains(loadedType))
{
if (loadedType == typeof(string) || loadedType == typeof(string[]))
{
return supportedTypes.FirstOrDefault() ?? typeof(string);
}
}
return loadedType ?? supportedTypes.FirstOrDefault() ?? typeof(string);
}
}
@@ -0,0 +1,24 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Input payload for activity execution, containing the input and other metadata.
/// </summary>
internal sealed class DurableActivityInput
{
/// <summary>
/// Gets or sets the serialized executor input.
/// </summary>
public string? Input { get; set; }
/// <summary>
/// Gets or sets the assembly-qualified type name of the input, used for proper deserialization.
/// </summary>
public string? InputTypeName { get; set; }
/// <summary>
/// Gets or sets the shared state dictionary (scope-prefixed key -> serialized value).
/// </summary>
public Dictionary<string, string> State { get; set; } = [];
}
@@ -0,0 +1,216 @@
// Copyright (c) Microsoft. All rights reserved.
// ConfigureAwait Usage in Orchestration Code:
// This file uses ConfigureAwait(true) because it runs within orchestration context.
// Durable Task orchestrations require deterministic replay - the same code must execute
// identically across replays. ConfigureAwait(true) ensures continuations run on the
// orchestration's synchronization context, which is essential for replay correctness.
// Using ConfigureAwait(false) here could cause non-deterministic behavior during replay.
using System.Text.Json;
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Dispatches workflow executors to activities, AI agents, sub-orchestrations, or external events (human-in-the-loop).
/// </summary>
/// <remarks>
/// Called during the dispatch phase of each superstep by
/// <c>DurableWorkflowRunner.DispatchExecutorsInParallelAsync</c>. For each executor that has
/// pending input, this dispatcher determines whether the executor is an AI agent (stateful,
/// backed by Durable Entities), a request port (human-in-the-loop, backed by external events),
/// a sub-workflow (dispatched as a sub-orchestration), or a regular activity, and invokes the
/// appropriate Durable Task API.
/// The serialised string result is returned to the runner for the routing phase.
/// </remarks>
internal static class DurableExecutorDispatcher
{
/// <summary>
/// Dispatches an executor based on its type (activity, AI agent, request port, or sub-workflow).
/// </summary>
/// <param name="context">The task orchestration context.</param>
/// <param name="executorInfo">Information about the executor to dispatch.</param>
/// <param name="envelope">The message envelope containing input and type information.</param>
/// <param name="sharedState">The shared state dictionary to pass to the executor.</param>
/// <param name="liveStatus">The live workflow status used to publish events and pending request port state.</param>
/// <param name="logger">The logger for tracing.</param>
/// <returns>The result from the executor.</returns>
internal static async Task<string> DispatchAsync(
TaskOrchestrationContext context,
WorkflowExecutorInfo executorInfo,
DurableMessageEnvelope envelope,
Dictionary<string, string> sharedState,
DurableWorkflowLiveStatus liveStatus,
ILogger logger)
{
logger.LogDispatchingExecutor(executorInfo.ExecutorId, executorInfo.IsAgenticExecutor);
if (executorInfo.IsRequestPortExecutor)
{
return await ExecuteRequestPortAsync(context, executorInfo, envelope.Message, liveStatus, logger).ConfigureAwait(true);
}
if (executorInfo.IsAgenticExecutor)
{
return await ExecuteAgentAsync(context, executorInfo, logger, envelope.Message).ConfigureAwait(true);
}
if (executorInfo.IsSubworkflowExecutor)
{
return await ExecuteSubWorkflowAsync(context, executorInfo, envelope.Message).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,
Dictionary<string, string> sharedState)
{
string executorName = WorkflowNamingHelper.GetExecutorName(executorInfo.ExecutorId);
string activityName = WorkflowNamingHelper.ToOrchestrationFunctionName(executorName);
DurableActivityInput activityInput = new()
{
Input = input,
InputTypeName = inputTypeName,
State = sharedState
};
string serializedInput = JsonSerializer.Serialize(activityInput, DurableWorkflowJsonContext.Default.DurableActivityInput);
return await context.CallActivityAsync<string>(activityName, serializedInput).ConfigureAwait(true);
}
/// <summary>
/// Executes a request port executor by waiting for an external event (human-in-the-loop).
/// </summary>
/// <remarks>
/// When the workflow reaches a <see cref="RequestPort"/> executor, the orchestration publishes
/// the pending request to <see cref="DurableWorkflowLiveStatus"/> and waits for an external actor
/// (e.g., a UI or API) to raise the corresponding event via
/// <see cref="IStreamingWorkflowRun.SendResponseAsync{TResponse}(DurableWorkflowWaitingForInputEvent, TResponse, CancellationToken)"/>.
/// Multiple RequestPorts may be dispatched in parallel during a fan-out superstep.
/// Each adds its pending request to <see cref="DurableWorkflowLiveStatus.PendingEvents"/>.
/// The wait has no built-in timeout; for time-limited approvals, callers can combine
/// <c>context.CreateTimer</c> with <c>Task.WhenAny</c> in a wrapper executor.
/// </remarks>
private static async Task<string> ExecuteRequestPortAsync(
TaskOrchestrationContext context,
WorkflowExecutorInfo executorInfo,
string input,
DurableWorkflowLiveStatus liveStatus,
ILogger logger)
{
RequestPort requestPort = executorInfo.RequestPort!;
string eventName = requestPort.Id;
logger.LogWaitingForExternalEvent(eventName);
// Publish pending request so external clients can discover what input is needed
liveStatus.PendingEvents.Add(new PendingRequestPortStatus(EventName: eventName, Input: input));
context.SetCustomStatus(liveStatus);
// Wait until the external actor raises the event
string response = await context.WaitForExternalEvent<string>(eventName).ConfigureAwait(true);
// Remove this pending request after receiving the response
liveStatus.PendingEvents.RemoveAll(p => p.EventName == eventName);
context.SetCustomStatus(liveStatus.Events.Count > 0 || liveStatus.PendingEvents.Count > 0 ? liveStatus : null);
logger.LogReceivedExternalEvent(eventName);
return response;
}
/// <summary>
/// Executes an AI agent executor through Durable Entities.
/// </summary>
/// <remarks>
/// AI agents are stateful and maintain conversation history. They use Durable Entities
/// to persist state across orchestration replays.
/// </remarks>
private static async Task<string> ExecuteAgentAsync(
TaskOrchestrationContext context,
WorkflowExecutorInfo executorInfo,
ILogger logger,
string input)
{
string agentName = WorkflowNamingHelper.GetExecutorName(executorInfo.ExecutorId);
DurableAIAgent agent = context.GetAgent(agentName);
if (agent is null)
{
logger.LogAgentNotFound(agentName);
return $"Agent '{agentName}' not found";
}
AgentSession session = await agent.CreateSessionAsync().ConfigureAwait(true);
AgentResponse response = await agent.RunAsync(input, session).ConfigureAwait(true);
return response.Text;
}
/// <summary>
/// Dispatches a sub-workflow executor as a sub-orchestration.
/// </summary>
/// <remarks>
/// Sub-workflows run as separate orchestration instances, providing independent
/// checkpointing, replay, and hierarchical visualization in the DTS dashboard.
/// The input is wrapped in <see cref="DurableWorkflowInput{T}"/> so the sub-orchestration
/// can extract it using the same envelope structure. The sub-orchestration returns a
/// <see cref="DurableWorkflowResult"/> directly (deserialized by the Durable Task SDK),
/// which this method converts to a <see cref="DurableExecutorOutput"/> so the parent
/// workflow's result processing picks up both the result and any accumulated events.
/// </remarks>
private static async Task<string> ExecuteSubWorkflowAsync(
TaskOrchestrationContext context,
WorkflowExecutorInfo executorInfo,
string input)
{
string orchestrationName = WorkflowNamingHelper.ToOrchestrationFunctionName(executorInfo.SubWorkflow!.Name!);
DurableWorkflowInput<string> workflowInput = new() { Input = input };
DurableWorkflowResult? workflowResult = await context.CallSubOrchestratorAsync<DurableWorkflowResult?>(
orchestrationName,
workflowInput).ConfigureAwait(true);
return ConvertWorkflowResultToExecutorOutput(workflowResult);
}
/// <summary>
/// Converts a <see cref="DurableWorkflowResult"/> from a sub-orchestration
/// into a <see cref="DurableExecutorOutput"/> JSON string. This bridges the sub-workflow's
/// output format to the parent workflow's result processing, preserving both the result
/// and any accumulated events from the sub-workflow.
/// </summary>
private static string ConvertWorkflowResultToExecutorOutput(DurableWorkflowResult? workflowResult)
{
if (workflowResult is null)
{
return string.Empty;
}
// Propagate the result, events, and sent messages from the sub-workflow.
// SentMessages carry the sub-workflow's output for typed routing in the parent,
// matching the in-process WorkflowHostExecutor behavior.
// Shared state is not included because each workflow instance maintains its own
// independent shared state; it is not shared between parent and sub-workflows.
DurableExecutorOutput executorOutput = new()
{
Result = workflowResult.Result,
Events = workflowResult.Events ?? [],
SentMessages = workflowResult.SentMessages ?? [],
HaltRequested = workflowResult.HaltRequested,
};
return JsonSerializer.Serialize(executorOutput, DurableWorkflowJsonContext.Default.DurableExecutorOutput);
}
}
@@ -0,0 +1,39 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Output payload from executor execution, containing the result, state updates, and emitted events.
/// </summary>
internal sealed class DurableExecutorOutput
{
/// <summary>
/// Gets the executor result.
/// </summary>
public string? Result { get; init; }
/// <summary>
/// Gets the state updates (scope-prefixed key to value; null indicates deletion).
/// </summary>
public Dictionary<string, string?> StateUpdates { get; init; } = [];
/// <summary>
/// Gets the scope names that were cleared.
/// </summary>
public List<string> ClearedScopes { get; init; } = [];
/// <summary>
/// Gets the workflow events emitted during execution.
/// </summary>
public List<string> Events { get; init; } = [];
/// <summary>
/// Gets the typed messages sent to downstream executors.
/// </summary>
public List<TypedPayload> SentMessages { get; init; } = [];
/// <summary>
/// Gets a value indicating whether the executor requested a workflow halt.
/// </summary>
public bool HaltRequested { get; init; }
}
@@ -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,51 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Represents a message envelope for durable workflow message passing.
/// </summary>
/// <remarks>
/// <para>
/// This is the durable equivalent of <c>MessageEnvelope</c> in the in-process runner.
/// Unlike the in-process version which holds native .NET objects, this envelope
/// contains serialized JSON strings suitable for Durable Task activities.
/// </para>
/// </remarks>
internal sealed class DurableMessageEnvelope
{
/// <summary>
/// Gets or sets the serialized JSON message content.
/// </summary>
public required string Message { get; init; }
/// <summary>
/// Gets or sets the full type name of the message for deserialization.
/// </summary>
public string? InputTypeName { get; init; }
/// <summary>
/// Gets or sets the ID of the executor that produced this message.
/// </summary>
/// <remarks>
/// Used for tracing and debugging. Null for initial workflow input.
/// </remarks>
public string? SourceExecutorId { get; init; }
/// <summary>
/// Creates a new message envelope.
/// </summary>
/// <param name="message">The serialized JSON message content.</param>
/// <param name="inputTypeName">The full type name of the message for deserialization.</param>
/// <param name="sourceExecutorId">The ID of the executor that produced this message, or null for initial input.</param>
/// <returns>A new <see cref="DurableMessageEnvelope"/> instance.</returns>
internal static DurableMessageEnvelope Create(string message, string? inputTypeName, string? sourceExecutorId = null)
{
return new DurableMessageEnvelope
{
Message = message,
InputTypeName = inputTypeName,
SourceExecutorId = sourceExecutorId
};
}
}
@@ -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 workflow instance was not found.
/// </summary>
NotFound,
/// <summary>
/// The workflow is pending and has not started.
/// </summary>
Pending,
/// <summary>
/// The workflow is currently running.
/// </summary>
Running,
/// <summary>
/// The workflow completed successfully.
/// </summary>
Completed,
/// <summary>
/// The workflow failed with an error.
/// </summary>
Failed,
/// <summary>
/// The workflow was terminated.
/// </summary>
Terminated,
/// <summary>
/// The workflow is suspended.
/// </summary>
Suspended,
/// <summary>
/// The workflow 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,452 @@
// 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;
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>
/// <para>
/// Events are detected by monitoring the orchestration's custom status at regular intervals.
/// When executors emit events via <see cref="IWorkflowContext.AddEventAsync"/> or
/// <see cref="IWorkflowContext.YieldOutputAsync"/>, they are written to the orchestration's
/// custom status and picked up by this streaming run.
/// </para>
/// <para>
/// When the workflow reaches a <see cref="RequestPort"/> executor, a <see cref="DurableWorkflowWaitingForInputEvent"/>
/// is yielded containing the request data. The caller should then call
/// <see cref="SendResponseAsync{TResponse}(DurableWorkflowWaitingForInputEvent, TResponse, CancellationToken)"/>
/// to provide the response and resume the workflow.
/// </para>
/// </remarks>
[DebuggerDisplay("{WorkflowName} ({RunId})")]
internal sealed class DurableStreamingWorkflowRun : IStreamingWorkflowRun
{
private readonly DurableTaskClient _client;
private readonly Dictionary<string, RequestPort> _requestPorts;
/// <summary>
/// Initializes a new instance of the <see cref="DurableStreamingWorkflowRun"/> class.
/// </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.WorkflowName = workflow.Name ?? string.Empty;
this._requestPorts = ExtractRequestPorts(workflow);
}
/// <inheritdoc/>
public string RunId { get; }
/// <summary>
/// Gets the name of the workflow being executed.
/// </summary>
public string WorkflowName { get; }
/// <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 minInterval = pollingInterval ?? TimeSpan.FromMilliseconds(100);
TimeSpan maxInterval = TimeSpan.FromSeconds(2);
TimeSpan currentInterval = minInterval;
// Track how many events we've already read from the durable workflow status
int lastReadEventIndex = 0;
// Track which pending events we've already yielded to avoid duplicates
HashSet<string> yieldedPendingEvents = [];
while (!cancellationToken.IsCancellationRequested)
{
// Poll with getInputsAndOutputs: true because SerializedCustomStatus
// (used for event streaming) is only populated when this flag is set.
OrchestrationMetadata? metadata = await this._client.GetInstanceAsync(
this.RunId,
getInputsAndOutputs: true,
cancellation: cancellationToken).ConfigureAwait(false);
if (metadata is null)
{
yield break;
}
bool hasNewEvents = false;
// Always drain any unread events from the durable workflow status before checking terminal states.
// The orchestration may complete before the next poll, so events would be lost if we
// check terminal status first.
if (metadata.SerializedCustomStatus is not null)
{
if (DurableWorkflowLiveStatus.TryParse(metadata.SerializedCustomStatus, out DurableWorkflowLiveStatus liveStatus))
{
(List<WorkflowEvent> events, lastReadEventIndex) = DrainNewEvents(liveStatus.Events, lastReadEventIndex);
foreach (WorkflowEvent evt in events)
{
hasNewEvents = true;
yield return evt;
}
// Yield a DurableWorkflowWaitingForInputEvent for each new pending request port
foreach (PendingRequestPortStatus pending in liveStatus.PendingEvents)
{
if (yieldedPendingEvents.Add(pending.EventName))
{
if (!this._requestPorts.TryGetValue(pending.EventName, out RequestPort? matchingPort))
{
// RequestPort may not exist in the current workflow definition (e.g., during rolling deployments).
continue;
}
hasNewEvents = true;
yield return new DurableWorkflowWaitingForInputEvent(
pending.Input,
matchingPort);
}
}
// Sync tracking with current pending events so re-used RequestPort names can be yielded again
if (liveStatus.PendingEvents.Count == 0)
{
yieldedPendingEvents.Clear();
}
else
{
yieldedPendingEvents.IntersectWith(liveStatus.PendingEvents.Select(p => p.EventName));
}
}
}
// Check terminal states after draining events from the durable workflow status
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Completed)
{
// The framework clears the durable workflow status on completion, so events may be in
// SerializedOutput as a DurableWorkflowResult wrapper.
if (TryParseWorkflowResult(metadata.SerializedOutput, out DurableWorkflowResult? outputResult))
{
(List<WorkflowEvent> events, _) = DrainNewEvents(outputResult.Events, lastReadEventIndex);
foreach (WorkflowEvent evt in events)
{
yield return evt;
}
yield return new DurableWorkflowCompletedEvent(outputResult.Result);
}
else
{
// The runner always wraps output in DurableWorkflowResult, so a parse
// failure here indicates a bug. Yield a failed event so the consumer
// gets a visible, handleable signal without crashing.
yield return new DurableWorkflowFailedEvent(
$"Workflow '{this.WorkflowName}' (RunId: {this.RunId}) completed but its output could not be parsed as DurableWorkflowResult.");
}
yield break;
}
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Failed)
{
string errorMessage = metadata.FailureDetails?.ErrorMessage ?? "Workflow execution failed.";
yield return new DurableWorkflowFailedEvent(errorMessage, metadata.FailureDetails);
yield break;
}
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Terminated)
{
yield return new DurableWorkflowFailedEvent("Workflow was terminated.");
yield break;
}
// Adaptive backoff: reset to minimum when events were found, increase otherwise
currentInterval = hasNewEvents
? minInterval
: TimeSpan.FromMilliseconds(Math.Min(currentInterval.TotalMilliseconds * 2, maxInterval.TotalMilliseconds));
try
{
await Task.Delay(currentInterval, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
yield break;
}
}
}
/// <summary>
/// Sends a response to a <see cref="DurableWorkflowWaitingForInputEvent"/> to resume the workflow.
/// </summary>
/// <typeparam name="TResponse">The type of the response data.</typeparam>
/// <param name="requestEvent">The request event to respond to.</param>
/// <param name="response">The response data to send.</param>
/// <param name="cancellationToken">A cancellation token to observe.</param>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing workflow types provided by the caller.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing workflow types provided by the caller.")]
public async ValueTask SendResponseAsync<TResponse>(DurableWorkflowWaitingForInputEvent requestEvent, TResponse response, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(requestEvent);
string serializedResponse = JsonSerializer.Serialize(response, DurableSerialization.Options);
await this._client.RaiseEventAsync(
this.RunId,
requestEvent.RequestPort.Id,
serializedResponse,
cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Waits for the workflow to complete and returns the result.
/// </summary>
/// <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>
/// <exception cref="TaskFailedException">Thrown when the workflow failed.</exception>
/// <exception cref="InvalidOperationException">Thrown when the workflow was terminated or ended with an unexpected status.</exception>
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)
{
if (metadata.FailureDetails is not null)
{
throw new TaskFailedException(
taskName: this.WorkflowName,
taskId: -1,
failureDetails: metadata.FailureDetails);
}
throw new InvalidOperationException(
$"Workflow '{this.WorkflowName}' (RunId: {this.RunId}) failed without failure details.");
}
throw new InvalidOperationException(
$"Workflow '{this.WorkflowName}' (RunId: {this.RunId}) 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);
}
/// <summary>
/// Attempts to parse the orchestration output as a <see cref="DurableWorkflowResult"/> wrapper.
/// </summary>
/// <remarks>
/// The orchestration returns a <see cref="DurableWorkflowResult"/> object directly.
/// The Durable Task framework's <c>DataConverter</c> serializes it as a JSON object
/// in <c>SerializedOutput</c>, so we deserialize it directly.
/// </remarks>
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow result wrapper.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow result wrapper.")]
private static bool TryParseWorkflowResult(string? serializedOutput, [NotNullWhen(true)] out DurableWorkflowResult? result)
{
if (serializedOutput is null)
{
result = default!;
return false;
}
try
{
result = JsonSerializer.Deserialize(serializedOutput, DurableWorkflowJsonContext.Default.DurableWorkflowResult)!;
return result is not null;
}
catch (JsonException)
{
result = default!;
return false;
}
}
/// <summary>
/// Extracts a typed result from the orchestration output by unwrapping the
/// <see cref="DurableWorkflowResult"/> wrapper.
/// </summary>
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow result.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow result.")]
internal static TResult? ExtractResult<TResult>(string? serializedOutput)
{
if (serializedOutput is null)
{
return default;
}
if (!TryParseWorkflowResult(serializedOutput, out DurableWorkflowResult? workflowResult))
{
throw new InvalidOperationException(
"Failed to parse orchestration output as DurableWorkflowResult. " +
"The orchestration runner should always wrap output in this format.");
}
string? resultJson = workflowResult.Result;
if (resultJson is null)
{
return default;
}
if (typeof(TResult) == typeof(string))
{
return (TResult)(object)resultJson;
}
return JsonSerializer.Deserialize<TResult>(resultJson, DurableSerialization.Options);
}
[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();
}
private static Dictionary<string, RequestPort> ExtractRequestPorts(Workflow workflow)
{
return WorkflowAnalyzer.GetExecutorsFromWorkflowInOrder(workflow)
.Where(e => e.RequestPort is not null)
.ToDictionary(e => e.RequestPort!.Id, e => e.RequestPort!);
}
}
@@ -0,0 +1,95 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask;
using Microsoft.DurableTask.Client;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Provides a durable task-based implementation of <see cref="IWorkflowClient"/> for running
/// workflows as durable orchestrations.
/// </summary>
internal sealed class DurableWorkflowClient : IWorkflowClient
{
private readonly DurableTaskClient _client;
/// <summary>
/// Initializes a new instance of the <see cref="DurableWorkflowClient"/> class.
/// </summary>
/// <param name="client">The durable task client for orchestration operations.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="client"/> is null.</exception>
public DurableWorkflowClient(DurableTaskClient client)
{
ArgumentNullException.ThrowIfNull(client);
this._client = client;
}
/// <inheritdoc/>
public async ValueTask<IWorkflowRun> RunAsync<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 DurableWorkflowRun(this._client, instanceId, workflow.Name);
}
/// <inheritdoc/>
public ValueTask<IWorkflowRun> RunAsync(
Workflow workflow,
string input,
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);
}
/// <inheritdoc/>
public ValueTask<IStreamingWorkflowRun> StreamAsync(
Workflow workflow,
string input,
string? runId = null,
CancellationToken cancellationToken = default)
=> this.StreamAsync<string>(workflow, input, runId, cancellationToken);
}
@@ -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,327 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Microsoft.Agents.AI.Workflows;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// A workflow context for durable workflow execution.
/// </summary>
/// <remarks>
/// 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 DurableWorkflowContext : 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;
/// <summary>
/// Initializes a new instance of the <see cref="DurableWorkflowContext"/> class.
/// </summary>
/// <param name="initialState">The shared state passed from the orchestration.</param>
/// <param name="executor">The executor running in this context.</param>
internal DurableWorkflowContext(Dictionary<string, string>? initialState, Executor executor)
{
this._executor = executor;
this._initialState = initialState ?? [];
}
/// <summary>
/// Gets the messages sent during activity execution via <see cref="SendMessageAsync"/>.
/// </summary>
internal List<TypedPayload> SentMessages { get; } = [];
/// <summary>
/// Gets the outbound events that were added during activity execution.
/// </summary>
internal List<WorkflowEvent> OutboundEvents { 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)
{
if (workflowEvent is not null)
{
this.OutboundEvents.Add(workflowEvent);
}
return default;
}
/// <inheritdoc/>
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing workflow message types registered at startup.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing workflow message types registered at startup.")]
public ValueTask SendMessageAsync(
object message,
string? targetId = null,
CancellationToken cancellationToken = default)
{
if (message is not null)
{
Type messageType = message.GetType();
this.SentMessages.Add(new TypedPayload
{
Data = JsonSerializer.Serialize(message, messageType, DurableSerialization.Options),
TypeName = messageType.AssemblyQualifiedName
});
}
return default;
}
/// <inheritdoc/>
public ValueTask YieldOutputAsync(
object output,
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.OutboundEvents.Add(new WorkflowOutputEvent(output, this._executor.Id));
}
return default;
}
/// <inheritdoc/>
public ValueTask RequestHaltAsync()
{
this.HaltRequested = true;
this.OutboundEvents.Add(new DurableHaltRequestedEvent(this._executor.Id));
return default;
}
/// <inheritdoc/>
public ValueTask<T?> ReadStateAsync<T>(
string key,
string? scopeName = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(key);
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 async ValueTask<T> ReadOrInitStateAsync<T>(
string key,
Func<T> initialStateFactory,
string? scopeName = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(key);
ArgumentNullException.ThrowIfNull(initialStateFactory);
// Cannot rely on `value is not null` because T? on an unconstrained generic
// parameter does not become Nullable<T> for value types — the null check is
// always true for types like int. Instead, check key existence directly.
if (this.HasStateKey(key, scopeName))
{
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)
{
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)
{
ArgumentException.ThrowIfNullOrEmpty(key);
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)
{
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.ToList())
{
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>
/// Checks whether the given key exists in local updates or initial state,
/// respecting cleared scopes.
/// </summary>
private bool HasStateKey(string key, string? scopeName)
{
string scopeKey = GetScopeKey(scopeName, key);
if (this.StateUpdates.TryGetValue(scopeKey, out string? updated))
{
return updated is not null;
}
string normalizedScope = scopeName ?? DefaultScopeName;
if (this.ClearedScopes.Contains(normalizedScope))
{
return false;
}
return this._initialState.ContainsKey(scopeKey);
}
/// <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));
}
}
@@ -0,0 +1,35 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask;
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>
/// <param name="failureDetails">The full failure details from the Durable Task runtime, if available.</param>
public DurableWorkflowFailedEvent(string errorMessage, TaskFailureDetails? failureDetails = null) : base(errorMessage)
{
this.ErrorMessage = errorMessage;
this.FailureDetails = failureDetails;
}
/// <summary>
/// Gets the error message describing the failure.
/// </summary>
public string ErrorMessage { get; }
/// <summary>
/// Gets the full failure details from the Durable Task runtime, including error type, stack trace, and inner failure.
/// </summary>
public TaskFailureDetails? FailureDetails { get; }
}
@@ -0,0 +1,16 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Represents the input envelope for a durable workflow orchestration.
/// </summary>
/// <typeparam name="TInput">The type of the workflow input.</typeparam>
internal sealed class DurableWorkflowInput<TInput>
where TInput : notnull
{
/// <summary>
/// Gets the workflow input data.
/// </summary>
public required TInput Input { get; init; }
}
@@ -0,0 +1,41 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Source-generated JSON serialization context for durable workflow types.
/// </summary>
/// <remarks>
/// <para>
/// This context provides AOT-compatible and trimmer-safe JSON serialization for the
/// internal data transfer types used by the durable workflow infrastructure:
/// </para>
/// <list type="bullet">
/// <item><description><see cref="DurableActivityInput"/>: Activity input wrapper with state</description></item>
/// <item><description><see cref="DurableExecutorOutput"/>: Executor output wrapper with results, events, and state updates</description></item>
/// <item><description><see cref="TypedPayload"/>: Serialized payload wrapper with type info (events and messages)</description></item>
/// <item><description><see cref="DurableWorkflowLiveStatus"/>: Live status payload (streaming events and pending request ports)</description></item>
/// </list>
/// <para>
/// Note: User-defined executor input/output types still use reflection-based serialization
/// since their types are not known at compile time.
/// </para>
/// </remarks>
[JsonSourceGenerationOptions(
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(DurableActivityInput))]
[JsonSerializable(typeof(DurableExecutorOutput))]
[JsonSerializable(typeof(TypedPayload))]
[JsonSerializable(typeof(List<TypedPayload>))]
[JsonSerializable(typeof(DurableWorkflowLiveStatus))]
[JsonSerializable(typeof(DurableWorkflowResult))]
[JsonSerializable(typeof(PendingRequestPortStatus))]
[JsonSerializable(typeof(List<PendingRequestPortStatus>))]
[JsonSerializable(typeof(List<string>))]
[JsonSerializable(typeof(Dictionary<string, string>))]
[JsonSerializable(typeof(Dictionary<string, string?>))]
internal partial class DurableWorkflowJsonContext : JsonSerializerContext;
@@ -0,0 +1,59 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Live status payload written to the orchestration via <c>SetCustomStatus</c>.
/// </summary>
/// <remarks>
/// <para>
/// This is the only orchestration state readable by external clients while the workflow
/// is still running. It is written after each superstep so that
/// <see cref="DurableStreamingWorkflowRun"/> can poll for new events.
/// On completion the framework clears it, so events are also
/// embedded in the output via <see cref="DurableWorkflowResult"/>.
/// </para>
/// <para>
/// When the workflow is paused at one or more <see cref="RequestPort"/> nodes,
/// <see cref="PendingEvents"/> contains the request data for each.
/// </para>
/// </remarks>
internal sealed class DurableWorkflowLiveStatus
{
/// <summary>
/// Gets or sets the pending request ports the workflow is waiting on. Empty when no input is needed.
/// </summary>
public List<PendingRequestPortStatus> PendingEvents { get; set; } = [];
/// <summary>
/// Gets or sets the serialized workflow events emitted so far.
/// </summary>
public List<string> Events { get; set; } = [];
/// <summary>
/// Attempts to deserialize a serialized custom status string into a <see cref="DurableWorkflowLiveStatus"/>.
/// </summary>
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing durable workflow status.")]
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing durable workflow status.")]
internal static bool TryParse(string? serializedStatus, out DurableWorkflowLiveStatus result)
{
if (serializedStatus is null)
{
result = default!;
return false;
}
try
{
result = System.Text.Json.JsonSerializer.Deserialize<DurableWorkflowLiveStatus>(serializedStatus, DurableSerialization.Options)!;
return result is not null;
}
catch (System.Text.Json.JsonException)
{
result = default!;
return false;
}
}
}
@@ -0,0 +1,111 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using Microsoft.Agents.AI.Workflows;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Provides configuration options for managing durable workflows within an application.
/// </summary>
[DebuggerDisplay("Workflows = {Workflows.Count}")]
public sealed class DurableWorkflowOptions
{
private readonly Dictionary<string, Workflow> _workflows = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Initializes a new instance of the <see cref="DurableWorkflowOptions"/> class.
/// </summary>
/// <param name="parentOptions">Optional parent options container for accessing related configuration.</param>
internal DurableWorkflowOptions(DurableOptions? parentOptions = null)
{
this.ParentOptions = parentOptions;
}
/// <summary>
/// Gets the parent <see cref="DurableOptions"/> container, if available.
/// </summary>
internal DurableOptions? ParentOptions { get; }
/// <summary>
/// Gets the collection of workflows available in the current context, keyed by their unique names.
/// </summary>
public IReadOnlyDictionary<string, Workflow> Workflows => this._workflows;
/// <summary>
/// Gets the executor registry for direct executor lookup.
/// </summary>
internal ExecutorRegistry Executors { get; } = new();
/// <summary>
/// Adds a workflow to the collection for processing or execution.
/// </summary>
/// <param name="workflow">The workflow instance to add. Cannot be null.</param>
/// <remarks>
/// When a workflow is added, all executors are registered in the executor registry.
/// Any AI agent executors will also be automatically registered with the
/// <see cref="DurableAgentsOptions"/> if available.
/// </remarks>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="workflow"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown when the workflow does not have a valid name.</exception>
public void AddWorkflow(Workflow workflow)
{
ArgumentNullException.ThrowIfNull(workflow);
if (string.IsNullOrEmpty(workflow.Name))
{
throw new ArgumentException("Workflow must have a valid Name property.", nameof(workflow));
}
this._workflows[workflow.Name] = workflow;
this.RegisterWorkflowExecutors(workflow);
}
/// <summary>
/// Adds a collection of workflows to the current instance.
/// </summary>
/// <param name="workflows">The collection of <see cref="Workflow"/> objects to add.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="workflows"/> is null.</exception>
public void AddWorkflows(params Workflow[] workflows)
{
ArgumentNullException.ThrowIfNull(workflows);
foreach (Workflow workflow in workflows)
{
this.AddWorkflow(workflow);
}
}
/// <summary>
/// Registers all executors from a workflow, including AI agents if agent options are available.
/// </summary>
private void RegisterWorkflowExecutors(Workflow workflow)
{
DurableAgentsOptions? agentOptions = this.ParentOptions?.Agents;
foreach ((string executorId, ExecutorBinding binding) in workflow.ReflectExecutors())
{
string executorName = WorkflowNamingHelper.GetExecutorName(executorId);
this.Executors.Register(executorName, executorId, workflow);
TryRegisterAgent(binding, agentOptions);
}
}
/// <summary>
/// Registers an AI agent with the agent options if the binding contains an unregistered agent.
/// </summary>
private static void TryRegisterAgent(ExecutorBinding binding, DurableAgentsOptions? agentOptions)
{
if (agentOptions is null)
{
return;
}
if (binding.RawValue is AIAgent { Name: not null } agent
&& !agentOptions.ContainsAgent(agent.Name))
{
agentOptions.AddAIAgent(agent);
}
}
}
@@ -0,0 +1,42 @@
// 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; } = [];
/// <summary>
/// Gets or sets the typed messages to forward to connected executors in the parent workflow.
/// </summary>
/// <remarks>
/// When this workflow runs as a sub-orchestration, these messages are propagated to the
/// parent workflow and routed to successor executors via the edge map.
/// </remarks>
public List<TypedPayload> SentMessages { get; set; } = [];
/// <summary>
/// Gets or sets a value indicating whether the workflow was halted by an executor.
/// </summary>
/// <remarks>
/// When this workflow runs as a sub-orchestration, this flag is propagated to the
/// parent workflow so halt semantics are preserved across nesting levels.
/// </remarks>
public bool HaltRequested { get; set; }
}
@@ -0,0 +1,116 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask;
using Microsoft.DurableTask.Client;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Represents a durable workflow run that tracks execution status and provides access to workflow events.
/// </summary>
[DebuggerDisplay("{WorkflowName} ({RunId})")]
internal sealed class DurableWorkflowRun : IAwaitableWorkflowRun
{
private readonly DurableTaskClient _client;
private readonly List<WorkflowEvent> _eventSink = [];
private int _lastBookmark;
/// <summary>
/// Initializes a new instance of the <see cref="DurableWorkflowRun"/> 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="workflowName">The name of the workflow being executed.</param>
internal DurableWorkflowRun(DurableTaskClient client, string instanceId, string workflowName)
{
this._client = client;
this.RunId = instanceId;
this.WorkflowName = workflowName;
}
/// <inheritdoc/>
public string RunId { get; }
/// <summary>
/// Gets the name of the workflow being executed.
/// </summary>
public string WorkflowName { get; }
/// <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>
/// <exception cref="TaskFailedException">Thrown when the workflow failed.</exception>
/// <exception cref="InvalidOperationException">Thrown when the workflow was terminated or ended with an unexpected status.</exception>
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 DurableStreamingWorkflowRun.ExtractResult<TResult>(metadata.SerializedOutput);
}
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Failed)
{
if (metadata.FailureDetails is not null)
{
// Use TaskFailedException to preserve full failure details including stack trace and inner exceptions
throw new TaskFailedException(
taskName: this.WorkflowName,
taskId: 0,
failureDetails: metadata.FailureDetails);
}
throw new InvalidOperationException(
$"Workflow '{this.WorkflowName}' (RunId: {this.RunId}) failed without failure details.");
}
throw new InvalidOperationException(
$"Workflow '{this.WorkflowName}' (RunId: {this.RunId}) ended with unexpected status: {metadata.RuntimeStatus}");
}
/// <summary>
/// Waits for the workflow to complete and returns the string result.
/// </summary>
/// <param name="cancellationToken">A cancellation token to observe.</param>
/// <returns>The string result of the workflow execution.</returns>
public ValueTask<string?> WaitForCompletionAsync(CancellationToken cancellationToken = default)
=> this.WaitForCompletionAsync<string>(cancellationToken);
/// <summary>
/// Gets all events that have been collected from the workflow.
/// </summary>
public IEnumerable<WorkflowEvent> OutgoingEvents => this._eventSink;
/// <summary>
/// Gets the number of events collected since the last access to <see cref="NewEvents"/>.
/// </summary>
public int NewEventCount => this._eventSink.Count - this._lastBookmark;
/// <summary>
/// Gets all events collected since the last access to <see cref="NewEvents"/>.
/// </summary>
public IEnumerable<WorkflowEvent> NewEvents
{
get
{
if (this._lastBookmark >= this._eventSink.Count)
{
return [];
}
int currentBookmark = this._lastBookmark;
this._lastBookmark = this._eventSink.Count;
return this._eventSink.Skip(currentBookmark);
}
}
}
@@ -0,0 +1,619 @@
// Copyright (c) Microsoft. All rights reserved.
// ConfigureAwait Usage in Orchestration Code:
// This file uses ConfigureAwait(true) because it runs within orchestration context.
// Durable Task orchestrations require deterministic replay - the same code must execute
// identically across replays. ConfigureAwait(true) ensures continuations run on the
// orchestration's synchronization context, which is essential for replay correctness.
// Using ConfigureAwait(false) here could cause non-deterministic behavior during replay.
// Superstep execution walkthrough for a workflow like below:
//
// [A] ──► [B] ──► [C] ──► [E] (B→D has condition: x => x.NeedsReview)
// │ ▲
// └──► [D] ──────┘
//
// Superstep 1 — A runs
// Queues before: A:[input] Results: {}
// Dispatch: A executes, returns resultA
// Route: EdgeMap routes A's output → B's queue
// Queues after: B:[resultA] Results: {A: resultA}
//
// Superstep 2 — B runs
// Queues before: B:[resultA] Results: {A: resultA}
// Dispatch: B executes, returns resultB (type: Order)
// Route: FanOutRouter sends resultB to:
// C's queue (unconditional)
// D's queue (only if resultB.NeedsReview == true)
// Queues after: C:[resultB], D:[resultB] Results: {A: .., B: resultB}
// (D may be empty if condition was false)
//
// Superstep 3 — C and D run in parallel
// Queues before: C:[resultB], D:[resultB]
// Dispatch: C and D execute concurrently via Task.WhenAll
// Route: Both route output → E's queue
// Queues after: E:[resultC, resultD] Results: {.., C: resultC, D: resultD}
//
// Superstep 4 — E runs (fan-in)
// Queues before: E:[resultC, resultD] ◄── IsFanInExecutor("E") = true
// Collect: AggregateQueueMessages merges into JSON array ["resultC","resultD"]
// Dispatch: E executes with aggregated input
// Route: E has no successors → nothing enqueued
// Queues after: (all empty) Results: {.., E: resultE}
//
// Superstep 5 — loop exits (no pending messages)
// GetFinalResult returns resultE
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Microsoft.Agents.AI.DurableTask.Workflows.EdgeRouters;
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
// Superstep loop:
//
// ┌───────────────┐ ┌───────────────┐ ┌───────────────────┐
// │ Collect │───►│ Dispatch │───►│ Process Results │
// │ Executor │ │ Executors │ │ & Route Messages │
// │ Inputs │ │ in Parallel │ │ │
// └───────────────┘ └───────────────┘ └───────────────────┘
// ▲ │
// └───────────────────────────────────────────┘
// (repeat until no pending messages)
/// <summary>
/// Runs workflow orchestrations using message-driven superstep execution with Durable Task.
/// </summary>
internal sealed class DurableWorkflowRunner
{
private const int MaxSupersteps = 100;
/// <summary>
/// Initializes a new instance of the <see cref="DurableWorkflowRunner"/> class.
/// </summary>
/// <param name="durableOptions">The durable options containing workflow configurations.</param>
public DurableWorkflowRunner(DurableOptions durableOptions)
{
ArgumentNullException.ThrowIfNull(durableOptions);
this.Options = durableOptions.Workflows;
}
/// <summary>
/// Gets the workflow options.
/// </summary>
private DurableWorkflowOptions Options { get; }
/// <summary>
/// Runs a workflow orchestration.
/// </summary>
/// <param name="context">The task orchestration context.</param>
/// <param name="workflowInput">The workflow input envelope containing workflow input and metadata.</param>
/// <param name="logger">The replay-safe logger for orchestration logging.</param>
/// <returns>The result of the workflow execution.</returns>
/// <exception cref="InvalidOperationException">Thrown when the specified workflow is not found.</exception>
internal async Task<DurableWorkflowResult> RunWorkflowOrchestrationAsync(
TaskOrchestrationContext context,
DurableWorkflowInput<object> workflowInput,
ILogger logger)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(workflowInput);
Workflow workflow = this.GetWorkflowOrThrow(context.Name);
string workflowName = context.Name;
string instanceId = context.InstanceId;
logger.LogWorkflowStarting(workflowName, instanceId);
WorkflowGraphInfo graphInfo = WorkflowAnalyzer.BuildGraphInfo(workflow);
DurableEdgeMap edgeMap = new(graphInfo);
// Extract input - the start executor determines the expected input type from its own InputTypes
object input = workflowInput.Input;
return await RunSuperstepLoopAsync(context, workflow, edgeMap, input, logger).ConfigureAwait(true);
}
private Workflow GetWorkflowOrThrow(string orchestrationName)
{
string workflowName = WorkflowNamingHelper.ToWorkflowName(orchestrationName);
if (!this.Options.Workflows.TryGetValue(workflowName, out Workflow? workflow))
{
throw new InvalidOperationException($"Workflow '{workflowName}' not found.");
}
return workflow;
}
/// <summary>
/// Runs the workflow execution loop using superstep-based processing.
/// </summary>
[UnconditionalSuppressMessage("AOT", "IL2026:RequiresUnreferencedCode", Justification = "Input types are preserved by the Durable Task framework's DataConverter.")]
[UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = "Input types are preserved by the Durable Task framework's DataConverter.")]
private static async Task<DurableWorkflowResult> RunSuperstepLoopAsync(
TaskOrchestrationContext context,
Workflow workflow,
DurableEdgeMap edgeMap,
object initialInput,
ILogger logger)
{
SuperstepState state = new(workflow, edgeMap);
// Convert input to string for the message queue.
// When DurableWorkflowInput<string> is deserialized as DurableWorkflowInput<object>,
// the Input property becomes a JsonElement instead of a string.
// We must extract the raw string value to avoid double-serialization.
string inputString = initialInput switch
{
string s => s,
JsonElement je when je.ValueKind == JsonValueKind.String => je.GetString() ?? string.Empty,
_ => JsonSerializer.Serialize(initialInput)
};
edgeMap.EnqueueInitialInput(inputString, state.MessageQueues);
bool haltRequested = false;
for (int superstep = 1; superstep <= MaxSupersteps; superstep++)
{
List<ExecutorInput> executorInputs = CollectExecutorInputs(state, logger);
if (executorInputs.Count == 0)
{
break;
}
logger.LogSuperstepStarting(superstep, executorInputs.Count);
if (logger.IsEnabled(LogLevel.Debug))
{
logger.LogSuperstepExecutors(superstep, string.Join(", ", executorInputs.Select(e => e.ExecutorId)));
}
string[] results = await DispatchExecutorsInParallelAsync(context, executorInputs, state, logger).ConfigureAwait(true);
haltRequested = ProcessSuperstepResults(executorInputs, results, state, context, logger);
if (haltRequested)
{
break;
}
// Check if we've reached the limit and still have work remaining
int remainingExecutors = CountRemainingExecutors(state.MessageQueues);
if (superstep == MaxSupersteps && remainingExecutors > 0)
{
logger.LogWorkflowMaxSuperstepsExceeded(context.InstanceId, MaxSupersteps, remainingExecutors);
}
}
// Publish final events for live streaming (skip during replay)
if (!context.IsReplaying)
{
PublishEventsToLiveStatus(context, state);
}
string finalResult = GetFinalResult(state.LastResults);
logger.LogWorkflowCompleted();
// 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).
// SentMessages carries the final result so parent workflows can route it
// to connected executors, matching the in-process WorkflowHostExecutor behavior.
return new DurableWorkflowResult
{
Result = finalResult,
Events = state.AccumulatedEvents,
SentMessages = !string.IsNullOrEmpty(finalResult)
? [new TypedPayload { Data = finalResult }]
: [],
HaltRequested = haltRequested
};
}
/// <summary>
/// Counts the number of executors with pending messages in their queues.
/// </summary>
private static int CountRemainingExecutors(Dictionary<string, Queue<DurableMessageEnvelope>> messageQueues)
{
return messageQueues.Count(kvp => kvp.Value.Count > 0);
}
private static async Task<string[]> DispatchExecutorsInParallelAsync(
TaskOrchestrationContext context,
List<ExecutorInput> executorInputs,
SuperstepState state,
ILogger logger)
{
Task<string>[] dispatchTasks = executorInputs
.Select(input => DurableExecutorDispatcher.DispatchAsync(context, input.Info, input.Envelope, state.SharedState, state.LiveStatus, logger))
.ToArray();
return await Task.WhenAll(dispatchTasks).ConfigureAwait(true);
}
/// <summary>
/// Holds state that accumulates and changes across superstep iterations during workflow execution.
/// </summary>
/// <remarks>
/// <para>
/// <c>MessageQueues</c> starts with one entry (the start executor's queue, seeded by
/// <see cref="DurableEdgeMap.EnqueueInitialInput"/>). After each superstep, <c>RouteOutputToSuccessors</c>
/// adds entries for successor executors that receive routed messages. Queues are drained during
/// <c>CollectExecutorInputs</c>; empty queues are skipped.
/// </para>
/// <para>
/// <c>LastResults</c> is updated after every superstep with the result of each executor that ran.
/// At workflow completion, the last non-empty value is returned as the workflow's final result.
/// </para>
/// </remarks>
private sealed class SuperstepState
{
public SuperstepState(Workflow workflow, DurableEdgeMap edgeMap)
{
this.EdgeMap = edgeMap;
this.ExecutorBindings = workflow.ReflectExecutors();
}
public DurableEdgeMap EdgeMap { get; }
public Dictionary<string, ExecutorBinding> ExecutorBindings { get; }
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 the durable workflow status (streaming consumption).
/// </summary>
public List<string> AccumulatedEvents { get; } = [];
/// <summary>
/// Workflow status published via <c>SetCustomStatus</c> so external clients can poll for streaming events and pending HITL requests.
/// </summary>
public DurableWorkflowLiveStatus LiveStatus { get; } = new();
}
/// <summary>
/// Represents prepared input for an executor ready for dispatch.
/// </summary>
private sealed record ExecutorInput(string ExecutorId, DurableMessageEnvelope Envelope, WorkflowExecutorInfo Info);
/// <summary>
/// Collects inputs for all active executors, applying Fan-In aggregation where needed.
/// </summary>
private static List<ExecutorInput> CollectExecutorInputs(
SuperstepState state,
ILogger logger)
{
List<ExecutorInput> inputs = [];
// Only process queues that have pending messages
foreach ((string executorId, Queue<DurableMessageEnvelope> queue) in state.MessageQueues
.Where(kvp => kvp.Value.Count > 0))
{
DurableMessageEnvelope envelope = GetNextEnvelope(executorId, queue, state.EdgeMap, logger);
WorkflowExecutorInfo executorInfo = CreateExecutorInfo(executorId, state.ExecutorBindings);
inputs.Add(new ExecutorInput(executorId, envelope, executorInfo));
}
return inputs;
}
private static DurableMessageEnvelope GetNextEnvelope(
string executorId,
Queue<DurableMessageEnvelope> queue,
DurableEdgeMap edgeMap,
ILogger logger)
{
bool shouldAggregate = edgeMap.IsFanInExecutor(executorId) && queue.Count > 1;
return shouldAggregate
? AggregateQueueMessages(queue, executorId, logger)
: queue.Dequeue();
}
/// <summary>
/// Aggregates all messages in a queue into a JSON array for Fan-In executors.
/// </summary>
private static DurableMessageEnvelope AggregateQueueMessages(
Queue<DurableMessageEnvelope> queue,
string executorId,
ILogger logger)
{
List<string> messages = [];
List<string> sourceIds = [];
while (queue.Count > 0)
{
DurableMessageEnvelope envelope = queue.Dequeue();
messages.Add(envelope.Message);
if (envelope.SourceExecutorId is not null)
{
sourceIds.Add(envelope.SourceExecutorId);
}
}
if (logger.IsEnabled(LogLevel.Debug))
{
logger.LogFanInAggregated(executorId, messages.Count, string.Join(", ", sourceIds));
}
return new DurableMessageEnvelope
{
Message = SerializeToJsonArray(messages),
InputTypeName = typeof(string[]).FullName,
SourceExecutorId = sourceIds.Count > 0 ? string.Join(",", sourceIds) : null
};
}
/// <summary>
/// Processes results from a superstep, updating state and routing messages to successors.
/// </summary>
/// <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;
ExecutorResultInfo resultInfo = ParseActivityResult(rawResults[i]);
logger.LogExecutorResultReceived(executorId, resultInfo.Result.Length, resultInfo.SentMessages.Count);
state.LastResults[executorId] = resultInfo.Result;
// Merge state updates from activity into shared state
MergeStateUpdates(state, resultInfo.StateUpdates, resultInfo.ClearedScopes);
// Accumulate events for the durable workflow 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)
{
PublishEventsToLiveStatus(context, state);
}
RouteOutputToSuccessors(executorId, resultInfo.Result, resultInfo.SentMessages, state, logger);
}
return haltRequested;
}
/// <summary>
/// Merges state updates from an executor into the shared state.
/// </summary>
/// <remarks>
/// When concurrent executors in the same superstep modify keys in the same scope,
/// last-write-wins semantics apply.
/// </remarks>
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 durable workflow's custom status,
/// making them available to <see cref="DurableStreamingWorkflowRun"/> for live streaming.
/// </summary>
/// <remarks>
/// Custom status is the only orchestration state readable by external clients while
/// the orchestration is still running. It is cleared by the framework on completion,
/// so events are also included in <see cref="DurableWorkflowResult"/> for final retrieval.
/// </remarks>
private static void PublishEventsToLiveStatus(
TaskOrchestrationContext context,
SuperstepState state)
{
state.LiveStatus.Events = state.AccumulatedEvents;
// Pass the object directly — the framework's DataConverter handles serialization.
// Pre-serializing would cause double-serialization (string wrapped in JSON quotes).
context.SetCustomStatus(state.LiveStatus);
}
/// <summary>
/// Routes executor output (explicit messages or return value) to successor executors.
/// </summary>
private static void RouteOutputToSuccessors(
string executorId,
string result,
List<TypedPayload> sentMessages,
SuperstepState state,
ILogger logger)
{
if (sentMessages.Count > 0)
{
// Only route messages that have content
foreach (TypedPayload message in sentMessages.Where(m => !string.IsNullOrEmpty(m.Data)))
{
state.EdgeMap.RouteMessage(executorId, message.Data!, message.TypeName, state.MessageQueues, logger);
}
return;
}
if (!string.IsNullOrEmpty(result))
{
state.EdgeMap.RouteMessage(executorId, result, inputTypeName: null, state.MessageQueues, logger);
}
}
/// <summary>
/// Serializes a list of messages into a JSON array.
/// </summary>
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing string array.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing string array.")]
private static string SerializeToJsonArray(List<string> messages)
{
return JsonSerializer.Serialize(messages);
}
/// <summary>
/// Creates a <see cref="WorkflowExecutorInfo"/> for the given executor ID.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown when the executor ID is not found in bindings.</exception>
private static WorkflowExecutorInfo CreateExecutorInfo(
string executorId,
Dictionary<string, ExecutorBinding> executorBindings)
{
if (!executorBindings.TryGetValue(executorId, out ExecutorBinding? binding))
{
throw new InvalidOperationException($"Executor '{executorId}' not found in workflow bindings.");
}
bool isAgentic = WorkflowAnalyzer.IsAgentExecutorType(binding.ExecutorType);
RequestPort? requestPort = (binding is RequestPortBinding rpb) ? rpb.Port : null;
Workflow? subWorkflow = (binding is SubworkflowBinding swb) ? swb.WorkflowInstance : null;
return new WorkflowExecutorInfo(executorId, isAgentic, requestPort, subWorkflow);
}
/// <summary>
/// Returns the last non-empty result from executed steps, or empty string if none.
/// </summary>
private static string GetFinalResult(Dictionary<string, string> lastResults)
{
return lastResults.Values.LastOrDefault(value => !string.IsNullOrEmpty(value)) ?? string.Empty;
}
/// <summary>
/// Output from an executor invocation, including its result,
/// messages, state updates, and emitted workflow events.
/// </summary>
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 new ExecutorResultInfo(rawResult, [], [], [], [], false);
}
try
{
DurableExecutorOutput? output = JsonSerializer.Deserialize(
rawResult,
DurableWorkflowJsonContext.Default.DurableExecutorOutput);
if (output is null || !HasMeaningfulContent(output))
{
return new ExecutorResultInfo(rawResult, [], [], [], [], false);
}
return new ExecutorResultInfo(
output.Result ?? string.Empty,
output.SentMessages,
output.StateUpdates,
output.ClearedScopes,
output.Events,
output.HaltRequested);
}
catch (JsonException)
{
return new ExecutorResultInfo(rawResult, [], [], [], [], false);
}
}
/// <summary>
/// Determines whether the activity output contains meaningful content.
/// </summary>
/// <remarks>
/// Distinguishes actual activity output from arbitrary JSON that deserialized
/// successfully but with all default/empty values.
/// </remarks>
private static bool HasMeaningfulContent(DurableExecutorOutput output)
{
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 System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Microsoft.Agents.AI.Workflows;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Event raised when the durable workflow is waiting for external input at a <see cref="RequestPort"/>.
/// </summary>
/// <param name="Input">The serialized input data that was passed to the RequestPort.</param>
/// <param name="RequestPort">The request port definition.</param>
[DebuggerDisplay("RequestPort = {RequestPort.Id}")]
public sealed class DurableWorkflowWaitingForInputEvent(
string Input,
RequestPort RequestPort) : WorkflowEvent
{
/// <summary>
/// Gets the serialized input data that was passed to the RequestPort.
/// </summary>
public string Input { get; } = Input;
/// <summary>
/// Gets the request port definition.
/// </summary>
public RequestPort RequestPort { get; } = RequestPort;
/// <summary>
/// Attempts to deserialize the input data to the specified type.
/// </summary>
/// <typeparam name="T">The type to deserialize to.</typeparam>
/// <returns>The deserialized input.</returns>
/// <exception cref="JsonException">Thrown when the input cannot be deserialized to the specified type.</exception>
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow types provided by the caller.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow types provided by the caller.")]
public T? GetInputAs<T>()
{
return JsonSerializer.Deserialize<T>(this.Input, DurableSerialization.Options);
}
}
@@ -0,0 +1,156 @@
// Copyright (c) Microsoft. All rights reserved.
// Routing decision flow for a single edge.
// Example: the B→D edge from a workflow like below:
//
// [A] ──► [B] ──► [C] ──► [E] (B→D has condition: x => x.NeedsReview)
// │ ▲
// └──► [D] ──────┘
//
// (condition: x => x.NeedsReview, _sourceOutputType: typeof(Order))
//
// RouteMessage(envelope) envelope.Message = "{\"NeedsReview\":true, ...}"
// │
// ▼
// Has condition? ──── No ────► Enqueue to sink's queue
// │
// Yes (B→D has one)
// │
// ▼
// Deserialize message JSON string → Order object using _sourceOutputType
// │
// ▼
// Evaluate _condition(order) order => order.NeedsReview
// │
// ┌──┴──┐
// true false
// │ │
// ▼ └──► Skip (log and return, D will not run)
// Enqueue to
// D's queue
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.DurableTask.Workflows.EdgeRouters;
/// <summary>
/// Routes messages from a source executor to a single target executor with optional condition evaluation.
/// </summary>
/// <remarks>
/// <para>
/// Created by <see cref="DurableEdgeMap"/> during construction — one instance per (source, sink) edge.
/// When an edge has a condition (e.g., <c>order =&gt; order.Total &gt; 1000</c>), the router deserialises
/// the serialised JSON message back to the source executor's output type so the condition delegate
/// can evaluate it against strongly-typed properties. If the condition returns <c>false</c>, the
/// message is not forwarded and the target executor will not run for this edge.
/// </para>
/// <para>
/// For sources with multiple successors, individual <see cref="DurableDirectEdgeRouter"/> instances
/// are wrapped in a <see cref="DurableFanOutEdgeRouter"/> so a single <c>RouteMessage</c> call
/// fans the same message out to all targets, each evaluating its own condition independently.
/// </para>
/// </remarks>
internal sealed class DurableDirectEdgeRouter : IDurableEdgeRouter
{
private readonly string _sourceId;
private readonly string _sinkId;
private readonly Func<object?, bool>? _condition;
private readonly Type? _sourceOutputType;
/// <summary>
/// Initializes a new instance of <see cref="DurableDirectEdgeRouter"/>.
/// </summary>
/// <param name="sourceId">The source executor ID.</param>
/// <param name="sinkId">The target executor ID.</param>
/// <param name="condition">Optional condition function to evaluate before routing.</param>
/// <param name="sourceOutputType">The output type of the source executor for deserialization.</param>
internal DurableDirectEdgeRouter(
string sourceId,
string sinkId,
Func<object?, bool>? condition,
Type? sourceOutputType)
{
this._sourceId = sourceId;
this._sinkId = sinkId;
this._condition = condition;
this._sourceOutputType = sourceOutputType;
}
/// <inheritdoc />
public void RouteMessage(
DurableMessageEnvelope envelope,
Dictionary<string, Queue<DurableMessageEnvelope>> messageQueues,
ILogger logger)
{
if (this._condition is not null)
{
try
{
object? messageObj = DeserializeForCondition(envelope.Message, this._sourceOutputType);
if (!this._condition(messageObj))
{
logger.LogEdgeConditionFalse(this._sourceId, this._sinkId);
return;
}
}
catch (Exception ex)
{
logger.LogEdgeConditionEvaluationFailed(ex, this._sourceId, this._sinkId);
return;
}
}
logger.LogEdgeRoutingMessage(this._sourceId, this._sinkId);
EnqueueMessage(messageQueues, this._sinkId, envelope);
}
/// <summary>
/// Deserializes a JSON message to an object for condition evaluation.
/// </summary>
/// <remarks>
/// Messages travel through the durable workflow as serialized JSON strings, but condition
/// delegates need typed objects to evaluate (e.g., order => order.Status == "Approved").
/// This method converts the JSON back to an object the condition delegate can evaluate.
/// </remarks>
/// <param name="json">The JSON string representation of the message.</param>
/// <param name="targetType">
/// The expected type of the message. When provided, enables strongly-typed deserialization
/// so the condition function receives the correct type to evaluate against.
/// </param>
/// <returns>
/// The deserialized object, or null if the JSON is empty.
/// </returns>
/// <exception cref="JsonException">Thrown when the JSON is invalid or cannot be deserialized to the target type.</exception>
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow types registered at startup.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow types registered at startup.")]
private static object? DeserializeForCondition(string json, Type? targetType)
{
if (string.IsNullOrEmpty(json))
{
return null;
}
// If we know the source executor's output type, deserialize to that specific type
// so the condition function can access strongly-typed properties.
// Otherwise, deserialize as a generic object for basic inspection.
return targetType is null
? JsonSerializer.Deserialize<object>(json, DurableSerialization.Options)
: JsonSerializer.Deserialize(json, targetType, DurableSerialization.Options);
}
private static void EnqueueMessage(
Dictionary<string, Queue<DurableMessageEnvelope>> queues,
string executorId,
DurableMessageEnvelope envelope)
{
if (!queues.TryGetValue(executorId, out Queue<DurableMessageEnvelope>? queue))
{
queue = new Queue<DurableMessageEnvelope>();
queues[executorId] = queue;
}
queue.Enqueue(envelope);
}
}
@@ -0,0 +1,205 @@
// Copyright (c) Microsoft. All rights reserved.
// How WorkflowGraphInfo maps to DurableEdgeMap at runtime.
// For a workflow like below:
//
// [A] ──► [B] ──► [C] ──► [E]
// │ ▲
// └──► [D] ──────┘
// (condition: x => x.NeedsReview)
//
// WorkflowGraphInfo DurableEdgeMap
// ┌──────────────────────────┐ ┌──────────────────────────────────────┐
// │ Successors: │ │ _routersBySource: │
// │ A → [B] │──constructs──►│ A → [DirectRouter(A→B)] │
// │ B → [C, D] │ │ B → [FanOutRouter([C, D])] │
// │ C → [E] │ │ C → [DirectRouter(C→E)] │
// │ D → [E] │ │ D → [DirectRouter(D→E)] │
// └──────────────────────────┘ │ │
// ┌──────────────────────────┐ │ _predecessorCounts: │
// │ Predecessors: │ │ A → 0 │
// │ E → [C, D] (fan-in!) │──constructs──►│ B → 1, C → 1, D → 1 │
// └──────────────────────────┘ │ E → 2 ◄── IsFanInExecutor = true │
// └──────────────────────────────────────┘
//
// Usage during superstep execution (continuing the example):
//
// 1. EnqueueInitialInput(msg) ──► MessageQueues["A"].Enqueue(envelope)
//
// 2. After B completes, RouteMessage("B", resultB) ──► _routersBySource["B"]
// │
// ▼
// FanOutRouter (B has 2 successors)
// ├─► DirectRouter(B→C) ──► no condition ──► enqueue to C
// └─► DirectRouter(B→D) ──► evaluate x => x.NeedsReview ──► enqueue to D (or skip)
//
// 3. Before superstep 4, IsFanInExecutor("E") returns true (count=2)
// → CollectExecutorInputs aggregates C and D results into ["resultC","resultD"]
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.DurableTask.Workflows.EdgeRouters;
/// <summary>
/// Manages message routing through workflow edges for durable orchestrations.
/// </summary>
/// <remarks>
/// <para>
/// This is the durable equivalent of <c>EdgeMap</c> in the in-process runner.
/// It is constructed from <see cref="WorkflowGraphInfo"/> (produced by <see cref="WorkflowAnalyzer.BuildGraphInfo"/>)
/// and converts the static graph structure into an active routing layer used during superstep execution.
/// </para>
/// <para>
/// <b>What it stores:</b>
/// </para>
/// <list type="bullet">
/// <item><description><c>_routersBySource</c> — For each source executor, a list of <see cref="IDurableEdgeRouter"/> instances
/// that know how to deliver messages to successor executors. When a source has multiple successors, a single
/// <see cref="DurableFanOutEdgeRouter"/> wraps the individual <see cref="DurableDirectEdgeRouter"/> instances.</description></item>
/// <item><description><c>_predecessorCounts</c> — The number of predecessors for each executor, used to detect
/// fan-in points where multiple incoming messages should be aggregated before execution.</description></item>
/// <item><description><c>_startExecutorId</c> — The entry-point executor that receives the initial workflow input.</description></item>
/// </list>
/// <para>
/// <b>How it is used during execution:</b>
/// </para>
/// <list type="number">
/// <item><description><see cref="EnqueueInitialInput"/> seeds the start executor's queue before the first superstep.</description></item>
/// <item><description>After each superstep, <c>DurableWorkflowRunner.RouteOutputToSuccessors</c> calls
/// <see cref="RouteMessage"/> which looks up the routers for the completed executor and forwards the
/// result to successor queues. Each router may evaluate an edge condition before enqueueing.</description></item>
/// <item><description><see cref="IsFanInExecutor"/> is checked during input collection to decide whether
/// to aggregate multiple queued messages into a single JSON array before dispatching.</description></item>
/// </list>
/// </remarks>
internal sealed class DurableEdgeMap
{
private readonly Dictionary<string, List<IDurableEdgeRouter>> _routersBySource = [];
private readonly Dictionary<string, int> _predecessorCounts = [];
private readonly string _startExecutorId;
/// <summary>
/// Initializes a new instance of <see cref="DurableEdgeMap"/> from workflow graph info.
/// </summary>
/// <param name="graphInfo">The workflow graph information containing routing structure.</param>
internal DurableEdgeMap(WorkflowGraphInfo graphInfo)
{
ArgumentNullException.ThrowIfNull(graphInfo);
this._startExecutorId = graphInfo.StartExecutorId;
// Build edge routers for each source executor
foreach (KeyValuePair<string, List<string>> entry in graphInfo.Successors)
{
string sourceId = entry.Key;
List<string> successorIds = entry.Value;
if (successorIds.Count == 0)
{
continue;
}
graphInfo.ExecutorOutputTypes.TryGetValue(sourceId, out Type? sourceOutputType);
List<IDurableEdgeRouter> routers = [];
foreach (string sinkId in successorIds)
{
graphInfo.EdgeConditions.TryGetValue((sourceId, sinkId), out Func<object?, bool>? condition);
routers.Add(new DurableDirectEdgeRouter(sourceId, sinkId, condition, sourceOutputType));
}
// If multiple successors, wrap in a fan-out router
if (routers.Count > 1)
{
this._routersBySource[sourceId] = [new DurableFanOutEdgeRouter(sourceId, routers)];
}
else
{
this._routersBySource[sourceId] = routers;
}
}
// Store predecessor counts for fan-in detection
foreach (KeyValuePair<string, List<string>> entry in graphInfo.Predecessors)
{
this._predecessorCounts[entry.Key] = entry.Value.Count;
}
}
/// <summary>
/// Routes a message from a source executor to its successors.
/// </summary>
/// <remarks>
/// Called by <c>DurableWorkflowRunner.RouteOutputToSuccessors</c> after each superstep.
/// Wraps the message in a <see cref="DurableMessageEnvelope"/> and delegates to the
/// appropriate <see cref="IDurableEdgeRouter"/>(s) for the source executor. Each router
/// may evaluate an edge condition and, if satisfied, enqueue the envelope into the
/// target executor's message queue for the next superstep.
/// </remarks>
/// <param name="sourceId">The source executor ID.</param>
/// <param name="message">The serialized message to route.</param>
/// <param name="inputTypeName">The type name of the message.</param>
/// <param name="messageQueues">The message queues to enqueue messages into.</param>
/// <param name="logger">The logger for tracing.</param>
internal void RouteMessage(
string sourceId,
string message,
string? inputTypeName,
Dictionary<string, Queue<DurableMessageEnvelope>> messageQueues,
ILogger logger)
{
if (!this._routersBySource.TryGetValue(sourceId, out List<IDurableEdgeRouter>? routers))
{
return;
}
DurableMessageEnvelope envelope = DurableMessageEnvelope.Create(message, inputTypeName, sourceId);
foreach (IDurableEdgeRouter router in routers)
{
router.RouteMessage(envelope, messageQueues, logger);
}
}
/// <summary>
/// Enqueues the initial workflow input to the start executor.
/// </summary>
/// <param name="message">The serialized initial input message.</param>
/// <param name="messageQueues">The message queues to enqueue into.</param>
/// <remarks>
/// This method is used only at workflow startup to provide input to the first executor.
/// No input type hint is required because the start executor determines its expected input type from its own <c>InputTypes</c> configuration.
/// </remarks>
internal void EnqueueInitialInput(
string message,
Dictionary<string, Queue<DurableMessageEnvelope>> messageQueues)
{
DurableMessageEnvelope envelope = DurableMessageEnvelope.Create(message, inputTypeName: null);
EnqueueMessage(messageQueues, this._startExecutorId, envelope);
}
/// <summary>
/// Determines if an executor is a fan-in point (has multiple predecessors).
/// </summary>
/// <param name="executorId">The executor ID to check.</param>
/// <returns><c>true</c> if the executor has multiple predecessors; otherwise, <c>false</c>.</returns>
internal bool IsFanInExecutor(string executorId)
{
return this._predecessorCounts.TryGetValue(executorId, out int count) && count > 1;
}
private static void EnqueueMessage(
Dictionary<string, Queue<DurableMessageEnvelope>> queues,
string executorId,
DurableMessageEnvelope envelope)
{
if (!queues.TryGetValue(executorId, out Queue<DurableMessageEnvelope>? queue))
{
queue = new Queue<DurableMessageEnvelope>();
queues[executorId] = queue;
}
queue.Enqueue(envelope);
}
}
@@ -0,0 +1,67 @@
// Copyright (c) Microsoft. All rights reserved.
// Fan-out routing: one source message is forwarded to multiple targets.
// Example from a workflow like below:
//
// [A] ──► [B] ──► [C] ──► [E] (B→D has condition: x => x.NeedsReview)
// │ ▲
// └──► [D] ──────┘
//
// B has two successors (C and D), so DurableEdgeMap wraps them:
//
// Executor B completes with resultB (type: Order)
// │
// ▼
// FanOutRouter(B)
// ├──► DirectRouter(B→C) ──► no condition ──► enqueue to C
// └──► DirectRouter(B→D) ──► x => x.NeedsReview ──► enqueue to D (or skip)
//
// Each DirectRouter independently evaluates its condition,
// so resultB always reaches C, but only reaches D if NeedsReview is true.
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.DurableTask.Workflows.EdgeRouters;
/// <summary>
/// Routes messages from a source executor to multiple target executors (fan-out pattern).
/// </summary>
/// <remarks>
/// Created by <see cref="DurableEdgeMap"/> when a source executor has more than one successor.
/// Wraps the individual <see cref="DurableDirectEdgeRouter"/> instances and delegates
/// <see cref="RouteMessage"/> to each of them, so the same message is evaluated and
/// potentially enqueued for every target independently.
/// </remarks>
internal sealed class DurableFanOutEdgeRouter : IDurableEdgeRouter
{
private readonly string _sourceId;
private readonly List<IDurableEdgeRouter> _targetRouters;
/// <summary>
/// Initializes a new instance of <see cref="DurableFanOutEdgeRouter"/>.
/// </summary>
/// <param name="sourceId">The source executor ID.</param>
/// <param name="targetRouters">The routers for each target executor.</param>
internal DurableFanOutEdgeRouter(string sourceId, List<IDurableEdgeRouter> targetRouters)
{
this._sourceId = sourceId;
this._targetRouters = targetRouters;
}
/// <inheritdoc />
public void RouteMessage(
DurableMessageEnvelope envelope,
Dictionary<string, Queue<DurableMessageEnvelope>> messageQueues,
ILogger logger)
{
if (logger.IsEnabled(LogLevel.Debug))
{
logger.LogDebug("Fan-Out from {Source}: routing to {Count} targets", this._sourceId, this._targetRouters.Count);
}
foreach (IDurableEdgeRouter targetRouter in this._targetRouters)
{
targetRouter.RouteMessage(envelope, messageQueues, logger);
}
}
}
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.DurableTask.Workflows.EdgeRouters;
/// <summary>
/// Defines the contract for routing messages through workflow edges in durable orchestrations.
/// </summary>
/// <remarks>
/// Implementations include <see cref="DurableDirectEdgeRouter"/> for single-target routing
/// and <see cref="DurableFanOutEdgeRouter"/> for multi-target fan-out patterns.
/// </remarks>
internal interface IDurableEdgeRouter
{
/// <summary>
/// Routes a message from the source executor to its target(s).
/// </summary>
/// <param name="envelope">The message envelope containing the message and metadata.</param>
/// <param name="messageQueues">The message queues to enqueue messages into.</param>
/// <param name="logger">The logger for tracing.</param>
void RouteMessage(
DurableMessageEnvelope envelope,
Dictionary<string, Queue<DurableMessageEnvelope>> messageQueues,
ILogger logger);
}
@@ -0,0 +1,83 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Agents.AI.Workflows;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Provides a registry for executor bindings used in durable workflow orchestrations.
/// </summary>
/// <remarks>
/// This registry enables lookup of executors by name, decoupled from specific workflow instances.
/// Executors are registered when workflows are added to <see cref="DurableWorkflowOptions"/>.
/// </remarks>
internal sealed class ExecutorRegistry
{
private readonly Dictionary<string, ExecutorRegistration> _executors = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Gets the number of registered executors.
/// </summary>
internal int Count => this._executors.Count;
/// <summary>
/// Attempts to get an executor registration by name.
/// </summary>
/// <param name="executorName">The executor name to look up.</param>
/// <param name="registration">When this method returns, contains the registration if found; otherwise, null.</param>
/// <returns><see langword="true"/> if the executor was found; otherwise, <see langword="false"/>.</returns>
internal bool TryGetExecutor(string executorName, [NotNullWhen(true)] out ExecutorRegistration? registration)
{
return this._executors.TryGetValue(executorName, out registration);
}
/// <summary>
/// Registers an executor binding from a workflow.
/// </summary>
/// <param name="executorName">The executor name (without GUID suffix).</param>
/// <param name="executorId">The full executor ID (may include GUID suffix).</param>
/// <param name="workflow">The workflow containing the executor.</param>
internal void Register(string executorName, string executorId, Workflow workflow)
{
ArgumentException.ThrowIfNullOrEmpty(executorName);
ArgumentException.ThrowIfNullOrEmpty(executorId);
ArgumentNullException.ThrowIfNull(workflow);
Dictionary<string, ExecutorBinding> bindings = workflow.ReflectExecutors();
if (!bindings.TryGetValue(executorId, out ExecutorBinding? binding))
{
throw new InvalidOperationException($"Executor '{executorId}' not found in workflow.");
}
this._executors.TryAdd(executorName, new ExecutorRegistration(executorId, binding));
}
}
/// <summary>
/// Represents a registered executor with its binding information.
/// </summary>
/// <remarks>
/// The <paramref name="ExecutorId"/> may differ from the registered name when the executor
/// ID includes an instance suffix (e.g., "ExecutorName_Guid").
/// </remarks>
/// <param name="ExecutorId">The full executor ID (may include instance suffix).</param>
/// <param name="Binding">The executor binding containing the factory and configuration.</param>
internal sealed record ExecutorRegistration(string ExecutorId, ExecutorBinding Binding)
{
/// <summary>
/// Creates an instance of the executor.
/// </summary>
/// <param name="runId">A unique identifier for the run context.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The created executor instance.</returns>
internal async ValueTask<Executor> CreateExecutorInstanceAsync(string runId, CancellationToken cancellationToken = default)
{
if (this.Binding.FactoryAsync is null)
{
throw new InvalidOperationException($"Cannot create executor '{this.ExecutorId}': Binding is a placeholder.");
}
return await this.Binding.FactoryAsync(runId).ConfigureAwait(false);
}
}
@@ -0,0 +1,34 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Represents a workflow run that can be awaited for completion.
/// </summary>
/// <remarks>
/// <para>
/// This interface extends <see cref="IWorkflowRun"/> to provide methods for waiting
/// until the workflow execution completes. Not all workflow runners support this capability.
/// </para>
/// <para>
/// Use pattern matching to check if a workflow run supports awaiting:
/// <code>
/// IWorkflowRun run = await client.RunAsync(workflow, input);
/// if (run is IAwaitableWorkflowRun awaitableRun)
/// {
/// string? result = await awaitableRun.WaitForCompletionAsync&lt;string&gt;();
/// }
/// </code>
/// </para>
/// </remarks>
public interface IAwaitableWorkflowRun : IWorkflowRun
{
/// <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>
/// <exception cref="InvalidOperationException">Thrown when the workflow failed or was terminated.</exception>
ValueTask<TResult?> WaitForCompletionAsync<TResult>(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,55 @@
// 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);
/// <summary>
/// Sends a response to a <see cref="DurableWorkflowWaitingForInputEvent"/> to resume the workflow.
/// </summary>
/// <typeparam name="TResponse">The type of the response data.</typeparam>
/// <param name="requestEvent">The request event to respond to.</param>
/// <param name="response">The response data to send.</param>
/// <param name="cancellationToken">A cancellation token to observe.</param>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
ValueTask SendResponseAsync<TResponse>(
DurableWorkflowWaitingForInputEvent requestEvent,
TResponse response,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,71 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Defines a client for running and managing workflow executions.
/// </summary>
public interface IWorkflowClient
{
/// <summary>
/// Runs a workflow and returns a handle to monitor its execution.
/// </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="IWorkflowRun"/> that can be used to monitor the workflow execution.</returns>
ValueTask<IWorkflowRun> RunAsync<TInput>(
Workflow workflow,
TInput input,
string? runId = null,
CancellationToken cancellationToken = default)
where TInput : notnull;
/// <summary>
/// Runs a workflow with string input and returns a handle to monitor its execution.
/// </summary>
/// <param name="workflow">The workflow to execute.</param>
/// <param name="input">The string input to pass to the workflow.</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="IWorkflowRun"/> that can be used to monitor the workflow execution.</returns>
ValueTask<IWorkflowRun> RunAsync(
Workflow workflow,
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;
/// <summary>
/// Starts a workflow with string input and returns a streaming handle to watch events in real-time.
/// </summary>
/// <param name="workflow">The workflow to execute.</param>
/// <param name="input">The string input to pass to the workflow.</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(
Workflow workflow,
string input,
string? runId = null,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,39 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Represents a running instance of a workflow.
/// </summary>
public interface IWorkflowRun
{
/// <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>
/// Gets all events that have been emitted by the workflow.
/// </summary>
IEnumerable<WorkflowEvent> OutgoingEvents { get; }
/// <summary>
/// Gets the number of events emitted since the last access to <see cref="NewEvents"/>.
/// </summary>
int NewEventCount { get; }
/// <summary>
/// Gets all events emitted by the workflow since the last access to this property.
/// </summary>
/// <remarks>
/// Each access to this property advances the bookmark, so subsequent accesses
/// will only return events emitted after the previous access.
/// </remarks>
IEnumerable<WorkflowEvent> NewEvents { get; }
}
@@ -0,0 +1,12 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Represents a RequestPort the workflow is paused at, waiting for a response.
/// </summary>
/// <param name="EventName">The RequestPort ID identifying which input is needed.</param>
/// <param name="Input">The serialized request data passed to the RequestPort.</param>
internal sealed record PendingRequestPortStatus(
string EventName,
string Input);
@@ -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; }
}
@@ -0,0 +1,245 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Analyzes workflow structure to extract executor metadata and build graph information
/// for message-driven execution.
/// </summary>
internal static class WorkflowAnalyzer
{
private const string AgentExecutorTypeName = "AIAgentHostExecutor";
private const string AgentAssemblyPrefix = "Microsoft.Agents.AI";
private const string ExecutorTypePrefix = "Executor";
/// <summary>
/// Analyzes a workflow instance and returns a list of executors with their metadata.
/// </summary>
/// <param name="workflow">The workflow instance to analyze.</param>
/// <returns>A list of executor information in workflow order.</returns>
internal static List<WorkflowExecutorInfo> GetExecutorsFromWorkflowInOrder(Workflow workflow)
{
ArgumentNullException.ThrowIfNull(workflow);
return workflow.ReflectExecutors()
.Select(kvp => CreateExecutorInfo(kvp.Key, kvp.Value))
.ToList();
}
/// <summary>
/// Builds the workflow graph information needed for message-driven execution.
/// </summary>
/// <remarks>
/// <para>
/// Extracts routing information including successors, predecessors, edge conditions,
/// and output types. Supports cyclic workflows through message-driven superstep execution.
/// </para>
/// <para>
/// The returned <see cref="WorkflowGraphInfo"/> is consumed by <c>DurableEdgeMap</c>
/// to build the runtime routing layer:
/// <c>Successors</c> become <c>IDurableEdgeRouter</c> instances,
/// <c>Predecessors</c> become fan-in counts, and
/// <c>EdgeConditions</c> / <c>ExecutorOutputTypes</c> are passed into
/// <c>DurableDirectEdgeRouter</c> for conditional routing with typed deserialization.
/// </para>
/// </remarks>
/// <param name="workflow">The workflow instance to analyze.</param>
/// <returns>A graph info object containing routing information.</returns>
internal static WorkflowGraphInfo BuildGraphInfo(Workflow workflow)
{
ArgumentNullException.ThrowIfNull(workflow);
Dictionary<string, ExecutorBinding> executors = workflow.ReflectExecutors();
WorkflowGraphInfo graphInfo = new()
{
StartExecutorId = workflow.StartExecutorId
};
InitializeExecutorMappings(graphInfo, executors);
PopulateGraphFromEdges(graphInfo, workflow.Edges);
return graphInfo;
}
/// <summary>
/// Determines whether the specified executor type is an agentic executor.
/// </summary>
/// <param name="executorType">The executor type to check.</param>
/// <returns><c>true</c> if the executor is an agentic executor; otherwise, <c>false</c>.</returns>
internal static bool IsAgentExecutorType(Type executorType)
{
string typeName = executorType.FullName ?? executorType.Name;
string assemblyName = executorType.Assembly.GetName().Name ?? string.Empty;
return typeName.Contains(AgentExecutorTypeName, StringComparison.OrdinalIgnoreCase)
&& assemblyName.Contains(AgentAssemblyPrefix, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Creates a <see cref="WorkflowExecutorInfo"/> from an executor binding.
/// </summary>
/// <param name="executorId">The unique identifier of the executor.</param>
/// <param name="binding">The executor binding containing type and configuration information.</param>
/// <returns>A new <see cref="WorkflowExecutorInfo"/> instance with extracted metadata.</returns>
private static WorkflowExecutorInfo CreateExecutorInfo(string executorId, ExecutorBinding binding)
{
bool isAgentic = IsAgentExecutorType(binding.ExecutorType);
RequestPort? requestPort = (binding is RequestPortBinding rpb) ? rpb.Port : null;
Workflow? subWorkflow = (binding is SubworkflowBinding swb) ? swb.WorkflowInstance : null;
return new WorkflowExecutorInfo(executorId, isAgentic, requestPort, subWorkflow);
}
/// <summary>
/// Initializes the graph info with empty collections for each executor.
/// </summary>
/// <param name="graphInfo">The graph info to initialize.</param>
/// <param name="executors">The dictionary of executor bindings.</param>
private static void InitializeExecutorMappings(WorkflowGraphInfo graphInfo, Dictionary<string, ExecutorBinding> executors)
{
foreach ((string executorId, ExecutorBinding binding) in executors)
{
graphInfo.Successors[executorId] = [];
graphInfo.Predecessors[executorId] = [];
graphInfo.ExecutorOutputTypes[executorId] = GetExecutorOutputType(binding.ExecutorType);
}
}
/// <summary>
/// Populates the graph info with successor/predecessor relationships and edge conditions.
/// </summary>
/// <param name="graphInfo">The graph info to populate.</param>
/// <param name="edges">The dictionary of edges grouped by source executor ID.</param>
private static void PopulateGraphFromEdges(WorkflowGraphInfo graphInfo, Dictionary<string, HashSet<Edge>> edges)
{
foreach ((string sourceId, HashSet<Edge> edgeSet) in edges)
{
List<string> successors = graphInfo.Successors[sourceId];
foreach (Edge edge in edgeSet)
{
AddSuccessorsFromEdge(graphInfo, sourceId, edge, successors);
TryAddEdgeCondition(graphInfo, edge);
}
}
}
/// <summary>
/// Adds successor relationships from an edge to the graph info.
/// </summary>
/// <param name="graphInfo">The graph info to update.</param>
/// <param name="sourceId">The source executor ID.</param>
/// <param name="edge">The edge containing connection information.</param>
/// <param name="successors">The list of successors to append to.</param>
private static void AddSuccessorsFromEdge(
WorkflowGraphInfo graphInfo,
string sourceId,
Edge edge,
List<string> successors)
{
foreach (string sinkId in edge.Data.Connection.SinkIds)
{
if (!graphInfo.Successors.ContainsKey(sinkId))
{
continue;
}
successors.Add(sinkId);
graphInfo.Predecessors[sinkId].Add(sourceId);
}
}
/// <summary>
/// Extracts and adds an edge condition to the graph info if present.
/// </summary>
/// <param name="graphInfo">The graph info to update.</param>
/// <param name="edge">The edge that may contain a condition.</param>
private static void TryAddEdgeCondition(WorkflowGraphInfo graphInfo, Edge edge)
{
DirectEdgeData? directEdge = edge.DirectEdgeData;
if (directEdge?.Condition is not null)
{
graphInfo.EdgeConditions[(directEdge.SourceId, directEdge.SinkId)] = directEdge.Condition;
}
}
/// <summary>
/// Extracts the output type from an executor type by walking the inheritance chain.
/// </summary>
/// <param name="executorType">The executor type to analyze.</param>
/// <returns>
/// The TOutput type for Executor&lt;TInput, TOutput&gt;,
/// or <c>null</c> for Executor&lt;TInput&gt; (void output) or non-executor types.
/// </returns>
private static Type? GetExecutorOutputType(Type executorType)
{
Type? currentType = executorType;
while (currentType is not null)
{
Type? outputType = TryExtractOutputTypeFromGeneric(currentType);
if (outputType is not null || IsVoidExecutorType(currentType))
{
return outputType;
}
currentType = currentType.BaseType;
}
return null;
}
/// <summary>
/// Attempts to extract the output type from a generic executor type.
/// </summary>
/// <param name="type">The type to inspect.</param>
/// <returns>The TOutput type if this is an Executor&lt;TInput, TOutput&gt;; otherwise, <c>null</c>.</returns>
private static Type? TryExtractOutputTypeFromGeneric(Type type)
{
if (!type.IsGenericType)
{
return null;
}
Type genericDefinition = type.GetGenericTypeDefinition();
Type[] genericArgs = type.GetGenericArguments();
bool isExecutorType = genericDefinition.Name.StartsWith(ExecutorTypePrefix, StringComparison.Ordinal);
if (!isExecutorType)
{
return null;
}
// Executor<TInput, TOutput> - return TOutput
if (genericArgs.Length == 2)
{
return genericArgs[1];
}
return null;
}
/// <summary>
/// Determines whether the type is a void-returning executor (Executor&lt;TInput&gt;).
/// </summary>
/// <param name="type">The type to check.</param>
/// <returns><c>true</c> if this is an Executor with a single type parameter; otherwise, <c>false</c>.</returns>
private static bool IsVoidExecutorType(Type type)
{
if (!type.IsGenericType)
{
return false;
}
Type genericDefinition = type.GetGenericTypeDefinition();
Type[] genericArgs = type.GetGenericArguments();
// Executor<TInput> with 1 type parameter indicates void return
return genericArgs.Length == 1
&& genericDefinition.Name.StartsWith(ExecutorTypePrefix, StringComparison.Ordinal);
}
}
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Represents an executor in the workflow with its metadata.
/// </summary>
/// <param name="ExecutorId">The unique identifier of the executor.</param>
/// <param name="IsAgenticExecutor">Indicates whether this executor is an agentic executor.</param>
/// <param name="RequestPort">The request port if this executor is a request port executor; otherwise, null.</param>
/// <param name="SubWorkflow">The sub-workflow if this executor is a sub-workflow executor; otherwise, null.</param>
internal sealed record WorkflowExecutorInfo(
string ExecutorId,
bool IsAgenticExecutor,
RequestPort? RequestPort = null,
Workflow? SubWorkflow = null)
{
/// <summary>
/// Gets a value indicating whether this executor is a request port executor (human-in-the-loop).
/// </summary>
public bool IsRequestPortExecutor => this.RequestPort is not null;
/// <summary>
/// Gets a value indicating whether this executor is a sub-workflow executor.
/// </summary>
public bool IsSubworkflowExecutor => this.SubWorkflow is not null;
}
@@ -0,0 +1,98 @@
// Copyright (c) Microsoft. All rights reserved.
// Example: Given this workflow graph with a fan-out from B and a fan-in at E,
// plus a conditional edge from B to D:
//
// [A] ──► [B] ──► [C] ──► [E]
// │ ▲
// └──► [D] ──────┘
// (condition:
// x => x.NeedsReview)
//
// WorkflowAnalyzer.BuildGraphInfo() produces:
//
// StartExecutorId = "A"
//
// Successors (who does each executor send output to?):
// ┌──────────┬──────────────┐
// │ "A" │ ["B"] │
// │ "B" │ ["C", "D"] │ ◄── fan-out: B sends to both C and D
// │ "C" │ ["E"] │
// │ "D" │ ["E"] │
// │ "E" │ [] │ ◄── terminal: no successors
// └──────────┴──────────────┘
//
// Predecessors (who feeds into each executor?):
// ┌──────────┬──────────────┐
// │ "A" │ [] │ ◄── start: no predecessors
// │ "B" │ ["A"] │
// │ "C" │ ["B"] │
// │ "D" │ ["B"] │
// │ "E" │ ["C", "D"] │ ◄── fan-in: count=2, messages will be aggregated
// └──────────┴──────────────┘
//
// EdgeConditions (which edges have routing conditions?):
// ┌──────────────────┬──────────────────────────┐
// │ ("B", "D") │ x => x.NeedsReview │ ◄── D only receives if condition is true
// └──────────────────┴──────────────────────────┘
// (The B→C edge has no condition, so C always receives B's output.)
//
// ExecutorOutputTypes (what type does each executor return?):
// ┌──────────┬──────────────────┐
// │ "A" │ typeof(string) │ ◄── used by DurableDirectEdgeRouter to deserialize
// │ "B" │ typeof(Order) │ the JSON message for condition evaluation
// │ "C" │ typeof(Report) │
// │ "D" │ typeof(Report) │
// │ "E" │ typeof(string) │
// └──────────┴──────────────────┘
//
// DurableEdgeMap then consumes this to build the runtime routing layer.
using System.Diagnostics;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Represents the workflow graph structure needed for message-driven execution.
/// </summary>
/// <remarks>
/// <para>
/// This is a simplified representation that contains only the information needed
/// for routing messages between executors during superstep execution:
/// </para>
/// <list type="bullet">
/// <item><description>Successors for routing messages forward</description></item>
/// <item><description>Predecessors for detecting fan-in points</description></item>
/// <item><description>Edge conditions for conditional routing</description></item>
/// <item><description>Output types for deserialization during condition evaluation</description></item>
/// </list>
/// </remarks>
[DebuggerDisplay("Start = {StartExecutorId}, Executors = {Successors.Count}")]
internal sealed class WorkflowGraphInfo
{
/// <summary>
/// Gets or sets the starting executor ID for the workflow.
/// </summary>
public string StartExecutorId { get; set; } = string.Empty;
/// <summary>
/// Maps each executor ID to its successors (for message routing).
/// </summary>
public Dictionary<string, List<string>> Successors { get; } = [];
/// <summary>
/// Maps each executor ID to its predecessors (for fan-in detection).
/// </summary>
public Dictionary<string, List<string>> Predecessors { get; } = [];
/// <summary>
/// Maps edge connections (sourceId, targetId) to their condition functions.
/// The condition function takes the predecessor's result and returns true if the edge should be followed.
/// </summary>
public Dictionary<(string SourceId, string TargetId), Func<object?, bool>?> EdgeConditions { get; } = [];
/// <summary>
/// Maps executor IDs to their output types (for proper deserialization during condition evaluation).
/// </summary>
public Dictionary<string, Type?> ExecutorOutputTypes { get; } = [];
}
@@ -0,0 +1,113 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Provides helper methods for workflow naming conventions used in durable orchestrations.
/// </summary>
internal static class WorkflowNamingHelper
{
internal const string OrchestrationFunctionPrefix = "dafx-";
private const char ExecutorIdSuffixSeparator = '_';
/// <summary>
/// Converts a workflow name to its corresponding orchestration function name.
/// </summary>
/// <param name="workflowName">The workflow name.</param>
/// <returns>The orchestration function name.</returns>
/// <exception cref="ArgumentException">Thrown when the workflow name is null or empty.</exception>
internal static string ToOrchestrationFunctionName(string workflowName)
{
ArgumentException.ThrowIfNullOrEmpty(workflowName);
return string.Concat(OrchestrationFunctionPrefix, workflowName);
}
/// <summary>
/// Converts an orchestration function name back to its workflow name.
/// </summary>
/// <param name="orchestrationFunctionName">The orchestration function name.</param>
/// <returns>The workflow name.</returns>
/// <exception cref="ArgumentException">Thrown when the orchestration function name is null, empty, or doesn't have the expected prefix.</exception>
internal static string ToWorkflowName(string orchestrationFunctionName)
{
ArgumentException.ThrowIfNullOrEmpty(orchestrationFunctionName);
if (!TryGetWorkflowName(orchestrationFunctionName, out string? workflowName))
{
throw new ArgumentException(
$"Orchestration function name '{orchestrationFunctionName}' does not have the expected '{OrchestrationFunctionPrefix}' prefix or is missing a workflow name.",
nameof(orchestrationFunctionName));
}
return workflowName;
}
/// <summary>
/// Extracts the executor name from an executor ID.
/// </summary>
/// <remarks>
/// <para>
/// For non-agentic executors, the executor ID is the same as the executor name (e.g., "OrderParser").
/// </para>
/// <para>
/// For agentic executors, the workflow builder appends a GUID suffix separated by an underscore
/// (e.g., "Physicist_8884e71021334ce49517fa2b17b1695b"). This method extracts just the name portion.
/// </para>
/// </remarks>
/// <param name="executorId">The executor ID, which may contain a GUID suffix.</param>
/// <returns>The executor name without any GUID suffix.</returns>
/// <exception cref="ArgumentException">Thrown when the executor ID is null or empty.</exception>
internal static string GetExecutorName(string executorId)
{
ArgumentException.ThrowIfNullOrEmpty(executorId);
int separatorIndex = executorId.LastIndexOf(ExecutorIdSuffixSeparator);
if (separatorIndex > 0)
{
ReadOnlySpan<char> suffix = executorId.AsSpan(separatorIndex + 1);
if (IsGuidSuffix(suffix))
{
return executorId[..separatorIndex];
}
}
return executorId;
}
/// <summary>
/// Checks whether the given span looks like a sanitized GUID (32 hex characters).
/// </summary>
private static bool IsGuidSuffix(ReadOnlySpan<char> value)
{
if (value.Length != 32)
{
return false;
}
foreach (char c in value)
{
if (!char.IsAsciiHexDigit(c))
{
return false;
}
}
return true;
}
private static bool TryGetWorkflowName(string? orchestrationFunctionName, [NotNullWhen(true)] out string? workflowName)
{
workflowName = null;
if (string.IsNullOrEmpty(orchestrationFunctionName) ||
!orchestrationFunctionName.StartsWith(OrchestrationFunctionPrefix, StringComparison.Ordinal))
{
return false;
}
workflowName = orchestrationFunctionName[OrchestrationFunctionPrefix.Length..];
return workflowName.Length > 0;
}
}
@@ -21,6 +21,15 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor
{
ArgumentNullException.ThrowIfNull(context);
// Orchestration triggers use a different input binding mechanism than other triggers.
// The encoded orchestrator state is retrieved via BindInputAsync on the orchestration trigger binding,
// not through IFunctionInputBindingFeature. Handle this case first to avoid unnecessary binding work.
if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RunWorkflowOrchestrationFunctionEntryPoint)
{
await ExecuteOrchestrationAsync(context);
return;
}
// Acquire the input binding feature (fail fast if missing rather than null-forgiving operator).
IFunctionInputBindingFeature? functionInputBindingFeature = context.Features.Get<IFunctionInputBindingFeature>() ??
throw new InvalidOperationException("Function input binding feature is not available on the current context.");
@@ -57,11 +66,67 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor
if (durableTaskClient is null)
{
// This is not expected to happen since all built-in functions are
// expected to have a Durable Task client binding.
// This is not expected to happen since all built-in functions (other than orchestration triggers)
// are expected to have a Durable Task client binding.
throw new InvalidOperationException($"Durable Task client binding is missing for the invocation {context.InvocationId}.");
}
if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RunWorkflowOrchestrationHttpFunctionEntryPoint)
{
if (httpRequestData == null)
{
throw new InvalidOperationException($"HTTP request data binding is missing for the invocation {context.InvocationId}.");
}
context.GetInvocationResult().Value = await BuiltInFunctions.RunWorkflowOrchestrationHttpTriggerAsync(
httpRequestData,
durableTaskClient,
context);
return;
}
if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.GetWorkflowStatusHttpFunctionEntryPoint)
{
if (httpRequestData == null)
{
throw new InvalidOperationException($"HTTP request data binding is missing for the invocation {context.InvocationId}.");
}
context.GetInvocationResult().Value = await BuiltInFunctions.GetWorkflowStatusAsync(
httpRequestData,
durableTaskClient,
context);
return;
}
if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RespondToWorkflowHttpFunctionEntryPoint)
{
if (httpRequestData == null)
{
throw new InvalidOperationException($"HTTP request data binding is missing for the invocation {context.InvocationId}.");
}
context.GetInvocationResult().Value = await BuiltInFunctions.RespondToWorkflowAsync(
httpRequestData,
durableTaskClient,
context);
return;
}
if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint)
{
if (encodedEntityRequest is null)
{
throw new InvalidOperationException($"Activity trigger input binding is missing for the invocation {context.InvocationId}.");
}
context.GetInvocationResult().Value = await BuiltInFunctions.InvokeWorkflowActivityAsync(
encodedEntityRequest,
durableTaskClient,
context);
return;
}
if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RunAgentHttpFunctionEntryPoint)
{
if (httpRequestData == null)
@@ -70,9 +135,9 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor
}
context.GetInvocationResult().Value = await BuiltInFunctions.RunAgentHttpAsync(
httpRequestData,
durableTaskClient,
context);
httpRequestData,
durableTaskClient,
context);
return;
}
@@ -104,4 +169,32 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor
throw new InvalidOperationException($"Unsupported function entry point '{context.FunctionDefinition.EntryPoint}' for invocation {context.InvocationId}.");
}
private static async ValueTask ExecuteOrchestrationAsync(FunctionContext context)
{
BindingMetadata? orchestrationBinding = null;
foreach (BindingMetadata binding in context.FunctionDefinition.InputBindings.Values)
{
if (string.Equals(binding.Type, "orchestrationTrigger", StringComparison.OrdinalIgnoreCase))
{
orchestrationBinding = binding;
break;
}
}
if (orchestrationBinding is null)
{
throw new InvalidOperationException($"Orchestration trigger binding is missing for the invocation {context.InvocationId}.");
}
InputBindingData<object> triggerInputData = await context.BindInputAsync<object>(orchestrationBinding);
if (triggerInputData?.Value is not string encodedOrchestratorState)
{
throw new InvalidOperationException($"Orchestration history state was either missing from the input or not a string value for invocation {context.InvocationId}.");
}
context.GetInvocationResult().Value = BuiltInFunctions.RunWorkflowOrchestration(
encodedOrchestratorState,
context);
}
}
@@ -1,11 +1,14 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Net;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Agents.AI.DurableTask.Workflows;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Extensions.Mcp;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.DurableTask;
using Microsoft.DurableTask.Client;
using Microsoft.DurableTask.Worker.Grpc;
using Microsoft.Extensions.AI;
@@ -21,6 +24,203 @@ internal static class BuiltInFunctions
internal static readonly string RunAgentHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunAgentHttpAsync)}";
internal static readonly string RunAgentEntityFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(InvokeAgentAsync)}";
internal static readonly string RunAgentMcpToolFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunMcpToolAsync)}";
internal static readonly string RunWorkflowOrchestrationHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunWorkflowOrchestrationHttpTriggerAsync)}";
internal static readonly string RunWorkflowOrchestrationFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunWorkflowOrchestration)}";
internal static readonly string InvokeWorkflowActivityFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(InvokeWorkflowActivityAsync)}";
internal static readonly string GetWorkflowStatusHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(GetWorkflowStatusAsync)}";
internal static readonly string RespondToWorkflowHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RespondToWorkflowAsync)}";
#pragma warning disable IL3000 // Avoid accessing Assembly file path when publishing as a single file - Azure Functions does not use single-file publishing
internal static readonly string ScriptFile = Path.GetFileName(typeof(BuiltInFunctions).Assembly.Location);
#pragma warning restore IL3000
/// <summary>
/// Starts a workflow orchestration in response to an HTTP request.
/// The workflow name is derived from the function name by stripping the <see cref="HttpPrefix"/>.
/// Callers can optionally provide a custom run ID via the <c>runId</c> query string parameter
/// (e.g., <c>/api/workflows/MyWorkflow/run?runId=my-id</c>). If not provided, one is auto-generated.
/// </summary>
public static async Task<HttpResponseData> RunWorkflowOrchestrationHttpTriggerAsync(
[HttpTrigger] HttpRequestData req,
[DurableClient] DurableTaskClient client,
FunctionContext context)
{
string workflowName = context.FunctionDefinition.Name.Replace(HttpPrefix, string.Empty);
string orchestrationFunctionName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflowName);
string? inputMessage = await req.ReadAsStringAsync();
if (string.IsNullOrEmpty(inputMessage))
{
return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Workflow input cannot be empty.");
}
DurableWorkflowInput<string> orchestrationInput = new() { Input = inputMessage };
// Allow users to provide a custom run ID via query string; otherwise, auto-generate one.
string? instanceId = req.Query["runId"];
StartOrchestrationOptions? options = instanceId is not null ? new StartOrchestrationOptions(instanceId) : null;
string resolvedInstanceId = await client.ScheduleNewOrchestrationInstanceAsync(orchestrationFunctionName, orchestrationInput, options);
HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted);
await response.WriteStringAsync($"Workflow orchestration started for {workflowName}. Orchestration runId: {resolvedInstanceId}");
return response;
}
/// <summary>
/// Returns the workflow status including any pending HITL requests.
/// The run ID is extracted from the route parameter <c>{runId}</c>.
/// </summary>
public static async Task<HttpResponseData> GetWorkflowStatusAsync(
[HttpTrigger] HttpRequestData req,
[DurableClient] DurableTaskClient client,
FunctionContext context)
{
string? runId = context.BindingContext.BindingData.TryGetValue("runId", out object? value) ? value?.ToString() : null;
if (string.IsNullOrEmpty(runId))
{
return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Run ID is required.");
}
OrchestrationMetadata? metadata = await client.GetInstanceAsync(runId, getInputsAndOutputs: true);
if (metadata is null)
{
return await CreateErrorResponseAsync(req, context, HttpStatusCode.NotFound, $"Workflow run '{runId}' not found.");
}
// Parse HITL inputs the workflow is waiting for from the durable workflow status
List<PendingRequestPortStatus>? waitingForInput = null;
if (DurableWorkflowLiveStatus.TryParse(metadata.SerializedCustomStatus, out DurableWorkflowLiveStatus liveStatus)
&& liveStatus.PendingEvents.Count > 0)
{
waitingForInput = liveStatus.PendingEvents;
}
HttpResponseData response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
runId,
status = metadata.RuntimeStatus.ToString(),
waitingForInput = waitingForInput?.Select(p => new { eventName = p.EventName, input = JsonDocument.Parse(p.Input).RootElement })
});
return response;
}
/// <summary>
/// Sends a response to a pending RequestPort, resuming the workflow.
/// Expects a JSON body: <c>{ "eventName": "...", "response": { ... } }</c>.
/// </summary>
public static async Task<HttpResponseData> RespondToWorkflowAsync(
[HttpTrigger] HttpRequestData req,
[DurableClient] DurableTaskClient client,
FunctionContext context)
{
string? runId = context.BindingContext.BindingData.TryGetValue("runId", out object? value) ? value?.ToString() : null;
if (string.IsNullOrEmpty(runId))
{
return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Run ID is required.");
}
WorkflowRespondRequest? request;
try
{
request = await req.ReadFromJsonAsync<WorkflowRespondRequest>(context.CancellationToken);
}
catch (JsonException)
{
return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Request body is not valid JSON.");
}
if (request is null || string.IsNullOrEmpty(request.EventName)
|| request.Response.ValueKind == JsonValueKind.Undefined)
{
return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Body must contain a non-empty 'eventName' and a 'response' property.");
}
// Verify the orchestration exists and is in a valid state
OrchestrationMetadata? metadata = await client.GetInstanceAsync(runId, getInputsAndOutputs: true);
if (metadata is null)
{
return await CreateErrorResponseAsync(req, context, HttpStatusCode.NotFound, $"Workflow run '{runId}' not found.");
}
if (metadata.RuntimeStatus is OrchestrationRuntimeStatus.Completed
or OrchestrationRuntimeStatus.Failed
or OrchestrationRuntimeStatus.Terminated)
{
return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest,
$"Workflow run '{runId}' is in terminal state '{metadata.RuntimeStatus}'.");
}
// Verify the workflow is waiting for the specified event.
// If status can't be parsed (e.g., not yet set during early execution), allow the event through —
// Durable Task safely queues it until the orchestration reaches WaitForExternalEvent.
bool eventValidated = false;
if (DurableWorkflowLiveStatus.TryParse(metadata.SerializedCustomStatus, out DurableWorkflowLiveStatus liveStatus))
{
if (!liveStatus.PendingEvents.Exists(p => string.Equals(p.EventName, request.EventName, StringComparison.Ordinal)))
{
return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest,
$"Workflow is not waiting for event '{request.EventName}'.");
}
eventValidated = true;
}
// Raise the external event to unblock the orchestration's WaitForExternalEvent call
await client.RaiseEventAsync(runId, request.EventName, request.Response.GetRawText());
HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted);
await response.WriteAsJsonAsync(new
{
message = eventValidated
? "Response sent to workflow."
: "Response sent to workflow. Event could not be validated against pending requests.",
runId,
eventName = request.EventName,
validated = eventValidated,
});
return response;
}
/// <summary>
/// Executes a workflow activity by looking up the registered executor and delegating to it.
/// The executor name is derived from the activity function name via <see cref="WorkflowNamingHelper"/>.
/// </summary>
public static Task<string> InvokeWorkflowActivityAsync(
[ActivityTrigger] string input,
[DurableClient] DurableTaskClient durableTaskClient,
FunctionContext functionContext)
{
ArgumentNullException.ThrowIfNull(input);
ArgumentNullException.ThrowIfNull(durableTaskClient);
ArgumentNullException.ThrowIfNull(functionContext);
string activityFunctionName = functionContext.FunctionDefinition.Name;
string executorName = WorkflowNamingHelper.ToWorkflowName(activityFunctionName);
DurableOptions durableOptions = functionContext.InstanceServices.GetRequiredService<DurableOptions>();
if (!durableOptions.Workflows.Executors.TryGetExecutor(executorName, out ExecutorRegistration? registration))
{
throw new InvalidOperationException($"Executor '{executorName}' not found in workflow options.");
}
return DurableActivityExecutor.ExecuteAsync(registration.Binding, input, functionContext.CancellationToken);
}
/// <summary>
/// Runs a workflow orchestration by delegating to <see cref="WorkflowOrchestrator"/>
/// via <see cref="GrpcOrchestrationRunner"/>.
/// </summary>
public static string RunWorkflowOrchestration(
string encodedOrchestratorRequest,
FunctionContext functionContext)
{
ArgumentNullException.ThrowIfNull(encodedOrchestratorRequest);
ArgumentNullException.ThrowIfNull(functionContext);
WorkflowOrchestrator orchestrator = new(functionContext.InstanceServices);
return GrpcOrchestrationRunner.LoadAndRun(encodedOrchestratorRequest, orchestrator, functionContext.InstanceServices);
}
// Exposed as an entity trigger via AgentFunctionsProvider
public static Task<string> InvokeAgentAsync(
@@ -332,6 +532,15 @@ internal static class BuiltInFunctions
[property: JsonPropertyName("status")] int Status,
[property: JsonPropertyName("thread_id")] string ThreadId);
/// <summary>
/// Represents a request to respond to a pending RequestPort in a workflow.
/// </summary>
/// <param name="EventName">The name of the event to raise (the RequestPort ID).</param>
/// <param name="Response">The response payload to send to the workflow.</param>
private sealed record WorkflowRespondRequest(
[property: JsonPropertyName("eventName")] string? EventName,
[property: JsonPropertyName("response")] JsonElement Response);
/// <summary>
/// A service provider that combines the original service provider with an additional DurableTaskClient instance.
/// </summary>
@@ -1,6 +1,10 @@
# Release History
## <version>
## [Unreleased]
- Added Azure Functions hosting support for durable workflows ([#4436](https://github.com/microsoft/agent-framework/pull/4436))
## v1.0.0-preview.251219.1
- Addressed incompatibility issue with `Microsoft.Azure.Functions.Worker.Extensions.DurableTask` >= 1.11.0 ([#2759](https://github.com/microsoft/agent-framework/pull/2759))
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata;
using Microsoft.Extensions.Logging;
@@ -17,10 +16,6 @@ internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadat
private readonly IServiceProvider _serviceProvider;
private readonly IFunctionsAgentOptionsProvider _functionsAgentOptionsProvider;
#pragma warning disable IL3000 // Avoid accessing Assembly file path when publishing as a single file - Azure Functions does not use single-file publishing
private static readonly string s_builtInFunctionsScriptFile = Path.GetFileName(typeof(BuiltInFunctions).Assembly.Location);
#pragma warning restore IL3000
public DurableAgentFunctionMetadataTransformer(
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> agents,
ILogger<DurableAgentFunctionMetadataTransformer> logger,
@@ -45,14 +40,14 @@ internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadat
this._logger.LogRegisteringTriggerForAgent(agentName, "entity");
original.Add(CreateAgentTrigger(agentName));
original.Add(FunctionMetadataFactory.CreateEntityTrigger(agentName));
if (this._functionsAgentOptionsProvider.TryGet(agentName, out FunctionsAgentOptions? agentTriggerOptions))
{
if (agentTriggerOptions.HttpTrigger.IsEnabled)
{
this._logger.LogRegisteringTriggerForAgent(agentName, "http");
original.Add(CreateHttpTrigger(agentName, $"agents/{agentName}/run"));
original.Add(FunctionMetadataFactory.CreateHttpTrigger(agentName, $"agents/{agentName}/run", BuiltInFunctions.RunAgentHttpFunctionEntryPoint));
}
if (agentTriggerOptions.McpToolTrigger.IsEnabled)
@@ -65,39 +60,6 @@ internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadat
}
}
private static DefaultFunctionMetadata CreateAgentTrigger(string name)
{
return new DefaultFunctionMetadata()
{
Name = AgentSessionId.ToEntityName(name),
Language = "dotnet-isolated",
RawBindings =
[
"""{"name":"encodedEntityRequest","type":"entityTrigger","direction":"In"}""",
"""{"name":"client","type":"durableClient","direction":"In"}"""
],
EntryPoint = BuiltInFunctions.RunAgentEntityFunctionEntryPoint,
ScriptFile = s_builtInFunctionsScriptFile,
};
}
private static DefaultFunctionMetadata CreateHttpTrigger(string name, string route)
{
return new DefaultFunctionMetadata()
{
Name = $"{BuiltInFunctions.HttpPrefix}{name}",
Language = "dotnet-isolated",
RawBindings =
[
$"{{\"name\":\"req\",\"type\":\"httpTrigger\",\"direction\":\"In\",\"authLevel\":\"function\",\"methods\": [\"post\"],\"route\":\"{route}\"}}",
"{\"name\":\"$return\",\"type\":\"http\",\"direction\":\"Out\"}",
"{\"name\":\"client\",\"type\":\"durableClient\",\"direction\":\"In\"}"
],
EntryPoint = BuiltInFunctions.RunAgentHttpFunctionEntryPoint,
ScriptFile = s_builtInFunctionsScriptFile,
};
}
private static DefaultFunctionMetadata CreateMcpToolTrigger(string agentName, string? description)
{
return new DefaultFunctionMetadata
@@ -112,7 +74,7 @@ internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadat
"""{"name":"client","type":"durableClient","direction":"In"}"""
],
EntryPoint = BuiltInFunctions.RunAgentMcpToolFunctionEntryPoint,
ScriptFile = s_builtInFunctionsScriptFile,
ScriptFile = BuiltInFunctions.ScriptFile,
};
}
}
@@ -0,0 +1,101 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata;
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
/// <summary>
/// Provides factory methods for creating common <see cref="DefaultFunctionMetadata"/> instances
/// used by function metadata transformers.
/// </summary>
internal static class FunctionMetadataFactory
{
/// <summary>
/// Creates function metadata for an entity trigger function.
/// </summary>
/// <param name="name">The base name used to derive the entity function name.</param>
/// <returns>A <see cref="DefaultFunctionMetadata"/> configured for an entity trigger.</returns>
internal static DefaultFunctionMetadata CreateEntityTrigger(string name)
{
return new DefaultFunctionMetadata()
{
Name = AgentSessionId.ToEntityName(name),
Language = "dotnet-isolated",
RawBindings =
[
"""{"name":"encodedEntityRequest","type":"entityTrigger","direction":"In"}""",
"""{"name":"client","type":"durableClient","direction":"In"}"""
],
EntryPoint = BuiltInFunctions.RunAgentEntityFunctionEntryPoint,
ScriptFile = BuiltInFunctions.ScriptFile,
};
}
/// <summary>
/// Creates function metadata for an HTTP trigger function.
/// </summary>
/// <param name="name">The base name used to derive the HTTP function name.</param>
/// <param name="route">The HTTP route for the trigger.</param>
/// <param name="entryPoint">The entry point method for the HTTP trigger.</param>
/// <param name="methods">The allowed HTTP methods as a JSON array fragment (e.g., <c>"\"get\""</c>). Defaults to POST.</param>
/// <returns>A <see cref="DefaultFunctionMetadata"/> configured for an HTTP trigger.</returns>
internal static DefaultFunctionMetadata CreateHttpTrigger(string name, string route, string entryPoint, string methods = "\"post\"")
{
return new DefaultFunctionMetadata()
{
Name = $"{BuiltInFunctions.HttpPrefix}{name}",
Language = "dotnet-isolated",
RawBindings =
[
$"{{\"name\":\"req\",\"type\":\"httpTrigger\",\"direction\":\"In\",\"authLevel\":\"function\",\"methods\": [{methods}],\"route\":\"{route}\"}}",
"{\"name\":\"$return\",\"type\":\"http\",\"direction\":\"Out\"}",
"{\"name\":\"client\",\"type\":\"durableClient\",\"direction\":\"In\"}"
],
EntryPoint = entryPoint,
ScriptFile = BuiltInFunctions.ScriptFile,
};
}
/// <summary>
/// Creates function metadata for an activity trigger function.
/// </summary>
/// <param name="functionName">The name of the activity function.</param>
/// <returns>A <see cref="DefaultFunctionMetadata"/> configured for an activity trigger.</returns>
internal static DefaultFunctionMetadata CreateActivityTrigger(string functionName)
{
return new DefaultFunctionMetadata()
{
Name = functionName,
Language = "dotnet-isolated",
RawBindings =
[
"""{"name":"input","type":"activityTrigger","direction":"In","dataType":"String"}""",
"""{"name":"durableTaskClient","type":"durableClient","direction":"In"}"""
],
EntryPoint = BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint,
ScriptFile = BuiltInFunctions.ScriptFile,
};
}
/// <summary>
/// Creates function metadata for an orchestration trigger function.
/// </summary>
/// <param name="functionName">The name of the orchestration function.</param>
/// <param name="entryPoint">The entry point method for the orchestration trigger.</param>
/// <returns>A <see cref="DefaultFunctionMetadata"/> configured for an orchestration trigger.</returns>
internal static DefaultFunctionMetadata CreateOrchestrationTrigger(string functionName, string entryPoint)
{
return new DefaultFunctionMetadata()
{
Name = functionName,
Language = "dotnet-isolated",
RawBindings =
[
"""{"name":"context","type":"orchestrationTrigger","direction":"In"}"""
],
EntryPoint = entryPoint,
ScriptFile = BuiltInFunctions.ScriptFile,
};
}
}
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Agents.AI.DurableTask.Workflows;
using Microsoft.Azure.Functions.Worker.Builder;
using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata;
using Microsoft.Extensions.DependencyInjection;
@@ -43,4 +44,90 @@ public static class FunctionsApplicationBuilderExtensions
return builder;
}
/// <summary>
/// Configures durable options for the functions application, allowing customization of Durable Task framework
/// settings.
/// </summary>
/// <remarks>This method ensures that a single shared <see cref="DurableOptions"/> instance is used across all
/// configuration calls. If any workflows have been added, it configures the necessary orchestrations and registers
/// required middleware.</remarks>
/// <param name="builder">The functions application builder to configure. Cannot be null.</param>
/// <param name="configure">An action that configures the <see cref="DurableOptions"/> instance. Cannot be null.</param>
/// <returns>The updated <see cref="FunctionsApplicationBuilder"/> instance, enabling method chaining.</returns>
public static FunctionsApplicationBuilder ConfigureDurableOptions(
this FunctionsApplicationBuilder builder,
Action<DurableOptions> configure)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(configure);
// Ensure FunctionsDurableOptions is registered BEFORE the core extension creates a plain DurableOptions
FunctionsDurableOptions sharedOptions = GetOrCreateSharedOptions(builder.Services);
builder.Services.ConfigureDurableOptions(configure);
if (sharedOptions.Workflows.Workflows.Count > 0)
{
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IFunctionMetadataTransformer, DurableWorkflowsFunctionMetadataTransformer>());
}
EnsureMiddlewareRegistered(builder);
return builder;
}
/// <summary>
/// Configures durable workflow support for the specified Azure Functions application builder.
/// </summary>
/// <param name="builder">The <see cref="FunctionsApplicationBuilder"/> instance to configure for durable workflows.</param>
/// <param name="configure">An action that configures the <see cref="DurableWorkflowOptions"/>, allowing customization of durable workflow behavior.</param>
/// <returns>The updated <see cref="FunctionsApplicationBuilder"/> instance, enabling method chaining.</returns>
public static FunctionsApplicationBuilder ConfigureDurableWorkflows(
this FunctionsApplicationBuilder builder,
Action<DurableWorkflowOptions> configure)
{
ArgumentNullException.ThrowIfNull(configure);
return builder.ConfigureDurableOptions(options => configure(options.Workflows));
}
private static void EnsureMiddlewareRegistered(FunctionsApplicationBuilder builder)
{
// Guard against registering the middleware filter multiple times in the pipeline.
if (builder.Services.Any(d => d.ServiceType == typeof(BuiltInFunctionExecutor)))
{
return;
}
builder.UseWhen<BuiltInFunctionExecutionMiddleware>(static context =>
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentHttpFunctionEntryPoint, StringComparison.Ordinal) ||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentEntityFunctionEntryPoint, StringComparison.Ordinal) ||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowOrchestrationHttpFunctionEntryPoint, StringComparison.Ordinal) ||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowOrchestrationFunctionEntryPoint, StringComparison.Ordinal) ||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint, StringComparison.Ordinal) ||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.GetWorkflowStatusHttpFunctionEntryPoint, StringComparison.Ordinal) ||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RespondToWorkflowHttpFunctionEntryPoint, StringComparison.Ordinal)
);
builder.Services.TryAddSingleton<BuiltInFunctionExecutor>();
}
/// <summary>
/// Gets or creates a shared <see cref="DurableOptions"/> instance from the service collection.
/// </summary>
private static FunctionsDurableOptions GetOrCreateSharedOptions(IServiceCollection services)
{
ServiceDescriptor? existingDescriptor = services.FirstOrDefault(
d => d.ServiceType == typeof(DurableOptions) && d.ImplementationInstance is not null);
if (existingDescriptor?.ImplementationInstance is FunctionsDurableOptions existing)
{
return existing;
}
FunctionsDurableOptions options = new();
services.AddSingleton<DurableOptions>(options);
services.AddSingleton(options);
return options;
}
}
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.DurableTask;
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
/// <summary>
/// Provides Azure Functionsspecific configuration for durable workflows.
/// </summary>
internal sealed class FunctionsDurableOptions : DurableOptions
{
private readonly HashSet<string> _statusEndpointWorkflows = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Enables the status HTTP endpoint for the specified workflow.
/// </summary>
internal void EnableStatusEndpoint(string workflowName)
{
this._statusEndpointWorkflows.Add(workflowName);
}
/// <summary>
/// Returns whether the status endpoint is enabled for the specified workflow.
/// </summary>
internal bool IsStatusEndpointEnabled(string workflowName)
{
return this._statusEndpointWorkflows.Contains(workflowName);
}
}
@@ -17,4 +17,16 @@ internal static partial class Logs
Level = LogLevel.Information,
Message = "Registering {TriggerType} function for agent '{AgentName}'")]
public static partial void LogRegisteringTriggerForAgent(this ILogger logger, string agentName, string triggerType);
[LoggerMessage(
EventId = 102,
Level = LogLevel.Information,
Message = "Registering {TriggerType} trigger function '{FunctionName}' for workflow '{WorkflowKey}'")]
public static partial void LogRegisteringWorkflowTrigger(this ILogger logger, string workflowKey, string functionName, string triggerType);
[LoggerMessage(
EventId = 103,
Level = LogLevel.Information,
Message = "Function metadata transformation complete. Added {AddedCount} workflow function(s). Total function count: {TotalCount}")]
public static partial void LogTransformationComplete(this ILogger logger, int addedCount, int totalCount);
}
@@ -4,7 +4,8 @@
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<!-- CA2007: This rule should generally be suppressed in Durable Task libraries. Also, this is not library code. -->
<NoWarn>$(NoWarn);CA2007</NoWarn>
<!-- AD0001: Temporary workaround for Microsoft.DurableTask.Analyzers v0.2.0 bug (ArgumentNullException on 'node'). Remove when upgrading to a fixed analyzer version. -->
<NoWarn>$(NoWarn);CA2007;AD0001</NoWarn>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
@@ -0,0 +1,30 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.DurableTask.Workflows;
using Microsoft.Agents.AI.Workflows;
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
/// <summary>
/// Extension methods for <see cref="DurableWorkflowOptions"/> to configure Azure Functions HTTP trigger options.
/// </summary>
public static class DurableWorkflowOptionsExtensions
{
/// <summary>
/// Adds a workflow and optionally exposes a status HTTP endpoint for querying pending HITL requests.
/// </summary>
/// <param name="options">The workflow options to add the workflow to.</param>
/// <param name="workflow">The workflow instance to add.</param>
/// <param name="exposeStatusEndpoint">If <see langword="true"/>, a GET endpoint is generated at <c>workflows/{name}/status/{runId}</c>.</param>
public static void AddWorkflow(this DurableWorkflowOptions options, Workflow workflow, bool exposeStatusEndpoint)
{
ArgumentNullException.ThrowIfNull(options);
options.AddWorkflow(workflow);
if (exposeStatusEndpoint && options.ParentOptions is FunctionsDurableOptions functionsOptions)
{
functionsOptions.EnableStatusEndpoint(workflow.Name!);
}
}
}
@@ -0,0 +1,152 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Agents.AI.DurableTask.Workflows;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
/// <summary>
/// Transforms function metadata by dynamically registering Azure Functions triggers
/// for each configured durable workflow and its executors.
/// </summary>
/// <remarks>
/// For each workflow, this transformer registers:
/// <list type="bullet">
/// <item><description>An HTTP trigger function to start the workflow orchestration via HTTP.</description></item>
/// <item><description>An orchestration trigger function to run the workflow orchestration.</description></item>
/// <item><description>An activity trigger function for each non-agent executor in the workflow.</description></item>
/// <item><description>An entity trigger function for each AI agent executor in the workflow.</description></item>
/// </list>
/// When multiple workflows share the same executor, the corresponding function is registered only once.
/// </remarks>
internal sealed class DurableWorkflowsFunctionMetadataTransformer : IFunctionMetadataTransformer
{
private readonly ILogger<DurableWorkflowsFunctionMetadataTransformer> _logger;
private readonly FunctionsDurableOptions _options;
/// <summary>
/// Initializes a new instance of the <see cref="DurableWorkflowsFunctionMetadataTransformer"/> class.
/// </summary>
/// <param name="logger">The logger instance for diagnostic output.</param>
/// <param name="durableOptions">The durable options containing workflow configurations.</param>
public DurableWorkflowsFunctionMetadataTransformer(
ILogger<DurableWorkflowsFunctionMetadataTransformer> logger,
FunctionsDurableOptions durableOptions)
{
this._logger = logger ?? throw new ArgumentNullException(nameof(logger));
ArgumentNullException.ThrowIfNull(durableOptions);
this._options = durableOptions;
}
/// <inheritdoc />
public string Name => nameof(DurableWorkflowsFunctionMetadataTransformer);
/// <inheritdoc />
public void Transform(IList<IFunctionMetadata> original)
{
int initialCount = original.Count;
this._logger.LogTransformingFunctionMetadata(initialCount);
// Track registered function names to avoid duplicates when workflows share executors.
HashSet<string> registeredFunctions = [];
DurableWorkflowOptions workflowOptions = this._options.Workflows;
foreach (var workflow in workflowOptions.Workflows)
{
string httpFunctionName = $"{BuiltInFunctions.HttpPrefix}{workflow.Key}";
if (this._logger.IsEnabled(LogLevel.Information))
{
this._logger.LogInformation("Registering durable workflow functions for workflow '{WorkflowKey}' with HTTP trigger function name '{HttpFunctionName}'", workflow.Key, httpFunctionName);
}
// Register an orchestration function for the workflow.
string orchestrationFunctionName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflow.Key);
if (registeredFunctions.Add(orchestrationFunctionName))
{
this._logger.LogRegisteringWorkflowTrigger(workflow.Key, orchestrationFunctionName, "orchestration");
original.Add(FunctionMetadataFactory.CreateOrchestrationTrigger(
orchestrationFunctionName,
BuiltInFunctions.RunWorkflowOrchestrationFunctionEntryPoint));
}
// Register an HTTP trigger so users can start this workflow via HTTP.
if (registeredFunctions.Add(httpFunctionName))
{
this._logger.LogRegisteringWorkflowTrigger(workflow.Key, httpFunctionName, "http");
original.Add(FunctionMetadataFactory.CreateHttpTrigger(
workflow.Key,
$"workflows/{workflow.Key}/run",
BuiltInFunctions.RunWorkflowOrchestrationHttpFunctionEntryPoint));
}
// Register a status endpoint if opted in via AddWorkflow(exposeStatusEndpoint: true).
if (this._options.IsStatusEndpointEnabled(workflow.Key))
{
string statusFunctionName = $"{BuiltInFunctions.HttpPrefix}{workflow.Key}-status";
if (registeredFunctions.Add(statusFunctionName))
{
this._logger.LogRegisteringWorkflowTrigger(workflow.Key, statusFunctionName, "http-status");
original.Add(FunctionMetadataFactory.CreateHttpTrigger(
$"{workflow.Key}-status",
$"workflows/{workflow.Key}/status/{{runId}}",
BuiltInFunctions.GetWorkflowStatusHttpFunctionEntryPoint,
methods: "\"get\""));
}
}
// Register a respond endpoint when the workflow contains RequestPort nodes.
bool hasRequestPorts = workflow.Value.ReflectExecutors().Values.Any(b => b is RequestPortBinding);
if (hasRequestPorts)
{
string respondFunctionName = $"{BuiltInFunctions.HttpPrefix}{workflow.Key}-respond";
if (registeredFunctions.Add(respondFunctionName))
{
this._logger.LogRegisteringWorkflowTrigger(workflow.Key, respondFunctionName, "http-respond");
original.Add(FunctionMetadataFactory.CreateHttpTrigger(
$"{workflow.Key}-respond",
$"workflows/{workflow.Key}/respond/{{runId}}",
BuiltInFunctions.RespondToWorkflowHttpFunctionEntryPoint));
}
}
// Register activity or entity functions for each executor in the workflow.
// ReflectExecutors() returns all executors across the graph; no need to manually traverse edges.
foreach (KeyValuePair<string, ExecutorBinding> entry in workflow.Value.ReflectExecutors())
{
// Sub-workflow and RequestPort bindings use specialized dispatch, not activities.
if (entry.Value is SubworkflowBinding or RequestPortBinding)
{
continue;
}
string executorName = WorkflowNamingHelper.GetExecutorName(entry.Key);
// AI agent executors are backed by durable entities; other executors use activity triggers.
if (entry.Value is AIAgentBinding)
{
string entityName = AgentSessionId.ToEntityName(executorName);
if (registeredFunctions.Add(entityName))
{
this._logger.LogRegisteringWorkflowTrigger(workflow.Key, entityName, "entity");
original.Add(FunctionMetadataFactory.CreateEntityTrigger(executorName));
}
}
else
{
string functionName = WorkflowNamingHelper.ToOrchestrationFunctionName(executorName);
if (registeredFunctions.Add(functionName))
{
this._logger.LogRegisteringWorkflowTrigger(workflow.Key, functionName, "activity");
original.Add(FunctionMetadataFactory.CreateActivityTrigger(functionName));
}
}
}
}
this._logger.LogTransformationComplete(original.Count - initialCount, original.Count);
}
}
@@ -0,0 +1,51 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.DurableTask.Workflows;
using Microsoft.DurableTask;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
/// <summary>
/// A custom <see cref="ITaskOrchestrator"/> implementation that delegates workflow orchestration
/// execution to the <see cref="DurableWorkflowRunner"/>.
/// </summary>
internal sealed class WorkflowOrchestrator : ITaskOrchestrator
{
private readonly IServiceProvider _serviceProvider;
/// <summary>
/// Initializes a new instance of the <see cref="WorkflowOrchestrator"/> class.
/// </summary>
/// <param name="serviceProvider">The service provider used to resolve workflow dependencies.</param>
public WorkflowOrchestrator(IServiceProvider serviceProvider)
{
this._serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
}
/// <inheritdoc />
public Type InputType => typeof(DurableWorkflowInput<object>);
/// <inheritdoc />
public Type OutputType => typeof(DurableWorkflowResult);
/// <inheritdoc />
public async Task<object?> RunAsync(TaskOrchestrationContext context, object? input)
{
ArgumentNullException.ThrowIfNull(context);
DurableWorkflowRunner runner = this._serviceProvider.GetRequiredService<DurableWorkflowRunner>();
ILogger logger = context.CreateReplaySafeLogger(context.Name);
DurableWorkflowInput<object> workflowInput = input switch
{
DurableWorkflowInput<object> existing => existing,
_ => new DurableWorkflowInput<object> { Input = input! }
};
// ConfigureAwait(true) is required to preserve the orchestration context
// across awaits, which the Durable Task framework uses for replay.
return await runner.RunWorkflowOrchestrationAsync(context, workflowInput, logger).ConfigureAwait(true);
}
}
@@ -25,6 +25,7 @@
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.DurableTask" />
<InternalsVisibleTo Include="Microsoft.Agents.AI.Workflows.UnitTests" />
<InternalsVisibleTo Include="Microsoft.Agents.AI.Workflows.Generators.UnitTests" />
</ItemGroup>