From af0f0777be598283a5de445bbbb4caee91e8605d Mon Sep 17 00:00:00 2001 From: Shyju Krishnankutty Date: Wed, 12 Aug 2026 06:26:40 -0700 Subject: [PATCH 1/4] Add failing tests for durable agent/workflow configuration composition Covers https://github.com/microsoft/agent-framework-durable-extension/issues/27. Calling ConfigureDurableAgents and ConfigureDurableWorkflows together does not compose today: - Azure Functions, agents configured first: the agent path registers the built-in execution middleware with a predicate covering only agent entry points, and the guard in EnsureMiddlewareRegistered then short-circuits the workflow path, so workflow entry points are never routed to BuiltInFunctionExecutor. - Azure Functions, workflows configured first: the agent path registers the middleware again, duplicating it in the invocation pipeline. - Core hosting: EnsureDurableServicesRegistered returns early on its marker, so worker and client builders passed to a later Configure* call are dropped. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 889a6ebe-a00c-48f4-b5e9-ec0872f872db --- .../DurableConfigurationCompositionTests.cs | 134 ++++++++++++ ...onsDurableConfigurationCompositionTests.cs | 194 ++++++++++++++++++ 2 files changed, 328 insertions(+) create mode 100644 dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableConfigurationCompositionTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/FunctionsDurableConfigurationCompositionTests.cs 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(); + } +} From fab1a179886e2656d823fbb1514d0999d63fd118 Mon Sep 17 00:00:00 2001 From: Shyju Krishnankutty Date: Wed, 12 Aug 2026 06:31:08 -0700 Subject: [PATCH 2/4] Fix durable agent and workflow configuration composition `ConfigureDurableAgents` and `ConfigureDurableWorkflows` did not compose when both were called on the same application, forcing users onto `ConfigureDurableOptions`. Azure Functions layer: - `ConfigureDurableAgents` registered its own `UseWhen` middleware filter whose predicate only covered the three agent entry points, then registered `BuiltInFunctionExecutor`. A later `ConfigureDurableWorkflows` saw that executor registration and early-returned from `EnsureMiddlewareRegistered`, so workflow functions got metadata but no `IFunctionExecutor` and never ran. - In the reverse order the agent path registered the middleware and executor a second time, duplicating them in the invocation pipeline. All three public entry points now funnel through a single `ConfigureDurableCore` helper, so the full middleware predicate is registered exactly once no matter which combination of methods is called or in which order. Default agent options are applied only to agents added by that call, so agents auto-registered by a workflow no longer gain an HTTP trigger just because `ConfigureDurableAgents` happened to run afterwards. Core service collection layer: - `EnsureDurableServicesRegistered` guarded the whole registration block with a single marker, so a `workerBuilder` or `clientBuilder` passed to any call after the first was silently dropped and no worker or client was registered. Worker and client registration are now tracked with their own markers and are independent of the shared services guard, so each is registered at most once while still honoring a builder supplied by a later call. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 889a6ebe-a00c-48f4-b5e9-ec0872f872db --- .../CHANGELOG.md | 1 + .../DurableServicesMarker.cs | 23 +++++ .../ServiceCollectionExtensions.cs | 76 +++++++++++---- .../CHANGELOG.md | 1 + .../FunctionsApplicationBuilderExtensions.cs | 95 +++++++++++-------- 5 files changed, 137 insertions(+), 59 deletions(-) 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..5b89305 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,62 @@ 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); + HashSet agentsBeforeConfigure = applyDefaultAgentOptions + ? [.. sharedOptions.Agents.GetAgentFactories().Keys] + : []; + 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 +125,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. From 1d1d773fab18e674ec64c5614730237af67bca98 Mon Sep 17 00:00:00 2001 From: Shyju Krishnankutty Date: Wed, 12 Aug 2026 08:09:06 -0700 Subject: [PATCH 3/4] Update samples and docs to show composable durable configuration The Azure Functions "workflow and agents" sample steered users to `ConfigureDurableOptions` as the way to register both agents and workflows, because separate `ConfigureDurableAgents` and `ConfigureDurableWorkflows` calls did not compose. Now that they do, the sample uses the split calls and mentions `ConfigureDurableOptions` as an equivalent alternative. Also corrects the console sample comments that described the three methods as mutually exclusive, and documents composition in the durable agents README. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 889a6ebe-a00c-48f4-b5e9-ec0872f872db --- docs/features/durable-agents/README.md | 14 ++++++++++ .../05_WorkflowAndAgents/Program.cs | 24 +++++++++-------- .../05_WorkflowAndAgents/README.md | 26 +++++++++++++++++-- .../04_WorkflowAndAgents/Program.cs | 15 ++++++----- 4 files changed, 59 insertions(+), 20 deletions(-) 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); From 12f59bd0e42a41c24e4765397449a48273f4bca6 Mon Sep 17 00:00:00 2001 From: Shyju Krishnankutty Date: Wed, 12 Aug 2026 09:12:07 -0700 Subject: [PATCH 4/4] Use case-insensitive comparer for the agent snapshot set Agent names are case-insensitive in DurableAgentsOptions and in the Functions agent options registry. Match that comparer when snapshotting agent keys so the set semantics cannot drift. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 889a6ebe-a00c-48f4-b5e9-ec0872f872db --- .../FunctionsApplicationBuilderExtensions.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs index 5b89305..b94688d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs @@ -96,9 +96,10 @@ private static FunctionsApplicationBuilder ConfigureDurableCore( // 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 - ? [.. sharedOptions.Agents.GetAgentFactories().Keys] - : []; + ? new HashSet(sharedOptions.Agents.GetAgentFactories().Keys, StringComparer.OrdinalIgnoreCase) + : new HashSet(StringComparer.OrdinalIgnoreCase); builder.Services.ConfigureDurableOptions(configure);