From 3f6d66df7af0d584373c085fcf533d455e64a1ed Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 10 Nov 2026 07:45:40 -0800 Subject: [PATCH 1/4] .NET: Support scoped and transient agent lifetimes in Foundry Hosting Resolve agents and session stores from a DI scope owned by each response run, so scoped and transient registrations are created per run and disposed when the event stream ends, including background and recovered runs. Health checks resolve agents from a short lived scope. Add AddFoundryResponses overloads that take an agent factory, a ServiceLifetime, and an optional session store factory sharing that lifetime. Factory registrations keep the name based session storage identity for every lifetime. --- .../Hosted-ChatClientAgent/README.md | 23 + .../AgentFrameworkResponseHandler.cs | 52 +- .../FoundryAgentFactoryRegistration.cs | 66 ++ .../HostApplicationBuilderExtensions.cs | 67 ++ .../HostedStoredOutputHealthCheck.cs | 34 +- .../HostedWorkflowCheckpointingHealthCheck.cs | 42 +- .../ServiceCollectionExtensions.cs | 164 +++++ .../FoundryAgentLifetimeTests.cs | 633 ++++++++++++++++++ .../HostedStoredOutputHealthCheckTests.cs | 27 + ...edWorkflowCheckpointingHealthCheckTests.cs | 21 + .../ResilientTwoLifetimeIntegrationTests.cs | 44 +- 11 files changed, 1112 insertions(+), 61 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentFactoryRegistration.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryAgentLifetimeTests.cs diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/README.md index eada65ad288..0db87f2a90b 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/README.md @@ -69,6 +69,29 @@ AZURE_TOKEN_CREDENTIALS=dev > credentials (Azure CLI, Visual Studio, `azd`) and skips the managed-identity probe. This variable > is only for local runs; the deployed agent in Foundry uses the platform-injected managed identity. +## Agent lifetime + +`builder.AddFoundryResponses(agent)` registers one agent object that serves every request for the +life of the process. When the agent needs services that should not live that long, for example a +database context, register a factory with a DI lifetime instead: + +```csharp +builder.AddFoundryResponses( + agentName, + sp => new MyAgent(sp.GetRequiredService()), + ServiceLifetime.Scoped, + // Optional: a session store created with the same lifetime. Omit it to use the default store. + sp => new MySessionStore(sp.GetRequiredService())); +``` + +Each response run (streaming, background, or resumed after a restart) gets its own DI scope, which is +disposed when that run's event stream ends. A `Scoped` agent, and a session store created by the +optional store factory, are therefore created once per response and disposed afterwards, even when a +background response keeps running after the HTTP request has returned. Without a store factory the +default session store is a single shared instance. Conversation state is not kept on the agent object; +each turn loads the saved session, and sessions are stored under `agentName`, so switching from the +instance overload keeps existing conversations. + ## Run and test locally Local runs use two terminals: one hosts the agent, the other is a code-first client that talks to it diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs index 715d0819091..4dde479e0f5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs @@ -41,6 +41,7 @@ internal sealed partial class AgentFrameworkResponseHandler : ResponseHandler private readonly ILogger _logger; private readonly FoundryResponsesServerOptions _options; private readonly FoundryToolboxService? _toolboxService; + private readonly FoundryAgentFactoryRegistrations _factoryRegistrations; /// /// Initializes a new instance of the class @@ -82,6 +83,8 @@ public AgentFrameworkResponseHandler( this._logger = logger; this._options = foundryResponsesOptions; this._toolboxService = toolboxService; + this._factoryRegistrations = new FoundryAgentFactoryRegistrations( + serviceProvider.GetServices()); } /// @@ -90,9 +93,15 @@ public override async IAsyncEnumerable CreateAsync( ResponseContext context, [EnumeratorCancellation] CancellationToken cancellationToken) { - // 1. Resolve agent - var agent = this.ResolveAgent(request); - var sessionStore = this.ResolveSessionStore(request); + // 1. Resolve agent and session store from a DI scope owned by this response run. The scope + // covers the whole enumeration, not the HTTP request: background runs continue after the + // HTTP response is sent, and recovery runs have no HTTP request at all. Scoped and transient + // registrations are therefore created per run and disposed when the event stream ends. + AsyncServiceScope serviceScope = this._serviceProvider.CreateAsyncScope(); + await using var configuredServiceScope = serviceScope.ConfigureAwait(false); + IServiceProvider services = serviceScope.ServiceProvider; + var agent = this.ResolveAgent(request, services); + var sessionStore = this.ResolveSessionStore(request, services); // 2. Resolve the per-request hosted user identity, so the session can be // loaded from a per-user partition. Fresh sessions are tagged once; resumed sessions are @@ -880,19 +889,23 @@ private bool ShouldPersistForResilience(CreateResponse request) /// Tries agent.name first, then falls back to metadata["entity_id"]. /// If neither is present, attempts to resolve a default (non-keyed) . /// - private AIAgent ResolveAgent(CreateResponse request) + /// The incoming request. + /// The service provider of the DI scope owned by the current response run. + private AIAgent ResolveAgent(CreateResponse request, IServiceProvider services) { var agentName = GetAgentName(request); if (!string.IsNullOrEmpty(agentName)) { - var agent = this._serviceProvider.GetKeyedService(agentName); + var agent = services.GetKeyedService(agentName); if (agent is not null) { - string storageIdentity = FoundryHostingAgent.ResolveSessionStorageIdentity( - agent, - agentName, - this._serviceProvider.GetService()); + string storageIdentity = this._factoryRegistrations.TryGetStorageIdentity(agentName, out string? factoryIdentity) + ? factoryIdentity + : FoundryHostingAgent.ResolveSessionStorageIdentity( + agent, + agentName, + services.GetService()); return this.PrepareResolvedAgent(agent, storageIdentity); } @@ -900,19 +913,20 @@ private AIAgent ResolveAgent(CreateResponse request) } // Try non-keyed default - var defaultAgent = this._serviceProvider.GetService(); + var defaultAgent = services.GetService(); if (defaultAgent is not null) { - string storageIdentity = FoundryHostingAgent.ResolveSessionStorageIdentity( - defaultAgent, - registrationKey: null, - defaultAgent); + string storageIdentity = this._factoryRegistrations.DefaultStorageIdentity + ?? FoundryHostingAgent.ResolveSessionStorageIdentity( + defaultAgent, + registrationKey: null, + defaultAgent); return this.PrepareResolvedAgent(defaultAgent, storageIdentity); } var errorMessage = string.IsNullOrEmpty(agentName) ? "No agent name specified in the request (via agent.name or metadata[\"entity_id\"]) and no default AIAgent is registered." - : $"Agent '{agentName}' not found. Ensure it is registered via builder.AddFoundryResponses(agent) or services.AddKeyedSingleton(\"{agentName}\", ...)."; + : $"Agent '{agentName}' not found. Ensure it is registered via builder.AddFoundryResponses(agent), builder.AddFoundryResponses(\"{agentName}\", agentFactory, lifetime), or a keyed AIAgent service registration such as services.AddKeyedSingleton(\"{agentName}\", ...)."; throw new InvalidOperationException(errorMessage); } @@ -934,13 +948,15 @@ private AIAgent PrepareResolvedAgent(AIAgent agent, string sessionStorageIdentit /// Tries agent.name first, then falls back to metadata["entity_id"]. /// If neither is present, attempts to resolve a default (non-keyed) . /// - private AgentSessionStore ResolveSessionStore(CreateResponse request) + /// The incoming request. + /// The service provider of the DI scope owned by the current response run. + private AgentSessionStore ResolveSessionStore(CreateResponse request, IServiceProvider services) { var agentName = GetAgentName(request); if (!string.IsNullOrEmpty(agentName)) { - var sessionStore = this._serviceProvider.GetKeyedService(agentName); + var sessionStore = services.GetKeyedService(agentName); if (sessionStore is not null) { return sessionStore; @@ -950,7 +966,7 @@ private AgentSessionStore ResolveSessionStore(CreateResponse request) } // Try non-keyed default - var defaultSessionStore = this._serviceProvider.GetService(); + var defaultSessionStore = services.GetService(); if (defaultSessionStore is not null) { return defaultSessionStore; diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentFactoryRegistration.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentFactoryRegistration.cs new file mode 100644 index 00000000000..96110315bfa --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentFactoryRegistration.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// Records an agent registered through the factory overload of AddFoundryResponses. +/// +/// +/// The response handler normally decides where sessions are stored by checking whether the keyed agent +/// and the default agent are the same object. For a factory registration that check depends on the +/// lifetime: a transient registration creates a new object on every lookup. This marker lets the +/// handler use the registration name instead, so the storage location stays the same for every lifetime +/// and matches what AddFoundryResponses(agent) uses for an agent with the same name. +/// +internal sealed class FoundryAgentFactoryRegistration +{ + public FoundryAgentFactoryRegistration(string agentName, bool isDefault) + { + this.AgentName = Throw.IfNullOrWhitespace(agentName); + this.IsDefault = isDefault; + } + + /// Gets the key the agent was registered under. + public string AgentName { get; } + + /// Gets a value indicating whether the registration also supplied the default (non-keyed) agent. + public bool IsDefault { get; } + + /// Gets the session storage identity used for this registration. + public string StorageIdentity => $"name:{this.AgentName}"; +} + +/// +/// Lookup of markers built once by the response handler. +/// +internal sealed class FoundryAgentFactoryRegistrations +{ + private readonly Dictionary _storageIdentities = new(StringComparer.Ordinal); + + public FoundryAgentFactoryRegistrations(IEnumerable registrations) + { + foreach (FoundryAgentFactoryRegistration registration in registrations) + { + this._storageIdentities[registration.AgentName] = registration.StorageIdentity; + if (registration.IsDefault) + { + this.DefaultStorageIdentity = registration.StorageIdentity; + } + } + } + + /// + /// Gets the storage identity of the factory registration that supplied the default agent, or + /// when the default agent was not registered through a factory overload. + /// + public string? DefaultStorageIdentity { get; } + + /// Gets the storage identity for a keyed factory registration. + public bool TryGetStorageIdentity(string agentName, [NotNullWhen(true)] out string? storageIdentity) + => this._storageIdentities.TryGetValue(agentName, out storageIdentity); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostApplicationBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostApplicationBuilderExtensions.cs index 35aed0370db..2bc00e4e1e5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostApplicationBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostApplicationBuilderExtensions.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -21,6 +22,9 @@ public static class HostApplicationBuilderExtensions /// Use this overload for applications that can access their host builder. In a Foundry hosted /// environment it binds the response-storage and resilient-task-storage identity and endpoint /// from the same ResponsesServer configuration section, as required by AgentServer. + /// Keyed agent registrations may use any DI lifetime. Each response run resolves its agent and + /// session store from a DI scope that is disposed when the run's event stream ends, so scoped and + /// transient agents are created per run, including background runs that outlive the HTTP request. /// /// builder.Services.AddKeyedSingleton<AIAgent>("my-agent", myAgent); /// builder.AddFoundryResponses(); @@ -105,6 +109,69 @@ public static IHostApplicationBuilder AddFoundryResponses( return host; } + /// + /// Registers the Azure AI Responses Server SDK and an created by a factory + /// with the specified DI lifetime on a host application builder. + /// + /// + /// + /// Use this overload when the agent or its session store depends on services that are not + /// singletons, for example a database context, or when they should not live for the whole process. + /// The agent and its session store are registered under and as the + /// default and , so requests without an agent + /// name also reach them. + /// + /// + /// Every response run (streaming, non-streaming, background, or a run resumed after a restart) gets + /// its own DI scope, created when the run starts and disposed when its event stream ends. That is + /// later than the end of the HTTP request for a background run. The lifetime applies to the agent + /// and, when is supplied, to the session store: + /// + /// : one instance for the process, disposed at shutdown. + /// : one instance per response run, disposed when the run ends. + /// : a new instance on every lookup, disposed when the run ends. + /// + /// Conversation state is not kept on the agent or store object: each turn loads the saved session + /// from the store's backing storage. Sessions are stored under , the same + /// location AddFoundryResponses(agent) uses for an agent with that name, so switching between + /// the two overloads or between lifetimes keeps existing sessions readable. + /// + /// + /// builder.AddFoundryResponses( + /// "my-agent", + /// sp => new MyAgent(sp.GetRequiredService<MyDbContext>()), + /// ServiceLifetime.Scoped, + /// sp => new MySessionStore(sp.GetRequiredService<MyDbContext>())); + /// + /// + /// The host application builder. + /// The name the agent is registered under and the name requests use to select it. + /// Creates the agent from the service provider of the current DI scope. + /// + /// The DI lifetime of the agent and of the session store created by . + /// Defaults to . + /// + /// + /// Creates the agent session store from the service provider of the current DI scope, with the same + /// as the agent. If null, the default singleton store is used. When the + /// factory returns an object created elsewhere with a scoped or transient lifetime, the DI container + /// still disposes it at the end of each response run if it is disposable. + /// + /// Optional hosting behavior. Required inputs belong on the extension method parameters. + /// The host application builder for chaining. + public static IHostApplicationBuilder AddFoundryResponses( + this IHostApplicationBuilder host, + string agentName, + Func agentFactory, + ServiceLifetime lifetime = ServiceLifetime.Singleton, + Func? agentSessionStoreFactory = null, + FoundryResponsesServerOptions? options = null) + { + _ = Throw.IfNull(host); + ServiceCollectionExtensions.RegisterAgentFactory(host.Services, agentName, agentFactory, lifetime, agentSessionStoreFactory); + return host.AddFoundryResponses(options); + } + private static FoundryResponsesServerOptions ResolveFoundryResponsesServerOptions( IHostApplicationBuilder host, FoundryResponsesServerOptions? suppliedOptions) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedStoredOutputHealthCheck.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedStoredOutputHealthCheck.cs index 127770bcb69..a1ed328413b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedStoredOutputHealthCheck.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedStoredOutputHealthCheck.cs @@ -5,6 +5,7 @@ using System.Globalization; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Logging; using Microsoft.Shared.Diagnostics; @@ -64,19 +65,25 @@ public async Task CheckHealthAsync(HealthCheckContext context List storingAgents = []; var checkedAgents = 0; - foreach (var agent in this.ResolveAgents()) + // Agents may be registered as scoped or transient, so they are resolved from a scope that is + // disposed once the check is done, the same way the response handler resolves them per run. + AsyncServiceScope serviceScope = this._serviceProvider.CreateAsyncScope(); + await using (serviceScope.ConfigureAwait(false)) { - if (agent.GetService() is not { } chatClientAgent) + foreach (var agent in ServiceCollectionExtensions.ResolveRegisteredAgents(serviceScope.ServiceProvider)) { - // Hosting only reaches the store setting through ChatClientAgent's chat options, so any - // other agent runs untouched and there is nothing to report. - continue; - } - - checkedAgents++; - if (await this.StoresItsOwnResponsesAsync(agent, cancellationToken).ConfigureAwait(false)) - { - storingAgents.Add(agent.Name ?? agent.Id); + if (agent.GetService() is not { } chatClientAgent) + { + // Hosting only reaches the store setting through ChatClientAgent's chat options, so any + // other agent runs untouched and there is nothing to report. + continue; + } + + checkedAgents++; + if (await this.StoresItsOwnResponsesAsync(agent, cancellationToken).ConfigureAwait(false)) + { + storingAgents.Add(agent.Name ?? agent.Id); + } } } @@ -154,11 +161,6 @@ private async Task StoresItsOwnResponsesAsync(AIAgent agent, CancellationT return probe.StoredOutputEnabled is true; } - /// - /// Every agent this container can serve: the ones registered under a name, plus the default. - /// - private List ResolveAgents() => ServiceCollectionExtensions.ResolveRegisteredAgents(this._serviceProvider); - [LoggerMessage(LogLevel.Debug, "Could not probe the stored output setting for agent '{AgentName}'.", EventId = 0)] private static partial void LogStoredOutputProbeFailed( ILogger logger, diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedWorkflowCheckpointingHealthCheck.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedWorkflowCheckpointingHealthCheck.cs index ec823f1b473..103f31ac1dd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedWorkflowCheckpointingHealthCheck.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedWorkflowCheckpointingHealthCheck.cs @@ -49,7 +49,7 @@ public HostedWorkflowCheckpointingHealthCheck(IServiceProvider serviceProvider) /// internal bool IsHosted { get; set; } = FoundryEnvironment.IsHosted; - public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) { _ = Throw.IfNull(context); @@ -57,42 +57,48 @@ public Task CheckHealthAsync(HealthCheckContext context, Canc { // Nothing redirects checkpoints outside a Foundry container, so an agent that brings its // own checkpoint manager is not competing with anything. - return Task.FromResult(HealthCheckResult.Healthy( - "Workflow checkpointing: not running in a Foundry container, so workflow checkpoints are left where each agent puts them.")); + return HealthCheckResult.Healthy( + "Workflow checkpointing: not running in a Foundry container, so workflow checkpoints are left where each agent puts them."); } List incompatibleAgents = []; var checkedAgents = 0; - foreach (var agent in ServiceCollectionExtensions.ResolveRegisteredAgents(this._serviceProvider)) + // Agents may be registered as scoped or transient, so they are resolved from a scope that is + // disposed once the check is done, the same way the response handler resolves them per run. + AsyncServiceScope serviceScope = this._serviceProvider.CreateAsyncScope(); + await using (serviceScope.ConfigureAwait(false)) { - if (agent.GetService() is not { } metadata) + foreach (var agent in ServiceCollectionExtensions.ResolveRegisteredAgents(serviceScope.ServiceProvider)) { - continue; - } + if (agent.GetService() is not { } metadata) + { + continue; + } - checkedAgents++; - AIAgent redirected = ServiceCollectionExtensions.ApplyWorkflowCheckpointing( - agent, - this._serviceProvider.GetService()); + checkedAgents++; + AIAgent redirected = ServiceCollectionExtensions.ApplyWorkflowCheckpointing( + agent, + this._serviceProvider.GetService()); - if (metadata.UsesOwnCheckpointStorage || ReferenceEquals(redirected, agent)) - { - incompatibleAgents.Add(agent.Name ?? agent.Id); + if (metadata.UsesOwnCheckpointStorage || ReferenceEquals(redirected, agent)) + { + incompatibleAgents.Add(agent.Name ?? agent.Id); + } } } if (incompatibleAgents.Count > 0) { - return Task.FromResult(new HealthCheckResult( + return new HealthCheckResult( status: context.Registration.FailureStatus, description: string.Create( CultureInfo.InvariantCulture, $"Workflow checkpointing: {incompatibleAgents.Count} registered workflow agent(s) cannot use the checkpoint store supplied by hosting. Remove a caller-configured checkpoint manager and register the workflow agent directly rather than behind middleware."), - data: new Dictionary(StringComparer.Ordinal) { ["incompatibleAgents"] = incompatibleAgents })); + data: new Dictionary(StringComparer.Ordinal) { ["incompatibleAgents"] = incompatibleAgents }); } - return Task.FromResult(HealthCheckResult.Healthy( - string.Create(CultureInfo.InvariantCulture, $"Workflow checkpointing: {checkedAgents} workflow agent(s) checked, all leaving their checkpoint storage to hosting."))); + return HealthCheckResult.Healthy( + string.Create(CultureInfo.InvariantCulture, $"Workflow checkpointing: {checkedAgents} workflow agent(s) checked, all leaving their checkpoint storage to hosting.")); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs index bac9b10b17d..73843baea86 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs @@ -52,6 +52,11 @@ public static class ServiceCollectionExtensions /// a caller-provided identity for session isolation is unsupported. /// /// + /// Keyed agent registrations may use any DI lifetime. Each response run resolves its agent and + /// session store from a DI scope that is disposed when the run's event stream ends, so scoped and + /// transient agents are created per run, including background runs that outlive the HTTP request. + /// + /// /// Example: /// /// builder.Services.AddKeyedSingleton<AIAgent>("my-agent", myAgent); @@ -167,6 +172,165 @@ public static IServiceCollection AddFoundryResponses( return services; } + /// + /// Registers the Azure AI Responses Server SDK and an created by a factory + /// with the specified DI lifetime, as the handler for all incoming requests. + /// + /// + /// + /// Use this overload when the agent or its session store depends on services that are not + /// singletons, for example a database context, or when they should not live for the whole process. + /// The agent and its session store are registered under and as the + /// default and , so requests without an agent + /// name also reach them. + /// + /// + /// Every response run (streaming, non-streaming, background, or a run resumed after a restart) gets + /// its own DI scope, created when the run starts and disposed when its event stream ends. That is + /// later than the end of the HTTP request for a background run. The lifetime applies to the agent + /// and, when is supplied, to the session store: + /// + /// : one instance for the process, disposed at shutdown. + /// : one instance per response run, disposed when the run ends. + /// : a new instance on every lookup, disposed when the run ends. + /// + /// Conversation state is not kept on the agent or store object: each turn loads the saved session + /// from the store's backing storage. Sessions are stored under , the same + /// location AddFoundryResponses(agent) uses for an agent with that name, so switching between + /// the two overloads or between lifetimes keeps existing sessions readable. + /// + /// + /// This overload is for local or non-hosted scenarios. Foundry hosted applications must use + /// the overload. + /// + /// + /// Example: + /// + /// builder.Services.AddFoundryResponses( + /// "my-agent", + /// sp => new MyAgent(sp.GetRequiredService<MyDbContext>()), + /// ServiceLifetime.Scoped, + /// sp => new MySessionStore(sp.GetRequiredService<MyDbContext>())); + /// + /// + /// + /// The service collection. + /// The name the agent is registered under and the name requests use to select it. + /// Creates the agent from the service provider of the current DI scope. + /// + /// The DI lifetime of the agent and of the session store created by . + /// Defaults to . + /// + /// + /// Creates the agent session store from the service provider of the current DI scope, with the same + /// as the agent. If null, the default store, a singleton that uses the + /// AgentServer SDK's local state-store fallback, is used. When the factory returns an object created + /// elsewhere with a scoped or transient lifetime, the DI container still disposes it at the end of + /// each response run if it is disposable. + /// + /// + /// Optional , for example to allow the + /// agent's own service to store the responses it produces, or to opt in to durable long-running + /// (resilient) background responses via . + /// + /// The service collection for chaining. + public static IServiceCollection AddFoundryResponses( + this IServiceCollection services, + string agentName, + Func agentFactory, + ServiceLifetime lifetime = ServiceLifetime.Singleton, + Func? agentSessionStoreFactory = null, + FoundryResponsesServerOptions? options = null) + { + _ = Throw.IfNull(services); + RegisterAgentFactory(services, agentName, agentFactory, lifetime, agentSessionStoreFactory); + return services.AddFoundryResponses(options); + } + + /// + /// Registers an agent factory, and optionally a session store factory, under + /// and as the defaults, before the shared Responses registration runs. + /// + /// + /// The default registrations forward to the keyed ones, so within one response run a scoped agent or + /// store is created once whether the request named the agent or not. Existing registrations win, the + /// same as for AddFoundryResponses(agent). + /// + internal static void RegisterAgentFactory( + IServiceCollection services, + string agentName, + Func agentFactory, + ServiceLifetime lifetime, + Func? agentSessionStoreFactory) + { + _ = Throw.IfNullOrWhitespace(agentName); + _ = Throw.IfNull(agentFactory); + if (lifetime is not (ServiceLifetime.Singleton or ServiceLifetime.Scoped or ServiceLifetime.Transient)) + { + Throw.ArgumentOutOfRangeException(nameof(lifetime), lifetime, "Unsupported service lifetime."); + } + + (bool agentKeyedAdded, bool agentDefaultAdded) = AddKeyedWithDefaultAlias( + services, agentName, agentFactory, lifetime, "agent factory"); + if (agentKeyedAdded) + { + services.AddSingleton(new FoundryAgentFactoryRegistration(agentName, isDefault: agentDefaultAdded)); + } + + if (agentSessionStoreFactory is not null) + { + _ = AddKeyedWithDefaultAlias(services, agentName, agentSessionStoreFactory, lifetime, "agent session store factory"); + } + else + { + AgentSessionStore defaultStore = CreateDefaultAgentSessionStore(); + services.TryAddKeyedSingleton(agentName, defaultStore); + services.TryAddSingleton(defaultStore); + } + } + + /// + /// Registers under and a default registration + /// that forwards to it, each only when no registration of that kind exists yet. + /// + /// Whether the keyed and the default registrations were added. + private static (bool KeyedAdded, bool DefaultAdded) AddKeyedWithDefaultAlias( + IServiceCollection services, + string name, + Func factory, + ServiceLifetime lifetime, + string factoryDescription) + where TService : class + { + bool keyedAdded = !services.Any(descriptor => + descriptor.ServiceType == typeof(TService) + && descriptor.IsKeyedService + && Equals(descriptor.ServiceKey, name)); + bool defaultAdded = !services.Any(descriptor => + descriptor.ServiceType == typeof(TService) + && !descriptor.IsKeyedService); + + if (keyedAdded) + { + services.Add(new ServiceDescriptor( + typeof(TService), + name, + (serviceProvider, _) => factory(serviceProvider) + ?? throw new InvalidOperationException($"The {factoryDescription} registered for '{name}' returned null."), + lifetime)); + } + + if (defaultAdded) + { + services.Add(new ServiceDescriptor( + typeof(TService), + serviceProvider => serviceProvider.GetRequiredKeyedService(name), + lifetime)); + } + + return (keyedAdded, defaultAdded); + } + /// /// Applies the caller's and registers the readiness checks /// that report a misconfigured agent: one having its own service store the responses it produces, diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryAgentLifetimeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryAgentLifetimeTests.cs new file mode 100644 index 00000000000..acb3c99e481 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryAgentLifetimeTests.cs @@ -0,0 +1,633 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.AgentServer.Responses; +using Azure.AI.AgentServer.Responses.Models; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using MeaiTextContent = Microsoft.Extensions.AI.TextContent; + +namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests; + +/// +/// Covers how the response handler resolves agents and session stores per response run, and the +/// AddFoundryResponses overloads that register an agent factory with a DI lifetime. +/// +public sealed class FoundryAgentLifetimeTests +{ + private const string AgentName = "lifetime-agent"; + + [Fact] + public async Task CreateAsync_ScopedKeyedAgent_CreatesOnePerResponseAndDisposesItAsync() + { + // Arrange + var log = new AgentLifetimeLog(); + var services = new ServiceCollection(); + services.AddSingleton(new TestAgentSessionStore()); + services.AddKeyedScoped(AgentName, (_, _) => new TrackingAgent(log)); + AgentFrameworkResponseHandler handler = CreateHandler(services); + + // Act + await DrainAsync(handler, CreateRequest(AgentName), CreateContext()); + await DrainAsync(handler, CreateRequest(AgentName), CreateContext()); + + // Assert + Assert.Equal(2, log.Created.Count); + Assert.NotSame(log.Created.First(), log.Created.Last()); + Assert.All(log.Created, agent => Assert.True(agent.IsDisposed)); + Assert.All(log.Created, agent => Assert.False(agent.WasDisposedDuringRun)); + } + + [Fact] + public async Task CreateAsync_TransientKeyedAgent_IsDisposedWhenTheResponseEndsAsync() + { + // Arrange + var log = new AgentLifetimeLog(); + var services = new ServiceCollection(); + services.AddSingleton(new TestAgentSessionStore()); + services.AddKeyedTransient(AgentName, (_, _) => new TrackingAgent(log)); + AgentFrameworkResponseHandler handler = CreateHandler(services); + + // Act + await DrainAsync(handler, CreateRequest(AgentName), CreateContext()); + + // Assert: the handler only needs the keyed agent, and the root container does not keep it. + Assert.NotEmpty(log.Created); + Assert.All(log.Created, agent => Assert.True(agent.IsDisposed)); + } + + [Fact] + public async Task CreateAsync_SingletonKeyedAgent_IsReusedAndNotDisposedAsync() + { + // Arrange + var log = new AgentLifetimeLog(); + var agent = new TrackingAgent(log); + var services = new ServiceCollection(); + services.AddSingleton(new TestAgentSessionStore()); + services.AddKeyedSingleton(AgentName, agent); + AgentFrameworkResponseHandler handler = CreateHandler(services); + + // Act + await DrainAsync(handler, CreateRequest(AgentName), CreateContext()); + await DrainAsync(handler, CreateRequest(AgentName), CreateContext()); + + // Assert + Assert.Same(agent, Assert.Single(log.Created)); + Assert.Equal(2, agent.RunCount); + Assert.False(agent.IsDisposed); + } + + [Fact] + public async Task CreateAsync_ScopedAgent_StaysAliveUntilTheEventStreamEndsAsync() + { + // Arrange: the agent waits mid-run, as a long background run would. + var log = new AgentLifetimeLog(); + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var services = new ServiceCollection(); + services.AddSingleton(new TestAgentSessionStore()); + services.AddKeyedScoped(AgentName, (_, _) => new TrackingAgent(log, gate)); + AgentFrameworkResponseHandler handler = CreateHandler(services); + + // Act + Task run = Task.Run(() => DrainAsync(handler, CreateRequest(AgentName), CreateContext())); + TrackingAgent agent = await log.RunStarted.Task.WaitAsync(TimeSpan.FromSeconds(10)); + bool disposedWhileRunning = agent.IsDisposed; + gate.SetResult(); + await run.WaitAsync(TimeSpan.FromSeconds(10)); + + // Assert + Assert.False(disposedWhileRunning); + Assert.False(agent.WasDisposedDuringRun); + Assert.True(agent.IsDisposed); + } + + [Fact] + public async Task CreateAsync_EnumerationStoppedEarly_DisposesScopedAgentAsync() + { + // Arrange: a client disconnect or cancellation stops reading the stream early. + var log = new AgentLifetimeLog(); + var services = new ServiceCollection(); + services.AddSingleton(new TestAgentSessionStore()); + services.AddKeyedScoped(AgentName, (_, _) => new TrackingAgent(log)); + AgentFrameworkResponseHandler handler = CreateHandler(services); + IAsyncEnumerator enumerator = handler + .CreateAsync(CreateRequest(AgentName), CreateContext(), CancellationToken.None) + .GetAsyncEnumerator(); + + // Act + Assert.True(await enumerator.MoveNextAsync()); + TrackingAgent agent = Assert.Single(log.Created); + bool disposedBeforeStop = agent.IsDisposed; + await enumerator.DisposeAsync(); + + // Assert + Assert.False(disposedBeforeStop); + Assert.True(agent.IsDisposed); + } + + [Fact] + public async Task CreateAsync_ScopedKeyedSessionStore_IsCreatedPerResponseAndDisposedAsync() + { + // Arrange + var log = new AgentLifetimeLog(); + var stores = new ConcurrentQueue(); + var services = new ServiceCollection(); + services.AddKeyedSingleton(AgentName, new TrackingAgent(log)); + services.AddKeyedScoped(AgentName, (_, _) => + { + var store = new RecordingSessionStore(); + stores.Enqueue(store); + return store; + }); + AgentFrameworkResponseHandler handler = CreateHandler(services); + + // Act + await DrainAsync(handler, CreateRequest(AgentName), CreateContext()); + await DrainAsync(handler, CreateRequest(AgentName), CreateContext()); + + // Assert + Assert.Equal(2, stores.Count); + Assert.All(stores, store => Assert.True(store.IsDisposed)); + Assert.All(stores, store => Assert.NotEmpty(store.Identities)); + } + + [Theory] + [InlineData(ServiceLifetime.Singleton)] + [InlineData(ServiceLifetime.Scoped)] + [InlineData(ServiceLifetime.Transient)] + public async Task AddFoundryResponses_Factory_UsesTheInstanceOverloadStorageIdentityAsync(ServiceLifetime lifetime) + { + // Arrange: the instance overload stores sessions under the agent name. + var instanceStore = new RecordingSessionStore(); + var instanceServices = new ServiceCollection(); + instanceServices.AddLogging(); + instanceServices.AddFoundryResponses(new TrackingAgent(new AgentLifetimeLog()), instanceStore); + AgentFrameworkResponseHandler instanceHandler = ResolveHandler(instanceServices); + await DrainAsync(instanceHandler, CreateRequest(AgentName), CreateContext()); + string instanceIdentity = Assert.Single(instanceStore.Identities.Distinct()); + + var factoryStore = new RecordingSessionStore(); + var factoryServices = new ServiceCollection(); + factoryServices.AddLogging(); + factoryServices.AddFoundryResponses( + AgentName, + _ => new TrackingAgent(new AgentLifetimeLog()), + lifetime, + _ => factoryStore); + AgentFrameworkResponseHandler factoryHandler = ResolveHandler(factoryServices); + + // Act: one request names the agent and one does not. + await DrainAsync(factoryHandler, CreateRequest(AgentName), CreateContext()); + await DrainAsync(factoryHandler, CreateRequest(agentName: null), CreateContext()); + + // Assert: every lifetime and both request shapes read and write the same sessions. + Assert.Equal($"name:{AgentName}", instanceIdentity); + Assert.All(factoryStore.Identities, identity => Assert.Equal(instanceIdentity, identity)); + } + + [Theory] + [InlineData(ServiceLifetime.Scoped)] + [InlineData(ServiceLifetime.Transient)] + public async Task AddFoundryResponses_Factory_CreatesOneAgentPerResponseAsync(ServiceLifetime lifetime) + { + // Arrange + var log = new AgentLifetimeLog(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddFoundryResponses(AgentName, _ => new TrackingAgent(log), lifetime, _ => new TestAgentSessionStore()); + AgentFrameworkResponseHandler handler = ResolveHandler(services); + + // Act + await DrainAsync(handler, CreateRequest(AgentName), CreateContext()); + await DrainAsync(handler, CreateRequest(agentName: null), CreateContext()); + + // Assert: the named and the default registration share one agent within a response run. + Assert.Equal(2, log.Created.Count); + Assert.All(log.Created, agent => Assert.Equal(1, agent.RunCount)); + Assert.All(log.Created, agent => Assert.True(agent.IsDisposed)); + } + + [Fact] + public async Task AddFoundryResponses_SingletonFactory_CreatesOneAgentForTheProcessAsync() + { + // Arrange + var log = new AgentLifetimeLog(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddFoundryResponses(AgentName, _ => new TrackingAgent(log), ServiceLifetime.Singleton, _ => new TestAgentSessionStore()); + AgentFrameworkResponseHandler handler = ResolveHandler(services); + + // Act + await DrainAsync(handler, CreateRequest(AgentName), CreateContext()); + await DrainAsync(handler, CreateRequest(agentName: null), CreateContext()); + + // Assert + TrackingAgent agent = Assert.Single(log.Created); + Assert.Equal(2, agent.RunCount); + Assert.False(agent.IsDisposed); + } + + [Theory] + [InlineData(ServiceLifetime.Scoped)] + [InlineData(ServiceLifetime.Transient)] + public async Task AddFoundryResponses_StoreFactory_CreatesOneStorePerResponseWithTheAgentLifetimeAsync(ServiceLifetime lifetime) + { + // Arrange + var stores = new ConcurrentQueue(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddFoundryResponses( + AgentName, + _ => new TrackingAgent(new AgentLifetimeLog()), + lifetime, + _ => + { + var store = new RecordingSessionStore(); + stores.Enqueue(store); + return store; + }); + AgentFrameworkResponseHandler handler = ResolveHandler(services); + + // Act + await DrainAsync(handler, CreateRequest(AgentName), CreateContext()); + await DrainAsync(handler, CreateRequest(agentName: null), CreateContext()); + + // Assert: each run uses one store, which is disposed when the run ends. + Assert.Equal(2, stores.Count); + Assert.All(stores, store => Assert.True(store.IsDisposed)); + Assert.All(stores, store => Assert.NotEmpty(store.Identities)); + Assert.All(stores.SelectMany(store => store.Identities), identity => Assert.Equal($"name:{AgentName}", identity)); + } + + [Fact] + public async Task AddFoundryResponses_SingletonStoreFactory_CreatesOneStoreForTheProcessAsync() + { + // Arrange + var stores = new ConcurrentQueue(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddFoundryResponses( + AgentName, + _ => new TrackingAgent(new AgentLifetimeLog()), + ServiceLifetime.Singleton, + _ => + { + var store = new RecordingSessionStore(); + stores.Enqueue(store); + return store; + }); + AgentFrameworkResponseHandler handler = ResolveHandler(services); + + // Act + await DrainAsync(handler, CreateRequest(AgentName), CreateContext()); + await DrainAsync(handler, CreateRequest(agentName: null), CreateContext()); + + // Assert + RecordingSessionStore store = Assert.Single(stores); + Assert.False(store.IsDisposed); + } + + [Fact] + public async Task AddFoundryResponses_StoreFactoryReturnsNull_ThrowsInvalidOperationExceptionAsync() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + services.AddFoundryResponses(AgentName, _ => new TrackingAgent(new AgentLifetimeLog()), ServiceLifetime.Scoped, _ => null!); + AgentFrameworkResponseHandler handler = ResolveHandler(services); + + // Act & Assert + InvalidOperationException exception = await Assert.ThrowsAsync( + () => DrainAsync(handler, CreateRequest(AgentName), CreateContext())); + Assert.Contains("session store factory", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task AddFoundryResponses_FactoryReturnsNull_ThrowsInvalidOperationExceptionAsync() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + services.AddFoundryResponses(AgentName, _ => null!, ServiceLifetime.Scoped, _ => new TestAgentSessionStore()); + AgentFrameworkResponseHandler handler = ResolveHandler(services); + + // Act & Assert + InvalidOperationException exception = await Assert.ThrowsAsync( + () => DrainAsync(handler, CreateRequest(AgentName), CreateContext())); + Assert.Contains(AgentName, exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void AddFoundryResponses_Factory_InvalidArguments_Throw() + { + // Arrange + var services = new ServiceCollection(); + + // Act & Assert + Assert.Throws(() => services.AddFoundryResponses(" ", _ => new TrackingAgent(new AgentLifetimeLog()))); + Assert.Throws(() => services.AddFoundryResponses(AgentName, null!)); + Assert.Throws(() => services.AddFoundryResponses( + AgentName, + _ => new TrackingAgent(new AgentLifetimeLog()), + (ServiceLifetime)42)); + } + + [Fact] + public void AddFoundryResponses_Factory_RegistersKeyedAndDefaultAgentWithTheRequestedLifetime() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddFoundryResponses( + AgentName, + _ => new TrackingAgent(new AgentLifetimeLog()), + ServiceLifetime.Scoped, + _ => new TestAgentSessionStore()); + + // Assert + ServiceDescriptor keyed = Assert.Single(services, d => d.ServiceType == typeof(AIAgent) && d.IsKeyedService); + ServiceDescriptor defaultAgent = Assert.Single(services, d => d.ServiceType == typeof(AIAgent) && !d.IsKeyedService); + Assert.Equal(AgentName, keyed.ServiceKey); + Assert.Equal(ServiceLifetime.Scoped, keyed.Lifetime); + Assert.Equal(ServiceLifetime.Scoped, defaultAgent.Lifetime); + ServiceDescriptor keyedStore = Assert.Single(services, d => d.ServiceType == typeof(AgentSessionStore) && d.IsKeyedService); + ServiceDescriptor defaultStore = Assert.Single(services, d => d.ServiceType == typeof(AgentSessionStore) && !d.IsKeyedService); + Assert.Equal(AgentName, keyedStore.ServiceKey); + Assert.Equal(ServiceLifetime.Scoped, keyedStore.Lifetime); + Assert.Equal(ServiceLifetime.Scoped, defaultStore.Lifetime); + FoundryAgentFactoryRegistration marker = Assert.Single( + services.Where(d => d.ServiceType == typeof(FoundryAgentFactoryRegistration)) + .Select(d => Assert.IsType(d.ImplementationInstance))); + Assert.Equal(AgentName, marker.AgentName); + Assert.True(marker.IsDefault); + } + + [Fact] + public void AddFoundryResponses_FactoryWithoutStoreFactory_RegistersTheDefaultStoreAsSingleton() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddFoundryResponses(AgentName, _ => new TrackingAgent(new AgentLifetimeLog()), ServiceLifetime.Scoped); + + // Assert: without a store factory, the lifetime applies to the agent only. + ServiceDescriptor keyedStore = Assert.Single(services, d => d.ServiceType == typeof(AgentSessionStore) && d.IsKeyedService); + ServiceDescriptor defaultStore = Assert.Single(services, d => d.ServiceType == typeof(AgentSessionStore) && !d.IsKeyedService); + Assert.Equal(ServiceLifetime.Singleton, keyedStore.Lifetime); + Assert.Equal(ServiceLifetime.Singleton, defaultStore.Lifetime); + Assert.IsType(keyedStore.KeyedImplementationInstance); + Assert.Same(keyedStore.KeyedImplementationInstance, defaultStore.ImplementationInstance); + } + + [Fact] + public void AddFoundryResponses_Factory_KeepsAnExistingKeyedRegistration() + { + // Arrange + var existing = new TrackingAgent(new AgentLifetimeLog()); + var services = new ServiceCollection(); + services.AddKeyedSingleton(AgentName, existing); + + // Act + services.AddFoundryResponses(AgentName, _ => new TrackingAgent(new AgentLifetimeLog()), ServiceLifetime.Scoped); + + // Assert: the existing registration wins, like the instance overload's TryAdd behavior. + ServiceDescriptor keyed = Assert.Single(services, d => d.ServiceType == typeof(AIAgent) && d.IsKeyedService); + Assert.Same(existing, keyed.KeyedImplementationInstance); + Assert.DoesNotContain(services, d => d.ServiceType == typeof(FoundryAgentFactoryRegistration)); + } + + [Fact] + public void AddFoundryResponses_HostBuilderFactory_RegistersAgentWithTheRequestedLifetime() + { + // Arrange + HostApplicationBuilder builder = Host.CreateApplicationBuilder(); + + // Act + builder.AddFoundryResponses(AgentName, _ => new TrackingAgent(new AgentLifetimeLog()), ServiceLifetime.Transient); + using ServiceProvider provider = builder.Services.BuildServiceProvider(); + + // Assert + ServiceDescriptor keyed = Assert.Single(builder.Services, d => d.ServiceType == typeof(AIAgent) && d.IsKeyedService); + Assert.Equal(ServiceLifetime.Transient, keyed.Lifetime); + Assert.IsType(provider.GetRequiredService()); + } + + [Fact] + public async Task BackgroundResponse_ScopedAgent_OutlivesTheHttpRequestAsync() + { + // Arrange + var log = new AgentLifetimeLog(); + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Services.AddFoundryResponses( + AgentName, + _ => new TrackingAgent(log, gate), + ServiceLifetime.Scoped, + _ => new TestAgentSessionStore()); + builder.Services.AddLogging(); + await using WebApplication app = builder.Build(); + app.MapFoundryResponses(); + await app.StartAsync(); + using HttpClient client = ((TestServer)app.Services.GetRequiredService()).CreateClient(); + + // Act: the POST returns while the agent is still running in the background. + string body = JsonSerializer.Serialize(new { model = "test", input = "Hello", store = true, background = true }); + using HttpResponseMessage response = await client.PostAsync( + new Uri("/responses", UriKind.Relative), + new StringContent(body, Encoding.UTF8, "application/json")); + response.EnsureSuccessStatusCode(); + TrackingAgent agent = await log.RunStarted.Task.WaitAsync(TimeSpan.FromSeconds(10)); + bool disposedAfterHttpReturned = agent.IsDisposed; + gate.SetResult(); + await agent.Disposed.Task.WaitAsync(TimeSpan.FromSeconds(10)); + + // Assert + Assert.False(disposedAfterHttpReturned); + Assert.False(agent.WasDisposedDuringRun); + Assert.True(agent.IsDisposed); + } + + private static AgentFrameworkResponseHandler CreateHandler(IServiceCollection services) => + new( + services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }), + NullLogger.Instance); + + private static AgentFrameworkResponseHandler ResolveHandler(IServiceCollection services) => + Assert.IsType( + services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }) + .GetRequiredService()); + + private static CreateResponse CreateRequest(string? agentName) + { + var request = agentName is null + ? new CreateResponse { Model = "test" } + : new CreateResponse { Model = "test", AgentReference = new AgentReference(agentName) }; + request.Input = BinaryData.FromObjectAsJson(new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new[] { new { type = "input_text", text = "Hello" } }, + }, + }); + return request; + } + + private static ResponseContext CreateContext() + { + var context = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + context.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + context.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + return context.Object; + } + + private static async Task DrainAsync(AgentFrameworkResponseHandler handler, CreateResponse request, ResponseContext context) + { + await foreach (ResponseStreamEvent _ in handler.CreateAsync(request, context, CancellationToken.None)) + { + } + } + + private sealed class AgentLifetimeLog + { + public ConcurrentQueue Created { get; } = new(); + + public TaskCompletionSource RunStarted { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + } + + /// An agent that records when it is created, run, and disposed. + private sealed class TrackingAgent : AIAgent, IAsyncDisposable + { + private readonly AgentLifetimeLog _log; + private readonly TaskCompletionSource? _gate; + private int _runCount; + + public TrackingAgent(AgentLifetimeLog log, TaskCompletionSource? gate = null) + { + this._log = log; + this._gate = gate; + log.Created.Enqueue(this); + } + + public override string? Name => AgentName; + + public int RunCount => this._runCount; + + public bool IsDisposed { get; private set; } + + public bool WasDisposedDuringRun { get; private set; } + + public TaskCompletionSource Disposed { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public ValueTask DisposeAsync() + { + this.IsDisposed = true; + this.Disposed.TrySetResult(); + return default; + } + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref this._runCount); + this._log.RunStarted.TrySetResult(this); + if (this._gate is not null) + { + await this._gate.Task.WaitAsync(cancellationToken); + } + + this.WasDisposedDuringRun = this.IsDisposed; + yield return new AgentResponseUpdate { MessageId = "resp_msg_1", Contents = [new MeaiTextContent("ok")] }; + } + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => + new(new TrackingSession()); + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + new(JsonDocument.Parse("{}").RootElement.Clone()); + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + new(new TrackingSession()); + + private sealed class TrackingSession : AgentSession; + } + + /// A session store that records the storage identity of every agent it serves. + private sealed class RecordingSessionStore : AgentSessionStore, IAsyncDisposable + { + public ConcurrentQueue Identities { get; } = new(); + + public bool IsDisposed { get; private set; } + + public ValueTask DisposeAsync() + { + this.IsDisposed = true; + return default; + } + + public override ValueTask GetSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) + { + this.Identities.Enqueue(FoundryHostingAgent.GetSessionStorageIdentity(agent, allowInstanceId: true)); + return new((AgentSession?)null); + } + + public override ValueTask SaveSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + AgentSession session, + CancellationToken cancellationToken = default) + { + this.Identities.Enqueue(FoundryHostingAgent.GetSessionStorageIdentity(agent, allowInstanceId: true)); + return default; + } + + public override ValueTask DeleteSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) => + default; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedStoredOutputHealthCheckTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedStoredOutputHealthCheckTests.cs index af2164c5f63..b948c524019 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedStoredOutputHealthCheckTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedStoredOutputHealthCheckTests.cs @@ -195,6 +195,33 @@ public async Task CheckHealthAsync_FoundryAgent_IsProbeableAsync() Assert.Contains("foundry-agent", (List)result.Data["storingAgents"]); } + [Fact] + public async Task CheckHealthAsync_ScopedAgentRegistration_IsResolvedFromAScopeAsync() + { + // Arrange: a scoped agent cannot be resolved from the root container when scopes are validated. + var services = new ServiceCollection(); + services.AddKeyedScoped("scoped-agent", (_, _) => new ChatClientAgent( + NewSilentChatClient(), + new ChatClientAgentOptions + { + Name = "scoped-agent", + ChatOptions = new ChatOptions + { + RawRepresentationFactory = _ => new CreateResponseOptions { StoredOutputEnabled = true }, + }, + })); + var check = new HostedStoredOutputHealthCheck( + services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }), + new FoundryResponsesServerOptions()); + + // Act + var result = await check.CheckHealthAsync(NewContext(), CancellationToken.None); + + // Assert: the agent was resolved and probed rather than failing the readiness probe. + Assert.Equal(HealthStatus.Unhealthy, result.Status); + Assert.Contains("scoped-agent", (List)result.Data["storingAgents"]); + } + private static HostedStoredOutputHealthCheck BuildCheckFor(AIAgent agent, FoundryResponsesServerOptions? hostingOptions = null) { var services = new ServiceCollection(); diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedWorkflowCheckpointingHealthCheckTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedWorkflowCheckpointingHealthCheckTests.cs index 30d928271d9..e1af9d66e8e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedWorkflowCheckpointingHealthCheckTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedWorkflowCheckpointingHealthCheckTests.cs @@ -120,6 +120,27 @@ public void ApplyWorkflowCheckpointing_NotInAFoundryContainer_UsesTheSdkLocalFal Assert.True(result.GetService()?.UsesOwnCheckpointStorage); } + [Fact] + public async Task CheckHealthAsync_ScopedWorkflowAgent_IsResolvedFromAScopeAsync() + { + // Arrange: a scoped agent cannot be resolved from the root container when scopes are validated. + var environment = InProcessExecution.OffThread.WithCheckpointing(CheckpointManager.CreateInMemory()); + var services = new ServiceCollection(); + services.AddKeyedScoped("workflow", (_, _) => BuildWorkflowAgent(environment)); + var check = new HostedWorkflowCheckpointingHealthCheck( + services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true })) + { + IsHosted = true, + }; + + // Act + var result = await check.CheckHealthAsync(NewContext(), CancellationToken.None); + + // Assert: the agent was resolved and inspected rather than failing the readiness probe. + Assert.Equal(HealthStatus.Unhealthy, result.Status); + Assert.Equal(["WorkflowAgent"], Assert.IsType>(result.Data["incompatibleAgents"])); + } + private static HostedWorkflowCheckpointingHealthCheck BuildCheckFor(AIAgent agent) { var services = new ServiceCollection(); diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ResilientTwoLifetimeIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ResilientTwoLifetimeIntegrationTests.cs index 1027e24dd79..8f7a46e46d3 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ResilientTwoLifetimeIntegrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ResilientTwoLifetimeIntegrationTests.cs @@ -125,8 +125,11 @@ await coordinator.PhasePersisted.Task.WaitAsync( } } - [Fact] - public async Task StoppedHost_RecoversWorkflowWithCompleteOrderedOutputAsync() + [Theory] + [InlineData(null)] + [InlineData(ServiceLifetime.Scoped)] + [InlineData(ServiceLifetime.Transient)] + public async Task StoppedHost_RecoversWorkflowWithCompleteOrderedOutputAsync(ServiceLifetime? factoryLifetime) { // Arrange string stateRoot = Path.Combine( @@ -152,7 +155,9 @@ public async Task StoppedHost_RecoversWorkflowWithCompleteOrderedOutputAsync() Directory.CreateDirectory(checkpointRoot))) { WebApplication firstHost = await StartServerAsync( - BuildCountdownWorkflowAgent(coordinator, checkpointStore), + "countdown-workflow", + () => BuildCountdownWorkflowAgent(coordinator, checkpointStore), + factoryLifetime, new FoundryAgentSessionStore(storeName: sessionStoreName), coordinator); try @@ -193,7 +198,9 @@ await WaitForPersistedOutputCountAsync( using var recoveryCheckpointStore = new FileSystemJsonCheckpointStore( Directory.CreateDirectory(checkpointRoot)); await using WebApplication secondHost = await StartServerAsync( - BuildCountdownWorkflowAgent(coordinator, recoveryCheckpointStore), + "countdown-workflow", + () => BuildCountdownWorkflowAgent(coordinator, recoveryCheckpointStore), + factoryLifetime, new FoundryAgentSessionStore(storeName: sessionStoreName)); using HttpClient secondClient = GetClient(secondHost); JsonElement completed = await WaitForTerminalAsync( @@ -229,17 +236,36 @@ await WaitForPersistedOutputCountAsync( } } - private static async Task StartServerAsync( + private static Task StartServerAsync( AIAgent agent, AgentSessionStore sessionStore, + CountdownRecoveryCoordinator? recoveryCoordinator = null) => + StartServerAsync(agent.Name!, () => agent, factoryLifetime: null, sessionStore, recoveryCoordinator); + + /// + /// Starts a host that registers the agent instance with AddFoundryResponses(agent) when + /// is , or with the factory overload and + /// that lifetime otherwise. + /// + private static async Task StartServerAsync( + string agentName, + Func createAgent, + ServiceLifetime? factoryLifetime, + AgentSessionStore sessionStore, CountdownRecoveryCoordinator? recoveryCoordinator = null) { WebApplicationBuilder builder = WebApplication.CreateBuilder(); builder.WebHost.UseTestServer(); - builder.Services.AddFoundryResponses( - agent, - sessionStore, - new FoundryResponsesServerOptions { ResilientBackground = true }); + var options = new FoundryResponsesServerOptions { ResilientBackground = true }; + if (factoryLifetime is { } lifetime) + { + builder.Services.AddFoundryResponses(agentName, _ => createAgent(), lifetime, _ => sessionStore, options); + } + else + { + builder.Services.AddFoundryResponses(createAgent(), sessionStore, options); + } + builder.Services.AddLogging(); if (recoveryCoordinator is not null) { From 2587519e3f2c20934b47757b9120350e63ca070c Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 10 Nov 2026 08:14:03 -0800 Subject: [PATCH 2/4] Address review feedback on Foundry agent lifetimes Register factory agents and stores only under their name so DI disposes each object once and no forwarding registration can mismatch lifetimes. Route requests without an agent name to the factory default in the response handler. Use the instance overload storage identities: name based for the effective default, key based otherwise. Decide the effective default from the built container so a non keyed agent registered later never shares sessions with a factory agent. Validate the hosted user identity before creating any agent or store, and document that the response scope is not the HTTP request scope. --- .../AgentFrameworkResponseHandler.cs | 71 +++--- .../FoundryAgentFactoryRegistration.cs | 62 +++-- .../HostApplicationBuilderExtensions.cs | 20 +- .../ServiceCollectionExtensions.cs | 109 +++++---- .../FoundryAgentLifetimeTests.cs | 216 ++++++++++++++++-- 5 files changed, 366 insertions(+), 112 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs index 4dde479e0f5..06ef70e3502 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs @@ -83,8 +83,14 @@ public AgentFrameworkResponseHandler( this._logger = logger; this._options = foundryResponsesOptions; this._toolboxService = toolboxService; + // A non-keyed AIAgent registered after the factory overload takes requests without an agent + // name. The factory agent is then not the default, so it must not use the default's + // name-based storage location, which the non-keyed agent uses. IServiceProviderIsService + // reports the registration without creating the agent. + bool? nonKeyedAgentRegistered = serviceProvider.GetService()?.IsService(typeof(AIAgent)); this._factoryRegistrations = new FoundryAgentFactoryRegistrations( - serviceProvider.GetServices()); + serviceProvider.GetServices(), + nonKeyedAgentRegistered); } /// @@ -93,19 +99,11 @@ public override async IAsyncEnumerable CreateAsync( ResponseContext context, [EnumeratorCancellation] CancellationToken cancellationToken) { - // 1. Resolve agent and session store from a DI scope owned by this response run. The scope - // covers the whole enumeration, not the HTTP request: background runs continue after the - // HTTP response is sent, and recovery runs have no HTTP request at all. Scoped and transient - // registrations are therefore created per run and disposed when the event stream ends. - AsyncServiceScope serviceScope = this._serviceProvider.CreateAsyncScope(); - await using var configuredServiceScope = serviceScope.ConfigureAwait(false); - IServiceProvider services = serviceScope.ServiceProvider; - var agent = this.ResolveAgent(request, services); - var sessionStore = this.ResolveSessionStore(request, services); - - // 2. Resolve the per-request hosted user identity, so the session can be + // 1. Resolve the per-request hosted user identity, so the session can be // loaded from a per-user partition. Fresh sessions are tagged once; resumed sessions are // validated against the live request to detect cross-user session leaks and in-process tampering. + // This runs before any agent or store is created, so a rejected request never invokes a + // caller-supplied agent or session store factory. string? resolvedUserId = context.PlatformContext?.UserIdKey; if (string.IsNullOrWhiteSpace(resolvedUserId)) { @@ -123,6 +121,16 @@ public override async IAsyncEnumerable CreateAsync( "Ensure the x-agent-user-id header is present."); } + // 2. Resolve agent and session store from a DI scope owned by this response run. The scope + // covers the whole enumeration, not the HTTP request: background runs continue after the + // HTTP response is sent, and recovery runs have no HTTP request at all. Scoped and transient + // registrations are therefore created per run and disposed when the event stream ends. + AsyncServiceScope serviceScope = this._serviceProvider.CreateAsyncScope(); + await using var configuredServiceScope = serviceScope.ConfigureAwait(false); + IServiceProvider services = serviceScope.ServiceProvider; + var (agent, sessionStoreKey) = this.ResolveAgent(request, services); + var sessionStore = this.ResolveSessionStore(sessionStoreKey, services); + // When resolvedUserId is null here the container is NOT hosted by Foundry (local // development: docker run / dotnet run outside the platform, so no x-agent-user-id header). // Per-user isolation simply does not apply in that case: no user partition is added to the @@ -887,11 +895,13 @@ 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, attempts to resolve a default (non-keyed) , and then the + /// agent registered through the factory overload of AddFoundryResponses as the default. /// /// The incoming request. /// The service provider of the DI scope owned by the current response run. - private AIAgent ResolveAgent(CreateResponse request, IServiceProvider services) + /// The prepared agent and the key to look its session store up with. + private (AIAgent Agent, string? SessionStoreKey) ResolveAgent(CreateResponse request, IServiceProvider services) { var agentName = GetAgentName(request); @@ -906,7 +916,7 @@ private AIAgent ResolveAgent(CreateResponse request, IServiceProvider services) agent, agentName, services.GetService()); - return this.PrepareResolvedAgent(agent, storageIdentity); + return (this.PrepareResolvedAgent(agent, storageIdentity), agentName); } LogKeyedAgentNotFound(this._logger, agentName); @@ -916,12 +926,20 @@ private AIAgent ResolveAgent(CreateResponse request, IServiceProvider services) var defaultAgent = services.GetService(); if (defaultAgent is not null) { - string storageIdentity = this._factoryRegistrations.DefaultStorageIdentity - ?? FoundryHostingAgent.ResolveSessionStorageIdentity( - defaultAgent, - registrationKey: null, - defaultAgent); - return this.PrepareResolvedAgent(defaultAgent, storageIdentity); + string storageIdentity = FoundryHostingAgent.ResolveSessionStorageIdentity( + defaultAgent, + registrationKey: null, + defaultAgent); + return (this.PrepareResolvedAgent(defaultAgent, storageIdentity), agentName); + } + + // The factory overload registers its agent only under its name, so that DI owns each created + // object once. Requests without a usable agent name reach it through the recorded default. + if (this._factoryRegistrations.DefaultAgentName is { } factoryDefaultName + && this._factoryRegistrations.DefaultStorageIdentity is { } factoryDefaultIdentity + && services.GetKeyedService(factoryDefaultName) is { } factoryDefaultAgent) + { + return (this.PrepareResolvedAgent(factoryDefaultAgent, factoryDefaultIdentity), factoryDefaultName); } var errorMessage = string.IsNullOrEmpty(agentName) @@ -944,16 +962,13 @@ 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 registered under , or the + /// default (non-keyed) store when there is none. /// - /// The incoming request. + /// The key returned by . /// The service provider of the DI scope owned by the current response run. - private AgentSessionStore ResolveSessionStore(CreateResponse request, IServiceProvider services) + private AgentSessionStore ResolveSessionStore(string? agentName, IServiceProvider services) { - var agentName = GetAgentName(request); - if (!string.IsNullOrEmpty(agentName)) { var sessionStore = services.GetKeyedService(agentName); diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentFactoryRegistration.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentFactoryRegistration.cs index 96110315bfa..49a4cbf00f2 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentFactoryRegistration.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentFactoryRegistration.cs @@ -11,11 +11,21 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; /// Records an agent registered through the factory overload of AddFoundryResponses. /// /// -/// The response handler normally decides where sessions are stored by checking whether the keyed agent -/// and the default agent are the same object. For a factory registration that check depends on the -/// lifetime: a transient registration creates a new object on every lookup. This marker lets the -/// handler use the registration name instead, so the storage location stays the same for every lifetime -/// and matches what AddFoundryResponses(agent) uses for an agent with the same name. +/// +/// The factory overload registers the agent and its session store only under the agent name. It does not +/// add a non-keyed registration that forwards to the keyed one, because Microsoft DI records the object a +/// factory returns for disposal once per registration: a forwarding registration would dispose the same +/// object twice, and its lifetime could disagree with the keyed registration it forwards to. Instead, when +/// no default agent existed at registration time, this marker records that requests without an agent name +/// are routed to this agent by the response handler. +/// +/// +/// The markers also fix where sessions are stored. The response handler normally decides that by checking +/// whether the keyed agent and the default agent are the same object, which depends on the lifetime for a +/// factory: a transient registration creates a new object on every lookup. The marker uses the location +/// AddFoundryResponses(agent) would use for an agent with the same name in the same position: +/// name:{agentName} for the default agent and key:{agentName} for any other agent. +/// /// internal sealed class FoundryAgentFactoryRegistration { @@ -28,11 +38,11 @@ public FoundryAgentFactoryRegistration(string agentName, bool isDefault) /// Gets the key the agent was registered under. public string AgentName { get; } - /// Gets a value indicating whether the registration also supplied the default (non-keyed) agent. + /// + /// Gets a value indicating whether no default agent existed when this agent was registered, so requests + /// without an agent name may be routed to it. + /// public bool IsDefault { get; } - - /// Gets the session storage identity used for this registration. - public string StorageIdentity => $"name:{this.AgentName}"; } /// @@ -42,22 +52,44 @@ internal sealed class FoundryAgentFactoryRegistrations { private readonly Dictionary _storageIdentities = new(StringComparer.Ordinal); - public FoundryAgentFactoryRegistrations(IEnumerable registrations) + /// + /// Initializes a new instance of the class. + /// + /// The markers registered by the factory overloads. + /// + /// Whether the built container has a non-keyed registration, or + /// when the container cannot report it. A non-keyed agent registered after a factory overload receives + /// requests without an agent name, so no factory registration is then treated as the default. When the + /// container cannot report it, the decision made at registration time is used. + /// + public FoundryAgentFactoryRegistrations( + IEnumerable registrations, + bool? nonKeyedAgentRegistered = null) { foreach (FoundryAgentFactoryRegistration registration in registrations) { - this._storageIdentities[registration.AgentName] = registration.StorageIdentity; - if (registration.IsDefault) + bool isEffectiveDefault = registration.IsDefault + && nonKeyedAgentRegistered is not true + && this.DefaultAgentName is null; + string storageIdentity = isEffectiveDefault + ? $"name:{registration.AgentName}" + : $"key:{registration.AgentName}"; + this._storageIdentities[registration.AgentName] = storageIdentity; + if (isEffectiveDefault) { - this.DefaultStorageIdentity = registration.StorageIdentity; + this.DefaultAgentName = registration.AgentName; + this.DefaultStorageIdentity = storageIdentity; } } } /// - /// Gets the storage identity of the factory registration that supplied the default agent, or - /// when the default agent was not registered through a factory overload. + /// Gets the name of the factory registration that receives requests without an agent name, or + /// when no factory registration was the default. /// + public string? DefaultAgentName { get; } + + /// Gets the storage identity of . public string? DefaultStorageIdentity { get; } /// Gets the storage identity for a keyed factory registration. diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostApplicationBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostApplicationBuilderExtensions.cs index 2bc00e4e1e5..f3bb98ffebb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostApplicationBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostApplicationBuilderExtensions.cs @@ -101,7 +101,7 @@ public static IHostApplicationBuilder AddFoundryResponses( host.Services.TryAddKeyedSingleton(agent.Name, agentSessionStore); } - host.Services.TryAddSingleton(agent); + ServiceCollectionExtensions.TryAddDefaultAgent(host.Services, agent); host.Services.TryAddSingleton(agentSessionStore); ServiceCollectionExtensions.RegisterResponseHandler(host.Services); @@ -117,9 +117,10 @@ public static IHostApplicationBuilder AddFoundryResponses( /// /// Use this overload when the agent or its session store depends on services that are not /// singletons, for example a database context, or when they should not live for the whole process. - /// The agent and its session store are registered under and as the - /// default and , so requests without an agent - /// name also reach them. + /// The agent and its session store are registered only under . When no + /// default agent is registered yet, requests without an agent name are also routed to this agent. + /// Existing registrations under are kept, the same as for + /// AddFoundryResponses(agent). /// /// /// Every response run (streaming, non-streaming, background, or a run resumed after a restart) gets @@ -131,10 +132,15 @@ public static IHostApplicationBuilder AddFoundryResponses( /// : one instance per response run, disposed when the run ends. /// : a new instance on every lookup, disposed when the run ends. /// + /// This scope is not the ASP.NET Core request scope, so scoped services that middleware fills in for an + /// HTTP request are new, empty instances here. Do not derive the caller's identity from such services in + /// the factories; the per-request user identity is supplied by the platform and applied by the response + /// handler when it loads and saves sessions. /// Conversation state is not kept on the agent or store object: each turn loads the saved session - /// from the store's backing storage. Sessions are stored under , the same - /// location AddFoundryResponses(agent) uses for an agent with that name, so switching between - /// the two overloads or between lifetimes keeps existing sessions readable. + /// from the store's backing storage. Sessions are stored in the same location + /// AddFoundryResponses(agent) uses for an agent with that name registered in the same order, + /// whatever the lifetime, so switching between the two overloads or between lifetimes keeps + /// existing sessions readable. /// /// /// builder.AddFoundryResponses( diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs index 73843baea86..aba461aace9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs @@ -164,7 +164,7 @@ public static IServiceCollection AddFoundryResponses( // Also register as the default (non-keyed) agent so requests // without an agent name can resolve it (e.g., local dev tooling). - services.TryAddSingleton(agent); + TryAddDefaultAgent(services, agent); services.TryAddSingleton(agentSessionStore); RegisterResponseHandler(services); @@ -180,9 +180,10 @@ public static IServiceCollection AddFoundryResponses( /// /// Use this overload when the agent or its session store depends on services that are not /// singletons, for example a database context, or when they should not live for the whole process. - /// The agent and its session store are registered under and as the - /// default and , so requests without an agent - /// name also reach them. + /// The agent and its session store are registered only under . When no + /// default agent is registered yet, requests without an agent name are also routed to this agent. + /// Existing registrations under are kept, the same as for + /// AddFoundryResponses(agent). /// /// /// Every response run (streaming, non-streaming, background, or a run resumed after a restart) gets @@ -194,10 +195,15 @@ public static IServiceCollection AddFoundryResponses( /// : one instance per response run, disposed when the run ends. /// : a new instance on every lookup, disposed when the run ends. /// + /// This scope is not the ASP.NET Core request scope, so scoped services that middleware fills in for an + /// HTTP request are new, empty instances here. Do not derive the caller's identity from such services in + /// the factories; the per-request user identity is supplied by the platform and applied by the response + /// handler when it loads and saves sessions. /// Conversation state is not kept on the agent or store object: each turn loads the saved session - /// from the store's backing storage. Sessions are stored under , the same - /// location AddFoundryResponses(agent) uses for an agent with that name, so switching between - /// the two overloads or between lifetimes keeps existing sessions readable. + /// from the store's backing storage. Sessions are stored in the same location + /// AddFoundryResponses(agent) uses for an agent with that name registered in the same order, + /// whatever the lifetime, so switching between the two overloads or between lifetimes keeps + /// existing sessions readable. /// /// /// This overload is for local or non-hosted scenarios. Foundry hosted applications must use @@ -248,13 +254,15 @@ public static IServiceCollection AddFoundryResponses( } /// - /// Registers an agent factory, and optionally a session store factory, under - /// and as the defaults, before the shared Responses registration runs. + /// Registers an agent factory, and optionally a session store factory, under + /// before the shared Responses registration runs. /// /// - /// The default registrations forward to the keyed ones, so within one response run a scoped agent or - /// store is created once whether the request named the agent or not. Existing registrations win, the - /// same as for AddFoundryResponses(agent). + /// Only keyed registrations are added, so each created object has exactly one registration that owns + /// its disposal. When no default agent exists yet, a marker + /// tells the response handler to route requests without an agent name to this agent. Existing keyed + /// registrations win, the same as for AddFoundryResponses(agent); when the agent registration + /// is skipped no marker is added, so the existing registration keeps its routing and storage location. /// internal static void RegisterAgentFactory( IServiceCollection services, @@ -270,31 +278,51 @@ internal static void RegisterAgentFactory( Throw.ArgumentOutOfRangeException(nameof(lifetime), lifetime, "Unsupported service lifetime."); } - (bool agentKeyedAdded, bool agentDefaultAdded) = AddKeyedWithDefaultAlias( - services, agentName, agentFactory, lifetime, "agent factory"); - if (agentKeyedAdded) + if (TryAddKeyedFactory(services, agentName, agentFactory, lifetime, "agent factory")) { - services.AddSingleton(new FoundryAgentFactoryRegistration(agentName, isDefault: agentDefaultAdded)); + services.AddSingleton(new FoundryAgentFactoryRegistration( + agentName, + isDefault: !HasDefaultAgentRegistration(services))); } if (agentSessionStoreFactory is not null) { - _ = AddKeyedWithDefaultAlias(services, agentName, agentSessionStoreFactory, lifetime, "agent session store factory"); + _ = TryAddKeyedFactory(services, agentName, agentSessionStoreFactory, lifetime, "agent session store factory"); } else { - AgentSessionStore defaultStore = CreateDefaultAgentSessionStore(); - services.TryAddKeyedSingleton(agentName, defaultStore); - services.TryAddSingleton(defaultStore); + services.TryAddKeyedSingleton(agentName, CreateDefaultAgentSessionStore()); } } /// - /// Registers under and a default registration - /// that forwards to it, each only when no registration of that kind exists yet. + /// Registers as the default (non-keyed) agent unless a default already exists, + /// including a factory registration that receives requests without an agent name. /// - /// Whether the keyed and the default registrations were added. - private static (bool KeyedAdded, bool DefaultAdded) AddKeyedWithDefaultAlias( + internal static void TryAddDefaultAgent(IServiceCollection services, AIAgent agent) + { + if (!HasDefaultAgentRegistration(services)) + { + services.AddSingleton(agent); + } + } + + /// + /// Whether a registration already receives requests without an agent name: a non-keyed + /// or a factory registration recorded as the default. + /// + private static bool HasDefaultAgentRegistration(IServiceCollection services) => + services.Any(descriptor => + (descriptor.ServiceType == typeof(AIAgent) && !descriptor.IsKeyedService) + || (descriptor.ServiceType == typeof(FoundryAgentFactoryRegistration) + && descriptor.ImplementationInstance is FoundryAgentFactoryRegistration { IsDefault: true })); + + /// + /// Registers under with + /// when no registration exists for that key. + /// + /// when the registration was added. + private static bool TryAddKeyedFactory( IServiceCollection services, string name, Func factory, @@ -302,35 +330,22 @@ private static (bool KeyedAdded, bool DefaultAdded) AddKeyedWithDefaultAlias + if (services.Any(descriptor => descriptor.ServiceType == typeof(TService) && descriptor.IsKeyedService - && Equals(descriptor.ServiceKey, name)); - bool defaultAdded = !services.Any(descriptor => - descriptor.ServiceType == typeof(TService) - && !descriptor.IsKeyedService); - - if (keyedAdded) + && Equals(descriptor.ServiceKey, name))) { - services.Add(new ServiceDescriptor( - typeof(TService), - name, - (serviceProvider, _) => factory(serviceProvider) - ?? throw new InvalidOperationException($"The {factoryDescription} registered for '{name}' returned null."), - lifetime)); - } - - if (defaultAdded) - { - services.Add(new ServiceDescriptor( - typeof(TService), - serviceProvider => serviceProvider.GetRequiredKeyedService(name), - lifetime)); + return false; } - return (keyedAdded, defaultAdded); + services.Add(new ServiceDescriptor( + typeof(TService), + name, + (serviceProvider, _) => factory(serviceProvider) + ?? throw new InvalidOperationException($"The {factoryDescription} registered for '{name}' returned null."), + lifetime)); + return true; } - /// /// Applies the caller's and registers the readiness checks /// that report a misconfigured agent: one having its own service store the responses it produces, diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryAgentLifetimeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryAgentLifetimeTests.cs index acb3c99e481..61cb7790446 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryAgentLifetimeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryAgentLifetimeTests.cs @@ -348,7 +348,7 @@ public void AddFoundryResponses_Factory_InvalidArguments_Throw() } [Fact] - public void AddFoundryResponses_Factory_RegistersKeyedAndDefaultAgentWithTheRequestedLifetime() + public void AddFoundryResponses_Factory_RegistersOnlyKeyedAgentAndStoreWithTheRequestedLifetime() { // Arrange var services = new ServiceCollection(); @@ -360,17 +360,17 @@ public void AddFoundryResponses_Factory_RegistersKeyedAndDefaultAgentWithTheRequ ServiceLifetime.Scoped, _ => new TestAgentSessionStore()); - // Assert - ServiceDescriptor keyed = Assert.Single(services, d => d.ServiceType == typeof(AIAgent) && d.IsKeyedService); - ServiceDescriptor defaultAgent = Assert.Single(services, d => d.ServiceType == typeof(AIAgent) && !d.IsKeyedService); + // Assert: one registration per created object, so DI owns its disposal once. + ServiceDescriptor keyed = Assert.Single(services, d => d.ServiceType == typeof(AIAgent)); + Assert.True(keyed.IsKeyedService); Assert.Equal(AgentName, keyed.ServiceKey); Assert.Equal(ServiceLifetime.Scoped, keyed.Lifetime); - Assert.Equal(ServiceLifetime.Scoped, defaultAgent.Lifetime); ServiceDescriptor keyedStore = Assert.Single(services, d => d.ServiceType == typeof(AgentSessionStore) && d.IsKeyedService); - ServiceDescriptor defaultStore = Assert.Single(services, d => d.ServiceType == typeof(AgentSessionStore) && !d.IsKeyedService); Assert.Equal(AgentName, keyedStore.ServiceKey); Assert.Equal(ServiceLifetime.Scoped, keyedStore.Lifetime); - Assert.Equal(ServiceLifetime.Scoped, defaultStore.Lifetime); + Assert.All( + services.Where(d => d.ServiceType == typeof(AgentSessionStore) && !d.IsKeyedService), + d => Assert.Equal(ServiceLifetime.Singleton, d.Lifetime)); FoundryAgentFactoryRegistration marker = Assert.Single( services.Where(d => d.ServiceType == typeof(FoundryAgentFactoryRegistration)) .Select(d => Assert.IsType(d.ImplementationInstance))); @@ -379,7 +379,7 @@ public void AddFoundryResponses_Factory_RegistersKeyedAndDefaultAgentWithTheRequ } [Fact] - public void AddFoundryResponses_FactoryWithoutStoreFactory_RegistersTheDefaultStoreAsSingleton() + public void AddFoundryResponses_FactoryWithoutStoreFactory_RegistersTheKeyedStoreAsSingleton() { // Arrange var services = new ServiceCollection(); @@ -389,11 +389,178 @@ public void AddFoundryResponses_FactoryWithoutStoreFactory_RegistersTheDefaultSt // Assert: without a store factory, the lifetime applies to the agent only. ServiceDescriptor keyedStore = Assert.Single(services, d => d.ServiceType == typeof(AgentSessionStore) && d.IsKeyedService); - ServiceDescriptor defaultStore = Assert.Single(services, d => d.ServiceType == typeof(AgentSessionStore) && !d.IsKeyedService); Assert.Equal(ServiceLifetime.Singleton, keyedStore.Lifetime); - Assert.Equal(ServiceLifetime.Singleton, defaultStore.Lifetime); Assert.IsType(keyedStore.KeyedImplementationInstance); - Assert.Same(keyedStore.KeyedImplementationInstance, defaultStore.ImplementationInstance); + } + + [Theory] + [InlineData(ServiceLifetime.Singleton)] + [InlineData(ServiceLifetime.Scoped)] + [InlineData(ServiceLifetime.Transient)] + public async Task AddFoundryResponses_TwoFactoryAgents_UseTheInstanceOverloadStorageIdentitiesAsync(ServiceLifetime lifetime) + { + // Arrange: the instance overload makes the first agent the default and keys the second by name. + var instanceStore = new RecordingSessionStore(); + var instanceServices = new ServiceCollection(); + instanceServices.AddLogging(); + instanceServices.AddFoundryResponses(new TrackingAgent(new AgentLifetimeLog(), name: "first"), instanceStore); + instanceServices.AddFoundryResponses(new TrackingAgent(new AgentLifetimeLog(), name: "second"), instanceStore); + AgentFrameworkResponseHandler instanceHandler = ResolveHandler(instanceServices); + + var factoryStore = new RecordingSessionStore(); + var factoryServices = new ServiceCollection(); + factoryServices.AddLogging(); + factoryServices.AddFoundryResponses("first", _ => new TrackingAgent(new AgentLifetimeLog(), name: "first"), lifetime, _ => factoryStore); + factoryServices.AddFoundryResponses("second", _ => new TrackingAgent(new AgentLifetimeLog(), name: "second"), lifetime, _ => factoryStore); + AgentFrameworkResponseHandler factoryHandler = ResolveHandler(factoryServices); + + // Act + string[] instanceIdentities = + [ + await DrainAndGetIdentityAsync(instanceHandler, instanceStore, "first"), + await DrainAndGetIdentityAsync(instanceHandler, instanceStore, "second"), + await DrainAndGetIdentityAsync(instanceHandler, instanceStore, agentName: null), + ]; + string[] factoryIdentities = + [ + await DrainAndGetIdentityAsync(factoryHandler, factoryStore, "first"), + await DrainAndGetIdentityAsync(factoryHandler, factoryStore, "second"), + await DrainAndGetIdentityAsync(factoryHandler, factoryStore, agentName: null), + ]; + + // Assert + Assert.Equal(["name:first", "key:second", "name:first"], instanceIdentities); + Assert.Equal(instanceIdentities, factoryIdentities); + } + + [Fact] + public async Task AddFoundryResponses_FactoryDefaultThenInstanceAgent_KeepsTheFactoryAgentAsDefaultAsync() + { + // Arrange: the instance overload must not take over the default slot the factory agent holds. + var log = new AgentLifetimeLog(); + var store = new RecordingSessionStore(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddFoundryResponses("first", _ => new TrackingAgent(log, name: "first"), ServiceLifetime.Scoped, _ => store); + services.AddFoundryResponses(new TrackingAgent(new AgentLifetimeLog(), name: "second"), store); + AgentFrameworkResponseHandler handler = ResolveHandler(services); + + // Act + string unnamedIdentity = await DrainAndGetIdentityAsync(handler, store, agentName: null); + string secondIdentity = await DrainAndGetIdentityAsync(handler, store, "second"); + + // Assert + Assert.Equal("name:first", unnamedIdentity); + Assert.Equal("key:second", secondIdentity); + Assert.DoesNotContain(services, d => d.ServiceType == typeof(AIAgent) && !d.IsKeyedService); + } + + [Fact] + public async Task AddFoundryResponses_NonKeyedAgentRegisteredLater_DoesNotShareTheFactoryAgentSessionsAsync() + { + // Arrange: a different agent with the same name is registered as the non-keyed default after the + // factory overload, so it receives requests without an agent name. + var store = new RecordingSessionStore(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(store); + services.AddFoundryResponses(AgentName, _ => new TrackingAgent(new AgentLifetimeLog()), ServiceLifetime.Scoped, _ => store); + services.AddSingleton(new TrackingAgent(new AgentLifetimeLog())); + AgentFrameworkResponseHandler handler = ResolveHandler(services); + + // Act + string factoryIdentity = await DrainAndGetIdentityAsync(handler, store, AgentName); + string nonKeyedIdentity = await DrainAndGetIdentityAsync(handler, store, agentName: null); + + // Assert: the two agents never read or write each other's sessions. + Assert.Equal($"key:{AgentName}", factoryIdentity); + Assert.Equal($"name:{AgentName}", nonKeyedIdentity); + } + + [Theory] + [InlineData(ServiceLifetime.Scoped)] + [InlineData(ServiceLifetime.Transient)] + public async Task AddFoundryResponses_Factory_DisposesEachAgentAndStoreExactlyOnceAsync(ServiceLifetime lifetime) + { + // Arrange + var log = new AgentLifetimeLog(); + var stores = new ConcurrentQueue(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddFoundryResponses( + AgentName, + _ => new TrackingAgent(log), + lifetime, + _ => + { + var store = new RecordingSessionStore(); + stores.Enqueue(store); + return store; + }); + ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); + var handler = Assert.IsType(provider.GetRequiredService()); + + // Act: a named request, an unnamed request, and the readiness enumeration of all agents. + await DrainAsync(handler, CreateRequest(AgentName), CreateContext()); + await DrainAsync(handler, CreateRequest(agentName: null), CreateContext()); + await using (AsyncServiceScope scope = provider.CreateAsyncScope()) + { + Assert.Single(ServiceCollectionExtensions.ResolveRegisteredAgents(scope.ServiceProvider)); + } + + await provider.DisposeAsync(); + + // Assert + Assert.Equal(3, log.Created.Count); + Assert.All(log.Created, agent => Assert.Equal(1, agent.DisposeCount)); + Assert.Equal(2, stores.Count); + Assert.All(stores, store => Assert.Equal(1, store.DisposeCount)); + } + + [Fact] + public async Task AddFoundryResponses_SingletonFactory_DisposesTheAgentAndStoreOnceAtShutdownAsync() + { + // Arrange + var log = new AgentLifetimeLog(); + var store = new RecordingSessionStore(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddFoundryResponses(AgentName, _ => new TrackingAgent(log), ServiceLifetime.Singleton, _ => store); + ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); + var handler = Assert.IsType(provider.GetRequiredService()); + + // Act + await DrainAsync(handler, CreateRequest(AgentName), CreateContext()); + await DrainAsync(handler, CreateRequest(agentName: null), CreateContext()); + bool disposedBeforeShutdown = store.IsDisposed || log.Created.Any(agent => agent.IsDisposed); + await provider.DisposeAsync(); + + // Assert + Assert.False(disposedBeforeShutdown); + Assert.Equal(1, Assert.Single(log.Created).DisposeCount); + Assert.Equal(1, store.DisposeCount); + } + + [Fact] + public async Task AddFoundryResponses_ExistingScopedKeyedAgent_IsNotCapturedBySingletonDefaultAsync() + { + // Arrange: the caller already registered a scoped agent under the name; the factory call uses + // the default singleton lifetime. + var log = new AgentLifetimeLog(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddKeyedScoped(AgentName, (_, _) => new TrackingAgent(log)); + services.AddFoundryResponses(AgentName, _ => new TrackingAgent(new AgentLifetimeLog()), agentSessionStoreFactory: _ => new TestAgentSessionStore()); + AgentFrameworkResponseHandler handler = ResolveHandler(services); + + // Act + await DrainAsync(handler, CreateRequest(AgentName), CreateContext()); + await DrainAsync(handler, CreateRequest(AgentName), CreateContext()); + + // Assert: the existing scoped registration is used per run and nothing forwards to it. + Assert.DoesNotContain(services, d => d.ServiceType == typeof(AIAgent) && !d.IsKeyedService); + Assert.Equal(2, log.Created.Count); + Assert.All(log.Created, agent => Assert.Equal(1, agent.DisposeCount)); } [Fact] @@ -511,6 +678,17 @@ private static async Task DrainAsync(AgentFrameworkResponseHandler handler, Crea } } + /// Runs one request and returns the single storage identity the store saw during it. + private static async Task DrainAndGetIdentityAsync( + AgentFrameworkResponseHandler handler, + RecordingSessionStore store, + string? agentName) + { + int before = store.Identities.Count; + await DrainAsync(handler, CreateRequest(agentName), CreateContext()); + return Assert.Single(store.Identities.Skip(before).Distinct()); + } + private sealed class AgentLifetimeLog { public ConcurrentQueue Created { get; } = new(); @@ -524,19 +702,24 @@ private sealed class TrackingAgent : AIAgent, IAsyncDisposable { private readonly AgentLifetimeLog _log; private readonly TaskCompletionSource? _gate; + private readonly string _name; private int _runCount; + private int _disposeCount; - public TrackingAgent(AgentLifetimeLog log, TaskCompletionSource? gate = null) + public TrackingAgent(AgentLifetimeLog log, TaskCompletionSource? gate = null, string name = AgentName) { this._log = log; this._gate = gate; + this._name = name; log.Created.Enqueue(this); } - public override string? Name => AgentName; + public override string? Name => this._name; public int RunCount => this._runCount; + public int DisposeCount => this._disposeCount; + public bool IsDisposed { get; private set; } public bool WasDisposedDuringRun { get; private set; } @@ -545,6 +728,7 @@ public TrackingAgent(AgentLifetimeLog log, TaskCompletionSource? gate = null) public ValueTask DisposeAsync() { + Interlocked.Increment(ref this._disposeCount); this.IsDisposed = true; this.Disposed.TrySetResult(); return default; @@ -597,11 +781,13 @@ private sealed class RecordingSessionStore : AgentSessionStore, IAsyncDisposable { public ConcurrentQueue Identities { get; } = new(); - public bool IsDisposed { get; private set; } + public bool IsDisposed => this.DisposeCount > 0; + + public int DisposeCount { get; private set; } public ValueTask DisposeAsync() { - this.IsDisposed = true; + this.DisposeCount++; return default; } From 94f66ee5e13a1f7194ef9a2a869a2fc547059e05 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:33:49 -0800 Subject: [PATCH 3/4] .NET: Register Foundry session stores separately from agents Add AddFoundryAgentSessionStore on IServiceCollection and IHostApplicationBuilder. It registers a session store object, or a factory with its own DI lifetime (Singleton by default), for one agent name or for every agent without a store of its own. A later call for the same target replaces the earlier one, in any order relative to AddFoundryResponses. The agent factory overload no longer takes a store factory, so a scoped agent no longer forces its store to be scoped too. A store passed to AddFoundryResponses(agent, store) is now used only by that agent, and agents without a store fall back to the store registered for all agents. The default agent looks up the store registered under its own name. Replace the factory registration markers with a single FoundryDefaultAgentName. The handler now checks at run time whether a non-keyed agent took the default role, so the IServiceProviderIsService lookup is no longer needed. --- .../Hosted-ChatClientAgent/README.md | 25 +- .../AgentFrameworkResponseHandler.cs | 69 ++-- .../FoundryAgentFactoryRegistration.cs | 98 ----- .../FoundryDefaultAgentName.cs | 34 ++ .../HostApplicationBuilderExtensions.cs | 129 ++++-- .../ServiceCollectionExtensions.cs | 301 ++++++++++---- .../FoundryAgentLifetimeTests.cs | 384 ++++++++++++++---- .../ResilientTwoLifetimeIntegrationTests.cs | 3 +- 8 files changed, 709 insertions(+), 334 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentFactoryRegistration.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryDefaultAgentName.cs diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/README.md index 0db87f2a90b..6c255898080 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/README.md @@ -79,18 +79,25 @@ database context, register a factory with a DI lifetime instead: builder.AddFoundryResponses( agentName, sp => new MyAgent(sp.GetRequiredService()), - ServiceLifetime.Scoped, - // Optional: a session store created with the same lifetime. Omit it to use the default store. - sp => new MySessionStore(sp.GetRequiredService())); + ServiceLifetime.Scoped); ``` Each response run (streaming, background, or resumed after a restart) gets its own DI scope, which is -disposed when that run's event stream ends. A `Scoped` agent, and a session store created by the -optional store factory, are therefore created once per response and disposed afterwards, even when a -background response keeps running after the HTTP request has returned. Without a store factory the -default session store is a single shared instance. Conversation state is not kept on the agent object; -each turn loads the saved session, and sessions are stored under `agentName`, so switching from the -instance overload keeps existing conversations. +disposed when that run's event stream ends. A `Scoped` agent is therefore created once per response and +disposed afterwards, even when a background response keeps running after the HTTP request has returned. +Conversation state is not kept on the agent object; each turn loads the saved session, and sessions are +stored under `agentName`, so switching from the instance overload keeps existing conversations. + +The session store has its own registration and lifetime, independent of the agent. Without one, every +agent uses the default store, a single shared instance: + +```csharp +// For every agent that has no store of its own. A store object you pass in is never disposed by DI. +builder.AddFoundryAgentSessionStore(sp => new MySessionStore(sp.GetRequiredService()), ServiceLifetime.Scoped); + +// Or for one agent only. +builder.AddFoundryAgentSessionStore(mySessionStore, agentName); +``` ## Run and test locally diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs index 06ef70e3502..0c5c1fa0277 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs @@ -41,7 +41,12 @@ internal sealed partial class AgentFrameworkResponseHandler : ResponseHandler private readonly ILogger _logger; private readonly FoundryResponsesServerOptions _options; private readonly FoundryToolboxService? _toolboxService; - private readonly FoundryAgentFactoryRegistrations _factoryRegistrations; + + /// + /// The factory agent that answers requests without a matching agent name when no agent is registered + /// without a key. See . + /// + private readonly string? _defaultAgentName; /// /// Initializes a new instance of the class @@ -83,14 +88,7 @@ public AgentFrameworkResponseHandler( this._logger = logger; this._options = foundryResponsesOptions; this._toolboxService = toolboxService; - // A non-keyed AIAgent registered after the factory overload takes requests without an agent - // name. The factory agent is then not the default, so it must not use the default's - // name-based storage location, which the non-keyed agent uses. IServiceProviderIsService - // reports the registration without creating the agent. - bool? nonKeyedAgentRegistered = serviceProvider.GetService()?.IsService(typeof(AIAgent)); - this._factoryRegistrations = new FoundryAgentFactoryRegistrations( - serviceProvider.GetServices(), - nonKeyedAgentRegistered); + this._defaultAgentName = serviceProvider.GetService()?.Name; } /// @@ -895,51 +893,56 @@ 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) , and then the - /// agent registered through the factory overload of AddFoundryResponses as the default. + /// If neither matches a keyed agent, attempts to resolve a default (non-keyed) , and + /// then the agent registered through the factory overload of AddFoundryResponses as the default. /// /// The incoming request. /// The service provider of the DI scope owned by the current response run. - /// The prepared agent and the key to look its session store up with. + /// The prepared agent and the name to look its session store up with. private (AIAgent Agent, string? SessionStoreKey) ResolveAgent(CreateResponse request, IServiceProvider services) { var agentName = GetAgentName(request); + var defaultAgent = services.GetService(); if (!string.IsNullOrEmpty(agentName)) { var agent = services.GetKeyedService(agentName); if (agent is not null) { - string storageIdentity = this._factoryRegistrations.TryGetStorageIdentity(agentName, out string? factoryIdentity) - ? factoryIdentity - : FoundryHostingAgent.ResolveSessionStorageIdentity( - agent, - agentName, - services.GetService()); + // The factory default has no non-keyed registration to compare against, so it is + // recognized by name. It keeps the location AddFoundryResponses(agent) uses for the + // default agent, unless a non-keyed agent took the default role. + string storageIdentity = defaultAgent is null + && string.Equals(agentName, this._defaultAgentName, StringComparison.Ordinal) + ? DefaultAgentStorageIdentity(agentName) + : FoundryHostingAgent.ResolveSessionStorageIdentity(agent, agentName, defaultAgent); return (this.PrepareResolvedAgent(agent, storageIdentity), agentName); } LogKeyedAgentNotFound(this._logger, agentName); } - // Try non-keyed default - var defaultAgent = services.GetService(); if (defaultAgent is not null) { string storageIdentity = FoundryHostingAgent.ResolveSessionStorageIdentity( defaultAgent, registrationKey: null, defaultAgent); - return (this.PrepareResolvedAgent(defaultAgent, storageIdentity), agentName); + + // The default agent uses the store registered under its own name, the same store a request + // naming that agent uses. + string? sessionStoreKey = string.IsNullOrWhiteSpace(defaultAgent.Name) ? null : defaultAgent.Name; + return (this.PrepareResolvedAgent(defaultAgent, storageIdentity), sessionStoreKey); } // The factory overload registers its agent only under its name, so that DI owns each created - // object once. Requests without a usable agent name reach it through the recorded default. - if (this._factoryRegistrations.DefaultAgentName is { } factoryDefaultName - && this._factoryRegistrations.DefaultStorageIdentity is { } factoryDefaultIdentity - && services.GetKeyedService(factoryDefaultName) is { } factoryDefaultAgent) + // object once. Requests without a matching agent name reach it through the recorded default name. + if (this._defaultAgentName is { } defaultAgentName + && services.GetKeyedService(defaultAgentName) is { } factoryDefaultAgent) { - return (this.PrepareResolvedAgent(factoryDefaultAgent, factoryDefaultIdentity), factoryDefaultName); + return ( + this.PrepareResolvedAgent(factoryDefaultAgent, DefaultAgentStorageIdentity(defaultAgentName)), + defaultAgentName); } var errorMessage = string.IsNullOrEmpty(agentName) @@ -949,6 +952,12 @@ private bool ShouldPersistForResilience(CreateResponse request) throw new InvalidOperationException(errorMessage); } + /// + /// The storage identity AddFoundryResponses(agent) gives the default agent, used for the factory + /// default whatever its lifetime. + /// + private static string DefaultAgentStorageIdentity(string agentName) => $"name:{agentName}"; + private AIAgent PrepareResolvedAgent(AIAgent agent, string sessionStorageIdentity) { ServiceCollectionExtensions.TryApplyUserAgent(agent); @@ -963,7 +972,7 @@ private AIAgent PrepareResolvedAgent(AIAgent agent, string sessionStorageIdentit /// /// Resolves the registered under , or the - /// default (non-keyed) store when there is none. + /// store registered for all agents (non-keyed) when there is none. /// /// The key returned by . /// The service provider of the DI scope owned by the current response run. @@ -980,7 +989,7 @@ private AgentSessionStore ResolveSessionStore(string? agentName, IServiceProvide LogKeyedSessionStoreNotFound(this._logger, agentName); } - // Try non-keyed default + // An agent without a store of its own uses the store registered for all agents. var defaultSessionStore = services.GetService(); if (defaultSessionStore is not null) { @@ -989,7 +998,7 @@ private AgentSessionStore ResolveSessionStore(string? agentName, IServiceProvide var errorMessage = string.IsNullOrEmpty(agentName) ? "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 builder.AddFoundryResponses(agent, agentSessionStore) or services.AddKeyedSingleton(\"{agentName}\", ...)."; + : $"AgentSessionStore for agent '{agentName}' not found. Ensure it is registered via services.AddFoundryAgentSessionStore(agentSessionStore, \"{agentName}\"), services.AddFoundryAgentSessionStore(agentSessionStore) for all agents, or builder.AddFoundryResponses(agent, agentSessionStore)."; throw new InvalidOperationException(errorMessage); } @@ -1065,6 +1074,6 @@ private static partial void LogConsentLinkRejected( [LoggerMessage(LogLevel.Warning, "Agent '{AgentName}' not found in keyed services. Attempting default resolution.", EventId = 0)] private static partial void LogKeyedAgentNotFound(ILogger logger, string agentName); - [LoggerMessage(LogLevel.Warning, "SessionStore for agent '{AgentName}' not found in keyed services. Attempting default resolution.", EventId = 0)] + [LoggerMessage(LogLevel.Debug, "No session store registered for agent '{AgentName}'. Using the store registered for all agents.", EventId = 0)] private static partial void LogKeyedSessionStoreNotFound(ILogger logger, string agentName); } diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentFactoryRegistration.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentFactoryRegistration.cs deleted file mode 100644 index 49a4cbf00f2..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentFactoryRegistration.cs +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI.Foundry.Hosting; - -/// -/// Records an agent registered through the factory overload of AddFoundryResponses. -/// -/// -/// -/// The factory overload registers the agent and its session store only under the agent name. It does not -/// add a non-keyed registration that forwards to the keyed one, because Microsoft DI records the object a -/// factory returns for disposal once per registration: a forwarding registration would dispose the same -/// object twice, and its lifetime could disagree with the keyed registration it forwards to. Instead, when -/// no default agent existed at registration time, this marker records that requests without an agent name -/// are routed to this agent by the response handler. -/// -/// -/// The markers also fix where sessions are stored. The response handler normally decides that by checking -/// whether the keyed agent and the default agent are the same object, which depends on the lifetime for a -/// factory: a transient registration creates a new object on every lookup. The marker uses the location -/// AddFoundryResponses(agent) would use for an agent with the same name in the same position: -/// name:{agentName} for the default agent and key:{agentName} for any other agent. -/// -/// -internal sealed class FoundryAgentFactoryRegistration -{ - public FoundryAgentFactoryRegistration(string agentName, bool isDefault) - { - this.AgentName = Throw.IfNullOrWhitespace(agentName); - this.IsDefault = isDefault; - } - - /// Gets the key the agent was registered under. - public string AgentName { get; } - - /// - /// Gets a value indicating whether no default agent existed when this agent was registered, so requests - /// without an agent name may be routed to it. - /// - public bool IsDefault { get; } -} - -/// -/// Lookup of markers built once by the response handler. -/// -internal sealed class FoundryAgentFactoryRegistrations -{ - private readonly Dictionary _storageIdentities = new(StringComparer.Ordinal); - - /// - /// Initializes a new instance of the class. - /// - /// The markers registered by the factory overloads. - /// - /// Whether the built container has a non-keyed registration, or - /// when the container cannot report it. A non-keyed agent registered after a factory overload receives - /// requests without an agent name, so no factory registration is then treated as the default. When the - /// container cannot report it, the decision made at registration time is used. - /// - public FoundryAgentFactoryRegistrations( - IEnumerable registrations, - bool? nonKeyedAgentRegistered = null) - { - foreach (FoundryAgentFactoryRegistration registration in registrations) - { - bool isEffectiveDefault = registration.IsDefault - && nonKeyedAgentRegistered is not true - && this.DefaultAgentName is null; - string storageIdentity = isEffectiveDefault - ? $"name:{registration.AgentName}" - : $"key:{registration.AgentName}"; - this._storageIdentities[registration.AgentName] = storageIdentity; - if (isEffectiveDefault) - { - this.DefaultAgentName = registration.AgentName; - this.DefaultStorageIdentity = storageIdentity; - } - } - } - - /// - /// Gets the name of the factory registration that receives requests without an agent name, or - /// when no factory registration was the default. - /// - public string? DefaultAgentName { get; } - - /// Gets the storage identity of . - public string? DefaultStorageIdentity { get; } - - /// Gets the storage identity for a keyed factory registration. - public bool TryGetStorageIdentity(string agentName, [NotNullWhen(true)] out string? storageIdentity) - => this._storageIdentities.TryGetValue(agentName, out storageIdentity); -} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryDefaultAgentName.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryDefaultAgentName.cs new file mode 100644 index 00000000000..50015608272 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryDefaultAgentName.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// The name of the agent registered through the factory overload of AddFoundryResponses that answers +/// requests without a matching agent name. +/// +/// +/// +/// The factory overload registers its agent only under the agent name. It does not add a non-keyed +/// registration that forwards to the keyed one, because Microsoft DI disposes the object a factory returns +/// once per registration that created or returned it, so a forwarding registration would dispose the same +/// object twice. This value is registered instead, only when no default agent existed at registration time, +/// so the response handler can route requests without an agent name to that agent. +/// +/// +/// The response handler ignores it when an is registered without a key, because that +/// agent receives requests without an agent name. Sessions of the agent named here are stored in the location +/// AddFoundryResponses(agent) uses for the default agent, name:{Name}, whatever its lifetime. +/// +/// +internal sealed class FoundryDefaultAgentName +{ + public FoundryDefaultAgentName(string name) + { + this.Name = Throw.IfNullOrWhitespace(name); + } + + /// Gets the key the default factory agent is registered under. + public string Name { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostApplicationBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostApplicationBuilderExtensions.cs index f3bb98ffebb..3f0d85af187 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostApplicationBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostApplicationBuilderExtensions.cs @@ -68,7 +68,11 @@ public static IHostApplicationBuilder AddFoundryResponses( /// /// The host application builder. /// The agent instance to register. - /// The agent session store to use, or for the default. + /// + /// The session store for this agent. It is registered under the agent's name and is not used by other + /// agents. If null, the agent uses the store registered for all agents with + /// , or the default store. + /// /// Optional hosting behavior. Required inputs belong on the extension method parameters. /// The host application builder for chaining. public static IHostApplicationBuilder AddFoundryResponses( @@ -93,17 +97,7 @@ public static IHostApplicationBuilder AddFoundryResponses( configuredOptions, includeServerOptions: serverAdded, applyOptions: serverAdded || options is not null); - agentSessionStore ??= ServiceCollectionExtensions.CreateDefaultAgentSessionStore(); - - if (!string.IsNullOrWhiteSpace(agent.Name)) - { - host.Services.TryAddKeyedSingleton(agent.Name, agent); - host.Services.TryAddKeyedSingleton(agent.Name, agentSessionStore); - } - - ServiceCollectionExtensions.TryAddDefaultAgent(host.Services, agent); - host.Services.TryAddSingleton(agentSessionStore); - + ServiceCollectionExtensions.RegisterAgentInstance(host.Services, agent, agentSessionStore); ServiceCollectionExtensions.RegisterResponseHandler(host.Services); ServiceCollectionExtensions.MarkFeatureUsed(); return host; @@ -115,18 +109,16 @@ public static IHostApplicationBuilder AddFoundryResponses( /// /// /// - /// Use this overload when the agent or its session store depends on services that are not - /// singletons, for example a database context, or when they should not live for the whole process. - /// The agent and its session store are registered only under . When no - /// default agent is registered yet, requests without an agent name are also routed to this agent. - /// Existing registrations under are kept, the same as for - /// AddFoundryResponses(agent). + /// Use this overload when the agent depends on services that are not singletons, for example a + /// database context, or when it should not live for the whole process. The agent is registered only + /// under . When no default agent is registered yet, requests without a + /// matching agent name are also routed to this agent. An existing registration under + /// is kept, the same as for AddFoundryResponses(agent). /// /// /// Every response run (streaming, non-streaming, background, or a run resumed after a restart) gets /// its own DI scope, created when the run starts and disposed when its event stream ends. That is - /// later than the end of the HTTP request for a background run. The lifetime applies to the agent - /// and, when is supplied, to the session store: + /// later than the end of the HTTP request for a background run. The lifetime applies to the agent: /// /// : one instance for the process, disposed at shutdown. /// : one instance per response run, disposed when the run ends. @@ -134,35 +126,28 @@ public static IHostApplicationBuilder AddFoundryResponses( /// /// This scope is not the ASP.NET Core request scope, so scoped services that middleware fills in for an /// HTTP request are new, empty instances here. Do not derive the caller's identity from such services in - /// the factories; the per-request user identity is supplied by the platform and applied by the response + /// the factory; the per-request user identity is supplied by the platform and applied by the response /// handler when it loads and saves sessions. - /// Conversation state is not kept on the agent or store object: each turn loads the saved session - /// from the store's backing storage. Sessions are stored in the same location - /// AddFoundryResponses(agent) uses for an agent with that name registered in the same order, - /// whatever the lifetime, so switching between the two overloads or between lifetimes keeps - /// existing sessions readable. + /// + /// + /// The session store is registered separately with + /// , + /// with its own lifetime. Without such a registration the agent uses the default store. Sessions are + /// stored in the same location AddFoundryResponses(agent) uses for an agent with that name + /// registered in the same order, whatever the lifetime, so switching between the two overloads or + /// between lifetimes keeps existing sessions readable. /// /// /// builder.AddFoundryResponses( /// "my-agent", /// sp => new MyAgent(sp.GetRequiredService<MyDbContext>()), - /// ServiceLifetime.Scoped, - /// sp => new MySessionStore(sp.GetRequiredService<MyDbContext>())); + /// ServiceLifetime.Scoped); /// /// /// The host application builder. /// The name the agent is registered under and the name requests use to select it. /// Creates the agent from the service provider of the current DI scope. - /// - /// The DI lifetime of the agent and of the session store created by . - /// Defaults to . - /// - /// - /// Creates the agent session store from the service provider of the current DI scope, with the same - /// as the agent. If null, the default singleton store is used. When the - /// factory returns an object created elsewhere with a scoped or transient lifetime, the DI container - /// still disposes it at the end of each response run if it is disposable. - /// + /// The DI lifetime of the agent. Defaults to . /// Optional hosting behavior. Required inputs belong on the extension method parameters. /// The host application builder for chaining. public static IHostApplicationBuilder AddFoundryResponses( @@ -170,14 +155,78 @@ public static IHostApplicationBuilder AddFoundryResponses( string agentName, Func agentFactory, ServiceLifetime lifetime = ServiceLifetime.Singleton, - Func? agentSessionStoreFactory = null, FoundryResponsesServerOptions? options = null) { _ = Throw.IfNull(host); - ServiceCollectionExtensions.RegisterAgentFactory(host.Services, agentName, agentFactory, lifetime, agentSessionStoreFactory); + ServiceCollectionExtensions.RegisterAgentFactory(host.Services, agentName, agentFactory, lifetime); return host.AddFoundryResponses(options); } + /// + /// Registers the that Foundry Responses hosting uses for one agent, or for + /// every agent that has no store of its own, on a host application builder. + /// + /// + /// The response handler uses the store registered under the agent's name, then the store registered for + /// all agents, then the default store. The DI container never disposes . + /// Calling this method again for the same , or again for all agents, replaces + /// the earlier store, in whatever order it is called relative to AddFoundryResponses. + /// + /// builder.AddFoundryAgentSessionStore(mySessionStore); + /// + /// + /// The host application builder. + /// The session store to use. + /// + /// The name of the agent the store is for, or to use it for every agent that has no + /// store of its own. + /// + /// The host application builder for chaining. + public static IHostApplicationBuilder AddFoundryAgentSessionStore( + this IHostApplicationBuilder host, + AgentSessionStore agentSessionStore, + string? agentName = null) + { + _ = Throw.IfNull(host); + host.Services.AddFoundryAgentSessionStore(agentSessionStore, agentName); + return host; + } + + /// + /// Registers a factory for the that Foundry Responses hosting uses for one + /// agent, or for every agent that has no store of its own, with the specified DI lifetime on a host + /// application builder. + /// + /// + /// The response handler looks the store up once per response run, from the DI scope of that run. The + /// store's lifetime is independent of the agent's lifetime. Calling this method again for the same + /// , or again for all agents, replaces the earlier store, in whatever order it + /// is called relative to AddFoundryResponses. + /// + /// builder.AddFoundryAgentSessionStore( + /// sp => new MySessionStore(sp.GetRequiredService<MyDbContext>()), + /// ServiceLifetime.Scoped); + /// + /// + /// The host application builder. + /// Creates the session store from the service provider of the current DI scope. + /// The DI lifetime of the session store. Defaults to . + /// + /// The name of the agent the store is for, or to use it for every agent that has no + /// store of its own. + /// + /// The host application builder for chaining. + public static IHostApplicationBuilder AddFoundryAgentSessionStore( + this IHostApplicationBuilder host, + Func agentSessionStoreFactory, + ServiceLifetime lifetime = ServiceLifetime.Singleton, + string? agentName = null) + { + _ = Throw.IfNull(host); + host.Services.AddFoundryAgentSessionStore(agentSessionStoreFactory, lifetime, agentName); + return host; + } + private static FoundryResponsesServerOptions ResolveFoundryResponsesServerOptions( IHostApplicationBuilder host, FoundryResponsesServerOptions? suppliedOptions) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs index aba461aace9..7f8e5a14fe8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs @@ -125,7 +125,12 @@ public static IServiceCollection AddFoundryResponses(this IServiceCollection ser /// /// The service collection. /// The agent instance to register. - /// The agent session store to use for managing agent sessions server-side. If null, the default store uses the AgentServer SDK's local state-store fallback. + /// + /// The session store for this agent. It is registered under the agent's name and is not used by other + /// agents. If null, the agent uses the store registered for all agents with + /// , or the default + /// store, which uses the AgentServer SDK's local state-store fallback. + /// /// /// Optional , for example to allow the /// agent's own service to store the responses it produces, or to opt in to durable long-running @@ -154,19 +159,7 @@ public static IServiceCollection AddFoundryResponses( configuredOptions, includeServerOptions: serverAdded, applyOptions: serverAdded || options is not null); - agentSessionStore ??= CreateDefaultAgentSessionStore(); - - if (!string.IsNullOrWhiteSpace(agent.Name)) - { - services.TryAddKeyedSingleton(agent.Name, agent); - services.TryAddKeyedSingleton(agent.Name, agentSessionStore); - } - - // Also register as the default (non-keyed) agent so requests - // without an agent name can resolve it (e.g., local dev tooling). - TryAddDefaultAgent(services, agent); - services.TryAddSingleton(agentSessionStore); - + RegisterAgentInstance(services, agent, agentSessionStore); RegisterResponseHandler(services); MarkFeatureUsed(); return services; @@ -178,18 +171,16 @@ public static IServiceCollection AddFoundryResponses( /// /// /// - /// Use this overload when the agent or its session store depends on services that are not - /// singletons, for example a database context, or when they should not live for the whole process. - /// The agent and its session store are registered only under . When no - /// default agent is registered yet, requests without an agent name are also routed to this agent. - /// Existing registrations under are kept, the same as for - /// AddFoundryResponses(agent). + /// Use this overload when the agent depends on services that are not singletons, for example a + /// database context, or when it should not live for the whole process. The agent is registered only + /// under . When no default agent is registered yet, requests without a + /// matching agent name are also routed to this agent. An existing registration under + /// is kept, the same as for AddFoundryResponses(agent). /// /// /// Every response run (streaming, non-streaming, background, or a run resumed after a restart) gets /// its own DI scope, created when the run starts and disposed when its event stream ends. That is - /// later than the end of the HTTP request for a background run. The lifetime applies to the agent - /// and, when is supplied, to the session store: + /// later than the end of the HTTP request for a background run. The lifetime applies to the agent: /// /// : one instance for the process, disposed at shutdown. /// : one instance per response run, disposed when the run ends. @@ -197,13 +188,18 @@ public static IServiceCollection AddFoundryResponses( /// /// This scope is not the ASP.NET Core request scope, so scoped services that middleware fills in for an /// HTTP request are new, empty instances here. Do not derive the caller's identity from such services in - /// the factories; the per-request user identity is supplied by the platform and applied by the response + /// the factory; the per-request user identity is supplied by the platform and applied by the response /// handler when it loads and saves sessions. - /// Conversation state is not kept on the agent or store object: each turn loads the saved session - /// from the store's backing storage. Sessions are stored in the same location - /// AddFoundryResponses(agent) uses for an agent with that name registered in the same order, - /// whatever the lifetime, so switching between the two overloads or between lifetimes keeps - /// existing sessions readable. + /// + /// + /// The session store is registered separately with + /// , + /// with its own lifetime, because the response handler uses the store directly rather than through the + /// agent. Without such a registration the agent uses the default store. Conversation state is not kept on + /// the agent object: each turn loads the saved session from the store's backing storage. Sessions are + /// stored in the same location AddFoundryResponses(agent) uses for an agent with that name + /// registered in the same order, whatever the lifetime, so switching between the two overloads or + /// between lifetimes keeps existing sessions readable. /// /// /// This overload is for local or non-hosted scenarios. Foundry hosted applications must use @@ -215,25 +211,14 @@ public static IServiceCollection AddFoundryResponses( /// builder.Services.AddFoundryResponses( /// "my-agent", /// sp => new MyAgent(sp.GetRequiredService<MyDbContext>()), - /// ServiceLifetime.Scoped, - /// sp => new MySessionStore(sp.GetRequiredService<MyDbContext>())); + /// ServiceLifetime.Scoped); /// /// /// /// The service collection. /// The name the agent is registered under and the name requests use to select it. /// Creates the agent from the service provider of the current DI scope. - /// - /// The DI lifetime of the agent and of the session store created by . - /// Defaults to . - /// - /// - /// Creates the agent session store from the service provider of the current DI scope, with the same - /// as the agent. If null, the default store, a singleton that uses the - /// AgentServer SDK's local state-store fallback, is used. When the factory returns an object created - /// elsewhere with a scoped or transient lifetime, the DI container still disposes it at the end of - /// each response run if it is disposable. - /// + /// The DI lifetime of the agent. Defaults to . /// /// Optional , for example to allow the /// agent's own service to store the responses it produces, or to opt in to durable long-running @@ -245,54 +230,191 @@ public static IServiceCollection AddFoundryResponses( string agentName, Func agentFactory, ServiceLifetime lifetime = ServiceLifetime.Singleton, - Func? agentSessionStoreFactory = null, FoundryResponsesServerOptions? options = null) { _ = Throw.IfNull(services); - RegisterAgentFactory(services, agentName, agentFactory, lifetime, agentSessionStoreFactory); + RegisterAgentFactory(services, agentName, agentFactory, lifetime); return services.AddFoundryResponses(options); } /// - /// Registers an agent factory, and optionally a session store factory, under - /// before the shared Responses registration runs. + /// Registers the that Foundry Responses hosting uses for one agent, or for + /// every agent that has no store of its own. + /// + /// + /// + /// For each response run the response handler uses the store registered under the agent's name, then the + /// store registered for all agents, then the default store, which uses the AgentServer SDK's local + /// state-store fallback. The store receives the agent on every call and partitions sessions by agent and + /// by user, so one store can serve several agents. + /// + /// + /// The DI container never disposes , because the caller created it. + /// Calling this method again for the same , or again for all agents, replaces + /// the earlier store, including one passed to AddFoundryResponses(agent, agentSessionStore), in + /// whatever order the methods are called. + /// + /// + /// Example: + /// + /// builder.Services.AddFoundryAgentSessionStore(mySessionStore); + /// + /// + /// + /// The service collection. + /// The session store to use. + /// + /// The name of the agent the store is for, or to use it for every agent that has no + /// store of its own. + /// + /// The service collection for chaining. + public static IServiceCollection AddFoundryAgentSessionStore( + this IServiceCollection services, + AgentSessionStore agentSessionStore, + string? agentName = null) + { + _ = Throw.IfNull(services); + _ = Throw.IfNull(agentSessionStore); + ValidateSessionStoreAgentName(agentName); + + RemoveAgentSessionStoreRegistrations(services, agentName); + services.Add(agentName is null + ? ServiceDescriptor.Singleton(agentSessionStore) + : ServiceDescriptor.KeyedSingleton(agentName, agentSessionStore)); + return services; + } + + /// + /// Registers a factory for the that Foundry Responses hosting uses for one + /// agent, or for every agent that has no store of its own, with the specified DI lifetime. /// /// - /// Only keyed registrations are added, so each created object has exactly one registration that owns - /// its disposal. When no default agent exists yet, a marker - /// tells the response handler to route requests without an agent name to this agent. Existing keyed - /// registrations win, the same as for AddFoundryResponses(agent); when the agent registration - /// is skipped no marker is added, so the existing registration keeps its routing and storage location. + /// + /// For each response run the response handler looks the store up once, from the DI scope of that run, + /// using the store registered under the agent's name, then the store registered for all agents, then the + /// default store. The store's lifetime is independent of the agent's lifetime: + /// + /// : one instance for the process, disposed at shutdown. + /// : one instance per response run, disposed when the run ends. + /// : a new instance on every lookup, disposed when the run ends. + /// + /// The DI container disposes what the factory returns according to that lifetime, even when the factory + /// returns an object it did not create. To share an object you dispose yourself, use + /// . + /// + /// + /// Calling this method again for the same , or again for all agents, replaces + /// the earlier store, including one passed to AddFoundryResponses(agent, agentSessionStore), in + /// whatever order the methods are called. + /// + /// + /// Example: + /// + /// builder.Services.AddFoundryAgentSessionStore( + /// sp => new MySessionStore(sp.GetRequiredService<MyDbContext>()), + /// ServiceLifetime.Scoped); + /// + /// + /// + /// The service collection. + /// Creates the session store from the service provider of the current DI scope. + /// The DI lifetime of the session store. Defaults to . + /// + /// The name of the agent the store is for, or to use it for every agent that has no + /// store of its own. + /// + /// The service collection for chaining. + public static IServiceCollection AddFoundryAgentSessionStore( + this IServiceCollection services, + Func agentSessionStoreFactory, + ServiceLifetime lifetime = ServiceLifetime.Singleton, + string? agentName = null) + { + _ = Throw.IfNull(services); + _ = Throw.IfNull(agentSessionStoreFactory); + ValidateLifetime(lifetime); + ValidateSessionStoreAgentName(agentName); + + string target = agentName is null ? "all agents" : $"agent '{agentName}'"; + AgentSessionStore CreateStore(IServiceProvider serviceProvider) => + agentSessionStoreFactory(serviceProvider) + ?? throw new InvalidOperationException($"The agent session store factory registered for {target} returned null."); + + RemoveAgentSessionStoreRegistrations(services, agentName); + services.Add(agentName is null + ? new ServiceDescriptor(typeof(AgentSessionStore), CreateStore, lifetime) + : new ServiceDescriptor(typeof(AgentSessionStore), agentName, (serviceProvider, _) => CreateStore(serviceProvider), lifetime)); + return services; + } + + /// + /// Registers an agent factory under before the shared Responses registration runs. + /// + /// + /// Only a keyed registration is added, so each created agent has exactly one registration that owns its + /// disposal. When no default agent exists yet, a tells the response + /// handler to route requests without a matching agent name to this agent. An existing keyed registration + /// wins, the same as for AddFoundryResponses(agent); this agent is then not recorded as the default, + /// so the existing registration keeps its routing and storage location. /// internal static void RegisterAgentFactory( IServiceCollection services, string agentName, Func agentFactory, - ServiceLifetime lifetime, - Func? agentSessionStoreFactory) + ServiceLifetime lifetime) { _ = Throw.IfNullOrWhitespace(agentName); _ = Throw.IfNull(agentFactory); - if (lifetime is not (ServiceLifetime.Singleton or ServiceLifetime.Scoped or ServiceLifetime.Transient)) + ValidateLifetime(lifetime); + + if (services.Any(descriptor => + descriptor.ServiceType == typeof(AIAgent) + && descriptor.IsKeyedService + && Equals(descriptor.ServiceKey, agentName))) { - Throw.ArgumentOutOfRangeException(nameof(lifetime), lifetime, "Unsupported service lifetime."); + return; } - if (TryAddKeyedFactory(services, agentName, agentFactory, lifetime, "agent factory")) + services.Add(new ServiceDescriptor( + typeof(AIAgent), + agentName, + (serviceProvider, _) => agentFactory(serviceProvider) + ?? throw new InvalidOperationException($"The agent factory registered for '{agentName}' returned null."), + lifetime)); + + if (!HasDefaultAgentRegistration(services)) { - services.AddSingleton(new FoundryAgentFactoryRegistration( - agentName, - isDefault: !HasDefaultAgentRegistration(services))); + services.AddSingleton(new FoundryDefaultAgentName(agentName)); } + } - if (agentSessionStoreFactory is not null) + /// + /// Registers an agent passed to AddFoundryResponses(agent, agentSessionStore), and its session store. + /// + /// + /// The store is registered only under the agent's name, so other agents do not use it. An agent without a + /// name has no key to register its store under, so its store is registered for all agents. The default + /// store is registered for all agents unless another store already is. + /// + internal static void RegisterAgentInstance(IServiceCollection services, AIAgent agent, AgentSessionStore? agentSessionStore) + { + if (!string.IsNullOrWhiteSpace(agent.Name)) { - _ = TryAddKeyedFactory(services, agentName, agentSessionStoreFactory, lifetime, "agent session store factory"); + services.TryAddKeyedSingleton(agent.Name, agent); + if (agentSessionStore is not null) + { + services.TryAddKeyedSingleton(agent.Name, agentSessionStore); + } } - else + else if (agentSessionStore is not null) { - services.TryAddKeyedSingleton(agentName, CreateDefaultAgentSessionStore()); + services.TryAddSingleton(agentSessionStore); } + + // Also register as the default (non-keyed) agent so requests + // without an agent name can resolve it (e.g., local dev tooling). + TryAddDefaultAgent(services, agent); + services.TryAddSingleton(_ => CreateDefaultAgentSessionStore()); } /// @@ -309,43 +431,46 @@ internal static void TryAddDefaultAgent(IServiceCollection services, AIAgent age /// /// Whether a registration already receives requests without an agent name: a non-keyed - /// or a factory registration recorded as the default. + /// or a factory agent recorded as the default. /// private static bool HasDefaultAgentRegistration(IServiceCollection services) => services.Any(descriptor => (descriptor.ServiceType == typeof(AIAgent) && !descriptor.IsKeyedService) - || (descriptor.ServiceType == typeof(FoundryAgentFactoryRegistration) - && descriptor.ImplementationInstance is FoundryAgentFactoryRegistration { IsDefault: true })); + || descriptor.ServiceType == typeof(FoundryDefaultAgentName)); + + private static void ValidateLifetime(ServiceLifetime lifetime) + { + if (lifetime is not (ServiceLifetime.Singleton or ServiceLifetime.Scoped or ServiceLifetime.Transient)) + { + Throw.ArgumentOutOfRangeException(nameof(lifetime), lifetime, "Unsupported service lifetime."); + } + } + + private static void ValidateSessionStoreAgentName(string? agentName) + { + if (agentName is not null) + { + _ = Throw.IfNullOrWhitespace(agentName); + } + } /// - /// Registers under with - /// when no registration exists for that key. + /// Removes the session store registrations for , or the non-keyed ones when it + /// is , so the store registered next replaces them. /// - /// when the registration was added. - private static bool TryAddKeyedFactory( - IServiceCollection services, - string name, - Func factory, - ServiceLifetime lifetime, - string factoryDescription) - where TService : class + private static void RemoveAgentSessionStoreRegistrations(IServiceCollection services, string? agentName) { - if (services.Any(descriptor => - descriptor.ServiceType == typeof(TService) - && descriptor.IsKeyedService - && Equals(descriptor.ServiceKey, name))) + for (int i = services.Count - 1; i >= 0; i--) { - return false; + ServiceDescriptor descriptor = services[i]; + if (descriptor.ServiceType == typeof(AgentSessionStore) + && Equals(descriptor.ServiceKey, agentName)) + { + services.RemoveAt(i); + } } - - services.Add(new ServiceDescriptor( - typeof(TService), - name, - (serviceProvider, _) => factory(serviceProvider) - ?? throw new InvalidOperationException($"The {factoryDescription} registered for '{name}' returned null."), - lifetime)); - return true; } + /// /// Applies the caller's and registers the readiness checks /// that report a misconfigured agent: one having its own service store the responses it produces, diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryAgentLifetimeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryAgentLifetimeTests.cs index 61cb7790446..f94b8ba178b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryAgentLifetimeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryAgentLifetimeTests.cs @@ -25,8 +25,9 @@ namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests; /// -/// Covers how the response handler resolves agents and session stores per response run, and the -/// AddFoundryResponses overloads that register an agent factory with a DI lifetime. +/// Covers how the response handler resolves agents and session stores per response run, the +/// AddFoundryResponses overloads that register an agent factory with a DI lifetime, and +/// AddFoundryAgentSessionStore, which registers session stores separately from agents. /// public sealed class FoundryAgentLifetimeTests { @@ -184,11 +185,11 @@ public async Task AddFoundryResponses_Factory_UsesTheInstanceOverloadStorageIden var factoryStore = new RecordingSessionStore(); var factoryServices = new ServiceCollection(); factoryServices.AddLogging(); + factoryServices.AddFoundryAgentSessionStore(factoryStore); factoryServices.AddFoundryResponses( AgentName, _ => new TrackingAgent(new AgentLifetimeLog()), - lifetime, - _ => factoryStore); + lifetime); AgentFrameworkResponseHandler factoryHandler = ResolveHandler(factoryServices); // Act: one request names the agent and one does not. @@ -209,7 +210,8 @@ public async Task AddFoundryResponses_Factory_CreatesOneAgentPerResponseAsync(Se var log = new AgentLifetimeLog(); var services = new ServiceCollection(); services.AddLogging(); - services.AddFoundryResponses(AgentName, _ => new TrackingAgent(log), lifetime, _ => new TestAgentSessionStore()); + services.AddFoundryAgentSessionStore(new TestAgentSessionStore()); + services.AddFoundryResponses(AgentName, _ => new TrackingAgent(log), lifetime); AgentFrameworkResponseHandler handler = ResolveHandler(services); // Act @@ -229,7 +231,8 @@ public async Task AddFoundryResponses_SingletonFactory_CreatesOneAgentForTheProc var log = new AgentLifetimeLog(); var services = new ServiceCollection(); services.AddLogging(); - services.AddFoundryResponses(AgentName, _ => new TrackingAgent(log), ServiceLifetime.Singleton, _ => new TestAgentSessionStore()); + services.AddFoundryAgentSessionStore(new TestAgentSessionStore()); + services.AddFoundryResponses(AgentName, _ => new TrackingAgent(log), ServiceLifetime.Singleton); AgentFrameworkResponseHandler handler = ResolveHandler(services); // Act @@ -245,52 +248,51 @@ public async Task AddFoundryResponses_SingletonFactory_CreatesOneAgentForTheProc [Theory] [InlineData(ServiceLifetime.Scoped)] [InlineData(ServiceLifetime.Transient)] - public async Task AddFoundryResponses_StoreFactory_CreatesOneStorePerResponseWithTheAgentLifetimeAsync(ServiceLifetime lifetime) + public async Task AddFoundryAgentSessionStore_Factory_CreatesOneStorePerResponseIndependentlyOfTheAgentAsync(ServiceLifetime lifetime) { - // Arrange + // Arrange: a singleton agent with a store that has its own, shorter lifetime. + var log = new AgentLifetimeLog(); var stores = new ConcurrentQueue(); var services = new ServiceCollection(); services.AddLogging(); - services.AddFoundryResponses( - AgentName, - _ => new TrackingAgent(new AgentLifetimeLog()), - lifetime, + services.AddFoundryResponses(AgentName, _ => new TrackingAgent(log)); + services.AddFoundryAgentSessionStore( _ => { var store = new RecordingSessionStore(); stores.Enqueue(store); return store; - }); + }, + lifetime); AgentFrameworkResponseHandler handler = ResolveHandler(services); // Act await DrainAsync(handler, CreateRequest(AgentName), CreateContext()); await DrainAsync(handler, CreateRequest(agentName: null), CreateContext()); - // Assert: each run uses one store, which is disposed when the run ends. + // Assert: each run uses one store, which is disposed when the run ends, while the agent lives on. Assert.Equal(2, stores.Count); - Assert.All(stores, store => Assert.True(store.IsDisposed)); + Assert.All(stores, store => Assert.Equal(1, store.DisposeCount)); Assert.All(stores, store => Assert.NotEmpty(store.Identities)); Assert.All(stores.SelectMany(store => store.Identities), identity => Assert.Equal($"name:{AgentName}", identity)); + TrackingAgent agent = Assert.Single(log.Created); + Assert.False(agent.IsDisposed); } [Fact] - public async Task AddFoundryResponses_SingletonStoreFactory_CreatesOneStoreForTheProcessAsync() + public async Task AddFoundryAgentSessionStore_Factory_DefaultsToOneStoreForTheProcessAsync() { - // Arrange + // Arrange: a scoped agent with a store registered without a lifetime. var stores = new ConcurrentQueue(); var services = new ServiceCollection(); services.AddLogging(); - services.AddFoundryResponses( - AgentName, - _ => new TrackingAgent(new AgentLifetimeLog()), - ServiceLifetime.Singleton, - _ => - { - var store = new RecordingSessionStore(); - stores.Enqueue(store); - return store; - }); + services.AddFoundryResponses(AgentName, _ => new TrackingAgent(new AgentLifetimeLog()), ServiceLifetime.Scoped); + services.AddFoundryAgentSessionStore(_ => + { + var store = new RecordingSessionStore(); + stores.Enqueue(store); + return store; + }); AgentFrameworkResponseHandler handler = ResolveHandler(services); // Act @@ -303,18 +305,42 @@ public async Task AddFoundryResponses_SingletonStoreFactory_CreatesOneStoreForTh } [Fact] - public async Task AddFoundryResponses_StoreFactoryReturnsNull_ThrowsInvalidOperationExceptionAsync() + public async Task AddFoundryAgentSessionStore_Instance_IsNeverDisposedByTheContainerAsync() + { + // Arrange: a shared store object used by a scoped agent. + var store = new RecordingSessionStore(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddFoundryResponses(AgentName, _ => new TrackingAgent(new AgentLifetimeLog()), ServiceLifetime.Scoped); + services.AddFoundryAgentSessionStore(store); + ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); + var handler = Assert.IsType(provider.GetRequiredService()); + + // Act + await DrainAsync(handler, CreateRequest(AgentName), CreateContext()); + await DrainAsync(handler, CreateRequest(agentName: null), CreateContext()); + await provider.DisposeAsync(); + + // Assert: the caller owns the store, so neither the response runs nor shutdown dispose it. + Assert.NotEmpty(store.Identities); + Assert.Equal(0, store.DisposeCount); + } + + [Fact] + public async Task AddFoundryAgentSessionStore_FactoryReturnsNull_ThrowsInvalidOperationExceptionAsync() { // Arrange var services = new ServiceCollection(); services.AddLogging(); - services.AddFoundryResponses(AgentName, _ => new TrackingAgent(new AgentLifetimeLog()), ServiceLifetime.Scoped, _ => null!); + services.AddFoundryResponses(AgentName, _ => new TrackingAgent(new AgentLifetimeLog()), ServiceLifetime.Scoped); + services.AddFoundryAgentSessionStore(_ => null!, ServiceLifetime.Scoped, AgentName); AgentFrameworkResponseHandler handler = ResolveHandler(services); // Act & Assert InvalidOperationException exception = await Assert.ThrowsAsync( () => DrainAsync(handler, CreateRequest(AgentName), CreateContext())); Assert.Contains("session store factory", exception.Message, StringComparison.Ordinal); + Assert.Contains(AgentName, exception.Message, StringComparison.Ordinal); } [Fact] @@ -323,7 +349,8 @@ public async Task AddFoundryResponses_FactoryReturnsNull_ThrowsInvalidOperationE // Arrange var services = new ServiceCollection(); services.AddLogging(); - services.AddFoundryResponses(AgentName, _ => null!, ServiceLifetime.Scoped, _ => new TestAgentSessionStore()); + services.AddFoundryAgentSessionStore(new TestAgentSessionStore()); + services.AddFoundryResponses(AgentName, _ => null!, ServiceLifetime.Scoped); AgentFrameworkResponseHandler handler = ResolveHandler(services); // Act & Assert @@ -348,51 +375,268 @@ public void AddFoundryResponses_Factory_InvalidArguments_Throw() } [Fact] - public void AddFoundryResponses_Factory_RegistersOnlyKeyedAgentAndStoreWithTheRequestedLifetime() + public void AddFoundryAgentSessionStore_InvalidArguments_Throw() + { + // Arrange + var services = new ServiceCollection(); + var store = new TestAgentSessionStore(); + + // Act & Assert + Assert.Throws(() => ((IServiceCollection)null!).AddFoundryAgentSessionStore(store)); + Assert.Throws(() => services.AddFoundryAgentSessionStore((AgentSessionStore)null!)); + Assert.Throws(() => services.AddFoundryAgentSessionStore((Func)null!)); + Assert.Throws(() => services.AddFoundryAgentSessionStore(store, " ")); + Assert.Throws(() => services.AddFoundryAgentSessionStore(_ => store, ServiceLifetime.Singleton, string.Empty)); + Assert.Throws(() => services.AddFoundryAgentSessionStore(_ => store, (ServiceLifetime)42)); + Assert.Throws(() => ((IHostApplicationBuilder)null!).AddFoundryAgentSessionStore(store)); + Assert.Empty(services); + } + + [Fact] + public void AddFoundryResponses_Factory_RegistersOnlyTheKeyedAgentWithTheRequestedLifetime() { // Arrange var services = new ServiceCollection(); // Act - services.AddFoundryResponses( - AgentName, - _ => new TrackingAgent(new AgentLifetimeLog()), - ServiceLifetime.Scoped, - _ => new TestAgentSessionStore()); + services.AddFoundryResponses(AgentName, _ => new TrackingAgent(new AgentLifetimeLog()), ServiceLifetime.Scoped); - // Assert: one registration per created object, so DI owns its disposal once. + // Assert: one registration per created agent, so DI owns its disposal once, and no store of its own. ServiceDescriptor keyed = Assert.Single(services, d => d.ServiceType == typeof(AIAgent)); Assert.True(keyed.IsKeyedService); Assert.Equal(AgentName, keyed.ServiceKey); Assert.Equal(ServiceLifetime.Scoped, keyed.Lifetime); - ServiceDescriptor keyedStore = Assert.Single(services, d => d.ServiceType == typeof(AgentSessionStore) && d.IsKeyedService); - Assert.Equal(AgentName, keyedStore.ServiceKey); - Assert.Equal(ServiceLifetime.Scoped, keyedStore.Lifetime); - Assert.All( - services.Where(d => d.ServiceType == typeof(AgentSessionStore) && !d.IsKeyedService), - d => Assert.Equal(ServiceLifetime.Singleton, d.Lifetime)); - FoundryAgentFactoryRegistration marker = Assert.Single( - services.Where(d => d.ServiceType == typeof(FoundryAgentFactoryRegistration)) - .Select(d => Assert.IsType(d.ImplementationInstance))); - Assert.Equal(AgentName, marker.AgentName); - Assert.True(marker.IsDefault); + Assert.DoesNotContain(services, d => d.ServiceType == typeof(AgentSessionStore) && d.IsKeyedService); + ServiceDescriptor defaultStore = Assert.Single(services, d => d.ServiceType == typeof(AgentSessionStore)); + Assert.Equal(ServiceLifetime.Singleton, defaultStore.Lifetime); + FoundryDefaultAgentName defaultAgentName = Assert.IsType( + Assert.Single(services, d => d.ServiceType == typeof(FoundryDefaultAgentName)).ImplementationInstance); + Assert.Equal(AgentName, defaultAgentName.Name); } [Fact] - public void AddFoundryResponses_FactoryWithoutStoreFactory_RegistersTheKeyedStoreAsSingleton() + public void AddFoundryResponses_SecondFactoryAgent_IsNotRecordedAsTheDefault() { // Arrange var services = new ServiceCollection(); // Act - services.AddFoundryResponses(AgentName, _ => new TrackingAgent(new AgentLifetimeLog()), ServiceLifetime.Scoped); + services.AddFoundryResponses("first", _ => new TrackingAgent(new AgentLifetimeLog(), name: "first")); + services.AddFoundryResponses("second", _ => new TrackingAgent(new AgentLifetimeLog(), name: "second")); - // Assert: without a store factory, the lifetime applies to the agent only. - ServiceDescriptor keyedStore = Assert.Single(services, d => d.ServiceType == typeof(AgentSessionStore) && d.IsKeyedService); - Assert.Equal(ServiceLifetime.Singleton, keyedStore.Lifetime); - Assert.IsType(keyedStore.KeyedImplementationInstance); + // Assert + FoundryDefaultAgentName defaultAgentName = Assert.IsType( + Assert.Single(services, d => d.ServiceType == typeof(FoundryDefaultAgentName)).ImplementationInstance); + Assert.Equal("first", defaultAgentName.Name); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AddFoundryAgentSessionStore_ForAllAgents_ReplacesTheDefaultStoreInAnyOrder(bool registerStoreFirst) + { + // Arrange + var store = new TestAgentSessionStore(); + var services = new ServiceCollection(); + services.AddLogging(); + + // Act + if (registerStoreFirst) + { + services.AddFoundryAgentSessionStore(store); + services.AddFoundryResponses(AgentName, _ => new TrackingAgent(new AgentLifetimeLog())); + } + else + { + services.AddFoundryResponses(AgentName, _ => new TrackingAgent(new AgentLifetimeLog())); + services.AddFoundryAgentSessionStore(store); + } + + using ServiceProvider provider = services.BuildServiceProvider(); + + // Assert + Assert.Single(services, d => d.ServiceType == typeof(AgentSessionStore)); + Assert.Same(store, provider.GetRequiredService()); + } + + [Fact] + public void AddFoundryAgentSessionStore_CalledTwice_TheLastCallWins() + { + // Arrange + var first = new TestAgentSessionStore(); + var second = new TestAgentSessionStore(); + var firstForAgent = new TestAgentSessionStore(); + var secondForAgent = new TestAgentSessionStore(); + var services = new ServiceCollection(); + + // Act + services.AddFoundryAgentSessionStore(first); + services.AddFoundryAgentSessionStore(firstForAgent, AgentName); + services.AddFoundryAgentSessionStore(_ => second, ServiceLifetime.Scoped); + services.AddFoundryAgentSessionStore(secondForAgent, AgentName); + using ServiceProvider provider = services.BuildServiceProvider(); + using IServiceScope scope = provider.CreateScope(); + + // Assert: replacing the store for all agents keeps the store registered for the agent name. + Assert.Equal(2, services.Count(d => d.ServiceType == typeof(AgentSessionStore))); + Assert.Same(second, scope.ServiceProvider.GetRequiredService()); + Assert.Same(secondForAgent, scope.ServiceProvider.GetRequiredKeyedService(AgentName)); + } + + [Fact] + public void AddFoundryAgentSessionStore_HostBuilder_RegistersTheStoreWithTheRequestedLifetime() + { + // Arrange + HostApplicationBuilder builder = Host.CreateApplicationBuilder(); + var store = new TestAgentSessionStore(); + + // Act + builder.AddFoundryAgentSessionStore(store); + builder.AddFoundryAgentSessionStore(_ => new TestAgentSessionStore(), ServiceLifetime.Transient, AgentName); + + // Assert + ServiceDescriptor forAllAgents = Assert.Single(builder.Services, d => d.ServiceType == typeof(AgentSessionStore) && !d.IsKeyedService); + Assert.Same(store, forAllAgents.ImplementationInstance); + ServiceDescriptor forAgent = Assert.Single(builder.Services, d => d.ServiceType == typeof(AgentSessionStore) && d.IsKeyedService); + Assert.Equal(AgentName, forAgent.ServiceKey); + Assert.Equal(ServiceLifetime.Transient, forAgent.Lifetime); + } + + [Fact] + public async Task AddFoundryAgentSessionStore_ForAgentName_TakesPrecedenceOverTheStoreForAllAgentsAsync() + { + // Arrange + var storeForAll = new RecordingSessionStore(); + var storeForSecond = new RecordingSessionStore(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddFoundryResponses("first", _ => new TrackingAgent(new AgentLifetimeLog(), name: "first"), ServiceLifetime.Scoped); + services.AddFoundryResponses("second", _ => new TrackingAgent(new AgentLifetimeLog(), name: "second"), ServiceLifetime.Scoped); + services.AddFoundryAgentSessionStore(storeForAll); + services.AddFoundryAgentSessionStore(storeForSecond, "second"); + AgentFrameworkResponseHandler handler = ResolveHandler(services); + + // Act + string firstIdentity = await DrainAndGetIdentityAsync(handler, storeForAll, "first"); + string secondIdentity = await DrainAndGetIdentityAsync(handler, storeForSecond, "second"); + + // Assert + Assert.Equal("name:first", firstIdentity); + Assert.Equal("key:second", secondIdentity); + Assert.DoesNotContain("key:second", storeForAll.Identities); + Assert.DoesNotContain("name:first", storeForSecond.Identities); + } + + [Fact] + public async Task AddFoundryAgentSessionStore_ForAgentName_ReplacesTheStorePassedToTheInstanceOverloadAsync() + { + // Arrange + var instanceStore = new RecordingSessionStore(); + var replacement = new RecordingSessionStore(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddFoundryResponses(new TrackingAgent(new AgentLifetimeLog()), instanceStore); + services.AddFoundryAgentSessionStore(replacement, AgentName); + AgentFrameworkResponseHandler handler = ResolveHandler(services); + + // Act + await DrainAsync(handler, CreateRequest(AgentName), CreateContext()); + + // Assert + Assert.Empty(instanceStore.Identities); + Assert.NotEmpty(replacement.Identities); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task AddFoundryResponses_InstanceWithoutStore_UsesTheStoreForAllAgentsAsync(bool registerStoreFirst) + { + // Arrange + var storeForAll = new RecordingSessionStore(); + var services = new ServiceCollection(); + services.AddLogging(); + if (registerStoreFirst) + { + services.AddFoundryAgentSessionStore(storeForAll); + } + + services.AddFoundryResponses(new TrackingAgent(new AgentLifetimeLog())); + if (!registerStoreFirst) + { + services.AddFoundryAgentSessionStore(storeForAll); + } + + AgentFrameworkResponseHandler handler = ResolveHandler(services); + + // Act + string namedIdentity = await DrainAndGetIdentityAsync(handler, storeForAll, AgentName); + string unnamedIdentity = await DrainAndGetIdentityAsync(handler, storeForAll, agentName: null); + + // Assert + Assert.Equal($"name:{AgentName}", namedIdentity); + Assert.Equal(namedIdentity, unnamedIdentity); + } + + [Fact] + public async Task AddFoundryResponses_InstanceWithStore_DoesNotShareItsStoreWithOtherAgentsAsync() + { + // Arrange: a store passed for one agent, and another agent registered by hand without a store. + var instanceStore = new RecordingSessionStore(); + var storeForAll = new RecordingSessionStore(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddFoundryResponses(new TrackingAgent(new AgentLifetimeLog(), name: "first"), instanceStore); + services.AddKeyedSingleton("other", new TrackingAgent(new AgentLifetimeLog(), name: "other")); + services.AddFoundryAgentSessionStore(storeForAll); + AgentFrameworkResponseHandler handler = ResolveHandler(services); + + // Act + await DrainAsync(handler, CreateRequest("other"), CreateContext()); + await DrainAsync(handler, CreateRequest("first"), CreateContext()); + + // Assert + Assert.Equal("key:other", Assert.Single(storeForAll.Identities.Distinct())); + Assert.Equal("name:first", Assert.Single(instanceStore.Identities.Distinct())); + } + + [Fact] + public void AddFoundryResponses_InstanceWithStore_KeepsTheDefaultStoreForOtherAgents() + { + // Arrange + var instanceStore = new TestAgentSessionStore(); + var services = new ServiceCollection(); + services.AddLogging(); + + // Act + services.AddFoundryResponses(new TrackingAgent(new AgentLifetimeLog()), instanceStore); + using ServiceProvider provider = services.BuildServiceProvider(); + + // Assert + Assert.Same(instanceStore, provider.GetRequiredKeyedService(AgentName)); + Assert.IsType(provider.GetRequiredService()); + } + + [Fact] + public async Task CreateAsync_UnknownAgentName_UsesTheDefaultAgentsOwnStoreAsync() + { + // Arrange: a request naming no registered agent is answered by the default agent. + var instanceStore = new RecordingSessionStore(); + var storeForAll = new RecordingSessionStore(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddFoundryResponses(new TrackingAgent(new AgentLifetimeLog()), instanceStore); + services.AddFoundryAgentSessionStore(storeForAll); + AgentFrameworkResponseHandler handler = ResolveHandler(services); + + // Act + await DrainAsync(handler, CreateRequest("unknown"), CreateContext()); + await DrainAsync(handler, CreateRequest(agentName: null), CreateContext()); + + // Assert + Assert.Empty(storeForAll.Identities); + Assert.Equal($"name:{AgentName}", Assert.Single(instanceStore.Identities.Distinct())); + } [Theory] [InlineData(ServiceLifetime.Singleton)] [InlineData(ServiceLifetime.Scoped)] @@ -410,8 +654,9 @@ public async Task AddFoundryResponses_TwoFactoryAgents_UseTheInstanceOverloadSto var factoryStore = new RecordingSessionStore(); var factoryServices = new ServiceCollection(); factoryServices.AddLogging(); - factoryServices.AddFoundryResponses("first", _ => new TrackingAgent(new AgentLifetimeLog(), name: "first"), lifetime, _ => factoryStore); - factoryServices.AddFoundryResponses("second", _ => new TrackingAgent(new AgentLifetimeLog(), name: "second"), lifetime, _ => factoryStore); + factoryServices.AddFoundryAgentSessionStore(factoryStore); + factoryServices.AddFoundryResponses("first", _ => new TrackingAgent(new AgentLifetimeLog(), name: "first"), lifetime); + factoryServices.AddFoundryResponses("second", _ => new TrackingAgent(new AgentLifetimeLog(), name: "second"), lifetime); AgentFrameworkResponseHandler factoryHandler = ResolveHandler(factoryServices); // Act @@ -441,7 +686,8 @@ public async Task AddFoundryResponses_FactoryDefaultThenInstanceAgent_KeepsTheFa var store = new RecordingSessionStore(); var services = new ServiceCollection(); services.AddLogging(); - services.AddFoundryResponses("first", _ => new TrackingAgent(log, name: "first"), ServiceLifetime.Scoped, _ => store); + services.AddFoundryAgentSessionStore(store); + services.AddFoundryResponses("first", _ => new TrackingAgent(log, name: "first"), ServiceLifetime.Scoped); services.AddFoundryResponses(new TrackingAgent(new AgentLifetimeLog(), name: "second"), store); AgentFrameworkResponseHandler handler = ResolveHandler(services); @@ -464,7 +710,7 @@ public async Task AddFoundryResponses_NonKeyedAgentRegisteredLater_DoesNotShareT var services = new ServiceCollection(); services.AddLogging(); services.AddSingleton(store); - services.AddFoundryResponses(AgentName, _ => new TrackingAgent(new AgentLifetimeLog()), ServiceLifetime.Scoped, _ => store); + services.AddFoundryResponses(AgentName, _ => new TrackingAgent(new AgentLifetimeLog()), ServiceLifetime.Scoped); services.AddSingleton(new TrackingAgent(new AgentLifetimeLog())); AgentFrameworkResponseHandler handler = ResolveHandler(services); @@ -487,16 +733,16 @@ public async Task AddFoundryResponses_Factory_DisposesEachAgentAndStoreExactlyOn var stores = new ConcurrentQueue(); var services = new ServiceCollection(); services.AddLogging(); - services.AddFoundryResponses( - AgentName, - _ => new TrackingAgent(log), - lifetime, + services.AddFoundryResponses(AgentName, _ => new TrackingAgent(log), lifetime); + services.AddFoundryAgentSessionStore( _ => { var store = new RecordingSessionStore(); stores.Enqueue(store); return store; - }); + }, + lifetime, + AgentName); ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); var handler = Assert.IsType(provider.GetRequiredService()); @@ -525,7 +771,8 @@ public async Task AddFoundryResponses_SingletonFactory_DisposesTheAgentAndStoreO var store = new RecordingSessionStore(); var services = new ServiceCollection(); services.AddLogging(); - services.AddFoundryResponses(AgentName, _ => new TrackingAgent(log), ServiceLifetime.Singleton, _ => store); + services.AddFoundryResponses(AgentName, _ => new TrackingAgent(log), ServiceLifetime.Singleton); + services.AddFoundryAgentSessionStore(_ => store); ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); var handler = Assert.IsType(provider.GetRequiredService()); @@ -550,7 +797,8 @@ public async Task AddFoundryResponses_ExistingScopedKeyedAgent_IsNotCapturedBySi var services = new ServiceCollection(); services.AddLogging(); services.AddKeyedScoped(AgentName, (_, _) => new TrackingAgent(log)); - services.AddFoundryResponses(AgentName, _ => new TrackingAgent(new AgentLifetimeLog()), agentSessionStoreFactory: _ => new TestAgentSessionStore()); + services.AddFoundryAgentSessionStore(new TestAgentSessionStore()); + services.AddFoundryResponses(AgentName, _ => new TrackingAgent(new AgentLifetimeLog())); AgentFrameworkResponseHandler handler = ResolveHandler(services); // Act @@ -577,7 +825,7 @@ public void AddFoundryResponses_Factory_KeepsAnExistingKeyedRegistration() // Assert: the existing registration wins, like the instance overload's TryAdd behavior. ServiceDescriptor keyed = Assert.Single(services, d => d.ServiceType == typeof(AIAgent) && d.IsKeyedService); Assert.Same(existing, keyed.KeyedImplementationInstance); - Assert.DoesNotContain(services, d => d.ServiceType == typeof(FoundryAgentFactoryRegistration)); + Assert.DoesNotContain(services, d => d.ServiceType == typeof(FoundryDefaultAgentName)); } [Fact] @@ -607,8 +855,8 @@ public async Task BackgroundResponse_ScopedAgent_OutlivesTheHttpRequestAsync() builder.Services.AddFoundryResponses( AgentName, _ => new TrackingAgent(log, gate), - ServiceLifetime.Scoped, - _ => new TestAgentSessionStore()); + ServiceLifetime.Scoped); + builder.Services.AddFoundryAgentSessionStore(_ => new TestAgentSessionStore(), ServiceLifetime.Scoped); builder.Services.AddLogging(); await using WebApplication app = builder.Build(); app.MapFoundryResponses(); diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ResilientTwoLifetimeIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ResilientTwoLifetimeIntegrationTests.cs index 8f7a46e46d3..838175aa322 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ResilientTwoLifetimeIntegrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ResilientTwoLifetimeIntegrationTests.cs @@ -259,7 +259,8 @@ private static async Task StartServerAsync( var options = new FoundryResponsesServerOptions { ResilientBackground = true }; if (factoryLifetime is { } lifetime) { - builder.Services.AddFoundryResponses(agentName, _ => createAgent(), lifetime, _ => sessionStore, options); + builder.Services.AddFoundryResponses(agentName, _ => createAgent(), lifetime, options); + builder.Services.AddFoundryAgentSessionStore(sessionStore); } else { From 8e316de27039cde6f9ade23f32ec798504e4e167 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:47:45 -0800 Subject: [PATCH 4/4] Document how Foundry agents choose their session store Explain in the XML docs and the sample README that a store passed to AddFoundryResponses(agent, store) serves only that agent, that agents without a store use the store registered for all agents, and that moving an agent to a different store leaves its earlier sessions in the old store. --- .../Hosted-ChatClientAgent/README.md | 4 +- .../HostApplicationBuilderExtensions.cs | 42 ++++++++++++---- .../ServiceCollectionExtensions.cs | 49 ++++++++++++++----- 3 files changed, 72 insertions(+), 23 deletions(-) diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/README.md index 6c255898080..e8d6096d7ad 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/README.md @@ -89,7 +89,9 @@ Conversation state is not kept on the agent object; each turn loads the saved se stored under `agentName`, so switching from the instance overload keeps existing conversations. The session store has its own registration and lifetime, independent of the agent. Without one, every -agent uses the default store, a single shared instance: +agent uses the default store, a single shared instance. A store passed to `AddFoundryResponses(agent, store)` +is used only by that agent. Each store keeps its own sessions, so moving an agent to a different store +means the sessions it saved in the old store are no longer found: ```csharp // For every agent that has no store of its own. A store object you pass in is never disposed by DI. diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostApplicationBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostApplicationBuilderExtensions.cs index 3f0d85af187..e6969ce8721 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostApplicationBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostApplicationBuilderExtensions.cs @@ -25,6 +25,12 @@ public static class HostApplicationBuilderExtensions /// Keyed agent registrations may use any DI lifetime. Each response run resolves its agent and /// session store from a DI scope that is disposed when the run's event stream ends, so scoped and /// transient agents are created per run, including background runs that outlive the HTTP request. + /// For each run the handler uses the registered under the agent's name, + /// then the store registered for all agents (see + /// ), then the + /// default store. A request answered by the default agent uses that agent's store. Each store keeps its own + /// sessions, so changing which store an agent uses means sessions saved in the previous store are no + /// longer found. /// /// builder.Services.AddKeyedSingleton<AIAgent>("my-agent", myAgent); /// builder.AddFoundryResponses(); @@ -62,6 +68,16 @@ public static IHostApplicationBuilder AddFoundryResponses( /// application builder. /// /// + /// + /// A store passed in agentSessionStore is registered only under the agent's name. Every request this + /// agent answers uses it, including requests without a matching agent name, and other agents do not use it. + /// When the agent has no name, the store has no key to be registered under and is used for all agents + /// instead. When no store is passed, the agent uses the store registered for all agents, with + /// or a + /// non-keyed registration, and otherwise the default store. Each store keeps + /// its own sessions, so changing which store an agent uses means sessions saved in the previous store are no + /// longer found. + /// /// /// builder.AddFoundryResponses(myAgent); /// @@ -69,9 +85,8 @@ public static IHostApplicationBuilder AddFoundryResponses( /// The host application builder. /// The agent instance to register. /// - /// The session store for this agent. It is registered under the agent's name and is not used by other - /// agents. If null, the agent uses the store registered for all agents with - /// , or the default store. + /// The session store for this agent, used only by this agent. If null, the agent uses the store registered + /// for all agents, or the default store. /// /// Optional hosting behavior. Required inputs belong on the extension method parameters. /// The host application builder for chaining. @@ -132,7 +147,8 @@ public static IHostApplicationBuilder AddFoundryResponses( /// /// The session store is registered separately with /// , - /// with its own lifetime. Without such a registration the agent uses the default store. Sessions are + /// with its own lifetime. Without such a registration for , the agent uses the + /// store registered for all agents, or the default store. Sessions are /// stored in the same location AddFoundryResponses(agent) uses for an agent with that name /// registered in the same order, whatever the lifetime, so switching between the two overloads or /// between lifetimes keeps existing sessions readable. @@ -168,9 +184,13 @@ public static IHostApplicationBuilder AddFoundryResponses( /// /// /// The response handler uses the store registered under the agent's name, then the store registered for - /// all agents, then the default store. The DI container never disposes . + /// all agents, then the default store, for agents registered with any AddFoundryResponses overload + /// or directly in DI. The DI container never disposes . /// Calling this method again for the same , or again for all agents, replaces - /// the earlier store, in whatever order it is called relative to AddFoundryResponses. + /// the earlier store, including one passed to AddFoundryResponses(agent, agentSessionStore) or + /// registered directly in DI, in whatever order it is called relative to AddFoundryResponses. Each + /// store keeps its own sessions, so changing which store an agent uses means sessions saved in the previous + /// store are no longer found. /// /// builder.AddFoundryAgentSessionStore(mySessionStore); /// @@ -198,10 +218,12 @@ public static IHostApplicationBuilder AddFoundryAgentSessionStore( /// application builder. /// /// - /// The response handler looks the store up once per response run, from the DI scope of that run. The - /// store's lifetime is independent of the agent's lifetime. Calling this method again for the same - /// , or again for all agents, replaces the earlier store, in whatever order it - /// is called relative to AddFoundryResponses. + /// The response handler looks the store up once per response run, from the DI scope of that run, for + /// agents registered with any AddFoundryResponses overload or directly in DI. The store's lifetime is + /// independent of the agent's lifetime. Calling this method again for the same , + /// or again for all agents, replaces the earlier store, including one passed to + /// AddFoundryResponses(agent, agentSessionStore) or registered directly in DI, in whatever order it is + /// called relative to AddFoundryResponses. /// /// builder.AddFoundryAgentSessionStore( /// sp => new MySessionStore(sp.GetRequiredService<MyDbContext>()), diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs index 7f8e5a14fe8..6268421227e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs @@ -57,6 +57,15 @@ public static class ServiceCollectionExtensions /// transient agents are created per run, including background runs that outlive the HTTP request. /// /// + /// For each response run the handler uses the registered under the agent's + /// name, for example with AddFoundryAgentSessionStore(store, "my-agent"). Otherwise it uses the store + /// registered for all agents, with AddFoundryAgentSessionStore(store) or a non-keyed + /// registration, and otherwise the default store, which uses the AgentServer + /// SDK's local state-store fallback. A request answered by the default agent uses that agent's store. Each + /// store keeps its own sessions, so changing which store an agent uses means sessions saved in the previous + /// store are no longer found. + /// + /// /// Example: /// /// builder.Services.AddKeyedSingleton<AIAgent>("my-agent", myAgent); @@ -114,6 +123,16 @@ public static IServiceCollection AddFoundryResponses(this IServiceCollection ser /// a caller-provided identity for session isolation is unsupported. /// /// + /// A store passed in agentSessionStore is registered only under the agent's name. Every request this + /// agent answers uses it, including requests without a matching agent name, and other agents do not use it. + /// When the agent has no name, the store has no key to be registered under and is used for all agents + /// instead. When no store is passed, the agent uses the store registered for all agents, with + /// or a non-keyed + /// registration, and otherwise the default store. Each store keeps its own + /// sessions, so changing which store an agent uses means sessions saved in the previous store are no + /// longer found. + /// + /// /// Example: /// /// builder.Services.AddFoundryResponses(myAgent); @@ -126,10 +145,8 @@ public static IServiceCollection AddFoundryResponses(this IServiceCollection ser /// The service collection. /// The agent instance to register. /// - /// The session store for this agent. It is registered under the agent's name and is not used by other - /// agents. If null, the agent uses the store registered for all agents with - /// , or the default - /// store, which uses the AgentServer SDK's local state-store fallback. + /// The session store for this agent, used only by this agent. If null, the agent uses the store registered + /// for all agents, or the default store, which uses the AgentServer SDK's local state-store fallback. /// /// /// Optional , for example to allow the @@ -195,7 +212,8 @@ public static IServiceCollection AddFoundryResponses( /// The session store is registered separately with /// , /// with its own lifetime, because the response handler uses the store directly rather than through the - /// agent. Without such a registration the agent uses the default store. Conversation state is not kept on + /// agent. Without such a registration for , the agent uses the store registered + /// for all agents, or the default store. Conversation state is not kept on /// the agent object: each turn loads the saved session from the store's backing storage. Sessions are /// stored in the same location AddFoundryResponses(agent) uses for an agent with that name /// registered in the same order, whatever the lifetime, so switching between the two overloads or @@ -245,14 +263,18 @@ public static IServiceCollection AddFoundryResponses( /// /// For each response run the response handler uses the store registered under the agent's name, then the /// store registered for all agents, then the default store, which uses the AgentServer SDK's local - /// state-store fallback. The store receives the agent on every call and partitions sessions by agent and - /// by user, so one store can serve several agents. + /// state-store fallback. This applies to agents registered with any AddFoundryResponses overload or + /// directly in DI, and a request answered by the default agent uses that agent's store. The store receives + /// the agent on every call and partitions sessions by agent and by user, so one store can serve several + /// agents. Each store keeps its own sessions, so changing which store an agent uses means sessions saved in + /// the previous store are no longer found. /// /// /// The DI container never disposes , because the caller created it. /// Calling this method again for the same , or again for all agents, replaces - /// the earlier store, including one passed to AddFoundryResponses(agent, agentSessionStore), in - /// whatever order the methods are called. + /// the earlier store, including one passed to AddFoundryResponses(agent, agentSessionStore) or + /// registered directly as a keyed or non-keyed , in whatever order the + /// methods are called. /// /// /// Example: @@ -292,7 +314,8 @@ public static IServiceCollection AddFoundryAgentSessionStore( /// /// For each response run the response handler looks the store up once, from the DI scope of that run, /// using the store registered under the agent's name, then the store registered for all agents, then the - /// default store. The store's lifetime is independent of the agent's lifetime: + /// default store. This applies to agents registered with any AddFoundryResponses overload or + /// directly in DI. The store's lifetime is independent of the agent's lifetime: /// /// : one instance for the process, disposed at shutdown. /// : one instance per response run, disposed when the run ends. @@ -304,8 +327,10 @@ public static IServiceCollection AddFoundryAgentSessionStore( /// /// /// Calling this method again for the same , or again for all agents, replaces - /// the earlier store, including one passed to AddFoundryResponses(agent, agentSessionStore), in - /// whatever order the methods are called. + /// the earlier store, including one passed to AddFoundryResponses(agent, agentSessionStore) or + /// registered directly as a keyed or non-keyed , in whatever order the + /// methods are called. Each store keeps its own sessions, so changing which store an agent uses means + /// sessions saved in the previous store are no longer found. /// /// /// Example: