Move relevant stuff to DurableTask project from Hosting.AzureFunctions

This commit is contained in:
Shyju Krishnankutty
2026-01-20 16:22:19 -08:00
Unverified
parent 56314f96f5
commit fcd7aa0b77
16 changed files with 469 additions and 349 deletions
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting.AzureFunctions\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,63 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace WorkflowExecutorsAndEdgesSample;
/// <summary>
/// This sample introduces the concepts of executors and edges in a workflow.
///
/// Workflows are built from executors (processing units) connected by edges (data flow paths).
/// In this example, we create a simple text processing pipeline that:
/// 1. Takes input text and converts it to uppercase using an UppercaseExecutor
/// 2. Takes the uppercase text and reverses it using a ReverseTextExecutor
///
/// The executors are connected sequentially, so data flows from one to the next in order.
/// For input "Hello, World!", the workflow produces "!DLROW ,OLLEH".
/// </summary>
public static class Program
{
private static async Task Main()
{
// Create the executors
Func<string, string> uppercaseFunc = s => s.ToUpperInvariant();
var uppercase = uppercaseFunc.BindAsExecutor("UppercaseExecutor");
ReverseTextExecutor reverse = new();
// Build the workflow by connecting executors sequentially
WorkflowBuilder builder = new(uppercase);
builder.AddEdge(uppercase, reverse).WithOutputFrom(reverse);
var workflow = builder.Build();
// Execute the workflow with input data
await using Run run = await InProcessExecution.RunAsync(workflow, "Hello, World!");
foreach (WorkflowEvent evt in run.NewEvents)
{
if (evt is ExecutorCompletedEvent executorComplete)
{
Console.WriteLine($"{executorComplete.ExecutorId}: {executorComplete.Data}");
}
}
}
}
/// <summary>
/// Second executor: reverses the input text and completes the workflow.
/// </summary>
internal sealed class ReverseTextExecutor() : Executor<string, string>("ReverseTextExecutor")
{
/// <summary>
/// Processes the input message by reversing the text.
/// </summary>
/// <param name="message">The input text to reverse</param>
/// <param name="context">Workflow context for accessing workflow services and adding events</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
/// The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>The input text reversed</returns>
public override ValueTask<string> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
// Because we do not suppress it, the returned result will be yielded as an output from this executor.
return ValueTask.FromResult(string.Concat(message.Reverse()));
}
}
@@ -7,10 +7,10 @@ using Microsoft.DurableTask.Client;
using Microsoft.DurableTask.Client.Entities;
using Microsoft.DurableTask.Entities;
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// An implementation of <see cref="IWorkflowContext"/> for workflow executors running as Azure Functions activities.
/// An implementation of <see cref="IWorkflowContext"/> for workflow executors running as durable activities.
/// Provides durable state management using Durable Entities. State is scoped to the orchestration instance
/// and shared between executors running on potentially different compute instances.
/// </summary>
@@ -21,7 +21,7 @@ namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
/// </remarks>
[RequiresUnreferencedCode("State serialization uses reflection-based JSON serialization.")]
[RequiresDynamicCode("State serialization uses reflection-based JSON serialization.")]
internal sealed class DurableExecutorContext : IWorkflowContext
public sealed class DurableExecutorContext : IWorkflowContext
{
private readonly string _instanceId;
private readonly DurableTaskClient _client;
@@ -2,12 +2,12 @@
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// Represents a request to run a durable workflow.
/// </summary>
internal sealed class DuableWorkflowRunRequest
public sealed class DurableWorkflowRunRequest
{
/// <summary>
/// Gets or sets the name of the workflow to execute.
@@ -0,0 +1,320 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask;
using Microsoft.DurableTask.Entities;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// Core workflow runner that executes workflow orchestrations using Durable Tasks.
/// This class contains the core workflow execution logic independent of the hosting environment.
/// </summary>
public class DurableWorkflowRunner
{
/// <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)
{
ArgumentNullException.ThrowIfNull(logger);
ArgumentNullException.ThrowIfNull(durableOptions);
this.Logger = logger;
this.Options = durableOptions.Workflows;
}
/// <summary>
/// Gets the workflow options.
/// </summary>
protected DurableWorkflowOptions Options { get; }
/// <summary>
/// Gets the logger instance.
/// </summary>
protected ILogger Logger { get; }
/// <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>
public async Task<List<string>> RunWorkflowOrchestrationAsync(
TaskOrchestrationContext context,
DurableWorkflowRunRequest request,
ILogger logger)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(request);
if (!this.Options.Workflows.TryGetValue(request.WorkflowName, out Workflow? workflow))
{
throw new InvalidOperationException($"Workflow '{request.WorkflowName}' not found.");
}
logger.LogRunningWorkflow(workflow.Name);
string result = await this.ExecuteWorkflowLevelsAsync(context, workflow, request.Input, logger).ConfigureAwait(true);
await CleanupWorkflowStateAsync(context).ConfigureAwait(true);
return [result];
}
/// <summary>
/// Cleans up the workflow state entity by signaling it to delete itself.
/// </summary>
private static async Task CleanupWorkflowStateAsync(TaskOrchestrationContext context)
{
EntityInstanceId stateEntityId = new(WorkflowSharedStateEntity.EntityName, context.InstanceId);
// Call the entity's Delete method to clean up state
// Using CallEntityAsync ensures the deletion completes before the orchestration finishes
await context.Entities.CallEntityAsync(stateEntityId, nameof(WorkflowSharedStateEntity.Delete)).ConfigureAwait(true);
}
/// <summary>
/// Parses the executor name from an activity function name.
/// </summary>
/// <param name="activityFunctionName">The activity function name.</param>
/// <returns>The extracted executor name.</returns>
protected 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;
}
/// <summary>
/// Gets the base name from an executor ID by removing any GUID suffix.
/// </summary>
/// <param name="executorId">The executor ID.</param>
/// <returns>The base name without the GUID suffix.</returns>
protected static string GetBaseName(string executorId)
{
int underscoreIndex = executorId.IndexOf('_');
return underscoreIndex > 0 ? executorId[..underscoreIndex] : executorId;
}
/// <summary>
/// Serializes a list of strings to JSON.
/// </summary>
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing known types.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing known types.")]
protected static string SerializeToJson(List<string> values)
{
return JsonSerializer.Serialize(values);
}
/// <summary>
/// Serializes a result object to JSON or string.
/// </summary>
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing workflow types registered at startup.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing workflow types registered at startup.")]
protected 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);
}
/// <summary>
/// Deserializes input from JSON to the target type.
/// </summary>
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow types registered at startup.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow types registered at startup.")]
protected 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}'.");
}
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)
{
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, 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, executorInfo, input, logger));
}
foreach ((string id, string result) in await Task.WhenAll(tasks).ConfigureAwait(true))
{
results[id] = result;
}
}
}
return GetFinalResult(plan, results);
}
private async Task<(string Id, string Result)> ExecuteExecutorWithIdAsync(
TaskOrchestrationContext context,
WorkflowExecutorInfo executorInfo,
string input,
ILogger logger)
{
string result = await this.ExecuteExecutorAsync(context, executorInfo, input, logger).ConfigureAwait(true);
return (executorInfo.ExecutorId, result);
}
private async Task<string> ExecuteExecutorAsync(
TaskOrchestrationContext context,
WorkflowExecutorInfo executorInfo,
string input,
ILogger logger)
{
if (!executorInfo.IsAgenticExecutor)
{
string triggerName = $"dafx-{GetBaseName(executorInfo.ExecutorId)}";
return await context.CallActivityAsync<string>(triggerName, input).ConfigureAwait(true);
}
return await ExecuteAgentAsync(context, executorInfo, input, logger).ConfigureAwait(true);
}
private static async Task<string> ExecuteAgentAsync(
TaskOrchestrationContext context,
WorkflowExecutorInfo executorInfo,
string input,
ILogger logger)
{
string agentName = GetBaseName(executorInfo.ExecutorId);
DurableAIAgent agent = context.GetAgent(agentName);
if (agent is null)
{
logger.LogWarning("Agent '{AgentName}' not found", agentName);
return $"Agent '{agentName}' not found";
}
AgentThread thread = agent.GetNewThread();
AgentRunResponse response = await agent.RunAsync(input, thread).ConfigureAwait(true);
return response.Text;
}
private static string GetExecutorInput(
string executorId,
string initialInput,
Dictionary<string, string> results,
WorkflowExecutionPlan plan)
{
List<string> predecessors = plan.Predecessors[executorId];
if (predecessors.Count == 0)
{
return initialInput;
}
if (predecessors.Count == 1)
{
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);
}
}
@@ -100,4 +100,34 @@ internal static partial class Logs
public static partial void LogTTLExpirationTimeCleared(
this ILogger logger,
AgentSessionId sessionId);
[LoggerMessage(
EventId = 12,
Level = LogLevel.Information,
Message = "Attempting to run workflow: {WorkflowName}")]
public static partial void LogAttemptingToRunWorkflow(this ILogger logger, string workflowName);
[LoggerMessage(
EventId = 13,
Level = LogLevel.Information,
Message = "Running workflow: {WorkflowName}")]
public static partial void LogRunningWorkflow(this ILogger logger, string? workflowName);
[LoggerMessage(
EventId = 14,
Level = LogLevel.Information,
Message = "Attempting to execute activity in workflow '{WorkflowName}' for executor '{ExecutorName}'")]
public static partial void LogAttemptingToExecuteActivity(this ILogger logger, string workflowName, string executorName);
[LoggerMessage(
EventId = 15,
Level = LogLevel.Information,
Message = "Executing activity for executor '{ExecutorId}' of type '{ExecutorType}'")]
public static partial void LogExecutingActivity(this ILogger logger, string executorId, string executorType);
[LoggerMessage(
EventId = 16,
Level = LogLevel.Information,
Message = "Activity executed for executor '{ExecutorId}' with result: {Result}")]
public static partial void LogActivityExecuted(this ILogger logger, string executorId, string result);
}
@@ -21,6 +21,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.DurableTask.Client" />
<PackageReference Include="Microsoft.DurableTask.Worker" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
@@ -3,14 +3,14 @@
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Checkpointing;
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
namespace Microsoft.Agents.AI.DurableTask;
/// <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>
internal sealed record WorkflowExecutorInfo(string ExecutorId, bool IsAgenticExecutor);
public sealed record WorkflowExecutorInfo(string ExecutorId, bool IsAgenticExecutor);
/// <summary>
/// Represents a level of executors that can be executed in parallel (Fan-Out).
@@ -19,12 +19,12 @@ internal sealed record WorkflowExecutorInfo(string ExecutorId, bool IsAgenticExe
/// <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);
public 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
public sealed class WorkflowExecutionPlan
{
/// <summary>
/// The execution levels in order. Each level contains executors that can run in parallel.
@@ -52,7 +52,10 @@ internal sealed class WorkflowExecutionPlan
public bool HasFanIn => this.Levels.Any(l => l.IsFanIn);
}
internal static class WorkflowHelper
/// <summary>
/// Provides helper methods for analyzing and executing workflows.
/// </summary>
public static class WorkflowHelper
{
/// <summary>
/// Accepts a workflow instance and returns a list of executors with metadata in the order they should be executed.
@@ -199,7 +202,7 @@ internal static class WorkflowHelper
/// </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>
private static bool IsAgentExecutorType(TypeId executorType)
internal static bool IsAgentExecutorType(TypeId executorType)
{
// hack for now. In the future, the MAF type could expose something which can help with this.
// Check if the type name or assembly indicates it's an agent executor
@@ -2,7 +2,7 @@
using Microsoft.DurableTask.Entities;
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// Durable entity that manages workflow state across activities within an orchestration run.
@@ -10,7 +10,7 @@ namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
/// ensuring state isolation between workflow runs. The entity is automatically cleaned up
/// when the orchestration completes.
/// </summary>
internal sealed class WorkflowSharedStateEntity : TaskEntity<WorkflowStateData>
public sealed class WorkflowSharedStateEntity : TaskEntity<WorkflowStateData>
{
/// <summary>
/// The entity name used for registration and lookup.
@@ -125,7 +125,7 @@ internal sealed class WorkflowSharedStateEntity : TaskEntity<WorkflowStateData>
/// <summary>
/// Represents the internal state data for a workflow state entity.
/// </summary>
internal sealed class WorkflowStateData
public sealed class WorkflowStateData
{
/// <summary>
/// Gets the state dictionary mapping scope-prefixed keys to serialized values.
@@ -136,7 +136,7 @@ internal sealed class WorkflowStateData
/// <summary>
/// Request model for reading workflow state.
/// </summary>
internal sealed class WorkflowStateReadRequest
public sealed class WorkflowStateReadRequest
{
/// <summary>
/// Gets or sets the state key.
@@ -152,7 +152,7 @@ internal sealed class WorkflowStateReadRequest
/// <summary>
/// Request model for writing workflow state.
/// </summary>
internal sealed class WorkflowStateWriteRequest
public sealed class WorkflowStateWriteRequest
{
/// <summary>
/// Gets or sets the state key.
@@ -42,7 +42,7 @@ internal static class BuiltInFunctions
string activityFunctionName = functionContext.FunctionDefinition.Name;
DurableWorkflowRunner runner = functionContext.InstanceServices.GetRequiredService<DurableWorkflowRunner>();
FunctionsWorkflowRunner runner = functionContext.InstanceServices.GetRequiredService<FunctionsWorkflowRunner>();
return runner.ExecuteActivityAsync(activityFunctionName, input, durableTaskClient, functionContext);
}
@@ -78,7 +78,7 @@ internal static class BuiltInFunctions
var workflowName = context.FunctionDefinition.Name.Replace("http-", "");
var orchestrationFunctionName = $"dafx-{workflowName}";
var inputMessage = await req.ReadAsStringAsync();
string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(orchestrationFunctionName, new DuableWorkflowRunRequest { WorkflowName = workflowName, Input = inputMessage! }); //OrchFunction"); // dafx-MyTestWorkflow");
string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(orchestrationFunctionName, new DurableWorkflowRunRequest { WorkflowName = workflowName, Input = inputMessage! }); //OrchFunction"); // dafx-MyTestWorkflow");
HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted);
await response.WriteStringAsync($"InvokeWorkflowOrechstrtationAsync is invoked for {workflowName}. Orchestration instanceId: {instanceId}");
@@ -221,7 +221,7 @@ internal static class BuiltInFunctions
}
#pragma warning disable DURTASK001 // Durable analyzer complained
public static Task<List<string>> WorkflowRunnerOrchestrationAsync(TaskOrchestrationContext context, DuableWorkflowRunRequest input)
public static Task<List<string>> WorkflowRunnerOrchestrationAsync(TaskOrchestrationContext context, DurableWorkflowRunRequest input)
{
ArgumentNullException.ThrowIfNull(context);
@@ -39,7 +39,7 @@ internal static class CoreAgentConfigurationExtensions
/// <returns>The functions application builder for method chaining.</returns>
internal static FunctionsApplicationBuilder RegisterWorkflowServices(this FunctionsApplicationBuilder builder)
{
builder.Services.TryAddSingleton<DurableWorkflowRunner>();
builder.Services.TryAddSingleton<FunctionsWorkflowRunner>();
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IFunctionMetadataTransformer, DurableWorkflowFunctionMetadataTransformer>());
return builder;
@@ -91,7 +91,7 @@ public static class DurableOptionsExtensions
foreach (string workflowName in workflows.Workflows.Select(kp => kp.Key))
{
tasks.AddOrchestratorFunc<DuableWorkflowRunRequest, List<string>>(
tasks.AddOrchestratorFunc<DurableWorkflowRunRequest, List<string>>(
$"dafx-{workflowName}",
async (orchestrationContext, request) =>
{
@@ -64,7 +64,7 @@ internal sealed class DurableWorkflowFunctionMetadataTransformer : IFunctionMeta
string functionName = $"dafx-{executorId.Split("_")[0]}";
// Check if the executor type is an agent-related type
if (IsAgentExecutorType(executorInfo.ExecutorType))
if (WorkflowHelper.IsAgentExecutorType(executorInfo.ExecutorType))
{
this._logger.LogAddingAgentEntityFunction(executorId, executorInfo.ExecutorType.TypeName, workflow.Key);
//original.Add(CreateAgentTrigger(functionName));
@@ -79,18 +79,6 @@ internal sealed class DurableWorkflowFunctionMetadataTransformer : IFunctionMeta
}
this._logger.LogTransformFinished(original.Count);
static bool IsAgentExecutorType(TypeId executorType)
{
// hack for now. In the future, the MAF type could expose something which can help with this.
// Check if the type name or assembly indicates it's an agent executor
// This includes AgentRunStreamingExecutor, AgentExecutor, ChatClientAgent wrappers, etc.
string typeName = executorType.TypeName;
string assemblyName = executorType.AssemblyName;
return typeName.Contains("AIAgentHostExecutor", StringComparison.OrdinalIgnoreCase) &&
assemblyName.Contains("Microsoft.Agents.AI", StringComparison.OrdinalIgnoreCase);
}
}
private static DefaultFunctionMetadata CreateHttpTrigger(string name, string route)
@@ -1,79 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Azure.Functions.Worker;
using Microsoft.DurableTask;
using Microsoft.DurableTask.Client;
using Microsoft.DurableTask.Entities;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
/// <summary>
/// Executes workflow orchestrations and activity functions for durable workflows.
/// Azure Functions-specific workflow runner that extends the base <see cref="DurableWorkflowRunner"/>
/// with Azure Functions activity execution support.
/// </summary>
internal sealed class DurableWorkflowRunner
internal sealed class FunctionsWorkflowRunner : DurableWorkflowRunner
{
private readonly DurableWorkflowOptions _options;
private readonly ILogger<DurableWorkflowRunner> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="DurableWorkflowRunner"/> class.
/// Initializes a new instance of the <see cref="FunctionsWorkflowRunner"/> 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)
public FunctionsWorkflowRunner(ILogger<FunctionsWorkflowRunner> logger, DurableOptions durableOptions)
: base(logger, durableOptions)
{
ArgumentNullException.ThrowIfNull(logger);
ArgumentNullException.ThrowIfNull(durableOptions);
this._logger = logger;
this._options = durableOptions.Workflows;
}
/// <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(context);
ArgumentNullException.ThrowIfNull(request);
if (!this._options.Workflows.TryGetValue(request.WorkflowName, out Workflow? workflow))
{
throw new InvalidOperationException($"Workflow '{request.WorkflowName}' not found.");
}
logger.LogRunningWorkflow(workflow.Name);
string result = await this.ExecuteWorkflowLevelsAsync(context, workflow, request.Input, logger).ConfigureAwait(true);
await CleanupWorkflowStateAsync(context).ConfigureAwait(true);
return [result];
}
/// <summary>
/// Cleans up the workflow state entity by signaling it to delete itself.
/// </summary>
private static async Task CleanupWorkflowStateAsync(TaskOrchestrationContext context)
{
EntityInstanceId stateEntityId = new(WorkflowSharedStateEntity.EntityName, context.InstanceId);
// Call the entity's Delete method to clean up state
// Using CallEntityAsync ensures the deletion completes before the orchestration finishes
await context.Entities.CallEntityAsync(stateEntityId, nameof(WorkflowSharedStateEntity.Delete)).ConfigureAwait(true);
}
/// <summary>
@@ -97,12 +47,12 @@ internal sealed class DurableWorkflowRunner
string executorName = ParseExecutorName(activityFunctionName);
if (!this._options.Executors.TryGetExecutor(executorName, out ExecutorRegistration? registration) || registration is null)
if (!this.Options.Executors.TryGetExecutor(executorName, out ExecutorRegistration? registration) || registration is null)
{
throw new InvalidOperationException($"Executor '{executorName}' not found in the executor registry.");
}
this._logger.LogExecutingActivity(registration.ExecutorId, executorName);
this.Logger.LogExecutingActivity(registration.ExecutorId, executorName);
Executor executor = await registration.CreateExecutorInstanceAsync("activity-run", CancellationToken.None)
.ConfigureAwait(false);
@@ -147,223 +97,4 @@ internal sealed class DurableWorkflowRunner
{
return new DurableExecutorContext(instanceId, client);
}
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)
{
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, 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, executorInfo, input, logger));
}
foreach ((string id, string result) in await Task.WhenAll(tasks).ConfigureAwait(true))
{
results[id] = result;
}
}
}
return GetFinalResult(plan, results);
}
private async Task<(string Id, string Result)> ExecuteExecutorWithIdAsync(
TaskOrchestrationContext context,
WorkflowExecutorInfo executorInfo,
string input,
ILogger logger)
{
string result = await this.ExecuteExecutorAsync(context, executorInfo, input, logger).ConfigureAwait(true);
return (executorInfo.ExecutorId, result);
}
private async Task<string> ExecuteExecutorAsync(
TaskOrchestrationContext context,
WorkflowExecutorInfo executorInfo,
string input,
ILogger logger)
{
if (!executorInfo.IsAgenticExecutor)
{
string triggerName = $"dafx-{GetBaseName(executorInfo.ExecutorId)}";
return await context.CallActivityAsync<string>(triggerName, input).ConfigureAwait(true);
}
return await ExecuteAgentAsync(context, executorInfo, input, logger).ConfigureAwait(true);
}
private static async Task<string> ExecuteAgentAsync(
TaskOrchestrationContext context,
WorkflowExecutorInfo executorInfo,
string input,
ILogger logger)
{
string agentName = GetBaseName(executorInfo.ExecutorId);
DurableAIAgent agent = context.GetAgent(agentName);
if (agent is null)
{
logger.LogWarning("Agent '{AgentName}' not found", agentName);
return $"Agent '{agentName}' not found";
}
AgentThread thread = agent.GetNewThread();
AgentRunResponse response = await agent.RunAsync(input, thread).ConfigureAwait(true);
return response.Text;
}
private static string GetExecutorInput(
string executorId,
string initialInput,
Dictionary<string, string> results,
WorkflowExecutionPlan plan)
{
List<string> predecessors = plan.Predecessors[executorId];
if (predecessors.Count == 0)
{
return initialInput;
}
if (predecessors.Count == 1)
{
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;
}
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing known types.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing known types.")]
private static string SerializeToJson(List<string> values)
{
return JsonSerializer.Serialize(values);
}
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing workflow types registered at startup.")]
[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);
}
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow types registered at startup.")]
[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}'.");
}
}
@@ -1,38 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
/// <summary>
/// Logging messages for <see cref="DurableWorkflowRunner"/>.
/// </summary>
[ExcludeFromCodeCoverage]
internal static partial class DurableWorkflowRunnerLogs
{
[LoggerMessage(
Level = LogLevel.Information,
Message = "Attempting to run workflow: {WorkflowName}")]
public static partial void LogAttemptingToRunWorkflow(this ILogger logger, string workflowName);
[LoggerMessage(
Level = LogLevel.Information,
Message = "Running workflow: {WorkflowName}")]
public static partial void LogRunningWorkflow(this ILogger logger, string? workflowName);
[LoggerMessage(
Level = LogLevel.Information,
Message = "Attempting to execute activity in workflow '{WorkflowName}' for executor '{ExecutorName}'")]
public static partial void LogAttemptingToExecuteActivity(this ILogger logger, string workflowName, string executorName);
[LoggerMessage(
Level = LogLevel.Information,
Message = "Executing activity for executor '{ExecutorId}' of type '{ExecutorType}'")]
public static partial void LogExecutingActivity(this ILogger logger, string executorId, string executorType);
[LoggerMessage(
Level = LogLevel.Information,
Message = "Activity executed for executor '{ExecutorId}' with result: {Result}")]
public static partial void LogActivityExecuted(this ILogger logger, string executorId, string result);
}