Merge branch 'main' into copilot/add-preconfigured-compaction-strategy

This commit is contained in:
Chris
2026-03-17 13:56:47 -07:00
committed by GitHub
265 changed files with 21828 additions and 2263 deletions
@@ -2,19 +2,47 @@
## [Unreleased]
- 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))
## v1.0.0-preview.260311.1
### Changed
- 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.
## v1.0.0-preview.251204.1
@@ -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);
}
}
}
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.DurableTask.State;
@@ -28,7 +29,10 @@ internal sealed class DurableAgentStateResponse : DurableAgentStateEntry
{
CorrelationId = correlationId,
CreatedAt = response.CreatedAt ?? response.Messages.Max(m => m.CreatedAt) ?? DateTimeOffset.UtcNow,
Messages = response.Messages.Select(DurableAgentStateMessage.FromChatMessage).ToList(),
Messages = response.Messages
.Where(HasSerializableContent)
.Select(DurableAgentStateMessage.FromChatMessage)
.ToList(),
Usage = DurableAgentStateUsage.FromUsage(response.Usage)
};
}
@@ -46,4 +50,18 @@ internal sealed class DurableAgentStateResponse : DurableAgentStateEntry
Usage = this.Usage?.ToUsageDetails(),
};
}
// Checks whether a ChatMessage has any content that will produce meaningful serialized data.
// Known derived AIContent types (TextContent, FunctionCallContent, etc.) are always serializable.
// Base AIContent instances only carry RawRepresentation (which is [JsonIgnore]), Annotations, and
// AdditionalProperties. We keep the message if any base AIContent has annotations or additional
// properties set. NOTE: if AIContent gains new serializable properties in the future, this check
// should be updated accordingly.
private static bool HasSerializableContent(ChatMessage message)
{
return message.Contents.Any(c =>
c.GetType() != typeof(AIContent) ||
c.Annotations?.Count > 0 ||
c.AdditionalProperties?.Count > 0);
}
}
@@ -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>