mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: [Feature Branch] Add basic durable workflow support (#3648)
* Add basic durable workflow support. * PR feedback fixes * Add conditional edge sample. * PR feedback fixes. * Minor cleanup. * Minor cleanup * Minor formatting improvements. * Improve comments/documentation on the execution flow.
This commit is contained in:
@@ -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,319 @@ 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.
|
||||
services.AddDurableTaskWorker(builder =>
|
||||
{
|
||||
workerBuilder?.Invoke(builder);
|
||||
|
||||
builder.AddTasks(registry => RegisterTasksFromOptions(registry, sharedOptions));
|
||||
});
|
||||
|
||||
// Configure Durable Task Client
|
||||
if (clientBuilder is not null)
|
||||
{
|
||||
services.AddDurableTaskClient(clientBuilder);
|
||||
}
|
||||
|
||||
// Register workflow and agent services
|
||||
services.TryAddSingleton<DurableWorkflowClient>();
|
||||
services.TryAddSingleton<IWorkflowClient>(sp => sp.GetRequiredService<DurableWorkflowClient>());
|
||||
services.TryAddSingleton<DataConverter, DurableDataConverter>();
|
||||
services.TryAddSingleton<IDurableAgentClient, DefaultDurableAgentClient>();
|
||||
|
||||
// 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 = [];
|
||||
|
||||
foreach (Workflow workflow in durableOptions.Workflows.Workflows.Values.ToList())
|
||||
{
|
||||
BuildWorkflowRegistrationRecursive(
|
||||
workflow,
|
||||
durableOptions.Workflows,
|
||||
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>, string>(
|
||||
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 = [];
|
||||
|
||||
// Filter out AI agents and subworkflows - they are not registered as activities.
|
||||
// AI agents use Durable Entities for stateful execution, and subworkflows are
|
||||
// registered as separate orchestrations via BuildWorkflowRegistrationRecursive.
|
||||
foreach (KeyValuePair<string, ExecutorBinding> entry in executorBindings
|
||||
.Where(e => e.Value is not AIAgentBinding and not SubworkflowBinding))
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
private static async Task<string> 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 +366,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user