diff --git a/docs/features/durable-agents/README.md b/docs/features/durable-agents/README.md index d18418b..3a801be 100644 --- a/docs/features/durable-agents/README.md +++ b/docs/features/durable-agents/README.md @@ -93,6 +93,20 @@ using IHost app = FunctionsApplication app.Run(); ``` +To host durable workflows alongside agents, add a `ConfigureDurableWorkflows` call. The two methods compose, in any order and any number of times: + +```csharp +using IHost app = FunctionsApplication + .CreateBuilder(args) + .ConfigureFunctionsWebApplication() + .ConfigureDurableAgents(agents => agents.AddAIAgent(agent)) + .ConfigureDurableWorkflows(workflows => workflows.AddWorkflow(workflow)) + .Build(); +app.Run(); +``` + +Alternatively, `ConfigureDurableOptions` configures both from a single delegate and can be freely mixed with the methods above. + **Python example:** ```python diff --git a/dotnet/samples/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Program.cs b/dotnet/samples/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Program.cs index fcb9906..06653b9 100644 --- a/dotnet/samples/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Program.cs +++ b/dotnet/samples/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Program.cs @@ -1,8 +1,12 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample demonstrates using ConfigureDurableOptions to register BOTH agents AND workflows -// in a single Azure Functions app. It uses a workflow to translate text and a standalone AI agent -// accessible via HTTP and MCP tool triggers. +// This sample demonstrates registering BOTH agents AND workflows in a single Azure Functions app. +// It uses a workflow to translate text and a standalone AI agent accessible via HTTP and MCP tool +// triggers. +// +// ConfigureDurableAgents and ConfigureDurableWorkflows compose: call them in any order, as many +// times as you like, and the configurations are additive. ConfigureDurableOptions is an equivalent +// alternative that configures both from a single delegate - see the README for that variant. #pragma warning disable IDE0002 // Simplify Member Access @@ -49,17 +53,15 @@ .AddEdge(translateText, formatOutput) .Build(); -// Use ConfigureDurableOptions to register both agents and workflows together +// Register agents and workflows through separate, composable calls. using IHost app = FunctionsApplication .CreateBuilder(args) .ConfigureFunctionsWebApplication() - .ConfigureDurableOptions(options => - { - // Register the standalone agent with HTTP and MCP tool triggers - options.Agents.AddAIAgent(assistant, enableHttpTrigger: true, enableMcpToolTrigger: true); - // Register the workflow with an HTTP endpoint and MCP tool trigger - options.Workflows.AddWorkflow(translateWorkflow, enableStatusEndpoint: false, enableMcpToolTrigger: true); - }) + // Register the standalone agent with HTTP and MCP tool triggers + .ConfigureDurableAgents(agents => agents.AddAIAgent(assistant, enableHttpTrigger: true, enableMcpToolTrigger: true)) + + // Register the workflow with an HTTP endpoint and MCP tool trigger + .ConfigureDurableWorkflows(workflows => workflows.AddWorkflow(translateWorkflow, enableStatusEndpoint: false, enableMcpToolTrigger: true)) .Build(); app.Run(); diff --git a/dotnet/samples/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/README.md b/dotnet/samples/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/README.md index 34d6843..12ffbb9 100644 --- a/dotnet/samples/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/README.md +++ b/dotnet/samples/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/README.md @@ -1,10 +1,32 @@ # Workflow and Agents Sample -This sample demonstrates how to use `ConfigureDurableOptions` to register **both** AI agents **and** workflows in a single Azure Functions app. This is the recommended approach when your application needs both standalone agents and orchestrated workflows. +This sample demonstrates how to register **both** AI agents **and** workflows in a single Azure Functions app, using separate `ConfigureDurableAgents` and `ConfigureDurableWorkflows` calls. + +These methods compose: call them in any order, as many times as you like, and the configurations are additive. + +```csharp +using IHost app = FunctionsApplication + .CreateBuilder(args) + .ConfigureFunctionsWebApplication() + .ConfigureDurableAgents(agents => agents.AddAIAgent(assistant, enableHttpTrigger: true, enableMcpToolTrigger: true)) + .ConfigureDurableWorkflows(workflows => workflows.AddWorkflow(translateWorkflow, enableMcpToolTrigger: true)) + .Build(); +app.Run(); +``` + +If you prefer to configure everything from a single delegate, `ConfigureDurableOptions` is an equivalent alternative and can be freely mixed with the two methods above: + +```csharp + .ConfigureDurableOptions(options => + { + options.Agents.AddAIAgent(assistant, enableHttpTrigger: true, enableMcpToolTrigger: true); + options.Workflows.AddWorkflow(translateWorkflow, enableMcpToolTrigger: true); + }) +``` ## Key Concepts Demonstrated -- **Unified Configuration**: Use `ConfigureDurableOptions` to register agents and workflows together +- **Composable Configuration**: `ConfigureDurableAgents` and `ConfigureDurableWorkflows` combine in the same app - **Standalone Agent**: An AI agent accessible via HTTP and MCP tool triggers - **Workflow**: A simple text translation workflow also exposed as an MCP tool - **Mixed Triggers**: Both agents and workflows coexist in the same Functions host diff --git a/dotnet/samples/DurableWorkflows/ConsoleApps/04_WorkflowAndAgents/Program.cs b/dotnet/samples/DurableWorkflows/ConsoleApps/04_WorkflowAndAgents/Program.cs index b94cbd2..df53f89 100644 --- a/dotnet/samples/DurableWorkflows/ConsoleApps/04_WorkflowAndAgents/Program.cs +++ b/dotnet/samples/DurableWorkflows/ConsoleApps/04_WorkflowAndAgents/Program.cs @@ -2,11 +2,12 @@ // This sample demonstrates the THREE ways to configure durable agents and workflows: // -// 1. ConfigureDurableAgents() - For standalone agents only -// 2. ConfigureDurableWorkflows() - For workflows only -// 3. ConfigureDurableOptions() - For both agents AND workflows +// 1. ConfigureDurableAgents() - Configures agents +// 2. ConfigureDurableWorkflows() - Configures workflows +// 3. ConfigureDurableOptions() - Configures agents and workflows from a single delegate // -// KEY: All methods can be called MULTIPLE times - configurations are ADDITIVE. +// KEY: All three methods compose. Call them in any order, as many times as you like - the +// configurations are ADDITIVE and share a single underlying options instance. using Azure.AI.OpenAI; using Azure.Identity; @@ -67,16 +68,16 @@ .ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning)) .ConfigureServices(services => { - // METHOD 1: ConfigureDurableAgents - for standalone agents only + // METHOD 1: ConfigureDurableAgents - configures agents services.ConfigureDurableAgents( options => options.AddAIAgent(biologist), workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString), clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString)); - // METHOD 2: ConfigureDurableWorkflows - for workflows only + // METHOD 2: ConfigureDurableWorkflows - configures workflows services.ConfigureDurableWorkflows(options => options.AddWorkflow(physicsWorkflow)); - // METHOD 3: ConfigureDurableOptions - for both agents AND workflows + // METHOD 3: ConfigureDurableOptions - configures both from a single delegate services.ConfigureDurableOptions(options => { options.Agents.AddAIAgent(chemist); diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md index 463b792..2ed7852 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] +- Fixed `ConfigureDurableAgents` and `ConfigureDurableWorkflows` silently dropping the `workerBuilder` or `clientBuilder` supplied to a later call, so the Durable Task worker and client are now registered regardless of which configuration call provides them ([#67](https://github.com/microsoft/agent-framework-durable-extension/pull/67)) - Fixed a `JsonTypeInfo metadata ... was not provided` failure when persisting agent state for function calls or results that carry values the state serializer has no metadata for, such as the `AIContent` results returned by MCP tools ([#57](https://github.com/microsoft/agent-framework-durable-extension/pull/57)) - [BREAKING] Added `IWorkflowClient` overloads that start a registered workflow by name, and made workflow result deserialization case-insensitive so results can be read back when hosted in Azure Functions. External implementations of `IWorkflowClient` must implement the new members, and an untyped `null` first argument is now ambiguous between the `Workflow` and workflow-name overloads ([#48](https://github.com/microsoft/agent-framework-durable-extension/pull/48)) - [BREAKING] Removed the `AddAIAgents` and `AddWorkflows` bulk registration APIs and changed `AddWorkflow` to return `DurableWorkflowOptions` so multiple workflows can be registered fluently ([#39](https://github.com/microsoft/agent-framework-durable-extension/pull/39)) diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableServicesMarker.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableServicesMarker.cs index 58dea9b..a00ac84 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableServicesMarker.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableServicesMarker.cs @@ -32,3 +32,26 @@ namespace Microsoft.Agents.AI.DurableTask; /// /// internal sealed class DurableServicesMarker; + +/// +/// Marker class used to track whether the Durable Task worker has been configured. +/// +/// +/// The worker is tracked separately from because a worker builder +/// may be supplied by any Configure* call, not just the first one: +/// +/// services.ConfigureDurableAgents(...); // 1st call - no worker builder +/// services.ConfigureDurableWorkflows(..., workerBuilder: b => ...); // 2nd call - worker builder +/// +/// A single shared marker would cause the second call's builder to be silently dropped. +/// +internal sealed class DurableTaskWorkerMarker; + +/// +/// Marker class used to track whether the Durable Task client has been configured. +/// +/// +/// See for why the client is tracked separately from +/// . +/// +internal sealed class DurableTaskClientMarker; diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs index 456e4ae..046580e 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs @@ -184,6 +184,13 @@ private static void EnsureDurableServicesRegistered( DurableOptions sharedOptions, Action? workerBuilder, Action? clientBuilder) + { + EnsureSharedServicesRegistered(services); + EnsureWorkerRegistered(services, sharedOptions, workerBuilder); + EnsureClientRegistered(services, clientBuilder); + } + + private static void EnsureSharedServicesRegistered(IServiceCollection services) { // Use a marker to ensure we only register core services once if (services.Any(d => d.ServiceType == typeof(DurableServicesMarker))) @@ -195,27 +202,6 @@ private static void EnsureDurableServicesRegistered( services.TryAddSingleton(); - // Configure Durable Task Worker - capture sharedOptions reference in closure. - // The options object is populated by all Configure* calls before the worker starts. - - if (workerBuilder is not null) - { - services.AddDurableTaskWorker(builder => - { - workerBuilder?.Invoke(builder); - - builder.AddTasks(registry => RegisterTasksFromOptions(registry, sharedOptions)); - }); - } - - // Configure Durable Task Client - if (clientBuilder is not null) - { - services.AddDurableTaskClient(clientBuilder); - services.TryAddSingleton(); - services.TryAddSingleton(); - } - // Register workflow and agent services services.TryAddSingleton(); @@ -227,6 +213,54 @@ private static void EnsureDurableServicesRegistered( services.TryAddSingleton(sp => sp.GetRequiredService().Agents); } + /// + /// Configures the Durable Task worker the first time a Configure* call supplies a worker + /// builder. The builder is tracked separately from the other core services so that a builder + /// supplied to a later call is still honored. + /// + private static void EnsureWorkerRegistered( + IServiceCollection services, + DurableOptions sharedOptions, + Action? workerBuilder) + { + if (workerBuilder is null || services.Any(d => d.ServiceType == typeof(DurableTaskWorkerMarker))) + { + return; + } + + services.AddSingleton(); + + // Capture the sharedOptions reference in the closure. + // The options object is populated by all Configure* calls before the worker starts. + services.AddDurableTaskWorker(builder => + { + workerBuilder(builder); + + builder.AddTasks(registry => RegisterTasksFromOptions(registry, sharedOptions)); + }); + } + + /// + /// Configures the Durable Task client the first time a Configure* call supplies a client + /// builder. The builder is tracked separately from the other core services so that a builder + /// supplied to a later call is still honored. + /// + private static void EnsureClientRegistered( + IServiceCollection services, + Action? clientBuilder) + { + if (clientBuilder is null || services.Any(d => d.ServiceType == typeof(DurableTaskClientMarker))) + { + return; + } + + services.AddSingleton(); + + services.AddDurableTaskClient(clientBuilder); + services.TryAddSingleton(); + services.TryAddSingleton(); + } + private static void RegisterTasksFromOptions(DurableTaskRegistry registry, DurableOptions durableOptions) { // Build registrations for all workflows including sub-workflows diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md index 7f27f09..7a3dbd6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] +- Fixed `ConfigureDurableAgents` and `ConfigureDurableWorkflows` not composing when both are called on the same application: calling `ConfigureDurableAgents` first left the workflow functions without an executor, and calling `ConfigureDurableWorkflows` first registered the built-in function execution middleware twice ([#67](https://github.com/microsoft/agent-framework-durable-extension/pull/67)) - [BREAKING] Support bounded synchronous workflow HTTP invocation through query parameters and default workflow run responses to JSON, with `Accept: text/plain` available for the legacy text format and the same negotiated asynchronous response returned on timeout ([#52](https://github.com/microsoft/agent-framework-durable-extension/pull/52)) - Added `DurableTaskClient.AsWorkflowClient` so functions can invoke durable workflows without constructing an `HttpClient` ([#48](https://github.com/microsoft/agent-framework-durable-extension/pull/48)) - [BREAKING] Consolidated the `AddWorkflow` extension overloads into a single method with optional `enableStatusEndpoint` and `enableMcpToolTrigger` parameters, and changed it to return `DurableWorkflowOptions` instead of `void` so multiple workflows can be registered fluently ([#39](https://github.com/microsoft/agent-framework-durable-extension/pull/39)) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs index 3c5e793..b94688d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs @@ -18,6 +18,10 @@ public static class FunctionsApplicationBuilderExtensions /// /// Configures the application to use durable agents with a builder pattern. /// + /// + /// Multiple calls to this method, and calls combined with or + /// , are supported and compose additively. + /// /// The functions application builder. /// A delegate to configure the durable agents. /// The functions application builder. @@ -25,31 +29,10 @@ public static FunctionsApplicationBuilder ConfigureDurableAgents( this FunctionsApplicationBuilder builder, Action configure) { + ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(configure); - // Create/get shared options BEFORE the DurableTask library call so it can find them. - FunctionsDurableOptions sharedOptions = GetOrCreateSharedOptions(builder.Services); - - // The main agent services registration is done in Microsoft.DurableTask.Agents. - builder.Services.ConfigureDurableAgents(configure); - - // Ensure all agents registered through this path have default FunctionsAgentOptions. - // This distinguishes them from agents auto-registered by workflows. - DurableAgentsOptionsExtensions.EnsureDefaultOptionsForAll(sharedOptions.Agents.GetAgentFactories().Keys); - - builder.Services.TryAddSingleton(_ => - new DefaultFunctionsAgentOptionsProvider(DurableAgentsOptionsExtensions.GetAgentOptionsSnapshot())); - - 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)); - builder.Services.AddSingleton(); - - return builder; + return ConfigureDurableCore(builder, options => configure(options.Agents), applyDefaultAgentOptions: true); } /// @@ -69,11 +52,63 @@ public static FunctionsApplicationBuilder ConfigureDurableOptions( ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(configure); + return ConfigureDurableCore(builder, configure, applyDefaultAgentOptions: false); + } + + /// + /// Configures durable workflow support for the specified Azure Functions application builder. + /// + /// + /// Multiple calls to this method, and calls combined with or + /// , are supported and compose additively. + /// + /// The instance to configure for durable workflows. + /// An action that configures the , allowing customization of durable workflow behavior. + /// The updated instance, enabling method chaining. + public static FunctionsApplicationBuilder ConfigureDurableWorkflows( + this FunctionsApplicationBuilder builder, + Action configure) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configure); + + return ConfigureDurableCore(builder, options => configure(options.Workflows), applyDefaultAgentOptions: false); + } + + /// + /// Applies a configuration delegate to the shared instance and registers the + /// Functions-specific services. All public configuration entry points funnel through here so that agents and + /// workflows are wired identically no matter which combination of methods the application calls, or in which + /// order. + /// + /// The functions application builder. + /// A delegate to apply to the shared durable options. + /// + /// When , agents added by that have no explicit + /// receive the defaults for the agent-focused entry point. Agents already + /// present, such as those auto-registered by a previously configured workflow, are left untouched. + /// + private static FunctionsApplicationBuilder ConfigureDurableCore( + FunctionsApplicationBuilder builder, + Action configure, + bool applyDefaultAgentOptions) + { // Ensure FunctionsDurableOptions is registered BEFORE the core extension creates a plain DurableOptions FunctionsDurableOptions sharedOptions = GetOrCreateSharedOptions(builder.Services); + // Agent names are case-insensitive everywhere else, so this snapshot must match that comparer. + HashSet agentsBeforeConfigure = applyDefaultAgentOptions + ? new HashSet(sharedOptions.Agents.GetAgentFactories().Keys, StringComparer.OrdinalIgnoreCase) + : new HashSet(StringComparer.OrdinalIgnoreCase); + builder.Services.ConfigureDurableOptions(configure); + if (applyDefaultAgentOptions) + { + DurableAgentsOptionsExtensions.EnsureDefaultOptionsForAll( + sharedOptions.Agents.GetAgentFactories().Keys.Where(name => !agentsBeforeConfigure.Contains(name))); + } + if (DurableAgentsOptionsExtensions.GetAgentOptionsSnapshot().Count > 0) { builder.Services.TryAddSingleton(_ => @@ -91,21 +126,6 @@ public static FunctionsApplicationBuilder ConfigureDurableOptions( return builder; } - /// - /// Configures durable workflow support for the specified Azure Functions application builder. - /// - /// The instance to configure for durable workflows. - /// An action that configures the , allowing customization of durable workflow behavior. - /// The updated instance, enabling method chaining. - public static FunctionsApplicationBuilder ConfigureDurableWorkflows( - this FunctionsApplicationBuilder builder, - Action configure) - { - ArgumentNullException.ThrowIfNull(configure); - - return builder.ConfigureDurableOptions(options => configure(options.Workflows)); - } - private static void EnsureMiddlewareRegistered(FunctionsApplicationBuilder builder) { // Guard against registering the middleware filter multiple times in the pipeline. diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableConfigurationCompositionTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableConfigurationCompositionTests.cs new file mode 100644 index 0000000..3da9240 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableConfigurationCompositionTests.cs @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Agents.AI.DurableTask.Workflows; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Agents.AI.DurableTask.UnitTests; + +/// +/// Tests that and +/// compose when both are called +/// on the same application, instead of forcing users onto +/// . +/// +/// +/// Regression coverage for https://github.com/microsoft/agent-framework-durable-extension/issues/27. +/// +public sealed class DurableConfigurationCompositionTests +{ + [Fact] + public void ConfigureDurableAgentsThenWorkflows_RegistersBothAgentAndWorkflow() + { + ServiceCollection services = new(); + + services.ConfigureDurableAgents(agents => agents.AddAIAgent(new CompositionTestAgent("Assistant"))); + services.ConfigureDurableWorkflows(workflows => workflows.AddWorkflow(BuildWorkflow("Translate"))); + + DurableOptions options = GetRegisteredOptions(services); + + Assert.Contains("Assistant", options.Agents.GetAgentFactories().Keys); + Assert.Contains("Translate", options.Workflows.Workflows.Keys); + } + + [Fact] + public void ConfigureDurableWorkflowsThenAgents_RegistersBothAgentAndWorkflow() + { + ServiceCollection services = new(); + + services.ConfigureDurableWorkflows(workflows => workflows.AddWorkflow(BuildWorkflow("Translate"))); + services.ConfigureDurableAgents(agents => agents.AddAIAgent(new CompositionTestAgent("Assistant"))); + + DurableOptions options = GetRegisteredOptions(services); + + Assert.Contains("Assistant", options.Agents.GetAgentFactories().Keys); + Assert.Contains("Translate", options.Workflows.Workflows.Keys); + } + + /// + /// The first Configure* call latches the core service registrations, so worker and client + /// builders supplied to a later call must still be honored. + /// + [Fact] + public void ConfigureDurableAgentsThenWorkflows_HonorsWorkerAndClientBuildersFromSecondCall() + { + ServiceCollection services = new(); + + services.ConfigureDurableAgents(agents => agents.AddAIAgent(new CompositionTestAgent("Assistant"))); + services.ConfigureDurableWorkflows( + workflows => workflows.AddWorkflow(BuildWorkflow("Translate")), + workerBuilder: _ => { }, + clientBuilder: _ => { }); + + Assert.Contains(services, d => d.ServiceType == typeof(IWorkflowClient)); + } + + /// + /// Builders supplied to the first call must survive additional Configure* calls, and the + /// core services must not be registered twice. + /// + [Fact] + public void ConfigureDurableAgentsThenWorkflows_RegistersCoreServicesExactlyOnce() + { + ServiceCollection services = new(); + + services.ConfigureDurableAgents( + agents => agents.AddAIAgent(new CompositionTestAgent("Assistant")), + workerBuilder: _ => { }, + clientBuilder: _ => { }); + services.ConfigureDurableWorkflows( + workflows => workflows.AddWorkflow(BuildWorkflow("Translate")), + workerBuilder: _ => { }, + clientBuilder: _ => { }); + + Assert.Equal(1, services.Count(d => d.ServiceType == typeof(IWorkflowClient))); + Assert.Equal(1, services.Count(d => d.ServiceType == typeof(DurableOptions))); + } + + private static DurableOptions GetRegisteredOptions(IServiceCollection services) + { + ServiceDescriptor descriptor = Assert.Single( + services, d => d.ServiceType == typeof(DurableOptions)); + + return Assert.IsType(descriptor.ImplementationInstance, exactMatch: false); + } + + private static Workflow BuildWorkflow(string name) => + new WorkflowBuilder(new FunctionExecutor("start", (_, _, _) => default)) + .WithName(name) + .Build(); + + private sealed class CompositionTestAgent(string name) : AIAgent + { + public override string? Name => name; + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + => new(new EmptySession()); + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) => new(new EmptySession()); + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) => Task.FromResult(new AgentResponse([.. messages])); + + protected override IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + private sealed class EmptySession : AgentSession; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/FunctionsDurableConfigurationCompositionTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/FunctionsDurableConfigurationCompositionTests.cs new file mode 100644 index 0000000..8321711 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/FunctionsDurableConfigurationCompositionTests.cs @@ -0,0 +1,194 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections; +using System.Reflection; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Builder; +using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata; +using Microsoft.Azure.Functions.Worker.Invocation; +using Microsoft.Azure.Functions.Worker.Middleware; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests; + +/// +/// Tests that and +/// compose when both are +/// called on the same Functions app, instead of forcing users onto +/// . +/// +/// +/// Regression coverage for https://github.com/microsoft/agent-framework-durable-extension/issues/27. +/// +public sealed class FunctionsDurableConfigurationCompositionTests +{ + /// + /// Entry points that must be routed to once both agents and + /// workflows are configured. Without the built-in executor the generated functions have no + /// implementation to run. + /// + public static TheoryData BuiltInEntryPointNames() => + [ + "AgentHttp", + "AgentEntity", + "WorkflowOrchestrationHttp", + "WorkflowOrchestration", + "WorkflowActivity", + "WorkflowStatusHttp", + ]; + + private static string ResolveEntryPoint(string name) => name switch + { + "AgentHttp" => BuiltInFunctions.RunAgentHttpFunctionEntryPoint, + "AgentEntity" => BuiltInFunctions.RunAgentEntityFunctionEntryPoint, + "WorkflowOrchestrationHttp" => BuiltInFunctions.RunWorkflowOrchestrationHttpFunctionEntryPoint, + "WorkflowOrchestration" => BuiltInFunctions.RunWorkflowOrchestrationFunctionEntryPoint, + "WorkflowActivity" => BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint, + "WorkflowStatusHttp" => BuiltInFunctions.GetWorkflowStatusHttpFunctionEntryPoint, + _ => throw new ArgumentOutOfRangeException(nameof(name), name, "Unknown built-in entry point."), + }; + + [Theory] + [MemberData(nameof(BuiltInEntryPointNames))] + public async Task ConfigureDurableAgentsThenWorkflows_RoutesAllBuiltInEntryPointsAsync(string entryPointName) + { + FunctionsApplicationBuilder builder = FunctionsApplication.CreateBuilder([]); + builder.ConfigureDurableAgents(agents => agents.AddAIAgent(new TestAgent("AgentsFirstAgent", "desc"))); + builder.ConfigureDurableWorkflows(workflows => workflows.AddWorkflow(BuildWorkflow("AgentsFirstWorkflow"))); + + await AssertRoutedToBuiltInExecutorAsync(builder, ResolveEntryPoint(entryPointName)); + } + + [Theory] + [MemberData(nameof(BuiltInEntryPointNames))] + public async Task ConfigureDurableWorkflowsThenAgents_RoutesAllBuiltInEntryPointsAsync(string entryPointName) + { + FunctionsApplicationBuilder builder = FunctionsApplication.CreateBuilder([]); + builder.ConfigureDurableWorkflows(workflows => workflows.AddWorkflow(BuildWorkflow("WorkflowsFirstWorkflow"))); + builder.ConfigureDurableAgents(agents => agents.AddAIAgent(new TestAgent("WorkflowsFirstAgent", "desc"))); + + await AssertRoutedToBuiltInExecutorAsync(builder, ResolveEntryPoint(entryPointName)); + } + + [Fact] + public void ConfigureDurableAgentsAndWorkflows_RegistersBuiltInMiddlewareOnce() + { + FunctionsApplicationBuilder builder = FunctionsApplication.CreateBuilder([]); + builder.ConfigureDurableWorkflows(workflows => workflows.AddWorkflow(BuildWorkflow("OnceWorkflow"))); + builder.ConfigureDurableAgents(agents => agents.AddAIAgent(new TestAgent("OnceAgent", "desc"))); + + Assert.Equal(1, builder.Services.Count(d => d.ServiceType == typeof(BuiltInFunctionExecutionMiddleware))); + Assert.Equal(1, builder.Services.Count(d => d.ServiceType == typeof(BuiltInFunctionExecutor))); + } + + [Fact] + public void ConfigureDurableAgentsAndWorkflows_RegistersBothMetadataTransformers() + { + FunctionsApplicationBuilder builder = FunctionsApplication.CreateBuilder([]); + builder.ConfigureDurableAgents(agents => agents.AddAIAgent(new TestAgent("TransformerAgent", "desc"))); + builder.ConfigureDurableWorkflows(workflows => workflows.AddWorkflow(BuildWorkflow("TransformerWorkflow"))); + + List transformers = [.. builder.Services + .Where(d => d.ServiceType == typeof(IFunctionMetadataTransformer)) + .Select(d => d.ImplementationType)]; + + Assert.Contains(typeof(DurableAgentFunctionMetadataTransformer), transformers); + Assert.Contains(typeof(DurableWorkflowsFunctionMetadataTransformer), transformers); + } + + private static async Task AssertRoutedToBuiltInExecutorAsync(FunctionsApplicationBuilder builder, string entryPoint) + { + using ServiceProvider provider = builder.Services.BuildServiceProvider(); + FunctionExecutionDelegate pipeline = BuildInvocationPipeline(builder); + + TestInvocationFeatures features = new(); + FunctionContext context = CreateContext(provider, features, entryPoint); + + await pipeline(context); + + IFunctionExecutor? executor = features.Get(); + Assert.NotNull(executor); + Assert.IsType(executor); + } + + /// + /// Builds the middleware pipeline that the Functions host would build. The pipeline is held by the + /// worker builder that wraps and is not otherwise reachable + /// before Build(). + /// + private static FunctionExecutionDelegate BuildInvocationPipeline(FunctionsApplicationBuilder builder) + { + object workerBuilder = GetFieldValue( + builder, + f => typeof(IFunctionsWorkerApplicationBuilder).IsAssignableFrom(f.FieldType)); + + object pipelineBuilder = GetFieldValue( + workerBuilder, + f => f.FieldType.Name.StartsWith("IInvocationPipelineBuilder", StringComparison.Ordinal)); + + MethodInfo build = pipelineBuilder.GetType().GetMethod("Build", Type.EmptyTypes) + ?? throw new InvalidOperationException("Could not find the invocation pipeline Build method."); + + return (FunctionExecutionDelegate)build.Invoke(pipelineBuilder, null)!; + } + + private static object GetFieldValue(object instance, Func predicate) + { + FieldInfo field = Array.Find( + instance.GetType().GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public), + f => predicate(f)) + ?? throw new InvalidOperationException( + $"Could not locate the expected field on {instance.GetType().FullName}."); + + return field.GetValue(instance) + ?? throw new InvalidOperationException( + $"Field '{field.Name}' on {instance.GetType().FullName} was null."); + } + + private static FunctionContext CreateContext( + IServiceProvider services, + IInvocationFeatures features, + string entryPoint) + { + Mock definition = new(); + definition.SetupGet(d => d.EntryPoint).Returns(entryPoint); + definition.SetupGet(d => d.Name).Returns("test-function"); + + Mock context = new(); + context.SetupGet(c => c.InstanceServices).Returns(services); + context.SetupGet(c => c.FunctionDefinition).Returns(definition.Object); + context.SetupGet(c => c.Features).Returns(features); + + return context.Object; + } + + private static Workflow BuildWorkflow(string name) => + new WorkflowBuilder(new FunctionExecutor("start", (_, _, _) => default)) + .WithName(name) + .Build(); + + private sealed class TestInvocationFeatures : IInvocationFeatures + { + private readonly Dictionary _features = []; + + public T? Get() => this._features.TryGetValue(typeof(T), out object? value) ? (T)value : default; + + public void Set(T instance) + { + if (instance is null) + { + this._features.Remove(typeof(T)); + } + else + { + this._features[typeof(T)] = instance; + } + } + + public IEnumerator> GetEnumerator() => this._features.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); + } +}