From 3c272025a98eed293d11b9b27cf2ab556119a7ee Mon Sep 17 00:00:00 2001 From: tomas-rampas Date: Fri, 18 Sep 2026 15:34:01 +0200 Subject: [PATCH 1/5] .NET: Add IHostedAgentBuilder.AsDefault() for non-keyed agent resolution Registers a forwarding non-keyed AIAgent with the builder's lifetime; fails at registration when another agent already claimed the default or no keyed registration backs the name. Documents keyed-by-name resolution on AddAIAgent and AddAsAIAgent. --- ...AgentHostingServiceCollectionExtensions.cs | 45 +- .../HostApplicationBuilderAgentExtensions.cs | 45 +- .../HostedAgentBuilderExtensions.cs | 124 ++++++ .../HostedWorkflowBuilderExtensions.cs | 14 + .../DummyAITool.cs | 10 + ...plicationBuilderWorkflowExtensionsTests.cs | 22 + ...tedAgentBuilderAsDefaultExtensionsTests.cs | 420 ++++++++++++++++++ .../HostedAgentBuilderToolsExtensionsTests.cs | 31 -- .../MockChatClient.cs | 30 ++ 9 files changed, 700 insertions(+), 41 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DummyAITool.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentBuilderAsDefaultExtensionsTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/MockChatClient.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/AgentHostingServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/AgentHostingServiceCollectionExtensions.cs index 40d5853027a..fcf01c3085f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/AgentHostingServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/AgentHostingServiceCollectionExtensions.cs @@ -20,8 +20,15 @@ public static class AgentHostingServiceCollectionExtensions /// The name of the agent. /// The instructions for the agent. /// The DI service lifetime for the agent registration. Defaults to . - /// The same instance so that additional calls can be chained. + /// The for the registered agent, so that additional calls can be chained. /// Thrown when or is . + /// + /// The agent is registered as a keyed service with as the service key. Resolve it with + /// [FromKeyedServices(name)] on an injected parameter (for example a constructor or minimal API + /// endpoint parameter), or with GetRequiredKeyedService<AIAgent>(name). + /// Call on the returned builder to also make + /// the agent resolvable without a service key. + /// public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, ServiceLifetime lifetime = ServiceLifetime.Singleton) { Throw.IfNull(services); @@ -42,8 +49,15 @@ public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, s /// The instructions for the agent. /// The chat client which the agent will use for inference. /// The DI service lifetime for the agent registration. Defaults to . - /// The same instance so that additional calls can be chained. + /// The for the registered agent, so that additional calls can be chained. /// Thrown when or is . + /// + /// The agent is registered as a keyed service with as the service key. Resolve it with + /// [FromKeyedServices(name)] on an injected parameter (for example a constructor or minimal API + /// endpoint parameter), or with GetRequiredKeyedService<AIAgent>(name). + /// Call on the returned builder to also make + /// the agent resolvable without a service key. + /// public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, IChatClient chatClient, ServiceLifetime lifetime = ServiceLifetime.Singleton) { Throw.IfNull(services); @@ -63,8 +77,15 @@ public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, s /// The instructions for the agent. /// The key to use when resolving the chat client from the service provider. If , a non-keyed service will be resolved. /// The DI service lifetime for the agent registration. Defaults to . - /// The same instance so that additional calls can be chained. + /// The for the registered agent, so that additional calls can be chained. /// Thrown when or is . + /// + /// The agent is registered as a keyed service with as the service key. Resolve it with + /// [FromKeyedServices(name)] on an injected parameter (for example a constructor or minimal API + /// endpoint parameter), or with GetRequiredKeyedService<AIAgent>(name). + /// Call on the returned builder to also make + /// the agent resolvable without a service key. + /// public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, object? chatClientServiceKey, ServiceLifetime lifetime = ServiceLifetime.Singleton) { Throw.IfNull(services); @@ -86,8 +107,15 @@ public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, s /// A description of the agent. /// The key to use when resolving the chat client from the service provider. If , a non-keyed service will be resolved. /// The DI service lifetime for the agent registration. Defaults to . - /// The same instance so that additional calls can be chained. + /// The for the registered agent, so that additional calls can be chained. /// Thrown when or is . + /// + /// The agent is registered as a keyed service with as the service key. Resolve it with + /// [FromKeyedServices(name)] on an injected parameter (for example a constructor or minimal API + /// endpoint parameter), or with GetRequiredKeyedService<AIAgent>(name). + /// Call on the returned builder to also make + /// the agent resolvable without a service key. + /// public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, string? description, object? chatClientServiceKey, ServiceLifetime lifetime = ServiceLifetime.Singleton) { Throw.IfNull(services); @@ -107,9 +135,16 @@ public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, s /// The name of the agent. /// A factory delegate that creates the AI agent instance. The delegate receives the service provider and agent key as parameters. /// The DI service lifetime for the agent registration. Defaults to . - /// The same instance so that additional calls can be chained. + /// The for the registered agent, so that additional calls can be chained. /// Thrown when , , or is . /// Thrown when the agent factory delegate returns or an agent whose does not match . + /// + /// The agent is registered as a keyed service with as the service key. Resolve it with + /// [FromKeyedServices(name)] on an injected parameter (for example a constructor or minimal API + /// endpoint parameter), or with GetRequiredKeyedService<AIAgent>(name). + /// Call on the returned builder to also make + /// the agent resolvable without a service key. + /// public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, Func createAgentDelegate, ServiceLifetime lifetime = ServiceLifetime.Singleton) { Throw.IfNull(services); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderAgentExtensions.cs index 2d8620611a9..1a5b31a514b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderAgentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderAgentExtensions.cs @@ -20,8 +20,15 @@ public static class HostApplicationBuilderAgentExtensions /// The name of the agent. /// The instructions for the agent. /// The DI service lifetime for the agent registration. Defaults to . - /// The configured host application builder. + /// The for the registered agent, so that additional calls can be chained. /// Thrown when , , or is null. + /// + /// The agent is registered as a keyed service with as the service key. Resolve it with + /// [FromKeyedServices(name)] on an injected parameter (for example a constructor or minimal API + /// endpoint parameter), or with GetRequiredKeyedService<AIAgent>(name). + /// Call on the returned builder to also make + /// the agent resolvable without a service key. + /// public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, ServiceLifetime lifetime = ServiceLifetime.Singleton) { Throw.IfNull(builder); @@ -36,8 +43,15 @@ public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builde /// The instructions for the agent. /// The chat client which the agent will use for inference. /// The DI service lifetime for the agent registration. Defaults to . - /// The configured host application builder. + /// The for the registered agent, so that additional calls can be chained. /// Thrown when , , or is null. + /// + /// The agent is registered as a keyed service with as the service key. Resolve it with + /// [FromKeyedServices(name)] on an injected parameter (for example a constructor or minimal API + /// endpoint parameter), or with GetRequiredKeyedService<AIAgent>(name). + /// Call on the returned builder to also make + /// the agent resolvable without a service key. + /// public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, IChatClient chatClient, ServiceLifetime lifetime = ServiceLifetime.Singleton) { Throw.IfNull(builder); @@ -54,8 +68,15 @@ public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builde /// A description of the agent. /// The key to use when resolving the chat client from the service provider. If null, a non-keyed service will be resolved. /// The DI service lifetime for the agent registration. Defaults to . - /// The configured host application builder. + /// The for the registered agent, so that additional calls can be chained. /// Thrown when , , or is null. + /// + /// The agent is registered as a keyed service with as the service key. Resolve it with + /// [FromKeyedServices(name)] on an injected parameter (for example a constructor or minimal API + /// endpoint parameter), or with GetRequiredKeyedService<AIAgent>(name). + /// Call on the returned builder to also make + /// the agent resolvable without a service key. + /// public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, string? description, object? chatClientServiceKey, ServiceLifetime lifetime = ServiceLifetime.Singleton) { Throw.IfNull(builder); @@ -71,8 +92,15 @@ public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builde /// The instructions for the agent. /// The key to use when resolving the chat client from the service provider. If null, a non-keyed service will be resolved. /// The DI service lifetime for the agent registration. Defaults to . - /// The configured host application builder. + /// The for the registered agent, so that additional calls can be chained. /// Thrown when , , or is null. + /// + /// The agent is registered as a keyed service with as the service key. Resolve it with + /// [FromKeyedServices(name)] on an injected parameter (for example a constructor or minimal API + /// endpoint parameter), or with GetRequiredKeyedService<AIAgent>(name). + /// Call on the returned builder to also make + /// the agent resolvable without a service key. + /// public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, object? chatClientServiceKey, ServiceLifetime lifetime = ServiceLifetime.Singleton) { Throw.IfNull(builder); @@ -86,9 +114,16 @@ public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builde /// The name of the agent. /// A factory delegate that creates the AI agent instance. The delegate receives the service provider and agent key as parameters. /// The DI service lifetime for the agent registration. Defaults to . - /// The configured host application builder. + /// The for the registered agent, so that additional calls can be chained. /// Thrown when , , or is null. /// Thrown when the agent factory delegate returns null or an invalid AI agent instance. + /// + /// The agent is registered as a keyed service with as the service key. Resolve it with + /// [FromKeyedServices(name)] on an injected parameter (for example a constructor or minimal API + /// endpoint parameter), or with GetRequiredKeyedService<AIAgent>(name). + /// Call on the returned builder to also make + /// the agent resolvable without a service key. + /// public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, Func createAgentDelegate, ServiceLifetime lifetime = ServiceLifetime.Singleton) { Throw.IfNull(builder); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs index dba3e2bfe7f..97b3be8dbcd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs @@ -13,6 +13,109 @@ namespace Microsoft.Agents.AI.Hosting; /// public static class HostedAgentBuilderExtensions { + /// + /// Additionally registers the agent being configured as the default, non-keyed service, so that it + /// can be resolved without a service key. + /// + /// The hosted agent builder. + /// The same instance so that additional calls can be chained. + /// Thrown when is . + /// + /// Thrown when an earlier call has already marked an agent as the default, including an + /// earlier call on this same builder. Also thrown when the service collection contains no keyed + /// registration whose service key is , because the + /// registration added here forwards to that keyed one. No descriptor is added when the exception is thrown. + /// + /// + /// + /// + /// and its overloads register the agent as a keyed service whose service key is the agent name. This method adds one + /// additional, non-keyed registration that forwards to the keyed one, using the agent's + /// and never invoking the agent factory independently, so a singleton agent + /// resolves to the same instance both ways. The keyed registration is unaffected. + /// + /// + /// Only registrations present when this method is called are checked, and only a second + /// call throws, on this builder or on any other. A non-keyed registered earlier by other + /// means is superseded by the registration added here under the usual last-registration-wins rule, and one + /// registered afterwards supersedes this one. A TryAdd-style registration added afterwards (for example + /// AddFoundryResponses(services, agent) from Microsoft.Agents.AI.Foundry.Hosting) is ignored, as is its + /// keyed registration under the same agent name, so AsDefault() wins over that call + /// in either order. A keyed that such a call registers under the same agent name + /// is not ignored: call if the + /// default agent must not share it. + /// + /// + /// Use for a default agent. Hosting integrations resolve it from the root + /// provider, so a agent fails scope validation or behaves as a singleton + /// there, and a agent is created per request, loses the reference identity + /// that keeps the keyed and non-keyed views of the agent de-duplicated and their session identity shared, and, if + /// it is , accumulates in the root scope. + /// + /// + /// Under Microsoft.Agents.AI.Foundry.Hosting the default agent serves every request that names no agent and every + /// request that names an unregistered agent, so do not mark a privileged agent as the default in a host that also + /// serves untrusted callers. That host looks the agent's session store up by the agent name; a keyed store + /// registered through is + /// isolation-scoped and throws on use when no is registered unless + /// withIsolation is or + /// is disabled. + /// + /// + /// If the agent implements , the container disposes it through both registrations, so + /// must be idempotent. + /// + /// + public static IHostedAgentBuilder AsDefault(this IHostedAgentBuilder builder) + { + Throw.IfNull(builder); + + var services = builder.ServiceCollection; + + var hasKeyedAgentRegistration = false; + foreach (var descriptor in services) + { + // ServiceDescriptor.ImplementationFactory and ImplementationInstance throw on keyed descriptors, so the + // keyed check has to come first. + if (descriptor.IsKeyedService) + { + if (!hasKeyedAgentRegistration && descriptor.ServiceType == typeof(AIAgent) && Equals(descriptor.ServiceKey, builder.Name)) + { + hasKeyedAgentRegistration = true; + } + + continue; + } + + // Only an earlier AsDefault() call is an error, because two of them are competing framework-owned claims on + // the same slot. A non-keyed AIAgent registered by any other means is left alone: the descriptor added below + // supersedes it under the standard last-registration-wins rule. + if (descriptor.ServiceType == typeof(AIAgent) && + descriptor.ImplementationFactory?.Target is DefaultAgentFactory existingDefaultAgentFactory) + { + throw new InvalidOperationException( + CreateDuplicateDefaultAgentMessage(builder.Name, existingDefaultAgentFactory.Name)); + } + } + + if (!hasKeyedAgentRegistration) + { + // The forwarding registration below would otherwise fail only at resolution time, and where DevUI's + // KeyedService.AnyKey agent factory is registered the two factories would call each other instead. + throw new InvalidOperationException( + $"No keyed {nameof(AIAgent)} registration exists for agent '{builder.Name}'; " + + $"call {nameof(AsDefault)}() on the builder returned by AddAIAgent or AddAsAIAgent."); + } + + // Forwarding to the keyed registration keeps a single instance per lifetime scope and ensures the agent factory is + // never invoked independently of the keyed path. The forwarding delegate is an instance method of a named type so + // that a later AsDefault() call can recover the agent name from the descriptor. + var defaultAgentFactory = new DefaultAgentFactory(builder.Name); + services.Add(new ServiceDescriptor(typeof(AIAgent), defaultAgentFactory.Resolve, builder.Lifetime)); + + return builder; + } + /// /// Configures the host agent builder to use an in-memory session store for agent session management. /// @@ -154,4 +257,25 @@ internal static void ValidateToolLifetime(ServiceLifetime agentLifetime, Service "The tool's lifetime must be at least as long as the agent's lifetime to avoid captive dependency issues."); } } + + /// + /// Builds the message of the thrown when is called + /// while an agent has already been marked as the default. Both branches quote the agent being marked. + /// + private static string CreateDuplicateDefaultAgentMessage(string name, string existingDefaultAgentName) + => string.Equals(name, existingDefaultAgentName, StringComparison.Ordinal) + ? $"{nameof(AsDefault)}() has already been called for agent '{name}'." + : $"Cannot register agent '{name}' as the default agent because agent '{existingDefaultAgentName}' has already been marked as the default with {nameof(AsDefault)}(). " + + $"Only one agent can be the default. Call {nameof(AsDefault)}() on only one agent."; + + /// + /// Resolves the keyed registration that a default (non-keyed) registration forwards to, and + /// carries the agent name so a later call can report which agent is already the default. + /// + private sealed class DefaultAgentFactory(string name) + { + public string Name { get; } = name; + + public AIAgent Resolve(IServiceProvider serviceProvider) => serviceProvider.GetRequiredKeyedService(this.Name); + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowBuilderExtensions.cs index c29705b0f9f..67d4dea00e4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowBuilderExtensions.cs @@ -17,6 +17,13 @@ public static class HostedWorkflowBuilderExtensions /// The DI service lifetime for the agent registration. Defaults to . /// If , workflow outputs are included in the agent response. /// An that can be used to further configure the agent. + /// + /// The agent is registered as a keyed service with the agent name as the service key. Resolve it with + /// [FromKeyedServices(name)] on an injected parameter (for example a constructor or minimal API + /// endpoint parameter), or with GetRequiredKeyedService<AIAgent>(name). + /// Call on the returned builder to also make + /// the agent resolvable without a service key. + /// public static IHostedAgentBuilder AddAsAIAgent( this IHostedWorkflowBuilder builder, ServiceLifetime lifetime = ServiceLifetime.Singleton, @@ -31,6 +38,13 @@ public static IHostedAgentBuilder AddAsAIAgent( /// The DI service lifetime for the agent registration. Defaults to . /// If , workflow outputs are included in the agent response. /// An that can be used to further configure the agent. + /// + /// The agent is registered as a keyed service with the agent name as the service key. Resolve it with + /// [FromKeyedServices(name)] on an injected parameter (for example a constructor or minimal API + /// endpoint parameter), or with GetRequiredKeyedService<AIAgent>(name). + /// Call on the returned builder to also make + /// the agent resolvable without a service key. + /// public static IHostedAgentBuilder AddAsAIAgent( this IHostedWorkflowBuilder builder, string? name, diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DummyAITool.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DummyAITool.cs new file mode 100644 index 00000000000..ead8fb5bc4a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DummyAITool.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.UnitTests; + +/// +/// Inert used where a test only needs a tool identity to register and assert on. +/// +internal sealed class DummyAITool : AITool; diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderWorkflowExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderWorkflowExtensionsTests.cs index 44cd39286f7..11cc4cdc6cb 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderWorkflowExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderWorkflowExtensionsTests.cs @@ -467,6 +467,28 @@ public void AddAsAIAgent_RespectsLifetime(ServiceLifetime lifetime) Assert.Equal(lifetime, agentBuilder.Lifetime); } + /// + /// Verifies that a workflow registered as an AI agent can be marked as the default, non-keyed agent, + /// and that the keyed and non-keyed resolutions return the same instance. + /// + [Fact] + public void AddAsAIAgent_AsDefault_ResolvesWorkflowAgentWithoutKey() + { + // Arrange + var builder = new HostApplicationBuilder(); + const string WorkflowName = "outputWorkflow"; + builder.AddWorkflow(WorkflowName, (sp, key) => ChatMessageOutputWorkflow.Build(key)) + .AddAsAIAgent() + .AsDefault(); + using var host = builder.Build(); + + // Act + AIAgent defaultAgent = host.Services.GetRequiredService(); + + // Assert + Assert.Same(host.Services.GetRequiredKeyedService(WorkflowName), defaultAgent); + } + /// /// Helper method to create a simple test workflow with a given name. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentBuilderAsDefaultExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentBuilderAsDefaultExtensionsTests.cs new file mode 100644 index 00000000000..8f8ac7b8575 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentBuilderAsDefaultExtensionsTests.cs @@ -0,0 +1,420 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Agents.AI.Hosting.UnitTests; + +/// +/// Unit tests for . +/// +public sealed class HostedAgentBuilderAsDefaultExtensionsTests +{ + /// + /// Verifies that AsDefault returns the same builder instance so that further With* calls chain. + /// + [Fact] + public void AsDefault_ReturnsSameBuilder() + { + // Arrange + var services = new ServiceCollection(); + var builder = services.AddAIAgent("writer", (sp, key) => new TestEchoAgent(name: key)); + + // Act + var returned = builder.AsDefault(); + + // Assert + Assert.Same(builder, returned); + } + + /// + /// Verifies that AsDefault throws for a null builder. + /// + [Fact] + public void AsDefault_NullBuilder_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => HostedAgentBuilderExtensions.AsDefault(null!)); + } + + /// + /// Verifies that after AsDefault the agent resolves without a key, exactly once, and keyed resolution still works. + /// + [Fact] + public void AsDefault_NonKeyedResolution_ReturnsRegisteredAgent() + { + // Arrange + var services = new ServiceCollection(); + services.AddAIAgent("writer", (sp, key) => new TestEchoAgent(name: key)).AsDefault(); + + // Act + using var provider = services.BuildServiceProvider(); + + // Assert + Assert.Equal("writer", provider.GetRequiredService().Name); + Assert.Single(provider.GetServices()); + _ = provider.GetRequiredKeyedService("writer"); + } + + /// + /// Verifies that without AsDefault no non-keyed registration exists. + /// + [Fact] + public void AddAIAgent_WithoutAsDefault_AddsNoNonKeyedRegistration() + { + // Arrange + var services = new ServiceCollection(); + services.AddAIAgent("writer", (sp, key) => new TestEchoAgent(name: key)); + + // Act + using var provider = services.BuildServiceProvider(); + + // Assert + Assert.DoesNotContain(services, d => d.ServiceType == typeof(AIAgent) && !d.IsKeyedService); + Assert.Null(provider.GetService()); + } + + /// + /// Verifies that a singleton default resolves to the same instance as the keyed registration and runs the factory once. + /// + [Fact] + public void AsDefault_SingletonLifetime_ForwardsToKeyedRegistration() + { + // Arrange + var factoryInvocations = 0; + var services = new ServiceCollection(); + services.AddAIAgent( + "a", + (sp, key) => + { + factoryInvocations++; + return new TestEchoAgent(name: key); + }, + ServiceLifetime.Singleton).AsDefault(); + + using var provider = services.BuildServiceProvider(); + + // Act + var fromDefault = provider.GetRequiredService(); + var fromKey = provider.GetRequiredKeyedService("a"); + + // Assert + Assert.Same(fromDefault, fromKey); + Assert.Equal(1, factoryInvocations); + } + + /// + /// Verifies that a scoped default is shared inside a scope, differs between scopes, and runs the factory once per scope. + /// + [Fact] + public void AsDefault_ScopedLifetime_SharesInstanceWithinScope() + { + // Arrange + var factoryInvocations = 0; + var services = new ServiceCollection(); + services.AddAIAgent( + "a", + (sp, key) => + { + factoryInvocations++; + return new TestEchoAgent(name: key); + }, + ServiceLifetime.Scoped).AsDefault(); + + using var provider = services.BuildServiceProvider(); + + // Act & Assert + AIAgent firstScopeAgent; + using (var firstScope = provider.CreateScope()) + { + firstScopeAgent = firstScope.ServiceProvider.GetRequiredService(); + Assert.Same(firstScopeAgent, firstScope.ServiceProvider.GetRequiredKeyedService("a")); + Assert.Equal(1, factoryInvocations); + } + + using (var secondScope = provider.CreateScope()) + { + var secondScopeAgent = secondScope.ServiceProvider.GetRequiredService(); + Assert.NotSame(firstScopeAgent, secondScopeAgent); + Assert.Equal(2, factoryInvocations); + } + } + + /// + /// Verifies that a transient default produces a new instance, and one factory invocation, per resolution. + /// + [Fact] + public void AsDefault_TransientLifetime_CreatesInstancePerResolution() + { + // Arrange + var factoryInvocations = 0; + var services = new ServiceCollection(); + services.AddAIAgent( + "a", + (sp, key) => + { + factoryInvocations++; + return new TestEchoAgent(name: key); + }, + ServiceLifetime.Transient).AsDefault(); + + using var provider = services.BuildServiceProvider(); + + // Act + var first = provider.GetRequiredService(); + var second = provider.GetRequiredService(); + + // Assert + Assert.NotSame(first, second); + Assert.Equal(2, factoryInvocations); + } + + /// + /// Verifies that the descriptor added by AsDefault is a single non-keyed registration + /// carrying the builder lifetime. + /// + [Theory] + [InlineData(ServiceLifetime.Singleton)] + [InlineData(ServiceLifetime.Scoped)] + [InlineData(ServiceLifetime.Transient)] + public void AsDefault_DescriptorShape_MatchesBuilderLifetime(ServiceLifetime lifetime) + { + // Arrange + var services = new ServiceCollection(); + var builder = services.AddAIAgent("a", (sp, key) => new TestEchoAgent(name: key), lifetime); + + // Act + builder.AsDefault(); + + // Assert + var descriptor = Assert.Single(services, d => d.ServiceType == typeof(AIAgent) && !d.IsKeyedService); + Assert.Equal(builder.Lifetime, descriptor.Lifetime); + } + + /// + /// Verifies that a second AsDefault on another builder throws, that the message names both agents, and that the + /// failing call adds no descriptor. + /// + [Fact] + public void AsDefault_SecondDefaultOnAnotherBuilder_ThrowsInvalidOperationException() + { + // Arrange + var services = new ServiceCollection(); + services.AddAIAgent("a", (sp, key) => new TestEchoAgent(name: key)).AsDefault(); + var second = services.AddAIAgent("b", (sp, key) => new TestEchoAgent(name: key)); + + // Act + var exception = Assert.Throws(() => second.AsDefault()); + + // Assert + Assert.Contains("'b'", exception.Message, StringComparison.Ordinal); + Assert.Contains("'a'", exception.Message, StringComparison.Ordinal); + _ = Assert.Single(services, d => d.ServiceType == typeof(AIAgent) && !d.IsKeyedService); + } + + /// + /// Verifies that a raw non-keyed registered between two AsDefault calls does not mask the + /// earlier default: the second AsDefault still throws and the message names both agents. + /// + [Fact] + public void AsDefault_SecondDefaultWithInterleavedRawRegistration_ThrowsInvalidOperationException() + { + // Arrange + var services = new ServiceCollection(); + services.AddAIAgent("a", (sp, key) => new TestEchoAgent(name: key)).AsDefault(); + services.AddSingleton(new TestEchoAgent(name: "raw")); + var second = services.AddAIAgent("b", (sp, key) => new TestEchoAgent(name: key)); + + // Act + var exception = Assert.Throws(() => second.AsDefault()); + + // Assert + Assert.Contains("'b'", exception.Message, StringComparison.Ordinal); + Assert.Contains("'a'", exception.Message, StringComparison.Ordinal); + } + + /// + /// Verifies that a raw non-keyed instance registration does not make AsDefault throw and is + /// superseded by it under the standard last-registration-wins rule. + /// + [Fact] + public void AsDefault_RawNonKeyedRegistrationExists_IsSupersededByDefault() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(new TestEchoAgent(name: "raw")); + + // Act + services.AddAIAgent("b", (sp, key) => new TestEchoAgent(name: key)).AsDefault(); + + // Assert + using var provider = services.BuildServiceProvider(); + Assert.Equal("b", provider.GetRequiredService().Name); + Assert.Equal(2, provider.GetServices().Count()); + } + + /// + /// Verifies that a factory-registered non-keyed does not make AsDefault throw and is + /// superseded by it under the standard last-registration-wins rule. + /// + [Fact] + public void AsDefault_RawNonKeyedFactoryRegistrationExists_IsSupersededByDefault() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(sp => new TestEchoAgent(name: "raw")); + + // Act + services.AddAIAgent("b", (sp, key) => new TestEchoAgent(name: key)).AsDefault(); + + // Assert + using var provider = services.BuildServiceProvider(); + Assert.Equal("b", provider.GetRequiredService().Name); + Assert.Equal(2, provider.GetServices().Count()); + } + + /// + /// Verifies that calling AsDefault twice on the same builder throws; there is no idempotency special case. + /// + [Fact] + public void AsDefault_CalledTwiceOnSameBuilder_ThrowsInvalidOperationException() + { + // Arrange + var services = new ServiceCollection(); + var builder = services.AddAIAgent("a", (sp, key) => new TestEchoAgent(name: key)).AsDefault(); + + // Act + var exception = Assert.Throws(() => builder.AsDefault()); + + // Assert + Assert.Contains("'a'", exception.Message, StringComparison.Ordinal); + } + + /// + /// Verifies that the default agent exposes the builder tools whichever order AsDefault and WithAITool are called in. + /// + [Fact] + public void AsDefault_BeforeOrAfterWithAITool_ResolvesSameTools() + { + // Arrange + var tool = new DummyAITool(); + + var defaultBeforeTool = new ServiceCollection(); + defaultBeforeTool.AddSingleton(new MockChatClient()); + defaultBeforeTool.AddAIAgent("writer", "instructions").AsDefault().WithAITool(tool); + + var defaultAfterTool = new ServiceCollection(); + defaultAfterTool.AddSingleton(new MockChatClient()); + defaultAfterTool.AddAIAgent("writer", "instructions").WithAITool(tool).AsDefault(); + + // Act + using var providerWithDefaultBeforeTool = defaultBeforeTool.BuildServiceProvider(); + using var providerWithDefaultAfterTool = defaultAfterTool.BuildServiceProvider(); + + // Assert + Assert.Contains(tool, ResolveToolsFromDefaultAgent(providerWithDefaultBeforeTool)); + Assert.Contains(tool, ResolveToolsFromDefaultAgent(providerWithDefaultAfterTool)); + } + + /// + /// Verifies that a raw non-keyed registration added after AsDefault wins and does not throw. + /// + [Fact] + public void AsDefault_LaterRawRegistration_Wins() + { + // Arrange + var services = new ServiceCollection(); + services.AddAIAgent("a", (sp, key) => new TestEchoAgent(name: key)).AsDefault(); + var other = new TestEchoAgent(name: "other"); + services.AddSingleton(other); + + // Act + using var provider = services.BuildServiceProvider(); + + // Assert + Assert.Same(other, provider.GetRequiredService()); + } + + /// + /// Verifies that keyed enumeration plus the non-keyed default yields a single distinct singleton instance. + /// + [Fact] + public void AsDefault_KeyedAndDefaultResolutions_YieldOneInstance() + { + // Arrange + var services = new ServiceCollection(); + services.AddAIAgent("a", (sp, key) => new TestEchoAgent(name: key)).AsDefault(); + + using var provider = services.BuildServiceProvider(); + + // Act + var distinctAgents = new HashSet(ReferenceEqualityComparer.Instance); + foreach (var keyedAgent in provider.GetKeyedServices(KeyedService.AnyKey)) + { + distinctAgents.Add(keyedAgent); + } + + distinctAgents.Add(provider.GetRequiredService()); + + // Assert + Assert.Single(distinctAgents); + } + + /// + /// Verifies that a scoped default resolved from the root of a scope-validating provider throws, as keyed resolution does. + /// + [Fact] + public void AsDefault_ScopedAgentFromRoot_ThrowsWhenScopesValidated() + { + // Arrange + var services = new ServiceCollection(); + services.AddAIAgent("a", (sp, key) => new TestEchoAgent(name: key), ServiceLifetime.Scoped).AsDefault(); + + // Act + using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); + + // Assert + Assert.Throws(() => provider.GetRequiredService()); + } + + /// + /// Verifies that AsDefault throws when no keyed registration exists under the builder's name, + /// and that it adds nothing to the service collection. + /// + [Fact] + public void AsDefault_NoKeyedRegistrationForName_ThrowsInvalidOperationException() + { + // Arrange + var builder = new StandaloneAgentBuilder("ghost"); + + // Act + var exception = Assert.Throws(() => builder.AsDefault()); + + // Assert + Assert.Contains("ghost", exception.Message, StringComparison.Ordinal); + Assert.Empty(builder.ServiceCollection); + } + + private static IList ResolveToolsFromDefaultAgent(IServiceProvider serviceProvider) + { + var agent = serviceProvider.GetRequiredService() as ChatClientAgent; + Assert.NotNull(agent?.ChatOptions?.Tools); + return agent.ChatOptions.Tools; + } + + /// + /// A hand-rolled over an empty service collection: the only way to reach + /// AsDefault() without the keyed registration that AddAIAgent adds. + /// + private sealed class StandaloneAgentBuilder(string name) : IHostedAgentBuilder + { + public string Name { get; } = name; + + public IServiceCollection ServiceCollection { get; } = new ServiceCollection(); + + public ServiceLifetime Lifetime => ServiceLifetime.Singleton; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentBuilderToolsExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentBuilderToolsExtensionsTests.cs index 40a3434cb09..9b447a330d8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentBuilderToolsExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentBuilderToolsExtensionsTests.cs @@ -486,35 +486,4 @@ public void WithSessionStore_WithoutExistingIsolation_AddsIsolation() private sealed class TestDelegatingAgentSessionStore(AgentSessionStore innerStore) : DelegatingAgentSessionStore(innerStore); private sealed class TestAgentSession : AgentSession; - - /// - /// Dummy AITool implementation for testing. - /// - private sealed class DummyAITool : AITool; - - /// - /// Mock chat client for testing. - /// - private sealed class MockChatClient : IChatClient - { - public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } - - public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } - - public object? GetService(Type serviceType, object? serviceKey = null) - { - return null; - } - - public void Dispose() - { - throw new NotImplementedException(); - } - } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/MockChatClient.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/MockChatClient.cs new file mode 100644 index 00000000000..203920a317d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/MockChatClient.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.UnitTests; + +/// +/// Inert that satisfies the dependency of agents built by +/// AddAIAgent(name, instructions). The request-issuing members throw and returns +/// , because no test drives inference through it. +/// +internal sealed class MockChatClient : IChatClient +{ + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() + { + // Nothing to dispose: this fake holds no resources. + } +} From f99b4c54263c4d33fc76715b0ede87a5e083eb3b Mon Sep 17 00:00:00 2001 From: tomas-rampas Date: Fri, 18 Sep 2026 15:34:02 +0200 Subject: [PATCH 2/5] .NET: Prefer the default agent's keyed session store in Foundry hosting When the default agent is the same instance as the keyed agent registered under its name, requests it serves use that name's AgentSessionStore before falling back to the non-keyed store, so named and nameless requests share one store. --- .../AgentFrameworkResponseHandler.cs | 68 +++- ...rkResponseHandlerDefaultAgentStoreTests.cs | 324 ++++++++++++++++++ 2 files changed, 377 insertions(+), 15 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerDefaultAgentStoreTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs index efe820a3691..9c7704c227d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs @@ -108,8 +108,8 @@ public override async IAsyncEnumerable CreateAsync( [EnumeratorCancellation] CancellationToken cancellationToken) { // 1. Resolve agent - var agent = this.ResolveAgent(request); - var sessionStore = this.ResolveSessionStore(request); + var agent = this.ResolveAgent(request, out var defaultAgentName); + var sessionStore = this.ResolveSessionStore(request, defaultAgentName); // 2. Resolve the per-request hosted session identity context, so the session can be // loaded from a per-user partition. Fresh sessions are tagged once; resumed sessions are @@ -819,11 +819,20 @@ private bool ShouldPersistForResilience(CreateResponse request) /// /// Resolves an from the request. /// Tries agent.name first, then falls back to metadata["entity_id"]. - /// If neither is present, attempts to resolve a default (non-keyed) . + /// If neither is present, or the named lookup finds no keyed registration, attempts to + /// resolve a default (non-keyed) . /// - private AIAgent ResolveAgent(CreateResponse request) + /// The request to resolve an agent for. + /// + /// When the request was served by the default (non-keyed) agent, receives that agent's + /// if it is non-blank and the keyed registration under that name is the same + /// instance as the default agent; otherwise . The session store lookup uses it so that + /// a nameless request and a named request for the same agent share one store. + /// + private AIAgent ResolveAgent(CreateResponse request, out string? defaultAgentName) { var agentName = GetAgentName(request); + defaultAgentName = null; if (!string.IsNullOrEmpty(agentName)) { @@ -847,6 +856,17 @@ private AIAgent ResolveAgent(CreateResponse request) var defaultAgent = this._serviceProvider.GetService(); if (defaultAgent is not null) { + // The name is only usable as a store key when the default agent is an alias of the keyed registration + // under that name. This mirrors the alias relationship FoundryHostingAgent.ResolveSessionStorageIdentity + // relies on, assuming the agent's Name is the key it was registered under (which AddAIAgent enforces), + // so store selection and storage identity agree for hosted agents. + // The probe assumes the keyed and non-keyed registrations share a lifetime, as the hosting builder guarantees. + var name = defaultAgent.Name; + defaultAgentName = !string.IsNullOrWhiteSpace(name) + && ReferenceEquals(this._serviceProvider.GetKeyedService(name), defaultAgent) + ? name + : null; + string storageIdentity = FoundryHostingAgent.ResolveSessionStorageIdentity( defaultAgent, registrationKey: null, @@ -874,25 +894,43 @@ private AIAgent PrepareResolvedAgent(AIAgent agent, string sessionStorageIdentit } /// - /// Resolves an from the request. - /// Tries agent.name first, then falls back to metadata["entity_id"]. - /// If neither is present, attempts to resolve a default (non-keyed) . + /// Resolves the that persists the session for the agent the request resolved to. + /// Tries the keyed store under the default agent's name when the request resolved to an aliased default agent, + /// otherwise under the name the request supplied; then the non-keyed store. + /// For a request served by the default agent, the keyed store registered under that agent's name takes precedence + /// over the non-keyed store when the keyed registration under that name is the same instance as the default agent. /// - private AgentSessionStore ResolveSessionStore(CreateResponse request) + /// The request whose session store is being resolved. + /// + /// The name of the default (non-keyed) agent the request resolved to, or when the request + /// resolved a keyed agent by name, the default agent has a blank Name, or the keyed registration under that name is + /// not the same instance as the default agent. When set, it takes precedence over the request's agent name as the + /// store key, so that a nameless request and a named request for the same default agent share the same keyed store. + /// + private AgentSessionStore ResolveSessionStore(CreateResponse request, string? defaultAgentName) { - var agentName = GetAgentName(request); + var storeKey = defaultAgentName ?? GetAgentName(request); - if (!string.IsNullOrEmpty(agentName)) + if (!string.IsNullOrEmpty(storeKey)) { - var sessionStore = this._serviceProvider.GetKeyedService(agentName); + var sessionStore = this._serviceProvider.GetKeyedService(storeKey); if (sessionStore is not null) { return sessionStore; } - if (this._logger.IsEnabled(LogLevel.Warning)) + if (defaultAgentName is not null) { - this._logger.LogWarning("SessionStore for agent '{AgentName}' not found in keyed services. Attempting default resolution.", agentName); + // The key came from the default agent rather than from the request, so a host that registered + // only a non-keyed store is an expected shape, not a misconfiguration worth a warning. + if (this._logger.IsEnabled(LogLevel.Debug)) + { + this._logger.LogDebug("No keyed SessionStore registered for default agent '{AgentName}'; falling back to the default SessionStore.", storeKey); + } + } + else if (this._logger.IsEnabled(LogLevel.Warning)) + { + this._logger.LogWarning("SessionStore for agent '{AgentName}' not found in keyed services. Attempting default resolution.", storeKey); } } @@ -903,9 +941,9 @@ private AgentSessionStore ResolveSessionStore(CreateResponse request) return defaultSessionStore; } - var errorMessage = string.IsNullOrEmpty(agentName) + var errorMessage = string.IsNullOrEmpty(storeKey) ? "No agent name specified in the request (via agent.name or metadata[\"entity_id\"]) and no default AgentSessionStore is registered." - : $"AgentSessionStore for agent '{agentName}' not found. Ensure it is registered via AddFoundryResponses(services, agent, agentSessionStore) or services.AddKeyedSingleton(\"{agentName}\", ...)."; + : $"AgentSessionStore for agent '{storeKey}' not found. Ensure it is registered via AddFoundryResponses(services, agent, agentSessionStore) or services.AddKeyedSingleton(\"{storeKey}\", ...)."; throw new InvalidOperationException(errorMessage); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerDefaultAgentStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerDefaultAgentStoreTests.cs new file mode 100644 index 00000000000..2f6c57316a5 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerDefaultAgentStoreTests.cs @@ -0,0 +1,324 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.AgentServer.Responses; +using Azure.AI.AgentServer.Responses.Models; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using MeaiTextContent = Microsoft.Extensions.AI.TextContent; + +namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests; + +/// +/// Tests the session-store lookup for requests that are served by the default (non-keyed) . +/// The registrations mirror what AddAIAgent("billing", ...).WithSessionStore(store).AsDefault() from +/// Microsoft.Agents.AI.Hosting produces, expressed by hand so that this project keeps its current +/// package references. +/// +public class AgentFrameworkResponseHandlerDefaultAgentStoreTests +{ + private const string DefaultAgentName = "billing"; + + [Fact] + public async Task CreateAsync_NamelessRequest_UsesKeyedStoreOfDefaultAgentAsync() + { + // Arrange + var keyedStore = new RecordingSessionStore(); + var nonKeyedStore = new RecordingSessionStore(); + var handler = CreateHandler(keyedStore, nonKeyedStore); + + // Act + await RunRequestAsync(handler, requestedAgentName: null); + + // Assert + Assert.True(keyedStore.WasUsed); + Assert.False(nonKeyedStore.WasUsed); + } + + [Fact] + public async Task CreateAsync_NamedRequestForDefaultAgent_UsesKeyedStoreOfDefaultAgentAsync() + { + // Arrange + var keyedStore = new RecordingSessionStore(); + var nonKeyedStore = new RecordingSessionStore(); + var handler = CreateHandler(keyedStore, nonKeyedStore); + + // Act + await RunRequestAsync(handler, requestedAgentName: DefaultAgentName); + + // Assert + Assert.True(keyedStore.WasUsed); + Assert.False(nonKeyedStore.WasUsed); + } + + [Fact] + public async Task CreateAsync_NamelessRequestWithoutKeyedStore_UsesNonKeyedStoreAsync() + { + // Arrange + var nonKeyedStore = new RecordingSessionStore(); + var handler = CreateHandler(keyedStore: null, nonKeyedStore); + + // Act + await RunRequestAsync(handler, requestedAgentName: null); + + // Assert + Assert.True(nonKeyedStore.WasUsed); + } + + [Fact] + public async Task CreateAsync_UnknownAgentNameFallsBackToDefaultAgent_UsesKeyedStoreOfDefaultAgentAsync() + { + // Arrange + var keyedStore = new RecordingSessionStore(); + var nonKeyedStore = new RecordingSessionStore(); + var handler = CreateHandler(keyedStore, nonKeyedStore); + + // Act + await RunRequestAsync(handler, requestedAgentName: "missing"); + + // Assert + Assert.True(keyedStore.WasUsed); + Assert.False(nonKeyedStore.WasUsed); + } + + [Fact] + public async Task CreateAsync_NamelessRequestWithoutAnyStore_ThrowsNamingDefaultAgentAsync() + { + // Arrange + var handler = CreateHandler(keyedStore: null, nonKeyedStore: null); + + // Act + var exception = await Assert.ThrowsAsync( + () => RunRequestAsync(handler, requestedAgentName: null)); + + // Assert + Assert.Contains($"'{DefaultAgentName}'", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task CreateAsync_NamedRequestForOtherKeyedAgent_UsesThatAgentsStoreAsync() + { + // Arrange + const string OtherAgentName = "support"; + var otherKeyedStore = new RecordingSessionStore(); + var defaultKeyedStore = new RecordingSessionStore(); + var nonKeyedStore = new RecordingSessionStore(); + + var services = CreateServices(); + services.AddKeyedSingleton(OtherAgentName, new NamedTestAgent(OtherAgentName)); + services.AddKeyedSingleton(OtherAgentName, otherKeyedStore); + AddAliasedDefaultAgent(services, new NamedTestAgent(DefaultAgentName), DefaultAgentName); + services.AddKeyedSingleton(DefaultAgentName, defaultKeyedStore); + services.AddSingleton(nonKeyedStore); + + var handler = CreateHandler(services); + + // Act + await RunRequestAsync(handler, requestedAgentName: OtherAgentName); + + // Assert + Assert.True(otherKeyedStore.WasUsed); + Assert.False(defaultKeyedStore.WasUsed); + Assert.False(nonKeyedStore.WasUsed); + } + + [Fact] + public async Task CreateAsync_NamelessRequestWhenDefaultIsNotAlias_UsesNonKeyedStoreAsync() + { + // Arrange + var keyedStore = new RecordingSessionStore(); + var nonKeyedStore = new RecordingSessionStore(); + + var services = CreateServices(); + + // The non-keyed agent is a separate instance that merely shares the keyed agent's name, so it is not an alias + // of that registration and the keyed store must stay out of the picture. + services.AddKeyedSingleton(DefaultAgentName, new NamedTestAgent(DefaultAgentName)); + services.AddSingleton(new NamedTestAgent(DefaultAgentName)); + services.AddKeyedSingleton(DefaultAgentName, keyedStore); + services.AddSingleton(nonKeyedStore); + + var handler = CreateHandler(services); + + // Act + await RunRequestAsync(handler, requestedAgentName: null); + + // Assert + Assert.True(nonKeyedStore.WasUsed); + Assert.False(keyedStore.WasUsed); + } + + [Fact] + public async Task CreateAsync_NamelessRequestWithUnnamedDefault_UsesNonKeyedStoreAsync() + { + // Arrange + var keyedStore = new RecordingSessionStore(); + var nonKeyedStore = new RecordingSessionStore(); + + var services = CreateServices(); + + // The alias shape, but the agent carries no name, so there is no key to look a keyed store up under. + AddAliasedDefaultAgent(services, new NamedTestAgent(name: null), DefaultAgentName); + services.AddKeyedSingleton(DefaultAgentName, keyedStore); + services.AddSingleton(nonKeyedStore); + + var handler = CreateHandler(services); + + // Act + await RunRequestAsync(handler, requestedAgentName: null); + + // Assert + Assert.True(nonKeyedStore.WasUsed); + Assert.False(keyedStore.WasUsed); + } + + private static AgentFrameworkResponseHandler CreateHandler(RecordingSessionStore? keyedStore, RecordingSessionStore? nonKeyedStore) + { + var services = CreateServices(); + + // The shape AddAIAgent(name, ...).WithSessionStore(store).AsDefault() registers: a keyed agent, a non-keyed + // agent forwarding to it, a keyed session store, plus whatever non-keyed store the host already had. + AddAliasedDefaultAgent(services, new NamedTestAgent(DefaultAgentName), DefaultAgentName); + if (keyedStore is not null) + { + services.AddKeyedSingleton(DefaultAgentName, keyedStore); + } + + if (nonKeyedStore is not null) + { + services.AddSingleton(nonKeyedStore); + } + + return CreateHandler(services); + } + + /// + /// Creates a service collection carrying only what the handler itself needs, so each test adds exactly the agent + /// and session-store registrations its scenario describes. + /// + private static IServiceCollection CreateServices() + { + IServiceCollection services = new ServiceCollection(); + services.AddSingleton>(NullLogger.Instance); + services.AddSingleton(new FakeHostedSessionIsolationKeyProvider()); + return services; + } + + /// + /// Registers the two descriptors AddAIAgent(key, ...).AsDefault() produces: the keyed agent, and a non-keyed + /// registration that forwards to it so both resolutions yield the same instance. + /// + private static void AddAliasedDefaultAgent(IServiceCollection services, AIAgent agent, string key) + { + services.AddKeyedSingleton(key, agent); + services.Add(new ServiceDescriptor(typeof(AIAgent), sp => sp.GetRequiredKeyedService(key), ServiceLifetime.Singleton)); + } + + private static AgentFrameworkResponseHandler CreateHandler(IServiceCollection services) + => new(services.BuildServiceProvider(), NullLogger.Instance); + + private static async Task RunRequestAsync(AgentFrameworkResponseHandler handler, string? requestedAgentName) + { + // An empty Model keeps the request genuinely nameless: GetAgentName falls back to Model when no + // AgentReference is present, so any non-empty value would take the named path instead. + var request = new CreateResponse { Model = requestedAgentName is null ? "" : "test" }; + if (requestedAgentName is not null) + { + request.AgentReference = new AgentReference(requestedAgentName); + } + + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + + await foreach (var _ in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + } + } + + /// + /// Session store fake that records whether the handler loaded or saved a session through it. + /// + private sealed class RecordingSessionStore : AgentSessionStore + { + public bool WasUsed { get; private set; } + + public override ValueTask GetSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) + { + this.WasUsed = true; + return new((AgentSession?)null); + } + + public override ValueTask SaveSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + AgentSession session, + CancellationToken cancellationToken = default) + { + this.WasUsed = true; + return default; + } + } + + private sealed class NamedTestAgent(string? name) : AIAgent + { + public override string? Name => name; + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + yield return new AgentResponseUpdate + { + MessageId = "resp_msg_1", + Contents = [new MeaiTextContent("done")] + }; + await Task.CompletedTask; + } + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => + new(new NamedTestAgentSession()); + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) => + new(JsonDocument.Parse("{}").RootElement); + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) => + new(new NamedTestAgentSession()); + } + + private sealed class NamedTestAgentSession : AgentSession; +} From e77079ea48c9835ebd43c592162381b15b744fe6 Mon Sep 17 00:00:00 2001 From: tomas-rampas Date: Fri, 18 Sep 2026 18:23:31 +0200 Subject: [PATCH 3/5] .NET: Document keyed-override and async disposal caveats for AsDefault() Registering another keyed AIAgent under the same name after AsDefault() is unsupported, and agents implementing IAsyncDisposable are disposed through both registrations too. --- .../HostedAgentBuilderExtensions.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs index 97b3be8dbcd..2f6b2459096 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs @@ -43,7 +43,9 @@ public static class HostedAgentBuilderExtensions /// keyed registration under the same agent name, so AsDefault() wins over that call /// in either order. A keyed that such a call registers under the same agent name /// is not ignored: call if the - /// default agent must not share it. + /// default agent must not share it. Registering another keyed under the same name after this + /// call is not supported: the registration added here would forward to the replacement and, with a singleton + /// lifetime, hold the first instance it resolves regardless of the replacement's lifetime. /// /// /// Use for a default agent. Hosting integrations resolve it from the root @@ -62,8 +64,9 @@ public static class HostedAgentBuilderExtensions /// is disabled. /// /// - /// If the agent implements , the container disposes it through both registrations, so - /// must be idempotent. + /// If the agent implements or , the container disposes it + /// through both registrations, so and + /// must be idempotent. /// /// public static IHostedAgentBuilder AsDefault(this IHostedAgentBuilder builder) From 5e3439a61af625b98af573ec0b5be276813f33b1 Mon Sep 17 00:00:00 2001 From: tomas-rampas Date: Fri, 18 Sep 2026 18:23:32 +0200 Subject: [PATCH 4/5] .NET: Keep the Foundry default-agent alias check from failing requests A keyed registration that cannot be resolved from the root provider (scoped under scope validation, or a faulting factory) now counts as not an alias instead of failing the request; a proven non-alias name is remembered so later requests skip the probe, and the identity lookup on the named path is guarded the same way. --- .../AgentFrameworkResponseHandler.cs | 96 +++++++++++++- ...rkResponseHandlerDefaultAgentStoreTests.cs | 125 +++++++++++++++++- 2 files changed, 215 insertions(+), 6 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs index 9c7704c227d..8d8567b4a46 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs @@ -49,6 +49,13 @@ public class AgentFrameworkResponseHandler : ResponseHandler /// private readonly bool _resilientBackground; + /// + /// A name proven not to alias the default agent can never start aliasing it: registrations are fixed once the + /// provider is built. Remembered so later requests do not resolve the keyed candidate again. Single slot because + /// a host has one default agent name. + /// + private volatile string? _knownNonAliasAgentName; + /// /// Cached fallback used when no is registered in DI. /// Avoids a per-request allocation on the request hot path. @@ -825,7 +832,7 @@ private bool ShouldPersistForResilience(CreateResponse request) /// The request to resolve an agent for. /// /// When the request was served by the default (non-keyed) agent, receives that agent's - /// if it is non-blank and the keyed registration under that name is the same + /// if it is non-blank and the keyed registration under that name resolves to the same /// instance as the default agent; otherwise . The session store lookup uses it so that /// a nameless request and a named request for the same agent share one store. /// @@ -842,7 +849,7 @@ private AIAgent ResolveAgent(CreateResponse request, out string? defaultAgentNam string storageIdentity = FoundryHostingAgent.ResolveSessionStorageIdentity( agent, agentName, - this._serviceProvider.GetService()); + this.TryResolveDefaultAgentForIdentity(agentName)); return this.PrepareResolvedAgent(agent, storageIdentity); } @@ -860,10 +867,9 @@ private AIAgent ResolveAgent(CreateResponse request, out string? defaultAgentNam // under that name. This mirrors the alias relationship FoundryHostingAgent.ResolveSessionStorageIdentity // relies on, assuming the agent's Name is the key it was registered under (which AddAIAgent enforces), // so store selection and storage identity agree for hosted agents. - // The probe assumes the keyed and non-keyed registrations share a lifetime, as the hosting builder guarantees. var name = defaultAgent.Name; defaultAgentName = !string.IsNullOrWhiteSpace(name) - && ReferenceEquals(this._serviceProvider.GetKeyedService(name), defaultAgent) + && this.IsDefaultAgentAliasOfKeyedRegistration(defaultAgent, name) ? name : null; @@ -881,6 +887,88 @@ private AIAgent ResolveAgent(CreateResponse request, out string? defaultAgentNam throw new InvalidOperationException(errorMessage); } + /// + /// Determines whether the already-resolved default (non-keyed) is the same instance as the + /// keyed registration under , which is what makes that name usable + /// as the session store key. The keyed candidate is resolved only to compare identity; a registration that cannot + /// be resolved from this handler's root provider (a scoped registration under scope validation, or a faulting + /// factory) is treated as not an alias, so the probe fails the request only when the keyed factory observes + /// cancellation; a name proven not to alias is remembered so later requests skip the probe. + /// + /// The default agent the request resolved to. + /// The default agent's . + /// + /// when the keyed registered under is the same + /// instance as ; otherwise . + /// + private bool IsDefaultAgentAliasOfKeyedRegistration(AIAgent defaultAgent, string name) + { + if (string.Equals(this._knownNonAliasAgentName, name, StringComparison.Ordinal)) + { + return false; + } + + try + { + if (ReferenceEquals(this._serviceProvider.GetKeyedService(name), defaultAgent)) + { + return true; + } + + this._knownNonAliasAgentName = name; + return false; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // A keyed registration that cannot be resolved from this handler's root provider (a scoped registration + // under scope validation, or a faulting factory) is not an alias of the already-resolved default agent. + // The request is served from the non-keyed session store rather than failing on the probe. + // Deliberately not cached: a resolution failure may be transient and must not permanently disable the + // keyed store. + if (this._logger.IsEnabled(LogLevel.Debug)) + { + this._logger.LogDebug( + ex, + "Keyed agent '{AgentName}' could not be resolved while checking whether the default agent aliases it; the default agent's name will not be used as the session store key.", + name); + } + + return false; + } + } + + /// + /// Resolves the default (non-keyed) for the sole purpose of computing the storage identity + /// of a request that already resolved a keyed agent. A default registration that cannot be resolved from this + /// handler's root provider (a scoped registration under scope validation, or a faulting factory) must not fail + /// such a request; the keyed identity is used instead. The result is never cached, because a resolution failure + /// may be transient. On the default path the same failure is the request's real error and stays unguarded. + /// + /// The name the request resolved its keyed agent under; used for logging only. + /// + /// The default , when none is registered, and + /// when resolving it threw. + /// + private AIAgent? TryResolveDefaultAgentForIdentity(string agentName) + { + try + { + return this._serviceProvider.GetService(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + if (this._logger.IsEnabled(LogLevel.Debug)) + { + this._logger.LogDebug( + ex, + "Default agent could not be resolved while computing the storage identity for agent '{AgentName}'; using the keyed identity.", + agentName); + } + + return null; + } + } + private AIAgent PrepareResolvedAgent(AIAgent agent, string sessionStorageIdentity) { FoundryHostingExtensions.TryApplyUserAgent(agent); diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerDefaultAgentStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerDefaultAgentStoreTests.cs index 2f6c57316a5..eb1553eb4cb 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerDefaultAgentStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerDefaultAgentStoreTests.cs @@ -180,6 +180,127 @@ public async Task CreateAsync_NamelessRequestWithUnnamedDefault_UsesNonKeyedStor Assert.False(keyedStore.WasUsed); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CreateAsync_NamelessRequestWhenKeyedAgentIsScoped_UsesNonKeyedStoreAsync(bool validateScopes) + { + // Arrange + var keyedStore = new RecordingSessionStore(); + var nonKeyedStore = new RecordingSessionStore(); + + var services = CreateServices(); + + // The default agent is a raw non-keyed singleton whose name merely collides with an unrelated scoped keyed + // registration. Under scope validation, resolving that keyed agent from the root provider throws and the alias + // probe must treat the failure as "not an alias"; without scope validation the resolution succeeds but yields a + // different instance. Either way the already-resolved default agent serves the request from the non-keyed store. + services.AddSingleton(new NamedTestAgent(DefaultAgentName)); + services.AddKeyedScoped(DefaultAgentName, (_, _) => new NamedTestAgent(DefaultAgentName)); + services.AddKeyedSingleton(DefaultAgentName, keyedStore); + services.AddSingleton(nonKeyedStore); + + var handler = CreateHandler(services, new ServiceProviderOptions { ValidateScopes = validateScopes }); + + // Act + await RunRequestAsync(handler, requestedAgentName: null); + + // Assert + Assert.True(nonKeyedStore.WasUsed); + Assert.False(keyedStore.WasUsed); + } + + [Fact] + public async Task CreateAsync_NamelessRequestWhenKeyedAgentIsTransient_ResolvesKeyedCandidateOnceAsync() + { + // Arrange + var keyedStore = new RecordingSessionStore(); + var nonKeyedStore = new RecordingSessionStore(); + var keyedResolutions = 0; + + var services = CreateServices(); + + // The keyed registration under the default agent's name is a transient that can never be the same instance as + // the non-keyed singleton default. Once the probe has proven that, the handler must remember it rather than + // construct a fresh candidate on every request. + services.AddSingleton(new NamedTestAgent(DefaultAgentName)); + services.AddKeyedTransient(DefaultAgentName, (_, _) => + { + keyedResolutions++; + return new NamedTestAgent(DefaultAgentName); + }); + services.AddKeyedSingleton(DefaultAgentName, keyedStore); + services.AddSingleton(nonKeyedStore); + + var handler = CreateHandler(services); + + // Act + await RunRequestAsync(handler, requestedAgentName: null); + await RunRequestAsync(handler, requestedAgentName: null); + + // Assert + Assert.Equal(1, keyedResolutions); + Assert.True(nonKeyedStore.WasUsed); + Assert.False(keyedStore.WasUsed); + } + + [Fact] + public async Task CreateAsync_NamedRequestWhenDefaultAgentIsScoped_UsesKeyedAgentAndStoreAsync() + { + // Arrange + const string OtherAgentName = "support"; + var keyedStore = new RecordingSessionStore(); + var nonKeyedStore = new RecordingSessionStore(); + + var services = CreateServices(); + + // The named request resolves its keyed agent without ever needing the default agent; the default agent is only + // consulted to compute the storage identity. A scoped default that cannot be resolved from the root provider + // must not turn that lookup into the request's error. + // The keyed agent is registered through a factory on purpose: with an instance descriptor the container serves + // the non-keyed scoped AIAgent from the root provider without raising the scope violation at all (measured on + // Microsoft.Extensions.DependencyInjection), which would leave the guard below untested. + services.AddKeyedSingleton(OtherAgentName, (_, _) => new NamedTestAgent(OtherAgentName)); + services.AddKeyedSingleton(OtherAgentName, keyedStore); + services.AddScoped(_ => new NamedTestAgent(DefaultAgentName)); + services.AddSingleton(nonKeyedStore); + + var handler = CreateHandler(services, new ServiceProviderOptions { ValidateScopes = true }); + + // Act + await RunRequestAsync(handler, requestedAgentName: OtherAgentName); + + // Assert + Assert.True(keyedStore.WasUsed); + Assert.False(nonKeyedStore.WasUsed); + } + + [Fact] + public async Task CreateAsync_NamelessRequestWhenKeyedAgentFactoryThrows_UsesNonKeyedStoreAsync() + { + // Arrange + var keyedStore = new RecordingSessionStore(); + var nonKeyedStore = new RecordingSessionStore(); + + var services = CreateServices(); + + // Same collision, but the unrelated keyed registration faults when its factory runs. A probe that throws means + // "not an alias", never a failed request. + services.AddSingleton(new NamedTestAgent(DefaultAgentName)); + services.AddKeyedSingleton(DefaultAgentName, (_, _) => throw new InvalidOperationException("boom")); + services.AddKeyedSingleton(DefaultAgentName, keyedStore); + services.AddSingleton(nonKeyedStore); + + var handler = CreateHandler(services); + + // Act + await RunRequestAsync(handler, requestedAgentName: null); + + // Assert + Assert.True(nonKeyedStore.WasUsed); + Assert.False(keyedStore.WasUsed); + } + private static AgentFrameworkResponseHandler CreateHandler(RecordingSessionStore? keyedStore, RecordingSessionStore? nonKeyedStore) { var services = CreateServices(); @@ -222,8 +343,8 @@ private static void AddAliasedDefaultAgent(IServiceCollection services, AIAgent services.Add(new ServiceDescriptor(typeof(AIAgent), sp => sp.GetRequiredKeyedService(key), ServiceLifetime.Singleton)); } - private static AgentFrameworkResponseHandler CreateHandler(IServiceCollection services) - => new(services.BuildServiceProvider(), NullLogger.Instance); + private static AgentFrameworkResponseHandler CreateHandler(IServiceCollection services, ServiceProviderOptions? options = null) + => new(services.BuildServiceProvider(options ?? new ServiceProviderOptions()), NullLogger.Instance); private static async Task RunRequestAsync(AgentFrameworkResponseHandler handler, string? requestedAgentName) { From c5d0c021fd49e7d606e640cf35a379517e93f8b4 Mon Sep 17 00:00:00 2001 From: tomas-rampas Date: Fri, 18 Sep 2026 19:05:11 +0200 Subject: [PATCH 5/5] .NET: Bind the alias-probe cache rules with tests A failed probe is never cached (second request finds the alias), and the scoped-default test asserts the scope-validation precondition it relies on. --- ...rkResponseHandlerDefaultAgentStoreTests.cs | 60 ++++++++++++++++++- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerDefaultAgentStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerDefaultAgentStoreTests.cs index eb1553eb4cb..cd1de2f85ee 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerDefaultAgentStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerDefaultAgentStoreTests.cs @@ -265,7 +265,13 @@ public async Task CreateAsync_NamedRequestWhenDefaultAgentIsScoped_UsesKeyedAgen services.AddScoped(_ => new NamedTestAgent(DefaultAgentName)); services.AddSingleton(nonKeyedStore); - var handler = CreateHandler(services, new ServiceProviderOptions { ValidateScopes = true }); + var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); + + // The scenario only exists while the default agent genuinely cannot be resolved from the root provider, so + // assert that precondition here rather than trusting the registrations above to keep producing it. + _ = Assert.Throws(() => provider.GetService()); + + var handler = CreateHandler(provider); // Act await RunRequestAsync(handler, requestedAgentName: OtherAgentName); @@ -301,6 +307,53 @@ public async Task CreateAsync_NamelessRequestWhenKeyedAgentFactoryThrows_UsesNon Assert.False(keyedStore.WasUsed); } + [Fact] + public async Task CreateAsync_NamelessRequestWhenKeyedAgentFactoryThrowsOnce_UsesKeyedStoreOnRetryAsync() + { + // Arrange + var keyedStore = new RecordingSessionStore(); + var nonKeyedStore = new RecordingSessionStore(); + var agent = new NamedTestAgent(DefaultAgentName); + var calls = 0; + + var services = CreateServices(); + + // The keyed registration faults the first time its factory runs and hands back the default agent itself on + // every later call, so the alias is only observable from the second request onwards. A probe that threw is + // deliberately not remembered as a proven non-alias, so that second request probes again rather than being + // answered from the cache. + services.AddKeyedTransient(DefaultAgentName, (_, _) => + { + if (++calls == 1) + { + throw new InvalidOperationException("boom"); + } + + return agent; + }); + + // The non-keyed default carries the forwarding shape AsDefault() produces, written as a descriptor whose + // factory returns the captured instance. + services.Add(new ServiceDescriptor(typeof(AIAgent), _ => agent, ServiceLifetime.Singleton)); + services.AddKeyedSingleton(DefaultAgentName, keyedStore); + services.AddSingleton(nonKeyedStore); + + var handler = CreateHandler(services); + + // Act + await RunRequestAsync(handler, requestedAgentName: null); + + // Assert + Assert.True(nonKeyedStore.WasUsed); + Assert.False(keyedStore.WasUsed); + + // Act: the second request re-runs the probe, which now succeeds and proves the alias. + await RunRequestAsync(handler, requestedAgentName: null); + + // Assert + Assert.True(keyedStore.WasUsed); + } + private static AgentFrameworkResponseHandler CreateHandler(RecordingSessionStore? keyedStore, RecordingSessionStore? nonKeyedStore) { var services = CreateServices(); @@ -344,7 +397,10 @@ private static void AddAliasedDefaultAgent(IServiceCollection services, AIAgent } private static AgentFrameworkResponseHandler CreateHandler(IServiceCollection services, ServiceProviderOptions? options = null) - => new(services.BuildServiceProvider(options ?? new ServiceProviderOptions()), NullLogger.Instance); + => CreateHandler(services.BuildServiceProvider(options ?? new ServiceProviderOptions())); + + private static AgentFrameworkResponseHandler CreateHandler(IServiceProvider provider) + => new(provider, NullLogger.Instance); private static async Task RunRequestAsync(AgentFrameworkResponseHandler handler, string? requestedAgentName) {