From b5e4553f05f866a97a0933881fde74b767692cbd Mon Sep 17 00:00:00 2001 From: Shyju Krishnankutty Date: Thu, 12 Feb 2026 16:28:24 -0800 Subject: [PATCH] WIP --- dotnet/agent-framework-dotnet.slnx | 1 + .../05_WorkflowAndAgents.csproj | 42 ++++ .../05_WorkflowAndAgents/Executors.cs | 54 +++++ .../05_WorkflowAndAgents/Program.cs | 99 ++++++++++ .../05_WorkflowAndAgents/README.md | 73 +++++++ .../05_WorkflowAndAgents/demo.http | 20 ++ .../05_WorkflowAndAgents/host.json | 20 ++ .../ServiceCollectionExtensions.cs | 48 +---- .../Workflows/DurableWorkflowOptions.cs | 11 ++ ...DurableAgentFunctionMetadataTransformer.cs | 12 ++ .../FunctionsApplicationBuilderExtensions.cs | 185 +++++++++--------- ...ableWorkflowFunctionMetadataTransformer.cs | 13 +- 12 files changed, 437 insertions(+), 141 deletions(-) create mode 100644 dotnet/samples/Durable/Workflow/AzureFunctions/05_WorkflowAndAgents/05_WorkflowAndAgents.csproj create mode 100644 dotnet/samples/Durable/Workflow/AzureFunctions/05_WorkflowAndAgents/Executors.cs create mode 100644 dotnet/samples/Durable/Workflow/AzureFunctions/05_WorkflowAndAgents/Program.cs create mode 100644 dotnet/samples/Durable/Workflow/AzureFunctions/05_WorkflowAndAgents/README.md create mode 100644 dotnet/samples/Durable/Workflow/AzureFunctions/05_WorkflowAndAgents/demo.http create mode 100644 dotnet/samples/Durable/Workflow/AzureFunctions/05_WorkflowAndAgents/host.json diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 6c564d81b8..06002ead71 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -59,6 +59,7 @@ + diff --git a/dotnet/samples/Durable/Workflow/AzureFunctions/05_WorkflowAndAgents/05_WorkflowAndAgents.csproj b/dotnet/samples/Durable/Workflow/AzureFunctions/05_WorkflowAndAgents/05_WorkflowAndAgents.csproj new file mode 100644 index 0000000000..b9efe37eff --- /dev/null +++ b/dotnet/samples/Durable/Workflow/AzureFunctions/05_WorkflowAndAgents/05_WorkflowAndAgents.csproj @@ -0,0 +1,42 @@ + + + net10.0 + v4 + Exe + enable + enable + + WorkflowAndAgentsFunctionApp + WorkflowAndAgentsFunctionApp + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/Durable/Workflow/AzureFunctions/05_WorkflowAndAgents/Executors.cs b/dotnet/samples/Durable/Workflow/AzureFunctions/05_WorkflowAndAgents/Executors.cs new file mode 100644 index 0000000000..f26d194154 --- /dev/null +++ b/dotnet/samples/Durable/Workflow/AzureFunctions/05_WorkflowAndAgents/Executors.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows; + +namespace WorkflowAndAgentsFunctionApp; + +/// +/// Parses and validates the incoming question before sending to AI agents. +/// +internal sealed class ParseQuestionExecutor() : Executor("ParseQuestion") +{ + public override ValueTask HandleAsync( + string message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + Console.WriteLine($"[ParseQuestion] Preparing question: \"{message}\""); + + string formattedQuestion = message.Trim(); + if (!formattedQuestion.EndsWith('?')) + { + formattedQuestion += "?"; + } + + return ValueTask.FromResult(formattedQuestion); + } +} + +/// +/// Aggregates responses from multiple AI agents into a unified response. +/// +internal sealed class ResponseAggregatorExecutor() : Executor("ResponseAggregator") +{ + public override ValueTask HandleAsync( + string[] message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + Console.WriteLine($"[Aggregator] Received {message.Length} AI agent responses, combining..."); + + string aggregatedResult = "AI EXPERT PANEL RESPONSES\n" + + "═════════════════════════\n\n"; + + for (int i = 0; i < message.Length; i++) + { + string expertLabel = i == 0 ? "PHYSICIST" : "CHEMIST"; + aggregatedResult += $"{expertLabel}:\n{message[i]}\n\n"; + } + + aggregatedResult += $"Summary: Received perspectives from {message.Length} AI experts."; + + return ValueTask.FromResult(aggregatedResult); + } +} diff --git a/dotnet/samples/Durable/Workflow/AzureFunctions/05_WorkflowAndAgents/Program.cs b/dotnet/samples/Durable/Workflow/AzureFunctions/05_WorkflowAndAgents/Program.cs new file mode 100644 index 0000000000..664613a1be --- /dev/null +++ b/dotnet/samples/Durable/Workflow/AzureFunctions/05_WorkflowAndAgents/Program.cs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates the THREE ways to configure durable agents and workflows +// in an Azure Functions app: +// +// 1. ConfigureDurableAgents() - For standalone agents only +// 2. ConfigureDurableWorkflows() - For workflows only +// 3. ConfigureDurableOptions() - For both agents AND workflows +// +// KEY: All methods can be called MULTIPLE times - configurations are ADDITIVE. +// +// Workflow structure: +// +// PhysicsExpertReview: ParseQuestion ──► Physicist (AI Agent) +// +// ExpertTeamReview: ParseQuestion ──┬──► Physicist (AI Agent) ──┬──► Aggregator +// └──► Chemist (AI Agent) ──┘ +// +// ChemistryExpertReview: ParseQuestion ──► Chemist (AI Agent) + +using Azure; +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting.AzureFunctions; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Azure.Functions.Worker.Builder; +using Microsoft.Extensions.Hosting; +using OpenAI.Chat; +using WorkflowAndAgentsFunctionApp; + +// Configuration +string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") + ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT") + ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT is not set."); +string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY"); + +// Create Azure OpenAI client +AzureOpenAIClient openAiClient = !string.IsNullOrEmpty(azureOpenAiKey) + ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) + : new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()); +ChatClient chatClient = openAiClient.GetChatClient(deploymentName); + +// Create AI agents +AIAgent physicist = chatClient.AsAIAgent("You are a physics expert. Explain concepts clearly in 2-3 sentences.", "Physicist"); +AIAgent chemist = chatClient.AsAIAgent("You are a chemistry expert. Explain concepts clearly in 2-3 sentences.", "Chemist"); +AIAgent biologist = chatClient.AsAIAgent("You are a biology expert. Explain concepts clearly in 2-3 sentences.", "Biologist"); + +// Create custom executors +ParseQuestionExecutor questionParser = new(); +ResponseAggregatorExecutor responseAggregator = new(); + +// Workflow 1: Single-agent workflow (Physics) +Workflow physicsWorkflow = new WorkflowBuilder(questionParser) + .WithName("PhysicsExpertReview") + .AddEdge(questionParser, physicist) + .Build(); + +// Workflow 2: Multi-agent workflow with fan-out/fan-in (Expert Team) +Workflow expertTeamWorkflow = new WorkflowBuilder(questionParser) + .WithName("ExpertTeamReview") + .AddFanOutEdge(questionParser, [physicist, chemist]) + .AddFanInEdge([physicist, chemist], responseAggregator) + .Build(); + +// Workflow 3: Single-agent workflow (Chemistry) +Workflow chemistryWorkflow = new WorkflowBuilder(questionParser) + .WithName("ChemistryExpertReview") + .AddEdge(questionParser, chemist) + .Build(); + +// Configure the function app using all 3 methods to demonstrate additive configuration. +// Each method can be called one or more times - configurations accumulate. +using IHost app = FunctionsApplication + .CreateBuilder(args) + .ConfigureFunctionsWebApplication() + + // METHOD 1: ConfigureDurableAgents - for standalone agents only. + // Registers the biologist agent as a standalone agent (not part of any workflow). + .ConfigureDurableAgents(options => options.AddAIAgent(biologist)) + + // METHOD 2: ConfigureDurableWorkflows - for workflows only. + // Registers the physics workflow. Agents referenced in the workflow (Physicist) are auto-discovered. + .ConfigureDurableWorkflows(options => options.AddWorkflow(physicsWorkflow)) + + // METHOD 3: ConfigureDurableOptions - for both agents AND workflows. + // Registers the chemist agent explicitly and the expert team workflow together. + .ConfigureDurableOptions(options => + { + options.Agents.AddAIAgent(chemist); + options.Workflows.AddWorkflow(expertTeamWorkflow); + }) + + // Second call to ConfigureDurableOptions (additive - adds to existing config). + .ConfigureDurableOptions(options => options.Workflows.AddWorkflow(chemistryWorkflow)) + + .Build(); +app.Run(); diff --git a/dotnet/samples/Durable/Workflow/AzureFunctions/05_WorkflowAndAgents/README.md b/dotnet/samples/Durable/Workflow/AzureFunctions/05_WorkflowAndAgents/README.md new file mode 100644 index 0000000000..433216636c --- /dev/null +++ b/dotnet/samples/Durable/Workflow/AzureFunctions/05_WorkflowAndAgents/README.md @@ -0,0 +1,73 @@ +# Workflow and Agents - Azure Functions Sample + +This sample demonstrates how to combine **custom executors with AI agents** (backed by Azure OpenAI) in workflows hosted as Azure Functions. It shows single-agent workflows, multi-agent fan-out/fan-in workflows, and how multiple workflows can be registered together. + +## Key Concepts Demonstrated + +- Using **AI agents** (Azure OpenAI) as workflow executors alongside custom executors +- **Fan-out/fan-in** pattern with multiple AI agents running in parallel +- Registering **multiple workflows** in a single Azure Functions app via `ConfigureDurableOptions` + +## Overview + +Three workflows are registered, each demonstrating different patterns: + +``` +PhysicsExpertReview: ParseQuestion ──► Physicist (AI Agent) + +ExpertTeamReview: ParseQuestion ──┬──► Physicist (AI Agent) ──┬──► Aggregator + └──► Chemist (AI Agent) ──┘ + +ChemistryExpertReview: ParseQuestion ──► Chemist (AI Agent) +``` + +| Executor | Type | Description | +|----------|------|-------------| +| ParseQuestion | Custom Executor | Validates and formats the incoming question | +| Physicist | AI Agent | Physics expert backed by Azure OpenAI | +| Chemist | AI Agent | Chemistry expert backed by Azure OpenAI | +| ResponseAggregator | Custom Executor | Combines responses from multiple AI agents | + +## Environment Setup + +This sample requires: + +- [.NET 10 SDK](https://dotnet.microsoft.com/download) +- [Azure Functions Core Tools v4](https://learn.microsoft.com/azure/azure-functions/functions-run-local) +- [Durable Task Scheduler](https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-azure-managed-storage) running locally (default: `http://localhost:8080`) +- [Azurite](https://learn.microsoft.com/azure/storage/common/storage-use-azurite) for local Azure Storage emulation +- An [Azure OpenAI](https://learn.microsoft.com/azure/ai-services/openai/) deployment + +### Configuration + +Set the following environment variables in `local.settings.json`: + +| Variable | Description | +|----------|-------------| +| `AZURE_OPENAI_ENDPOINT` | Your Azure OpenAI endpoint URL | +| `AZURE_OPENAI_DEPLOYMENT` | The model deployment name (e.g., `gpt-4o`) | +| `AZURE_OPENAI_KEY` | *(Optional)* API key. If not set, uses `AzureCliCredential` | + +## Running the Sample + +```bash +cd dotnet/samples/Durable/Workflow/AzureFunctions/05_WorkflowAndAgents +func start +``` + +### Testing + +**Single-agent workflow (Physics):** +```bash +curl -X POST http://localhost:7071/api/workflows/PhysicsExpertReview/run -H "Content-Type: text/plain" -d "What is the relationship between energy and mass?" +``` + +**Multi-agent workflow (Expert Team):** +```bash +curl -X POST http://localhost:7071/api/workflows/ExpertTeamReview/run -H "Content-Type: text/plain" -d "How does radiation affect living cells?" +``` + +**Single-agent workflow (Chemistry):** +```bash +curl -X POST http://localhost:7071/api/workflows/ChemistryExpertReview/run -H "Content-Type: text/plain" -d "What happens during combustion?" +``` diff --git a/dotnet/samples/Durable/Workflow/AzureFunctions/05_WorkflowAndAgents/demo.http b/dotnet/samples/Durable/Workflow/AzureFunctions/05_WorkflowAndAgents/demo.http new file mode 100644 index 0000000000..177d3d243f --- /dev/null +++ b/dotnet/samples/Durable/Workflow/AzureFunctions/05_WorkflowAndAgents/demo.http @@ -0,0 +1,20 @@ +# Default endpoint address for local testing +@authority=http://localhost:7071 + +### Single-agent workflow: Physics expert +POST {{authority}}/api/workflows/PhysicsExpertReview/run +Content-Type: text/plain + +What is the relationship between energy and mass? + +### Multi-agent workflow: Expert team (Physicist + Chemist in parallel) +POST {{authority}}/api/workflows/ExpertTeamReview/run +Content-Type: text/plain + +How does radiation affect living cells? + +### Single-agent workflow: Chemistry expert +POST {{authority}}/api/workflows/ChemistryExpertReview/run +Content-Type: text/plain + +What happens during combustion? diff --git a/dotnet/samples/Durable/Workflow/AzureFunctions/05_WorkflowAndAgents/host.json b/dotnet/samples/Durable/Workflow/AzureFunctions/05_WorkflowAndAgents/host.json new file mode 100644 index 0000000000..9384a0a583 --- /dev/null +++ b/dotnet/samples/Durable/Workflow/AzureFunctions/05_WorkflowAndAgents/host.json @@ -0,0 +1,20 @@ +{ + "version": "2.0", + "logging": { + "logLevel": { + "Microsoft.Agents.AI.DurableTask": "Information", + "Microsoft.Agents.AI.Hosting.AzureFunctions": "Information", + "DurableTask": "Information", + "Microsoft.DurableTask": "Information" + } + }, + "extensions": { + "durableTask": { + "hubName": "default", + "storageProvider": { + "type": "AzureManaged", + "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING" + } + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs index c7d8cc5a85..59f9043a36 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs @@ -235,19 +235,18 @@ public static class ServiceCollectionExtensions private static void RegisterTasksFromOptions(DurableTaskRegistry registry, DurableOptions durableOptions) { - // Build registrations for all workflows including sub-workflows + // Build registrations for all workflows including discovered sub-workflows List registrations = []; HashSet registeredActivities = []; HashSet registeredOrchestrations = []; - foreach (Workflow workflow in durableOptions.Workflows.Workflows.Values.ToList()) + foreach (Workflow workflow in durableOptions.Workflows.Workflows.Values) { - BuildWorkflowRegistrationRecursive( - workflow, - durableOptions.Workflows, - registrations, - registeredActivities, - registeredOrchestrations); + string orchestrationName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflow.Name!); + if (registeredOrchestrations.Add(orchestrationName)) + { + registrations.Add(BuildWorkflowRegistration(workflow, registeredActivities)); + } } IReadOnlyDictionary> agentFactories = @@ -281,39 +280,6 @@ public static class ServiceCollectionExtensions } } - private static void BuildWorkflowRegistrationRecursive( - Workflow workflow, - DurableWorkflowOptions workflowOptions, - List registrations, - HashSet registeredActivities, - HashSet 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()) - { - Workflow subWorkflow = subworkflowBinding.WorkflowInstance; - workflowOptions.AddWorkflow(subWorkflow); - - BuildWorkflowRegistrationRecursive( - subWorkflow, - workflowOptions, - registrations, - registeredActivities, - registeredOrchestrations); - } - } - private static WorkflowRegistrationInfo BuildWorkflowRegistration( Workflow workflow, HashSet registeredActivities) diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowOptions.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowOptions.cs index eb9ee92758..866cf55b4c 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowOptions.cs @@ -74,6 +74,7 @@ public sealed class DurableWorkflowOptions /// /// Registers all executors from a workflow, including AI agents if agent options are available. + /// Any sub-workflow bindings are automatically discovered and added recursively. /// private void RegisterWorkflowExecutors(Workflow workflow) { @@ -85,6 +86,16 @@ public sealed class DurableWorkflowOptions this.Executors.Register(executorName, executorId, workflow); TryRegisterAgent(binding, agentOptions); + + // Automatically discover and register sub-workflows + if (binding is SubworkflowBinding subworkflowBinding) + { + Workflow subWorkflow = subworkflowBinding.WorkflowInstance; + if (subWorkflow.Name is not null && !this._workflows.ContainsKey(subWorkflow.Name)) + { + this.AddWorkflow(subWorkflow); + } + } } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentFunctionMetadataTransformer.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentFunctionMetadataTransformer.cs index f626db2a90..ebe2eda471 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentFunctionMetadataTransformer.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentFunctionMetadataTransformer.cs @@ -21,6 +21,11 @@ internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadat private static readonly string s_builtInFunctionsScriptFile = Path.GetFileName(typeof(BuiltInFunctions).Assembly.Location); #pragma warning restore IL3000 + /// + /// Tracks all registered function names to prevent duplicates across multiple metadata transformers. + /// + internal static readonly HashSet RegisteredFunctionNames = new(StringComparer.OrdinalIgnoreCase); + public DurableAgentFunctionMetadataTransformer( IReadOnlyDictionary> agents, ILogger logger, @@ -42,6 +47,13 @@ internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadat foreach (KeyValuePair> kvp in this._agents) { string agentName = kvp.Key; + string entityFunctionName = AgentSessionId.ToEntityName(agentName); + + // Skip if this entity function has already been registered by another transformer + if (!RegisteredFunctionNames.Add(entityFunctionName)) + { + continue; + } this._logger.LogRegisteringTriggerForAgent(agentName, "entity"); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs index eb549952db..7c6157823e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs @@ -3,7 +3,6 @@ using Microsoft.Agents.AI.DurableTask; using Microsoft.Agents.AI.DurableTask.Workflows; using Microsoft.Agents.AI.Hosting.AzureFunctions.Workflows; -using Microsoft.Agents.AI.Workflows; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Builder; using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata; @@ -21,6 +20,16 @@ namespace Microsoft.Agents.AI.Hosting.AzureFunctions; /// public static class FunctionsApplicationBuilderExtensions { + /// + /// Tracks workflow orchestration names that have already been registered to prevent duplicates. + /// + private static readonly HashSet s_registeredOrchestrations = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Tracks whether middleware and shared services have been registered. + /// + private static bool s_middlewareRegistered; + /// /// Configures durable agents and workflows in a unified way. /// @@ -31,6 +40,7 @@ public static class FunctionsApplicationBuilderExtensions /// This method provides a unified configuration point for both durable agents and workflows. /// It automatically generates HTTP API endpoints for agents and workflows, and configures /// the necessary middleware and services for durable execution. + /// Multiple calls to this method are supported and configurations are composed additively. /// public static FunctionsApplicationBuilder ConfigureDurableOptions( this FunctionsApplicationBuilder builder, @@ -39,56 +49,60 @@ public static class FunctionsApplicationBuilderExtensions ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(configure); - DurableOptions options = new(); - configure(options); + // Delegate to the shared DurableOptions registration in Microsoft.Agents.AI.DurableTask. + // This ensures a single shared DurableOptions instance across all Configure* calls. + builder.Services.ConfigureDurableOptions(options => configure(options)); - builder.Services.AddSingleton(options); + // Read the shared options to check if workflows were added + DurableOptions sharedOptions = GetOrCreateSharedOptions(builder.Services); - if (options.Workflows.Workflows.Count > 0) + if (sharedOptions.Workflows.Workflows.Count > 0) { - ConfigureWorkflowOrchestrations(builder, options.Workflows); - // Do things to enable workflow as orchestrator functions. - // Register the Workflow metadata transformer. - builder.ConfigureDurableWorkflows(durableWorkflwoOptions => - { - // what - }); + ConfigureWorkflowOrchestrations(builder, sharedOptions.Workflows); - builder.Services.AddSingleton(); - - builder.UseWhen(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.RunAgentEntityFunctionEntryPoint, StringComparison.Ordinal) - || string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowOrechstrtationHttpFunctionEntryPoint, StringComparison.Ordinal) - - || string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowOrechstrtationHttpFunctionEntryPoint, StringComparison.Ordinal) - || string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.InvokeWorkflowOrchestrationFunctionEntryPoint, StringComparison.Ordinal) - || string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint, StringComparison.Ordinal) - ); - builder.Services.AddSingleton(); - - //builder.UseWhen(static context => - // string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowOrechstrtationHttpFunctionEntryPoint, StringComparison.Ordinal) - // || string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.InvokeWorkflowOrchestrationFunctionEntryPoint, StringComparison.Ordinal) - // || string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint, StringComparison.Ordinal) - // || string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentHttpFunctionEntryPoint, StringComparison.Ordinal) - // ); - //builder.Services.AddSingleton(); + builder.Services.TryAddEnumerable( + ServiceDescriptor.Singleton()); } + EnsureMiddlewareRegistered(builder); + return builder; } - private static void ConfigureWorkflowOrchestrations(FunctionsApplicationBuilder builder, DurableWorkflowOptions workflows) + /// + /// Gets or creates a shared instance from the service collection. + /// + private static DurableOptions GetOrCreateSharedOptions(IServiceCollection services) { - // Discover sub-workflows recursively and add them to the workflows dictionary - // so they are registered as separate orchestrations alongside the main workflows. - DiscoverSubWorkflows(workflows); + ServiceDescriptor? existingDescriptor = services.FirstOrDefault( + d => d.ServiceType == typeof(DurableOptions) && d.ImplementationInstance is not null); + + if (existingDescriptor?.ImplementationInstance is DurableOptions existing) + { + return existing; + } + + DurableOptions options = new(); + services.AddSingleton(options); + return options; + } + + private static void ConfigureWorkflowOrchestrations(FunctionsApplicationBuilder builder, DurableWorkflowOptions workflowOptions) + { + // Collect only workflows that haven't been registered yet + List newWorkflowNames = workflowOptions.Workflows + .Select(kp => kp.Key) + .Where(name => s_registeredOrchestrations.Add(name)) + .ToList(); + + if (newWorkflowNames.Count == 0) + { + return; + } builder.ConfigureDurableWorker().AddTasks(tasks => { - foreach (string workflowName in workflows.Workflows.Select(kp => kp.Key)) + foreach (string workflowName in newWorkflowNames) { string orchestrationFunctionName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflowName); @@ -109,48 +123,19 @@ public static class FunctionsApplicationBuilderExtensions }); } - private static void DiscoverSubWorkflows(DurableWorkflowOptions workflows) - { - HashSet visited = new(workflows.Workflows.Keys); - Queue queue = new(workflows.Workflows.Values); - - while (queue.Count > 0) - { - Workflow workflow = queue.Dequeue(); - - foreach (ExecutorBinding binding in workflow.ReflectExecutors().Values) - { - if (binding is SubworkflowBinding subworkflowBinding) - { - Workflow subWorkflow = subworkflowBinding.WorkflowInstance; - if (subWorkflow.Name is not null && visited.Add(subWorkflow.Name)) - { - workflows.AddWorkflow(subWorkflow); - queue.Enqueue(subWorkflow); - } - } - } - } - } - internal static FunctionsApplicationBuilder RegisterWorkflowServices(this FunctionsApplicationBuilder builder) - { - // Register FunctionsWorkflowRunner as a singleton - // builder.Services.TryAddSingleton(); - - // Also register it as DurableWorkflowRunner so orchestrations can resolve it by base type - //builder.Services.TryAddSingleton(sp => sp.GetRequiredService()); - - builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); - - return builder; - } - /// - /// Configures durable workflow services for the application and allows customization of durable workflow options. + /// Configures durable workflow services for the Azure Functions application. /// - /// This method registers the services required for durable workflows using - /// Microsoft.DurableTask.Workflows. Call this method during application startup to enable durable workflows in your - /// Azure Functions app. + /// + /// + /// This method registers the services required for durable workflows in an Azure Functions app, + /// including orchestration triggers, HTTP triggers, and activity/entity triggers for executors. + /// + /// + /// Multiple calls to this method are supported and configurations are composed additively. + /// Agents referenced in workflows are automatically discovered and registered. + /// + /// /// The application builder used to configure services and middleware for the Azure Functions app. /// A delegate that is used to configure the durable workflow options. Cannot be null. /// The same instance that this method was called on, to support method @@ -159,13 +144,7 @@ public static class FunctionsApplicationBuilderExtensions { ArgumentNullException.ThrowIfNull(configure); - //RegisterWorkflowServices(builder); - //builder.Services.AddSingleton(); - - // The main durable workflows services registration is done in Microsoft.DurableTask.Workflows. - builder.Services.ConfigureDurableWorkflows(configure); - - return builder; + return builder.ConfigureDurableOptions(options => configure(options.Workflows)); } /// @@ -188,19 +167,31 @@ public static class FunctionsApplicationBuilderExtensions builder.Services.AddSingleton(); - // Handling of built-in function execution for Agent HTTP, MCP tool, or Entity invocations. - builder.UseWhen(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.RunAgentEntityFunctionEntryPoint, StringComparison.Ordinal) - || string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowOrechstrtationHttpFunctionEntryPoint, StringComparison.Ordinal) - - || string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowOrechstrtationHttpFunctionEntryPoint, StringComparison.Ordinal) - || string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.InvokeWorkflowOrchestrationFunctionEntryPoint, StringComparison.Ordinal) - || string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint, StringComparison.Ordinal) - ); - builder.Services.AddSingleton(); + EnsureMiddlewareRegistered(builder); return builder; } + + /// + /// Registers the built-in function execution middleware and executor exactly once. + /// + private static void EnsureMiddlewareRegistered(FunctionsApplicationBuilder builder) + { + if (s_middlewareRegistered) + { + return; + } + + s_middlewareRegistered = true; + + builder.UseWhen(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.RunAgentEntityFunctionEntryPoint, StringComparison.Ordinal) || + string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowOrechstrtationHttpFunctionEntryPoint, StringComparison.Ordinal) || + string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.InvokeWorkflowOrchestrationFunctionEntryPoint, StringComparison.Ordinal) || + string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint, StringComparison.Ordinal) + ); + builder.Services.TryAddSingleton(); + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowFunctionMetadataTransformer.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowFunctionMetadataTransformer.cs index 49a23d0584..682ec6e292 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowFunctionMetadataTransformer.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowFunctionMetadataTransformer.cs @@ -10,7 +10,6 @@ namespace Microsoft.Agents.AI.Hosting.AzureFunctions.Workflows; internal sealed class DurableWorkflowFunctionMetadataTransformer : IFunctionMetadataTransformer { - private static readonly HashSet _registeredFunctionNames = new(); private readonly ILogger _logger; private readonly DurableWorkflowOptions _options; @@ -29,6 +28,14 @@ internal sealed class DurableWorkflowFunctionMetadataTransformer : IFunctionMeta foreach (var workflow in this._options.Workflows) { + string httpFunctionName = $"{BuiltInFunctions.HttpPrefix}{workflow.Key}"; + + // Skip if this workflow's HTTP trigger has already been registered by another transformer + if (!DurableAgentFunctionMetadataTransformer.RegisteredFunctionNames.Add(httpFunctionName)) + { + continue; + } + this._logger.LogAddingWorkflowFunction(workflow.Key); // Currently due to how durable executor is registered, we are not able to bind TaskOrechestrationContext parameter properly @@ -80,8 +87,8 @@ internal sealed class DurableWorkflowFunctionMetadataTransformer : IFunctionMeta string executorName = WorkflowNamingHelper.GetExecutorName(executorId); string functionName = WorkflowNamingHelper.ToOrchestrationFunctionName(executorName); - // Skip if this function has already been registered by another workflow - if (!_registeredFunctionNames.Add(functionName)) + // Skip if this function has already been registered by another workflow or transformer + if (!DurableAgentFunctionMetadataTransformer.RegisteredFunctionNames.Add(functionName)) { this._logger.LogSkippingDuplicateFunction(functionName, workflow.Key); continue;