From 87ba16735c9a6bb0c777188ecf99e56898b7707a Mon Sep 17 00:00:00 2001 From: Shyju Krishnankutty Date: Tue, 24 Mar 2026 14:48:54 -0700 Subject: [PATCH] Ensure duplicate agent registrations are properly handled. --- .../DefaultFunctionsAgentOptionsProvider.cs | 30 +++++--------- ...DurableAgentFunctionMetadataTransformer.cs | 37 +++++++++++------- .../DurableAgentsOptionsExtensions.cs | 13 +++++++ .../FunctionsApplicationBuilderExtensions.cs | 9 ++++- ...bleWorkflowsFunctionMetadataTransformer.cs | 7 +++- ...leAgentFunctionMetadataTransformerTests.cs | 39 +++++++++++++++++++ 6 files changed, 96 insertions(+), 39 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DefaultFunctionsAgentOptionsProvider.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DefaultFunctionsAgentOptionsProvider.cs index 1039fb5aec..4debb5facf 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DefaultFunctionsAgentOptionsProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DefaultFunctionsAgentOptionsProvider.cs @@ -6,7 +6,8 @@ namespace Microsoft.Agents.AI.Hosting.AzureFunctions; /// /// Provides access to agent-specific options for functions agents by name. -/// Returns default options (HTTP trigger enabled, MCP tool disabled) when no explicit options were configured. +/// Returns when no explicit options have been configured for an agent, +/// which distinguishes standalone agents from those auto-registered by workflows. /// internal sealed class DefaultFunctionsAgentOptionsProvider(IReadOnlyDictionary functionsAgentOptions) : IFunctionsAgentOptionsProvider @@ -14,32 +15,19 @@ internal sealed class DefaultFunctionsAgentOptionsProvider(IReadOnlyDictionary _functionsAgentOptions = functionsAgentOptions ?? throw new ArgumentNullException(nameof(functionsAgentOptions)); - // Default options. HTTP trigger enabled, MCP tool disabled. - private static readonly FunctionsAgentOptions s_defaultOptions = new() - { - HttpTrigger = { IsEnabled = true }, - McpToolTrigger = { IsEnabled = false } - }; - /// /// Attempts to retrieve the options associated with the specified agent name. - /// If not found, a default options instance (with HTTP trigger enabled) is returned. + /// Returns when no options have been explicitly configured for the agent. /// /// The name of the agent whose options are to be retrieved. Cannot be null or empty. - /// The options for the specified agent. Will never be null. - /// Always true. Returns configured options if present; otherwise default fallback options. + /// + /// When this method returns , contains the options for the specified agent; + /// otherwise, . + /// + /// if options were found for the agent; otherwise, . public bool TryGet(string agentName, [NotNullWhen(true)] out FunctionsAgentOptions? options) { ArgumentException.ThrowIfNullOrEmpty(agentName); - - if (this._functionsAgentOptions.TryGetValue(agentName, out FunctionsAgentOptions? existing)) - { - options = existing; - return true; - } - - // If not defined, return default options. - options = s_defaultOptions; - return true; + return this._functionsAgentOptions.TryGetValue(agentName, out options); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentFunctionMetadataTransformer.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentFunctionMetadataTransformer.cs index 65578a7383..fe20eeb6f9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentFunctionMetadataTransformer.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentFunctionMetadataTransformer.cs @@ -6,9 +6,13 @@ using Microsoft.Extensions.Logging; namespace Microsoft.Agents.AI.Hosting.AzureFunctions; /// -/// Transforms function metadata by registering durable agent functions for each configured agent. +/// Transforms function metadata by registering durable agent functions for each explicitly configured agent. /// -/// This transformer adds both entity trigger and HTTP trigger functions for every agent registered in the application. +/// +/// This transformer adds entity, HTTP, and MCP tool trigger functions for agents that have +/// explicit . Agents auto-registered by workflows +/// (which lack explicit options) are handled by . +/// internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadataTransformer { private readonly ILogger _logger; @@ -38,24 +42,27 @@ internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadat { string agentName = kvp.Key; - this._logger.LogRegisteringTriggerForAgent(agentName, "entity"); + // Only generate triggers for agents with explicit Functions agent options. + // Agents auto-registered by workflows are handled by DurableWorkflowsFunctionMetadataTransformer. + if (!this._functionsAgentOptionsProvider.TryGet(agentName, out FunctionsAgentOptions? agentTriggerOptions)) + { + continue; + } + this._logger.LogRegisteringTriggerForAgent(agentName, "entity"); original.Add(FunctionMetadataFactory.CreateEntityTrigger(agentName)); - if (this._functionsAgentOptionsProvider.TryGet(agentName, out FunctionsAgentOptions? agentTriggerOptions)) + if (agentTriggerOptions.HttpTrigger.IsEnabled) { - if (agentTriggerOptions.HttpTrigger.IsEnabled) - { - this._logger.LogRegisteringTriggerForAgent(agentName, "http"); - original.Add(FunctionMetadataFactory.CreateHttpTrigger(agentName, $"agents/{agentName}/run", BuiltInFunctions.RunAgentHttpFunctionEntryPoint)); - } + this._logger.LogRegisteringTriggerForAgent(agentName, "http"); + original.Add(FunctionMetadataFactory.CreateHttpTrigger(agentName, $"agents/{agentName}/run", BuiltInFunctions.RunAgentHttpFunctionEntryPoint)); + } - if (agentTriggerOptions.McpToolTrigger.IsEnabled) - { - AIAgent agent = kvp.Value(this._serviceProvider); - this._logger.LogRegisteringTriggerForAgent(agentName, "mcpTool"); - original.Add(CreateMcpToolTrigger(agentName, agent.Description)); - } + if (agentTriggerOptions.McpToolTrigger.IsEnabled) + { + AIAgent agent = kvp.Value(this._serviceProvider); + this._logger.LogRegisteringTriggerForAgent(agentName, "mcpTool"); + original.Add(CreateMcpToolTrigger(agentName, agent.Description)); } } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentsOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentsOptionsExtensions.cs index ad21d8f4e1..8d161710ae 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentsOptionsExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentsOptionsExtensions.cs @@ -134,4 +134,17 @@ public static class DurableAgentsOptionsExtensions { return new Dictionary(s_agentOptions, StringComparer.OrdinalIgnoreCase); } + + /// + /// Ensures every agent in has an entry in the + /// options registry. Agents that already have explicit options are left untouched. + /// New entries receive the default configuration (HTTP trigger enabled, MCP tool disabled). + /// + internal static void EnsureDefaultOptionsForAll(IEnumerable agentNames) + { + foreach (string name in agentNames) + { + s_agentOptions.TryAdd(name, new FunctionsAgentOptions { HttpTrigger = { IsEnabled = true } }); + } + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs index 959ffab2f6..3c5e7936da 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs @@ -27,9 +27,16 @@ public static class FunctionsApplicationBuilderExtensions { 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())); @@ -67,7 +74,7 @@ public static class FunctionsApplicationBuilderExtensions builder.Services.ConfigureDurableOptions(configure); - if (sharedOptions.Agents.GetAgentFactories().Count > 0) + if (DurableAgentsOptionsExtensions.GetAgentOptionsSnapshot().Count > 0) { builder.Services.TryAddSingleton(_ => new DefaultFunctionsAgentOptionsProvider(DurableAgentsOptionsExtensions.GetAgentOptionsSnapshot())); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowsFunctionMetadataTransformer.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowsFunctionMetadataTransformer.cs index 8066eefccc..dc7b799b00 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowsFunctionMetadataTransformer.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowsFunctionMetadataTransformer.cs @@ -50,8 +50,11 @@ internal sealed class DurableWorkflowsFunctionMetadataTransformer : IFunctionMet int initialCount = original.Count; this._logger.LogTransformingFunctionMetadata(initialCount); - // Track registered function names to avoid duplicates when workflows share executors. - HashSet registeredFunctions = []; + // Seed with existing function names to avoid duplicates across transformers + // (e.g., when DurableAgentFunctionMetadataTransformer already registered entity triggers). + HashSet registeredFunctions = new( + original.Select(f => f.Name!), + StringComparer.OrdinalIgnoreCase); DurableWorkflowOptions workflowOptions = this._options.Workflows; foreach (var workflow in workflowOptions.Workflows) diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableAgentFunctionMetadataTransformerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableAgentFunctionMetadataTransformerTests.cs index 7d3a2ec13e..82824e0d8c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableAgentFunctionMetadataTransformerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableAgentFunctionMetadataTransformerTests.cs @@ -148,6 +148,45 @@ public sealed class DurableAgentFunctionMetadataTransformerTests } } + [Fact] + public void Transform_SkipsAgents_WithoutExplicitOptions() + { + // Arrange: two agents in the dictionary, but only one has explicit FunctionsAgentOptions. + // This simulates a workflow-auto-registered agent (workflowAgent) alongside a standalone agent. + Dictionary> agents = new() + { + { "standaloneAgent", _ => new TestAgent("standaloneAgent", "Standalone agent") }, + { "workflowAgent", _ => new TestAgent("workflowAgent", "Auto-registered by workflow") } + }; + + FunctionsAgentOptions standaloneOptions = new(); + standaloneOptions.HttpTrigger.IsEnabled = true; + + // Only standaloneAgent has explicit options; workflowAgent does not. + IFunctionsAgentOptionsProvider agentOptionsProvider = new FakeOptionsProvider(new Dictionary + { + { "standaloneAgent", standaloneOptions } + }); + + List metadataList = []; + + DurableAgentFunctionMetadataTransformer transformer = new( + agents, + NullLogger.Instance, + new FakeServiceProvider(), + agentOptionsProvider); + + // Act + transformer.Transform(metadataList); + + // Assert: only standaloneAgent should have triggers (entity + http = 2). + // workflowAgent should be skipped entirely. + Assert.Equal(2, metadataList.Count); + Assert.Contains(metadataList, m => m.Name == "dafx-standaloneAgent"); + Assert.Contains(metadataList, m => m.Name == "http-standaloneAgent"); + Assert.DoesNotContain(metadataList, m => m.Name!.Contains("workflowAgent")); + } + private static List BuildFunctionMetadataList(int numberOfFunctions) { List list = [];