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