This commit is contained in:
Shyju Krishnankutty
2026-01-15 07:49:07 -08:00
Unverified
parent 8b21180cf2
commit 6f69299f4a
26 changed files with 1551 additions and 302 deletions
@@ -10,6 +10,7 @@ public sealed class DurableAgentsOptions
// Agent names are case-insensitive
private readonly Dictionary<string, Func<IServiceProvider, AIAgent>> _agentFactories = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, TimeSpan?> _agentTimeToLive = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _workflowOnlyAgents = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Initializes a new instance of the <see cref="DurableAgentsOptions"/> class.
@@ -104,6 +105,22 @@ public sealed class DurableAgentsOptions
/// Thrown when <paramref name="agent.Name"/> is null or whitespace or when an agent with the same name has already been registered.
/// </exception>
public DurableAgentsOptions AddAIAgent(AIAgent agent, TimeSpan? timeToLive = null)
{
return this.AddAIAgent(agent, workflowOnly: false, timeToLive);
}
/// <summary>
/// Adds an AI agent to the options with workflow-only configuration.
/// </summary>
/// <param name="agent">The agent to add.</param>
/// <param name="workflowOnly">If true, the agent is only accessible within workflows and won't have HTTP triggers.</param>
/// <param name="timeToLive">Optional time-to-live for this agent's entities. If not specified, uses <see cref="DefaultTimeToLive"/>.</param>
/// <returns>The options instance.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="agent"/> is null.</exception>
/// <exception cref="ArgumentException">
/// Thrown when <paramref name="agent.Name"/> is null or whitespace or when an agent with the same name has already been registered.
/// </exception>
public DurableAgentsOptions AddAIAgent(AIAgent agent, bool workflowOnly, TimeSpan? timeToLive = null)
{
ArgumentNullException.ThrowIfNull(agent);
@@ -123,6 +140,11 @@ public sealed class DurableAgentsOptions
this._agentTimeToLive[agent.Name] = timeToLive;
}
if (workflowOnly)
{
this._workflowOnlyAgents.Add(agent.Name);
}
return this;
}
@@ -144,4 +166,14 @@ public sealed class DurableAgentsOptions
{
return this._agentTimeToLive.TryGetValue(agentName, out TimeSpan? ttl) ? ttl : this.DefaultTimeToLive;
}
/// <summary>
/// Determines whether an agent is configured as workflow-only (no HTTP triggers).
/// </summary>
/// <param name="agentName">The name of the agent.</param>
/// <returns>True if the agent is workflow-only; otherwise, false.</returns>
internal bool IsWorkflowOnly(string agentName)
{
return this._workflowOnlyAgents.Contains(agentName);
}
}
@@ -1,11 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.DurableTask;
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// Provides configuration options for durable features in Azure Functions.
/// Provides configuration options for durable agents and workflows.
/// </summary>
public sealed class DurableOptions
{
@@ -10,17 +10,31 @@ namespace Microsoft.Agents.AI.DurableTask;
public sealed class DurableWorkflowOptions
{
private readonly Dictionary<string, Workflow> _workflows = new(StringComparer.OrdinalIgnoreCase);
private readonly object? _parentOptions;
private readonly DurableOptions? _parentOptions;
/// <summary>
/// Initializes a new instance of the <see cref="DurableWorkflowOptions"/> class.
/// </summary>
/// <param name="parentOptions">Optional parent options container for accessing related configuration.</param>
public DurableWorkflowOptions(object? parentOptions = null)
internal DurableWorkflowOptions(DurableOptions? parentOptions = null)
{
this._parentOptions = parentOptions;
this.Executors = new ExecutorRegistry();
}
/// <summary>
/// Gets the collection of workflows available in the current context, keyed by their unique names.
/// </summary>
/// <remarks>The returned dictionary is read-only and reflects the current set of registered workflows.
/// Changes to the underlying workflow collection are immediately visible through this property. Accessing a
/// workflow by name that does not exist will result in a KeyNotFoundException.</remarks>
public IReadOnlyDictionary<string, Workflow> Workflows => this._workflows;
/// <summary>
/// Gets the executor registry.
/// </summary>
internal ExecutorRegistry Executors { get; }
/// <summary>
/// Adds a workflow to the collection for processing or execution.
/// </summary>
@@ -40,6 +54,9 @@ public sealed class DurableWorkflowOptions
this._workflows[workflow.Name] = workflow;
// Register executors in the registry for direct lookup
RegisterExecutors(workflow, this.Executors);
// Register any agentic executors with DurableAgentsOptions if available through parent
DurableAgentsOptions? agentOptions = this.TryGetAgentOptions();
if (agentOptions is not null)
@@ -48,36 +65,9 @@ public sealed class DurableWorkflowOptions
}
}
/// <summary>
/// Gets the collection of workflows available in the current context, keyed by their unique names.
/// </summary>
/// <remarks>The returned dictionary is read-only and reflects the current set of registered workflows.
/// Changes to the underlying workflow collection are immediately visible through this property. Accessing a
/// workflow by name that does not exist will result in a KeyNotFoundException.</remarks>
public IReadOnlyDictionary<string, Workflow> Workflows => this._workflows;
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("ReflectionAnalysis", "IL2075",
Justification = "Reflection is used to access Agents property from parent options container for automatic agent registration.")]
private DurableAgentsOptions? TryGetAgentOptions()
{
// Try to extract DurableAgentsOptions from the parent container
// This uses reflection to access the Agents property if available
if (this._parentOptions is null)
{
return null;
}
// Check if parent has an Agents property (DurableOptions pattern)
System.Reflection.PropertyInfo? agentsProperty = this._parentOptions.GetType()
.GetProperty("Agents", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
if (agentsProperty?.PropertyType == typeof(DurableAgentsOptions))
{
return agentsProperty.GetValue(this._parentOptions) as DurableAgentsOptions;
}
// If parent is directly DurableAgentsOptions (for backward compatibility)
return this._parentOptions as DurableAgentsOptions;
return this._parentOptions?.Agents;
}
private static void RegisterAgenticExecutors(Workflow workflow, DurableAgentsOptions agentOptions)
@@ -87,8 +77,8 @@ public sealed class DurableWorkflowOptions
{
try
{
// Register the agent with DurableAgentsOptions
agentOptions.AddAIAgent(agent);
// Register the agent as workflow-only (no HTTP trigger)
agentOptions.AddAIAgent(agent, workflowOnly: true);
}
catch (ArgumentException)
{
@@ -97,4 +87,15 @@ public sealed class DurableWorkflowOptions
}
}
}
private static void RegisterExecutors(Workflow workflow, ExecutorRegistry registry)
{
// Register all executors from the workflow in the registry
foreach (KeyValuePair<string, Workflows.Checkpointing.ExecutorInfo> executor in workflow.ReflectExecutors())
{
// Extract the executor name (without GUID suffix)
string executorName = executor.Key.Split('_')[0];
registry.Register(executorName, executor.Key, workflow);
}
}
}
@@ -0,0 +1,75 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// Provides a registry for storing and retrieving executor bindings independently from workflows.
/// </summary>
/// <remarks>
/// This registry allows executors to be looked up by name without needing to search through all workflows,
/// which is useful for activity function execution where only the executor name is known.
/// </remarks>
internal sealed class ExecutorRegistry
{
private readonly Dictionary<string, ExecutorRegistration> _executors = new(StringComparer.OrdinalIgnoreCase);
/// <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>
/// <remarks>
/// If an executor with the same name is already registered, it will be skipped.
/// This is expected behavior when the same executor is used across multiple workflows.
/// </remarks>
internal void Register(string executorName, string executorId, Workflow workflow)
{
ArgumentException.ThrowIfNullOrEmpty(executorName);
ArgumentException.ThrowIfNullOrEmpty(executorId);
ArgumentNullException.ThrowIfNull(workflow);
// Only register if not already present (first registration wins)
if (!this._executors.ContainsKey(executorName))
{
this._executors[executorName] = new ExecutorRegistration(executorId, workflow);
}
}
/// <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><c>true</c> if the executor was found; otherwise, <c>false</c>.</returns>
public bool TryGetExecutor(string executorName, out ExecutorRegistration? registration)
{
return this._executors.TryGetValue(executorName, out registration);
}
/// <summary>
/// Gets the number of registered executors.
/// </summary>
public int Count => this._executors.Count;
}
/// <summary>
/// Represents a registered executor with its associated workflow.
/// </summary>
/// <param name="ExecutorId">The full executor ID (may include GUID suffix).</param>
/// <param name="Workflow">The workflow containing the executor.</param>
internal sealed record ExecutorRegistration(string ExecutorId, Workflow Workflow)
{
/// <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>
public ValueTask<Executor> CreateExecutorInstanceAsync(string runId, CancellationToken cancellationToken = default)
{
return this.Workflow.CreateExecutorInstanceAsync(this.ExecutorId, runId, cancellationToken);
}
}
@@ -113,6 +113,29 @@ public static class DurableAgentsOptionsExtensions
Func<IServiceProvider, AIAgent> factory,
bool enableHttpTrigger,
bool enableMcpToolTrigger)
{
return AddAIAgentFactory(options, name, factory, enableHttpTrigger, enableMcpToolTrigger, timeToLive: null);
}
/// <summary>
/// Registers an AI agent factory with the specified name, trigger options, and time-to-live configuration.
/// </summary>
/// <remarks>If both triggers are disabled, the agent will not be accessible via HTTP or MCP tool
/// endpoints. This method can be used to register multiple agent factories with different configurations.</remarks>
/// <param name="options">The options object to which the AI agent factory will be added. Cannot be null.</param>
/// <param name="name">The unique name used to identify the AI agent factory. Cannot be null.</param>
/// <param name="factory">A delegate that creates an instance of the AI agent using the provided service provider. Cannot be null.</param>
/// <param name="enableHttpTrigger">true to enable the HTTP trigger for the agent; otherwise, false.</param>
/// <param name="enableMcpToolTrigger">true to enable the MCP tool trigger for the agent; otherwise, false.</param>
/// <param name="timeToLive">Optional time-to-live for this agent's entities.</param>
/// <returns>The same DurableAgentsOptions instance, allowing for method chaining.</returns>
public static DurableAgentsOptions AddAIAgentFactory(
this DurableAgentsOptions options,
string name,
Func<IServiceProvider, AIAgent> factory,
bool enableHttpTrigger,
bool enableMcpToolTrigger,
TimeSpan? timeToLive)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(name);
@@ -122,7 +145,7 @@ public static class DurableAgentsOptionsExtensions
agentOptions.HttpTrigger.IsEnabled = enableHttpTrigger;
agentOptions.McpToolTrigger.IsEnabled = enableMcpToolTrigger;
options.AddAIAgentFactory(name, factory);
options.AddAIAgentFactory(name, factory, timeToLive);
s_agentOptions[name] = agentOptions;
return options;
}
@@ -26,78 +26,90 @@ public static class DurableOptionsExtensions
/// <returns>The Functions application builder for method chaining.</returns>
/// <remarks>
/// This method provides a unified configuration point for both durable agents and workflows.
/// Agents configured here will be automatically registered when referenced in workflows.
/// It automatically generates HTTP API endpoints for agents and workflows, and configures
/// the necessary middleware and services for durable execution.
/// </remarks>
public static FunctionsApplicationBuilder ConfigureDurableOptions(
this FunctionsApplicationBuilder builder,
Action<DurableOptions> configure)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(configure);
DurableOptions options = new();
configure(options);
// Register the unified DurableOptions for components that need both agents and workflows
builder.Services.AddSingleton(options);
// Register AgentsOption for backward compatibility. Can be removed if needed, after syncing with team.
builder.Services.AddSingleton(options.Agents);
// Configure agents using the existing infrastructure
builder.Services.ConfigureDurableAgents(agentOpts =>
{
// Copy agent registrations from the unified options to the service-level options
foreach (KeyValuePair<string, Func<IServiceProvider, AIAgent>> agentFactory in options.Agents.GetAgentFactories())
{
agentOpts.AddAIAgentFactory(
agentFactory.Key,
agentFactory.Value,
options.Agents.GetTimeToLive(agentFactory.Key));
}
// Copy TTL settings
agentOpts.DefaultTimeToLive = options.Agents.DefaultTimeToLive;
agentOpts.MinimumTimeToLiveSignalDelay = options.Agents.MinimumTimeToLiveSignalDelay;
});
builder.Services.TryAddSingleton<IFunctionsAgentOptionsProvider>(_ =>
new DefaultFunctionsAgentOptionsProvider(DurableAgentsOptionsExtensions.GetAgentOptionsSnapshot()));
builder.Services.AddSingleton<IFunctionMetadataTransformer, DurableAgentFunctionMetadataTransformer>();
// Handling of built-in function execution for Agent HTTP, MCP tool, or Entity invocations.
builder.UseWhen<BuiltInFunctionExecutionMiddleware>(static context =>
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentHttpFunctionEntryPoint, StringComparison.Ordinal) ||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentMcpToolFunctionEntryPoint, StringComparison.Ordinal) ||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowOrechstrtationHttpFunctionEntryPoint, StringComparison.Ordinal) ||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint, StringComparison.Ordinal) ||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentEntityFunctionEntryPoint, StringComparison.Ordinal));
builder.Services.AddSingleton<BuiltInFunctionExecutor>();
builder.Services.AddSingleton<DurableWorkflowRunner>();
builder.ConfigureDurableWorker().AddTasks(t => t.AddOrchestratorFunc<DuableWorkflowRunRequest, List<string>>(
"WorkflowRunnerOrchestration",
async (tc, inputBindingData) =>
{
FunctionContext? functionContext = tc.GetFunctionContext();
if (functionContext == null)
{
throw new InvalidOperationException("FunctionContext is not available in the orchestration context.");
}
var logger = tc.CreateReplaySafeLogger("WorkflowRunnerOrchestration");
DurableWorkflowRunner runner = functionContext.InstanceServices.GetRequiredService<DurableWorkflowRunner>();
var orchestrationResult = await runner.RunWorkflowOrchestrationAsync(tc, inputBindingData, logger);
if (logger.IsEnabled(LogLevel.Information))
{
logger.LogInformation("Durable workflow orchestration completed. Result:{Result}", string.Join(",", orchestrationResult));
}
return orchestrationResult;
}));
builder.Services.AddSingleton<IFunctionMetadataTransformer, DurableWorkflowFunctionMetadataTransformer>();
RegisterServices(builder, options);
ConfigureAgents(builder, options);
ConfigureMiddleware(builder);
ConfigureWorkflowOrchestration(builder);
return builder;
}
private static void RegisterServices(FunctionsApplicationBuilder builder, DurableOptions options)
{
builder.Services.AddSingleton(options);
builder.Services.AddSingleton(options.Agents);
builder.Services.AddSingleton<BuiltInFunctionExecutor>();
builder.Services.AddSingleton<DurableWorkflowRunner>();
builder.Services.AddSingleton<IFunctionMetadataTransformer, DurableAgentFunctionMetadataTransformer>();
builder.Services.AddSingleton<IFunctionMetadataTransformer, DurableWorkflowFunctionMetadataTransformer>();
builder.Services.TryAddSingleton<IFunctionsAgentOptionsProvider>(_ =>
new DefaultFunctionsAgentOptionsProvider(DurableAgentsOptionsExtensions.GetAgentOptionsSnapshot()));
}
private static void ConfigureAgents(FunctionsApplicationBuilder builder, DurableOptions options)
{
builder.Services.ConfigureDurableAgents(agentOpts =>
{
foreach (KeyValuePair<string, Func<IServiceProvider, AIAgent>> agentFactory in options.Agents.GetAgentFactories())
{
bool isWorkflowOnly = options.Agents.IsWorkflowOnly(agentFactory.Key);
agentOpts.AddAIAgentFactory(
agentFactory.Key,
agentFactory.Value,
enableHttpTrigger: !isWorkflowOnly,
enableMcpToolTrigger: false,
timeToLive: options.Agents.GetTimeToLive(agentFactory.Key));
}
agentOpts.DefaultTimeToLive = options.Agents.DefaultTimeToLive;
agentOpts.MinimumTimeToLiveSignalDelay = options.Agents.MinimumTimeToLiveSignalDelay;
});
}
private static void ConfigureMiddleware(FunctionsApplicationBuilder builder)
{
builder.UseWhen<BuiltInFunctionExecutionMiddleware>(static context =>
IsBuiltInFunction(context.FunctionDefinition.EntryPoint));
}
private static bool IsBuiltInFunction(string? entryPoint)
{
return string.Equals(entryPoint, BuiltInFunctions.RunAgentHttpFunctionEntryPoint, StringComparison.Ordinal)
|| string.Equals(entryPoint, BuiltInFunctions.RunAgentMcpToolFunctionEntryPoint, StringComparison.Ordinal)
|| string.Equals(entryPoint, BuiltInFunctions.RunWorkflowOrechstrtationHttpFunctionEntryPoint, StringComparison.Ordinal)
|| string.Equals(entryPoint, BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint, StringComparison.Ordinal)
|| string.Equals(entryPoint, BuiltInFunctions.RunAgentEntityFunctionEntryPoint, StringComparison.Ordinal);
}
private static void ConfigureWorkflowOrchestration(FunctionsApplicationBuilder builder)
{
builder.ConfigureDurableWorker().AddTasks(tasks =>
tasks.AddOrchestratorFunc<DuableWorkflowRunRequest, List<string>>(
"WorkflowRunnerOrchestration",
async (orchestrationContext, request) =>
{
FunctionContext functionContext = orchestrationContext.GetFunctionContext()
?? throw new InvalidOperationException("FunctionContext is not available in the orchestration context.");
DurableWorkflowRunner runner = functionContext.InstanceServices.GetRequiredService<DurableWorkflowRunner>();
ILogger logger = orchestrationContext.CreateReplaySafeLogger("WorkflowRunnerOrchestration");
return await runner.RunWorkflowOrchestrationAsync(orchestrationContext, request, logger).ConfigureAwait(true);
}));
}
}
@@ -29,6 +29,9 @@ internal sealed class DurableWorkflowFunctionMetadataTransformer : IFunctionMeta
{
this._logger.LogAddingWorkflowFunction(workflow.Key);
// Currently due to how durable executor is registered, we are not able to bind TaskOrechestrationContext parameter properly
// because the InputBinding for TOC happens inside the DurableExecutor (rathen than in an input converter).
// So for now, we are going to use single orchestration function for all workflows.
//original.Add(CreateOrchestrationTrigger(workflow.Key));
// We also want to create an HTTP trigger for this orchestration so users can start it via HTTP.
@@ -58,14 +61,13 @@ internal sealed class DurableWorkflowFunctionMetadataTransformer : IFunctionMeta
{
if (executorInfos.TryGetValue(executorId, out ExecutorInfo? executorInfo))
{
// string functionName = $"{AgentSessionId.ToEntityName(workflow.Key)}-{executorId}"; //.Split("_")[0]
string functionName = $"{AgentSessionId.ToEntityName(workflow.Key)}-{executorId.Split("_")[0]}"; //.Split("_")[0]
string functionName = $"dafx-{executorId.Split("_")[0]}";
// Check if the executor type is an agent-related type
if (IsAgentExecutorType(executorInfo.ExecutorType))
{
this._logger.LogAddingAgentEntityFunction(executorId, executorInfo.ExecutorType.TypeName, workflow.Key);
original.Add(CreateAgentTrigger(functionName));
//original.Add(CreateAgentTrigger(functionName));
}
else
{
@@ -140,19 +142,19 @@ internal sealed class DurableWorkflowFunctionMetadataTransformer : IFunctionMeta
};
}
private static DefaultFunctionMetadata CreateAgentTrigger(string functionName)
{
return new DefaultFunctionMetadata()
{
Name = functionName,
Language = "dotnet-isolated",
RawBindings =
[
"""{"name":"encodedEntityRequest","type":"entityTrigger","direction":"In"}""",
"""{"name":"client","type":"durableClient","direction":"In"}"""
],
EntryPoint = BuiltInFunctions.RunAgentEntityFunctionEntryPoint,
ScriptFile = BuiltInFunctions.ScriptFile,
};
}
//private static DefaultFunctionMetadata CreateAgentTrigger(string functionName)
//{
// return new DefaultFunctionMetadata()
// {
// Name = functionName,
// Language = "dotnet-isolated",
// RawBindings =
// [
// """{"name":"encodedEntityRequest","type":"entityTrigger","direction":"In"}""",
// """{"name":"client","type":"durableClient","direction":"In"}"""
// ],
// EntryPoint = BuiltInFunctions.RunAgentEntityFunctionEntryPoint,
// ScriptFile = BuiltInFunctions.ScriptFile,
// };
//}
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Checkpointing;
@@ -9,181 +10,312 @@ using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
/// <summary>
/// Executes workflow orchestrations and activity functions for durable workflows.
/// </summary>
internal sealed class DurableWorkflowRunner
{
private readonly DurableWorkflowOptions _options;
private readonly ILogger<DurableWorkflowRunner> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="DurableWorkflowRunner"/> class.
/// </summary>
/// <param name="logger">The logger instance.</param>
/// <param name="durableOptions">The durable options containing workflow configurations.</param>
public DurableWorkflowRunner(ILogger<DurableWorkflowRunner> logger, DurableOptions durableOptions)
{
this._logger = logger;
ArgumentNullException.ThrowIfNull(logger);
ArgumentNullException.ThrowIfNull(durableOptions);
this._logger = logger;
this._options = durableOptions.Workflows;
}
internal async Task<List<string>> RunWorkflowOrchestrationAsync(TaskOrchestrationContext taskOrchestrationContext, DuableWorkflowRunRequest input, ILogger logger)
/// <summary>
/// Runs a workflow orchestration.
/// </summary>
/// <param name="context">The task orchestration context.</param>
/// <param name="request">The workflow run request containing workflow name and input.</param>
/// <param name="logger">The replay-safe logger for orchestration logging.</param>
/// <returns>A list containing the workflow execution result.</returns>
internal async Task<List<string>> RunWorkflowOrchestrationAsync(
TaskOrchestrationContext context,
DuableWorkflowRunRequest request,
ILogger logger)
{
ArgumentNullException.ThrowIfNull(taskOrchestrationContext);
ArgumentNullException.ThrowIfNull(input);
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(request);
string workflowName = input.WorkflowName;
logger.LogAttemptingToRunWorkflow(workflowName);
if (!this._options.Workflows.TryGetValue(workflowName, out Workflow? wf))
if (!this._options.Workflows.TryGetValue(request.WorkflowName, out Workflow? workflow))
{
throw new InvalidOperationException($"Workflow '{workflowName}' not found.");
throw new InvalidOperationException($"Workflow '{request.WorkflowName}' not found.");
}
logger.LogRunningWorkflow(wf.Name);
var result = await this.RunExecutorsInWorkFlowAsync(taskOrchestrationContext, wf, input.Input, logger);
logger.LogRunningWorkflow(workflow.Name);
string result = await this.ExecuteWorkflowLevelsAsync(context, workflow, request.Input, logger).ConfigureAwait(true);
return [result];
}
private async Task<string> RunExecutorsInWorkFlowAsync(
TaskOrchestrationContext taskOrchestrationContext,
Workflow workflow,
string initialInput,
ILogger logger)
{
List<string> executorResult = [];
if (logger.IsEnabled(LogLevel.Information))
{
foreach (WorkflowExecutorInfo executorInfo in WorkflowHelper.GetExecutorsFromWorkflowInOrder(workflow))
{
string triggerName = this.BuildTriggerName(workflow.Name!, executorInfo.ExecutorId);
logger.LogInformation(
" Scheduling executor '{ExecutorId}' (IsAgentic: {IsAgentic}) with trigger name '{TriggerName}'",
executorInfo.ExecutorId,
executorInfo.IsAgenticExecutor,
triggerName);
string input = executorResult.Count == 0 ? initialInput : executorResult.Last()!;
if (!executorInfo.IsAgenticExecutor)
{
var result = await taskOrchestrationContext.CallActivityAsync<string>(triggerName, input);
executorResult.Add(result);
}
else
{
string AgentName = this.GetAgentNameFromExecutorId(workflow.Name!, executorInfo.ExecutorId);
logger.LogInformation(
" Invoking agentic executor '{ExecutorId}'",
AgentName);
DurableAIAgent agent = taskOrchestrationContext.GetAgent(AgentName);
if (agent != null)
{
AgentThread destinationThread = agent.GetNewThread();
var agentResponse = await agent.RunAsync(input, destinationThread);
executorResult.Add(agentResponse.Text);
logger.LogInformation(
"Agentic executor '{ExecutorId}' completed with response: {AgentResponse}",
AgentName,
agentResponse);
}
}
}
// return final result
return executorResult.Last();
}
return string.Empty;
}
private string GetAgentNameFromExecutorId(string workflowName, string executorId)
{
// Example: "InspirationBot_edaac621050849efb1a62805fa03d3f8"
var parts = executorId.Split('_');
return parts.Length > 0 ? parts[0] : executorId;
}
private string BuildTriggerName(string workflowName, string executorName)
{
return $"dafx-{workflowName}-{executorName}";
}
internal async Task<string> ExecuteActivityAsync(string activityFunctionName, string input, FunctionContext functionContext)
/// <summary>
/// Executes an activity function for a workflow executor.
/// </summary>
/// <param name="activityFunctionName">The name of the activity function to execute.</param>
/// <param name="input">The input string for the executor.</param>
/// <param name="functionContext">The Azure Functions context.</param>
/// <returns>The serialized result of the executor.</returns>
internal async Task<string> ExecuteActivityAsync(
string activityFunctionName,
string input,
FunctionContext functionContext)
{
ArgumentNullException.ThrowIfNull(activityFunctionName);
ArgumentNullException.ThrowIfNull(input);
ArgumentNullException.ThrowIfNull(functionContext);
// Parse the activity function name to extract workflow name and executor name
// Format: "dafx-{workflowName}-{executorName}"
if (!activityFunctionName.StartsWith("dafx-", StringComparison.Ordinal))
string executorName = ParseExecutorName(activityFunctionName);
if (!this._options.Executors.TryGetExecutor(executorName, out ExecutorRegistration? registration) || registration is null)
{
throw new InvalidOperationException($"Activity function name '{activityFunctionName}' does not start with 'dafx-' prefix.");
throw new InvalidOperationException($"Executor '{executorName}' not found in the executor registry.");
}
string nameWithoutPrefix = activityFunctionName["dafx-".Length..];
string[] parts = nameWithoutPrefix.Split('-', 2);
this._logger.LogExecutingActivity(registration.ExecutorId, executorName);
if (parts.Length != 2)
Executor executor = await registration.CreateExecutorInstanceAsync("activity-run", CancellationToken.None)
.ConfigureAwait(false);
Type inputType = executor.InputTypes.FirstOrDefault() ?? typeof(string);
object typedInput = DeserializeInput(input, inputType);
object? result = await executor.ExecuteAsync(
typedInput,
new TypeId(inputType),
new MinimalActivityContext(registration.ExecutorId),
CancellationToken.None).ConfigureAwait(false);
return SerializeResult(result);
}
private async Task<string> ExecuteWorkflowLevelsAsync(
TaskOrchestrationContext context,
Workflow workflow,
string initialInput,
ILogger logger)
{
WorkflowExecutionPlan plan = WorkflowHelper.GetExecutionPlan(workflow);
Dictionary<string, string> results = [];
foreach (WorkflowExecutionLevel level in plan.Levels)
{
throw new InvalidOperationException($"Activity function name '{activityFunctionName}' is not in the expected format 'dafx-{{workflowName}}-{{executorName}}'.");
if (level.Executors.Count == 1)
{
WorkflowExecutorInfo executorInfo = level.Executors[0];
string input = GetExecutorInput(executorInfo.ExecutorId, initialInput, results, plan);
results[executorInfo.ExecutorId] = await this.ExecuteExecutorAsync(context, workflow, executorInfo, input, logger).ConfigureAwait(true);
}
else
{
List<Task<(string Id, string Result)>> tasks = [];
foreach (WorkflowExecutorInfo executorInfo in level.Executors)
{
string input = GetExecutorInput(executorInfo.ExecutorId, initialInput, results, plan);
tasks.Add(this.ExecuteExecutorWithIdAsync(context, workflow, executorInfo, input, logger));
}
foreach ((string id, string result) in await Task.WhenAll(tasks).ConfigureAwait(true))
{
results[id] = result;
}
}
}
string workflowName = parts[0];
string executorName = parts[1];
return GetFinalResult(plan, results);
}
this._logger.LogAttemptingToExecuteActivity(workflowName, executorName);
private async Task<(string Id, string Result)> ExecuteExecutorWithIdAsync(
TaskOrchestrationContext context,
Workflow workflow,
WorkflowExecutorInfo executorInfo,
string input,
ILogger logger)
{
string result = await this.ExecuteExecutorAsync(context, workflow, executorInfo, input, logger).ConfigureAwait(true);
return (executorInfo.ExecutorId, result);
}
// Get the workflow
if (!this._options.Workflows.TryGetValue(workflowName, out Workflow? workflow))
private async Task<string> ExecuteExecutorAsync(
TaskOrchestrationContext context,
Workflow workflow,
WorkflowExecutorInfo executorInfo,
string input,
ILogger logger)
{
if (!executorInfo.IsAgenticExecutor)
{
throw new InvalidOperationException($"Workflow '{workflowName}' not found.");
string triggerName = $"dafx-{GetBaseName(executorInfo.ExecutorId)}";
return await context.CallActivityAsync<string>(triggerName, input).ConfigureAwait(true);
}
// Get the executor info
Dictionary<string, ExecutorInfo> executorInfos = workflow.ReflectExecutors();
return await this.ExecuteAgentAsync(context, executorInfo, input, logger).ConfigureAwait(true);
}
// Find the executor by matching the executor name (which may have a GUID suffix)
KeyValuePair<string, ExecutorInfo> executorPair = executorInfos.FirstOrDefault(e =>
e.Key.StartsWith(executorName + "_", StringComparison.Ordinal) ||
string.Equals(e.Key, executorName, StringComparison.Ordinal));
private async Task<string> ExecuteAgentAsync(
TaskOrchestrationContext context,
WorkflowExecutorInfo executorInfo,
string input,
ILogger logger)
{
string agentName = GetBaseName(executorInfo.ExecutorId);
DurableAIAgent agent = context.GetAgent(agentName);
if (executorPair.Key == null)
if (agent is null)
{
throw new InvalidOperationException($"Executor '{executorName}' not found in workflow '{workflowName}'.");
logger.LogWarning("Agent '{AgentName}' not found", agentName);
return $"Agent '{agentName}' not found";
}
this._logger.LogExecutingActivity(executorPair.Key, executorPair.Value.ExecutorType.TypeName);
AgentThread thread = agent.GetNewThread();
AgentRunResponse response = await agent.RunAsync(input, thread).ConfigureAwait(true);
return response.Text;
}
// Attempt to invoke the executor using Executor.ExecuteAsync
// This allows the executor to handle its own execution logic
try
private static string GetExecutorInput(
string executorId,
string initialInput,
Dictionary<string, string> results,
WorkflowExecutionPlan plan)
{
List<string> predecessors = plan.Predecessors[executorId];
if (predecessors.Count == 0)
{
// Create the executor instance
Executor executor = await workflow.CreateExecutorInstanceAsync(
executorPair.Key,
"activity-run",
CancellationToken.None).ConfigureAwait(false);
// Create a minimal workflow taskOrchestrationContext for the executor
MinimalActivityContext context = new(executorPair.Key);
// Execute the executor with the input
// The executor handles its own routing logic internally
object? result = await executor.ExecuteAsync(
input,
new TypeId(typeof(string)),
context,
CancellationToken.None).ConfigureAwait(false);
// Convert result to string
string resultString = result?.ToString() ?? string.Empty;
this._logger.LogActivityExecuted(executorPair.Key, resultString);
return resultString;
return initialInput;
}
catch (Exception ex)
if (predecessors.Count == 1)
{
this._logger.LogError(ex, "Error executing executor '{ExecutorId}' in activity", executorPair.Key);
throw;
return results.TryGetValue(predecessors[0], out string? result) ? result : initialInput;
}
List<string> aggregated = [];
foreach (string predecessorId in predecessors)
{
if (results.TryGetValue(predecessorId, out string? result))
{
aggregated.Add(result);
}
}
return SerializeToJson(aggregated);
}
private static string GetFinalResult(WorkflowExecutionPlan plan, Dictionary<string, string> results)
{
WorkflowExecutionLevel lastLevel = plan.Levels[^1];
if (lastLevel.Executors.Count == 1)
{
return results[lastLevel.Executors[0].ExecutorId];
}
List<string> finalResults = [];
foreach (WorkflowExecutorInfo executor in lastLevel.Executors)
{
if (results.TryGetValue(executor.ExecutorId, out string? result))
{
finalResults.Add(result);
}
}
return string.Join("\n---\n", finalResults);
}
private static string ParseExecutorName(string activityFunctionName)
{
const string Prefix = "dafx-";
if (!activityFunctionName.StartsWith(Prefix, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"Activity function name '{activityFunctionName}' does not start with '{Prefix}' prefix.");
}
string executorName = activityFunctionName[Prefix.Length..];
if (string.IsNullOrEmpty(executorName))
{
throw new InvalidOperationException(
$"Activity function name '{activityFunctionName}' is not in the expected format '{Prefix}{{executorName}}'.");
}
return executorName;
}
private static string GetBaseName(string executorId)
{
int underscoreIndex = executorId.IndexOf('_');
return underscoreIndex > 0 ? executorId[..underscoreIndex] : executorId;
}
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing known types.")]
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing known types.")]
private static string SerializeToJson(List<string> values)
{
return JsonSerializer.Serialize(values);
}
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing workflow types registered at startup.")]
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing workflow types registered at startup.")]
private static string SerializeResult(object? result)
{
if (result is null)
{
return string.Empty;
}
if (result is string str)
{
return str;
}
Type resultType = result.GetType();
if (resultType.IsPrimitive || resultType == typeof(decimal))
{
return result.ToString() ?? string.Empty;
}
return JsonSerializer.Serialize(result, resultType);
}
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow types registered at startup.")]
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow types registered at startup.")]
private static object DeserializeInput(string input, Type targetType)
{
if (targetType == typeof(string))
{
return input;
}
string json = input;
if (input.StartsWith('"') && input.EndsWith('"'))
{
try
{
string? innerJson = JsonSerializer.Deserialize<string>(input);
if (innerJson is not null)
{
json = innerJson;
}
}
catch (JsonException)
{
// Not double-serialized, use original
}
}
return JsonSerializer.Deserialize(json, targetType)
?? throw new InvalidOperationException($"Failed to deserialize input to type '{targetType.Name}'.");
}
}
@@ -12,6 +12,46 @@ namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
/// <param name="IsAgenticExecutor">Indicates whether this executor is an agentic executor.</param>
internal sealed record WorkflowExecutorInfo(string ExecutorId, bool IsAgenticExecutor);
/// <summary>
/// Represents a level of executors that can be executed in parallel (Fan-Out).
/// All executors in the same level have their dependencies satisfied by previous levels.
/// </summary>
/// <param name="Level">The level number (0-based, starting from the root executor).</param>
/// <param name="Executors">The executors that can run in parallel at this level.</param>
/// <param name="IsFanIn">Indicates if this level is a Fan-In point (has executors with multiple predecessors).</param>
internal sealed record WorkflowExecutionLevel(int Level, List<WorkflowExecutorInfo> Executors, bool IsFanIn);
/// <summary>
/// Represents the complete execution plan for a workflow, including parallel execution levels.
/// </summary>
internal sealed class WorkflowExecutionPlan
{
/// <summary>
/// The execution levels in order. Each level contains executors that can run in parallel.
/// </summary>
public List<WorkflowExecutionLevel> Levels { get; } = [];
/// <summary>
/// Maps each executor ID to its predecessors (for Fan-In result aggregation).
/// </summary>
public Dictionary<string, List<string>> Predecessors { get; } = [];
/// <summary>
/// Maps each executor ID to its successors (for Fan-Out result distribution).
/// </summary>
public Dictionary<string, List<string>> Successors { get; } = [];
/// <summary>
/// Gets whether this workflow has any parallel execution opportunities.
/// </summary>
public bool HasParallelism => this.Levels.Any(l => l.Executors.Count > 1);
/// <summary>
/// Gets whether this workflow has any Fan-In points.
/// </summary>
public bool HasFanIn => this.Levels.Any(l => l.IsFanIn);
}
internal static class WorkflowHelper
{
/// <summary>
@@ -20,20 +60,45 @@ internal static class WorkflowHelper
/// <param name="workflow">The workflow instance to analyze.</param>
/// <returns>A list of executor information in topological order (execution order).</returns>
public static List<WorkflowExecutorInfo> GetExecutorsFromWorkflowInOrder(Workflow workflow)
{
WorkflowExecutionPlan plan = GetExecutionPlan(workflow);
// Flatten the levels into a single list for backward compatibility
List<WorkflowExecutorInfo> result = [];
foreach (WorkflowExecutionLevel level in plan.Levels)
{
result.AddRange(level.Executors);
}
return result;
}
/// <summary>
/// Analyzes the workflow and returns an execution plan that supports Fan-Out/Fan-In patterns.
/// Executors at the same level can be executed in parallel (Fan-Out).
/// Fan-In points are identified where multiple executors converge.
/// </summary>
/// <param name="workflow">The workflow instance to analyze.</param>
/// <returns>An execution plan with parallel execution levels.</returns>
public static WorkflowExecutionPlan GetExecutionPlan(Workflow workflow)
{
ArgumentNullException.ThrowIfNull(workflow);
Dictionary<string, ExecutorInfo> executors = workflow.ReflectExecutors();
Dictionary<string, HashSet<EdgeInfo>> edges = workflow.ReflectEdges();
// Build adjacency list and in-degree map
Dictionary<string, List<string>> adjacencyList = new();
Dictionary<string, int> inDegree = new();
WorkflowExecutionPlan plan = new();
// Initialize all executors with in-degree 0
// Build adjacency lists (successors and predecessors)
Dictionary<string, List<string>> successors = [];
Dictionary<string, List<string>> predecessors = [];
Dictionary<string, int> inDegree = [];
// Initialize all executors
foreach (string executorId in executors.Keys)
{
adjacencyList[executorId] = new List<string>();
successors[executorId] = [];
predecessors[executorId] = [];
inDegree[executorId] = 0;
}
@@ -44,80 +109,89 @@ internal static class WorkflowHelper
foreach (EdgeInfo edge in edgeGroup.Value)
{
// For each sink (target) in this edge
foreach (string sinkId in edge.Connection.SinkIds)
{
// Add edge from source to sink
adjacencyList[sourceId].Add(sinkId);
// Increment in-degree of the sink
if (inDegree.TryGetValue(sinkId, out int currentDegree))
if (executors.ContainsKey(sinkId))
{
inDegree[sinkId] = currentDegree + 1;
successors[sourceId].Add(sinkId);
predecessors[sinkId].Add(sourceId);
inDegree[sinkId]++;
}
}
}
}
// Perform topological sort using Kahn's algorithm
List<string> orderedExecutorIds = new();
Queue<string> queue = new();
// Start with the workflow's starting executor
queue.Enqueue(workflow.StartExecutorId);
// Also add any other executors with in-degree 0 (shouldn't be any if workflow is well-formed)
foreach (KeyValuePair<string, int> kvp in inDegree)
// Store the graph structure in the plan
foreach (string executorId in executors.Keys)
{
if (kvp.Value == 0 && kvp.Key != workflow.StartExecutorId)
{
queue.Enqueue(kvp.Key);
}
plan.Predecessors[executorId] = [.. predecessors[executorId]];
plan.Successors[executorId] = [.. successors[executorId]];
}
while (queue.Count > 0)
// Build execution levels using modified Kahn's algorithm
// Instead of processing one at a time, we process all nodes with in-degree 0 at once (same level)
HashSet<string> processed = [];
Dictionary<string, int> currentInDegree = new(inDegree);
int levelNumber = 0;
while (processed.Count < executors.Count)
{
string current = queue.Dequeue();
orderedExecutorIds.Add(current);
// Find all executors that can be executed at this level (in-degree == 0 and not yet processed)
List<string> currentLevelIds = [];
// For each neighbor of the current executor
foreach (string neighbor in adjacencyList[current])
foreach (KeyValuePair<string, int> kvp in currentInDegree)
{
inDegree[neighbor]--;
// If in-degree becomes 0, add to queue
if (inDegree[neighbor] == 0)
if (kvp.Value == 0 && !processed.Contains(kvp.Key))
{
queue.Enqueue(neighbor);
currentLevelIds.Add(kvp.Key);
}
}
}
// If result doesn't contain all executors, there might be a cycle or disconnected components
if (orderedExecutorIds.Count != executors.Count)
{
// Add any remaining executors that weren't reached
foreach (string executorId in executors.Keys)
// If no executors found but not all processed, there might be a cycle
if (currentLevelIds.Count == 0)
{
if (!orderedExecutorIds.Contains(executorId))
// Add remaining unprocessed executors
foreach (string executorId in executors.Keys)
{
orderedExecutorIds.Add(executorId);
if (!processed.Contains(executorId))
{
currentLevelIds.Add(executorId);
}
}
if (currentLevelIds.Count == 0)
{
break;
}
}
}
// Convert to WorkflowExecutorInfo with agentic executor detection
List<WorkflowExecutorInfo> result = new();
foreach (string executorId in orderedExecutorIds)
{
if (executors.TryGetValue(executorId, out ExecutorInfo? executorInfo))
// Check if this level is a Fan-In point (any executor has multiple predecessors)
bool isFanIn = currentLevelIds.Any(id => predecessors[id].Count > 1);
// Convert to WorkflowExecutorInfo
List<WorkflowExecutorInfo> levelExecutors = [];
foreach (string executorId in currentLevelIds)
{
bool isAgentic = IsAgentExecutorType(executorInfo.ExecutorType);
result.Add(new WorkflowExecutorInfo(executorId, isAgentic));
processed.Add(executorId);
if (executors.TryGetValue(executorId, out ExecutorInfo? executorInfo))
{
bool isAgentic = IsAgentExecutorType(executorInfo.ExecutorType);
levelExecutors.Add(new WorkflowExecutorInfo(executorId, isAgentic));
}
// Decrement in-degree of all successors
foreach (string successor in successors[executorId])
{
currentInDegree[successor]--;
}
}
plan.Levels.Add(new WorkflowExecutionLevel(levelNumber, levelExecutors, isFanIn));
levelNumber++;
}
return result;
return plan;
}
/// <summary>