diff --git a/dotnet/eng/MSBuild/Shared.props b/dotnet/eng/MSBuild/Shared.props
index 10db0a47ef4..3c8f9670284 100644
--- a/dotnet/eng/MSBuild/Shared.props
+++ b/dotnet/eng/MSBuild/Shared.props
@@ -38,6 +38,9 @@
+
+
+
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs
index e71a6893bac..2dc62f20800 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs
@@ -40,8 +40,16 @@ public static class AGUIEndpointRouteBuilderExtensions
/// The URL pattern for the endpoint.
/// An for the mapped endpoint.
///
+ ///
+ /// Singleton registrations are resolved once when the endpoint is mapped. Scoped and transient registrations are
+ /// resolved for each request as described in .
+ /// In both cases, the session store is resolved for each request and persisted sessions are partitioned by the
+ /// registration name.
+ ///
+ ///
/// See for authentication,
/// authorization, and caller-scoped session isolation requirements.
+ ///
///
public static IEndpointConventionBuilder MapAGUIServer(
this IEndpointRouteBuilder endpoints,
@@ -50,7 +58,16 @@ public static IEndpointConventionBuilder MapAGUIServer(
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentNullException.ThrowIfNull(agentBuilder);
- return endpoints.MapAGUIServer(agentBuilder.Name, pattern);
+
+ if (agentBuilder.Lifetime != ServiceLifetime.Singleton)
+ {
+ return endpoints.MapAGUIServer(agentBuilder.Name, pattern);
+ }
+
+ string agentName = agentBuilder.Name;
+ var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName);
+ return MapAGUIServerCore(endpoints, pattern, context =>
+ CreateHostAgent(context.RequestServices, agent, agentName, sessionStorageIdentity: agentName));
}
///
@@ -61,8 +78,17 @@ public static IEndpointConventionBuilder MapAGUIServer(
/// The URL pattern for the endpoint.
/// An for the mapped endpoint.
///
+ ///
+ /// The keyed , its keyed , and the
+ /// are resolved from
+ /// for each request, so the configured DI lifetime of the agent registration is honored.
+ /// Persisted sessions are partitioned by , which remains stable across
+ /// scoped or transient agent instances.
+ ///
+ ///
/// See for authentication,
/// authorization, and caller-scoped session isolation requirements.
+ ///
///
public static IEndpointConventionBuilder MapAGUIServer(
this IEndpointRouteBuilder endpoints,
@@ -72,8 +98,63 @@ public static IEndpointConventionBuilder MapAGUIServer(
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentNullException.ThrowIfNull(agentName);
- var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName);
- return endpoints.MapAGUIServer(pattern, agent);
+ HostedAgentResolution.EnsureAgentRegistered(endpoints.ServiceProvider, agentName);
+
+ return MapAGUIServerCore(endpoints, pattern, context =>
+ {
+ var agent = context.RequestServices.GetRequiredKeyedService(agentName);
+ return CreateHostAgent(context.RequestServices, agent, agentName, sessionStorageIdentity: agentName);
+ });
+ }
+
+ ///
+ /// Maps an AG-UI agent endpoint that selects the agent to invoke for each request.
+ ///
+ /// The endpoint route builder.
+ /// The URL pattern for the endpoint.
+ ///
+ /// A delegate invoked once per request that returns the agent to invoke, or when
+ /// no agent matches the request. The delegate can use route values, claims, headers, and
+ /// to select or resolve the agent.
+ ///
+ /// An for the mapped endpoint.
+ ///
+ ///
+ /// When returns , the endpoint responds with
+ /// 404 Not Found without loading session state.
+ ///
+ ///
+ /// The selected agent's is its stable logical identity: it is used to resolve the
+ /// keyed from and to partition
+ /// persisted sessions, so conversations continue across requests even when the selector returns a new agent
+ /// instance each time. The selected agent must therefore have a non-empty name, and agents that must not
+ /// share persisted sessions must have different names.
+ ///
+ ///
+ /// Selection is not an authorization boundary. Enforce authorization with ASP.NET Core authorization
+ /// policies, or return from for agents the caller must
+ /// not reach. See for authentication,
+ /// authorization, and caller-scoped session isolation requirements.
+ ///
+ ///
+ public static IEndpointConventionBuilder MapAGUIServer(
+ this IEndpointRouteBuilder endpoints,
+ [StringSyntax("route")] string pattern,
+ Func agentSelector)
+ {
+ ArgumentNullException.ThrowIfNull(endpoints);
+ ArgumentNullException.ThrowIfNull(agentSelector);
+
+ return MapAGUIServerCore(endpoints, pattern, context =>
+ {
+ if (agentSelector(context) is not { } agent)
+ {
+ return null;
+ }
+
+ string agentName = HostedAgentResolution.GetSelectedAgentName(agent);
+ return CreateHostAgent(context.RequestServices, agent, agentName, sessionStorageIdentity: agentName);
+ });
}
///
@@ -88,6 +169,15 @@ public static IEndpointConventionBuilder MapAGUIServer(
/// If an is registered in dependency injection keyed by the agent's name,
/// it will be used to persist conversation sessions across requests using the AG-UI thread ID as the
/// conversation identifier. If no session store is registered, sessions are ephemeral (not persisted).
+ /// The supplied agent instance is used for every request, while the session store is resolved from
+ /// for each request.
+ ///
+ ///
+ /// The agent's is its stable logical identity: it keys the session store and partitions
+ /// persisted sessions, as it does for the named, hosted, and selector mappings. The same agent exposed through
+ /// several mappings therefore continues the same conversations for a given thread ID, and so does any agent
+ /// instance with the same name that uses the same session store. Agents that must not share persisted sessions must
+ /// have different names. Sessions of an agent without a name are partitioned by its instance identity.
///
///
/// Trust model. The AG-UI RunAgentInput.ThreadId arrives
@@ -125,18 +215,18 @@ public static IEndpointConventionBuilder MapAGUIServer(
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentNullException.ThrowIfNull(aiAgent);
- var agentSessionStore = endpoints.ServiceProvider.GetKeyedService(aiAgent.Name);
-
- // Ensure that we have an IsolationKeyScopedAgentSessionStore registered.
- var isolationKeyProvider = endpoints.ServiceProvider.GetService();
- if (agentSessionStore?.GetService() is null)
- {
- agentSessionStore ??= new NoopAgentSessionStore();
- agentSessionStore = new IsolationKeyScopedAgentSessionStore(agentSessionStore, isolationKeyProvider, new() { Strict = isolationKeyProvider != null });
- }
-
- var hostAgent = new AIHostAgent(aiAgent, agentSessionStore);
+ // A named agent is identified by its name, like the named, hosted, and selector mappings, so the same agent
+ // exposed through several mappings continues the same conversations. Unnamed agents keep their instance identity.
+ string? sessionStorageIdentity = string.IsNullOrWhiteSpace(aiAgent.Name) ? null : aiAgent.Name;
+ return MapAGUIServerCore(endpoints, pattern, context =>
+ CreateHostAgent(context.RequestServices, aiAgent, aiAgent.Name, sessionStorageIdentity));
+ }
+ private static IEndpointConventionBuilder MapAGUIServerCore(
+ IEndpointRouteBuilder endpoints,
+ string pattern,
+ Func hostAgentFactory)
+ {
IEndpointConventionBuilder endpoint = endpoints.MapPost(pattern, async (
[FromBody] RunAgentInput? input,
[FromServices] IOptions jsonOptions,
@@ -148,6 +238,12 @@ public static IEndpointConventionBuilder MapAGUIServer(
return Results.BadRequest();
}
+ // Resolve the agent before any session state is loaded so an unmatched selection never touches storage.
+ if (hostAgentFactory(context) is not { } hostAgent)
+ {
+ return Results.NotFound();
+ }
+
var jsonSerializerOptions = jsonOptions.Value.SerializerOptions;
var streamOptions = context.GetEndpoint()?.Metadata.GetMetadata()
?? context.RequestServices.GetService>()?.Value;
@@ -190,6 +286,25 @@ public static IEndpointConventionBuilder MapAGUIServer(
return endpoint;
}
+ private static AIHostAgent CreateHostAgent(
+ IServiceProvider services,
+ AIAgent agent,
+ string? sessionStoreKey,
+ string? sessionStorageIdentity)
+ {
+ var agentSessionStore = services.GetKeyedService(sessionStoreKey);
+
+ // Ensure that we have an IsolationKeyScopedAgentSessionStore registered.
+ var isolationKeyProvider = services.GetService();
+ if (agentSessionStore?.GetService() is null)
+ {
+ agentSessionStore ??= new NoopAgentSessionStore();
+ agentSessionStore = new IsolationKeyScopedAgentSessionStore(agentSessionStore, isolationKeyProvider, new() { Strict = isolationKeyProvider != null });
+ }
+
+ return new AIHostAgent(agent, agentSessionStore, sessionStorageIdentity);
+ }
+
private static void MarkFeatureUsed()
{
#pragma warning disable MAAI001
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj
index ac9b03f75a4..3cb7c69355d 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj
@@ -7,6 +7,7 @@
$(NoWarn);MAAI001$(InterceptorsNamespaces);Microsoft.AspNetCore.Http.Generatedtrue
+ true
diff --git a/dotnet/src/Shared/HostedAgents/HostedAgentResolution.cs b/dotnet/src/Shared/HostedAgents/HostedAgentResolution.cs
new file mode 100644
index 00000000000..42d9638d61a
--- /dev/null
+++ b/dotnet/src/Shared/HostedAgents/HostedAgentResolution.cs
@@ -0,0 +1,52 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Microsoft.Agents.AI.Hosting;
+
+///
+/// Provides validation shared by protocol hosting integrations that resolve agents for each request.
+///
+internal static class HostedAgentResolution
+{
+ ///
+ /// Verifies that a keyed registration exists without resolving it.
+ ///
+ /// The application service provider.
+ /// The name of the keyed agent registration.
+ /// No agent is registered with .
+ ///
+ /// Resolving the agent from the application service provider would capture scoped or transient registrations,
+ /// so the check is skipped when the provider cannot report keyed registrations.
+ ///
+ public static void EnsureAgentRegistered(IServiceProvider services, string agentName)
+ {
+ if (services.GetService() is { } isKeyedService &&
+ !isKeyedService.IsKeyedService(typeof(AIAgent), agentName))
+ {
+ throw new InvalidOperationException(
+ $"No {nameof(AIAgent)} is registered with the name '{agentName}'. " +
+ $"Register it with AddAIAgent(\"{agentName}\", ...) before mapping the endpoint.");
+ }
+ }
+
+ ///
+ /// Gets the stable logical identity of an agent returned by a request-time agent selector.
+ ///
+ /// The selected agent.
+ /// The agent name, which identifies the agent's hosted services and persisted sessions.
+ /// The agent does not have a name.
+ public static string GetSelectedAgentName(AIAgent agent)
+ {
+ string? agentName = agent.Name;
+ if (string.IsNullOrWhiteSpace(agentName))
+ {
+ throw new InvalidOperationException(
+ $"The agent selector returned an {nameof(AIAgent)} without a {nameof(AIAgent.Name)}. " +
+ "Selected agents must have a stable name, which identifies their hosted services and persisted sessions across requests.");
+ }
+
+ return agentName!;
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs
index 5c6367ef652..591c3c4909a 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs
@@ -6,6 +6,7 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
@@ -74,16 +75,12 @@ public void MapAGUIServer_MapsEndpoint_AtSpecifiedPattern()
}
[Fact]
- public void MapAGUIServer_WithAgentName_ResolvesKeyedAgentFromDI()
+ public void MapAGUIServer_WithAgentName_DoesNotResolveAgentAtMapTime()
{
// Arrange
Mock endpointsMock = new();
Mock serviceProviderMock = new();
- AIAgent agent = new NamedTestAgent();
-
- serviceProviderMock.As()
- .Setup(sp => sp.GetRequiredKeyedService(typeof(AIAgent), "test-agent"))
- .Returns(agent);
+ serviceProviderMock.As();
endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object);
endpointsMock.Setup(e => e.DataSources).Returns([]);
@@ -94,23 +91,48 @@ public void MapAGUIServer_WithAgentName_ResolvesKeyedAgentFromDI()
// Assert
Assert.NotNull(result);
serviceProviderMock.As()
- .Verify(sp => sp.GetRequiredKeyedService(typeof(AIAgent), "test-agent"), Times.Once);
+ .Verify(sp => sp.GetRequiredKeyedService(typeof(AIAgent), It.IsAny