diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs index efe820a3691..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. @@ -108,8 +115,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 +826,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 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. + /// + private AIAgent ResolveAgent(CreateResponse request, out string? defaultAgentName) { var agentName = GetAgentName(request); + defaultAgentName = null; if (!string.IsNullOrEmpty(agentName)) { @@ -833,7 +849,7 @@ private AIAgent ResolveAgent(CreateResponse request) string storageIdentity = FoundryHostingAgent.ResolveSessionStorageIdentity( agent, agentName, - this._serviceProvider.GetService()); + this.TryResolveDefaultAgentForIdentity(agentName)); return this.PrepareResolvedAgent(agent, storageIdentity); } @@ -847,6 +863,16 @@ 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. + var name = defaultAgent.Name; + defaultAgentName = !string.IsNullOrWhiteSpace(name) + && this.IsDefaultAgentAliasOfKeyedRegistration(defaultAgent, name) + ? name + : null; + string storageIdentity = FoundryHostingAgent.ResolveSessionStorageIdentity( defaultAgent, registrationKey: null, @@ -861,6 +887,88 @@ private AIAgent ResolveAgent(CreateResponse request) 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); @@ -874,25 +982,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 +1029,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/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..2f6b2459096 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs @@ -13,6 +13,112 @@ 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. 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 + /// 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 or , the container disposes it + /// through both registrations, so and + /// 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 +260,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.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerDefaultAgentStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerDefaultAgentStoreTests.cs new file mode 100644 index 00000000000..cd1de2f85ee --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerDefaultAgentStoreTests.cs @@ -0,0 +1,501 @@ +// 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); + } + + [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 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); + + // 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); + } + + [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(); + + // 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, ServiceProviderOptions? options = null) + => 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) + { + // 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; +} 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. + } +}