From c3b697d6fcbd7b36754da3479d1d7de6fdda58b4 Mon Sep 17 00:00:00 2001 From: Marco Minerva Date: Tue, 29 Sep 2026 17:10:59 +0000 Subject: [PATCH 1/6] .NET: Honor hosted agent lifetimes and add request-time agent selection --- dotnet/eng/MSBuild/Shared.props | 3 + .../A2AEndpointRouteBuilderExtensions.cs | 121 ++++++ ...ft.Agents.AI.Hosting.A2A.AspNetCore.csproj | 4 +- .../SelectedAgentA2ARequestHandler.cs | 160 ++++++++ .../A2AAgentHandler.cs | 12 +- .../A2AServerServiceCollectionExtensions.cs | 113 ++++-- .../Microsoft.Agents.AI.Hosting.A2A.csproj | 2 + .../ScopedA2AAgentHandler.cs | 84 +++++ .../AGUIEndpointRouteBuilderExtensions.cs | 133 ++++++- ...t.Agents.AI.Hosting.AGUI.AspNetCore.csproj | 1 + ...tRouteBuilderExtensions.ChatCompletions.cs | 113 +++++- ...ndpointRouteBuilderExtensions.Responses.cs | 102 ++++- .../Microsoft.Agents.AI.Hosting.OpenAI.csproj | 1 + .../Responses/AIAgentResponseExecutor.cs | 9 +- .../Responses/IResponsesService.cs | 36 ++ .../Responses/InMemoryResponsesService.cs | 49 ++- .../Responses/ResponsesHttpHandler.cs | 44 ++- .../HostedAgents/HostedAgentResolution.cs | 52 +++ .../A2ARequestScopedAgentTests.cs | 357 ++++++++++++++++++ ...AServerServiceCollectionExtensionsTests.cs | 4 +- ...AGUIEndpointRouteBuilderExtensionsTests.cs | 91 ++++- .../AGUIRequestScopedAgentTests.cs | 357 ++++++++++++++++++ ...I.Hosting.AGUI.AspNetCore.UnitTests.csproj | 1 + ...enAIChatCompletionsAgentResolutionTests.cs | 196 ++++++++++ .../ResponseSessionRegressionTests.cs | 109 ++++++ 25 files changed, 2047 insertions(+), 107 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/SelectedAgentA2ARequestHandler.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.A2A/ScopedA2AAgentHandler.cs create mode 100644 dotnet/src/Shared/HostedAgents/HostedAgentResolution.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2ARequestScopedAgentTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIRequestScopedAgentTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsAgentResolutionTests.cs diff --git a/dotnet/eng/MSBuild/Shared.props b/dotnet/eng/MSBuild/Shared.props index 4e126e4be64..249acf90448 100644 --- a/dotnet/eng/MSBuild/Shared.props +++ b/dotnet/eng/MSBuild/Shared.props @@ -38,4 +38,7 @@ + + + diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs index 919f40b5937..72ba452191b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs @@ -6,6 +6,8 @@ using A2A.AspNetCore; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Hosting; +using Microsoft.Agents.AI.Hosting.A2A; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Shared.DiagnosticIds; @@ -99,6 +101,56 @@ public static IEndpointConventionBuilder MapA2AHttpJson(this IEndpointRouteBuild return endpoint; } + /// + /// Maps A2A HTTP+JSON endpoints that select the agent to serve for each request. + /// + /// The to add the A2A endpoints to. + /// The route path prefix for A2A endpoints. + /// + /// A delegate invoked once per request that returns the agent to serve, or when + /// no agent matches the request. The delegate can use route values, claims, headers, and + /// to select or resolve the agent. + /// + /// An optional callback to configure the applied to every selected agent. + /// An for further endpoint configuration. + /// + /// + /// When returns , the endpoint responds with + /// 404 Not Found before any session or task state is loaded. No AddA2AServer registration is required. + /// + /// + /// The selected agent's is its stable logical identity. It keys the agent's + /// and registrations, partitions persisted sessions, and + /// identifies the A2A task state shared by the requests that select it through this endpoint, so conversations + /// and tasks continue across requests even when the selector returns a new agent instance each time. The selected + /// agent must therefore have a non-empty name. The session store is resolved from + /// . Tasks returned immediately keep running the selected agent after the + /// HTTP request completes, so agents used for such tasks must not depend on request-scoped services. + /// + /// + /// Selection is not an authorization boundary. See + /// for endpoint authorization and caller isolation requirements. + /// + /// + public static IEndpointConventionBuilder MapA2AHttpJson( + this IEndpointRouteBuilder endpoints, + string path, + Func agentSelector, + Action? configureOptions = null) + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentException.ThrowIfNullOrWhiteSpace(path); + ArgumentNullException.ThrowIfNull(agentSelector); + + var requestHandler = CreateSelectedAgentRequestHandler(endpoints, configureOptions); + var stubAgentCard = new AgentCard { Name = "A2A Agent" }; + + IEndpointConventionBuilder endpoint = endpoints.MapHttpA2A(requestHandler, stubAgentCard, path); + AddAgentSelection(endpoint, requestHandler, agentSelector); + MarkFeatureUsed(); + return endpoint; + } + /// /// Maps A2A JSON-RPC endpoints for the specified agent to the given path. /// An for the agent must be registered first by calling @@ -169,6 +221,75 @@ public static IEndpointConventionBuilder MapA2AJsonRpc(this IEndpointRouteBuilde return endpoint; } + /// + /// Maps A2A JSON-RPC endpoints that select the agent to serve for each request. + /// + /// The to add the A2A endpoints to. + /// The route path prefix for A2A endpoints. + /// + /// A delegate invoked once per request that returns the agent to serve, or when + /// no agent matches the request. The delegate can use route values, claims, headers, and + /// to select or resolve the agent. + /// + /// An optional callback to configure the applied to every selected agent. + /// An for further endpoint configuration. + /// + /// See + /// for selection, identity, and lifetime behavior, and + /// for endpoint authorization and caller isolation requirements, which also apply to this JSON-RPC binding. + /// + public static IEndpointConventionBuilder MapA2AJsonRpc( + this IEndpointRouteBuilder endpoints, + string path, + Func agentSelector, + Action? configureOptions = null) + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentException.ThrowIfNullOrWhiteSpace(path); + ArgumentNullException.ThrowIfNull(agentSelector); + + var requestHandler = CreateSelectedAgentRequestHandler(endpoints, configureOptions); + + IEndpointConventionBuilder endpoint = endpoints.MapA2A(requestHandler, path); + AddAgentSelection(endpoint, requestHandler, agentSelector); + MarkFeatureUsed(); + return endpoint; + } + + private static SelectedAgentA2ARequestHandler CreateSelectedAgentRequestHandler( + IEndpointRouteBuilder endpoints, + Action? configureOptions) + { + A2AServerRegistrationOptions? options = null; + if (configureOptions is not null) + { + options = new A2AServerRegistrationOptions(); + configureOptions(options); + } + + return new SelectedAgentA2ARequestHandler(endpoints.ServiceProvider, options); + } + + private static void AddAgentSelection( + IEndpointConventionBuilder endpoint, + SelectedAgentA2ARequestHandler requestHandler, + Func agentSelector) + { + // The A2A endpoints call the request handler without request context, so select the agent for the + // asynchronous flow of the request before the endpoint runs. + endpoint.AddEndpointFilter(async (invocationContext, next) => + { + HttpContext httpContext = invocationContext.HttpContext; + if (agentSelector(httpContext) is not { } agent) + { + return Results.NotFound(); + } + + requestHandler.Select(agent, httpContext.RequestServices); + return await next(invocationContext).ConfigureAwait(false); + }); + } + private static void MarkFeatureUsed() { #pragma warning disable MAAI001 diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj index b91ea40baa7..0333f13f23e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj @@ -12,8 +12,8 @@ - true - true + true diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/SelectedAgentA2ARequestHandler.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/SelectedAgentA2ARequestHandler.cs new file mode 100644 index 00000000000..c21bf1a9c16 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/SelectedAgentA2ARequestHandler.cs @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using A2A; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI.Hosting.A2A; + +/// +/// An that dispatches each A2A operation to the agent selected for the current request. +/// +/// +/// The A2A endpoints invoke this handler without request context, so the endpoint filter records the selected agent +/// for the asynchronous flow of the request before the endpoint runs. Each selected agent name owns an +/// whose task state is shared by all requests that select that name. +/// +[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)] +internal sealed class SelectedAgentA2ARequestHandler : IA2ARequestHandler +{ + private readonly AsyncLocal _selection = new(); + private readonly ConcurrentDictionary _servers = new(StringComparer.Ordinal); + private readonly IServiceProvider _applicationServices; + private readonly A2AServerRegistrationOptions? _options; + + /// + /// Initializes a new instance of the class. + /// + /// The application service provider that owns the shared task state. + /// The optional registration options applied to every selected agent. + public SelectedAgentA2ARequestHandler(IServiceProvider applicationServices, A2AServerRegistrationOptions? options) + { + this._applicationServices = applicationServices; + this._options = options; + } + + /// + /// Makes the target of the A2A operations dispatched in the current asynchronous flow. + /// + /// The agent selected for the request. + /// The request services that own the agent's session services. + public void Select(AIAgent agent, IServiceProvider requestServices) + { + string agentName = HostedAgentResolution.GetSelectedAgentName(agent); + SelectedAgentServer server = this._servers.GetOrAdd( + agentName, + static (name, state) => new SelectedAgentServer(state._applicationServices, name, state._options), + this); + + this._selection.Value = new Selection(agent, agentName, requestServices, server, this._options?.AgentRunMode ?? AgentRunMode.ReturnMessage); + } + + /// + public Task SendMessageAsync(SendMessageRequest request, CancellationToken cancellationToken = default) + { + Selection selection = this.GetSelection(); + selection.BindAgent(request.Message); + return selection.A2AServer.SendMessageAsync(request, cancellationToken); + } + + /// + public IAsyncEnumerable SendStreamingMessageAsync(SendMessageRequest request, CancellationToken cancellationToken = default) + { + Selection selection = this.GetSelection(); + selection.BindAgent(request.Message); + return selection.A2AServer.SendStreamingMessageAsync(request, cancellationToken); + } + + /// + public Task GetTaskAsync(GetTaskRequest request, CancellationToken cancellationToken = default) + => this.GetSelection().A2AServer.GetTaskAsync(request, cancellationToken); + + /// + public Task ListTasksAsync(ListTasksRequest request, CancellationToken cancellationToken = default) + => this.GetSelection().A2AServer.ListTasksAsync(request, cancellationToken); + + /// + public Task CancelTaskAsync(CancelTaskRequest request, CancellationToken cancellationToken = default) + => this.GetSelection().A2AServer.CancelTaskAsync(request, cancellationToken); + + /// + public IAsyncEnumerable SubscribeToTaskAsync(SubscribeToTaskRequest request, CancellationToken cancellationToken = default) + => this.GetSelection().A2AServer.SubscribeToTaskAsync(request, cancellationToken); + + /// + public Task CreateTaskPushNotificationConfigAsync(CreateTaskPushNotificationConfigRequest request, CancellationToken cancellationToken = default) + => this.GetSelection().A2AServer.CreateTaskPushNotificationConfigAsync(request, cancellationToken); + + /// + public Task GetTaskPushNotificationConfigAsync(GetTaskPushNotificationConfigRequest request, CancellationToken cancellationToken = default) + => this.GetSelection().A2AServer.GetTaskPushNotificationConfigAsync(request, cancellationToken); + + /// + public Task ListTaskPushNotificationConfigAsync(ListTaskPushNotificationConfigRequest request, CancellationToken cancellationToken = default) + => this.GetSelection().A2AServer.ListTaskPushNotificationConfigAsync(request, cancellationToken); + + /// + public Task DeleteTaskPushNotificationConfigAsync(DeleteTaskPushNotificationConfigRequest request, CancellationToken cancellationToken = default) + => this.GetSelection().A2AServer.DeleteTaskPushNotificationConfigAsync(request, cancellationToken); + + /// + public Task GetExtendedAgentCardAsync(GetExtendedAgentCardRequest request, CancellationToken cancellationToken = default) + => this.GetSelection().A2AServer.GetExtendedAgentCardAsync(request, cancellationToken); + + private Selection GetSelection() => + this._selection.Value ?? throw new InvalidOperationException("No agent was selected for the current A2A request."); + + private sealed class Selection( + AIAgent agent, + string agentName, + IServiceProvider requestServices, + SelectedAgentServer server, + AgentRunMode runMode) + { + public A2AServer A2AServer => server.A2AServer; + + public void BindAgent(Message message) + { + AIHostAgent hostAgent = A2AServerServiceCollectionExtensions.CreateHostAgent(requestServices, agent, agentName, sessionStorageIdentity: agentName); + server.Bind(message, new A2AAgentHandler(hostAgent, runMode)); + } + } + + /// + /// Owns the task state of one selected agent name and runs each message with the agent selected by its request. + /// + private sealed class SelectedAgentServer : IAgentHandler + { + // A2AServer passes the incoming message to the handler, including when it runs the handler in the background. + private readonly ConditionalWeakTable _handlers = new(); + + public SelectedAgentServer(IServiceProvider applicationServices, string agentName, A2AServerRegistrationOptions? options) + { + this.A2AServer = A2AServerServiceCollectionExtensions.CreateA2AServer(applicationServices, agentName, this, options); + } + + public A2AServer A2AServer { get; } + + public void Bind(Message message, IAgentHandler handler) => this._handlers.AddOrUpdate(message, handler); + + public Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) + { + if (context.Message is null || !this._handlers.TryGetValue(context.Message, out IAgentHandler? handler)) + { + throw new InvalidOperationException("The A2A message is not associated with the agent selected for its request."); + } + + return handler.ExecuteAsync(context, eventQueue, cancellationToken); + } + + public Task CancelAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) + => A2AAgentHandler.CancelTaskAsync(context, eventQueue, cancellationToken); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs index 8d3de412b6b..bdfd0ce2b9d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs @@ -67,7 +67,17 @@ public Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, Can } /// - public async Task CancelAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) + public Task CancelAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) + => CancelTaskAsync(context, eventQueue, cancellationToken); + + /// + /// Transitions the task identified by to the canceled state. + /// + /// The request context of the task to cancel. + /// The queue the cancellation event is written to. + /// A to cancel the operation. + /// A task that represents the asynchronous operation. + internal static async Task CancelTaskAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) { var taskUpdater = new TaskUpdater(eventQueue, context.TaskId, context.ContextId); await taskUpdater.CancelAsync(cancellationToken).ConfigureAwait(false); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs index b3fc808b213..c466e394de4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs @@ -30,6 +30,11 @@ public static class A2AServerServiceCollectionExtensions /// The for chaining. /// /// + /// Singleton agent registrations are resolved once when the server is created. Scoped and transient registrations + /// are resolved for each A2A operation as described in + /// . + /// + /// /// Trust model. The A2A contextId and taskId arrive /// from the wire and are treated as chain-resume identifiers — not as /// authorization tokens. accepts an explicit user partition, @@ -55,7 +60,7 @@ public static IHostedAgentBuilder AddA2AServer(this IHostedAgentBuilder agentBui { ArgumentNullException.ThrowIfNull(agentBuilder); - agentBuilder.ServiceCollection.AddA2AServer(agentBuilder.Name, configureOptions); + AddA2AServer(agentBuilder.ServiceCollection, agentBuilder.Name, agentBuilder.Lifetime, configureOptions); return agentBuilder; } @@ -121,13 +126,25 @@ public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder /// An optional callback to configure . /// The for chaining. /// + /// + /// The server and its task store are singletons shared by all requests. The keyed , its keyed + /// , and an optional keyed are resolved from a new + /// service scope for each A2A operation, so the configured DI lifetimes of these registrations are honored and + /// operations that continue in the background keep their services alive until they complete. Persisted sessions + /// are partitioned by , which remains stable across scoped or transient agent instances. + /// + /// /// See the trust-model remarks on /// for guidance on multi-user hosts (the wire contextId and taskId /// are chain-resume identifiers, not authorization tokens; multi-user hosts must /// supply a trusted user partition via UseClaimsBasedAgentIsolation(...) or /// a custom ). + /// /// public static IServiceCollection AddA2AServer(this IServiceCollection services, string agentName, Action? configureOptions = null) + => AddA2AServer(services, agentName, agentLifetime: null, configureOptions); + + private static IServiceCollection AddA2AServer(IServiceCollection services, string agentName, ServiceLifetime? agentLifetime, Action? configureOptions) { ArgumentNullException.ThrowIfNull(services); ArgumentException.ThrowIfNullOrWhiteSpace(agentName); @@ -141,8 +158,19 @@ public static IServiceCollection AddA2AServer(this IServiceCollection services, services.AddKeyedSingleton(agentName, (sp, _) => { - var agent = sp.GetRequiredKeyedService(agentName); - return CreateA2AServer(sp, agent, options); + // Custom handlers replace the agent, so the agent registration is only required without one. + bool hasCustomHandler = sp.GetService()?.IsKeyedService(typeof(IAgentHandler), agentName) ?? false; + AIAgent? agent = null; + if (!hasCustomHandler) + { + HostedAgentResolution.EnsureAgentRegistered(sp, agentName); + if (agentLifetime == ServiceLifetime.Singleton) + { + agent = sp.GetRequiredKeyedService(agentName); + } + } + + return CreateA2AServer(sp, agentName, agent, sessionStorageIdentity: agentName, options); }); return services; @@ -178,39 +206,45 @@ public static IServiceCollection AddA2AServer(this IServiceCollection services, configureOptions(options); } - services.AddKeyedSingleton(agent.Name, (sp, _) => CreateA2AServer(sp, agent, options)); + services.AddKeyedSingleton(agent.Name, (sp, _) => CreateA2AServer(sp, agent.Name, agent, sessionStorageIdentity: null, options)); return services; } - private static A2AServer CreateA2AServer(IServiceProvider serviceProvider, AIAgent agent, A2AServerRegistrationOptions? options) + private static A2AServer CreateA2AServer( + IServiceProvider serviceProvider, + string agentName, + AIAgent? agent, + string? sessionStorageIdentity, + A2AServerRegistrationOptions? options) { - var isolationKeyProvider = serviceProvider.GetService(); - - var agentHandler = serviceProvider.GetKeyedService(agent.Name); - if (agentHandler is null) - { - var agentSessionStore = serviceProvider.GetKeyedService(agent.Name); - var runMode = options?.AgentRunMode ?? AgentRunMode.ReturnMessage; - - // Ensure that we have an IsolationKeyScopedAgentSessionStore registered. - if (agentSessionStore?.GetService() is null) - { - agentSessionStore ??= new NoopAgentSessionStore(); - agentSessionStore = new IsolationKeyScopedAgentSessionStore(agentSessionStore, isolationKeyProvider, new() { Strict = isolationKeyProvider != null }); - } + // The server owns task state shared across requests, while the agent and its session services are + // resolved for each operation so that scoped and transient registrations keep their lifetimes. + var agentHandler = new ScopedA2AAgentHandler( + serviceProvider.GetRequiredService(), + agentName, + agent, + sessionStorageIdentity, + options?.AgentRunMode ?? AgentRunMode.ReturnMessage); - var hostAgent = new AIHostAgent( - innerAgent: agent, - sessionStore: agentSessionStore); - - agentHandler = new A2AAgentHandler(hostAgent, runMode); - } + return CreateA2AServer(serviceProvider, agentName, agentHandler, options); + } + /// + /// Creates an for the specified agent name with its keyed or default task store. + /// + /// The application service provider. + /// The name that keys the agent's hosted services. + /// The handler that executes A2A operations. + /// The optional registration options. + /// The created server. + internal static A2AServer CreateA2AServer(IServiceProvider serviceProvider, string agentName, IAgentHandler agentHandler, A2AServerRegistrationOptions? options) + { + var isolationKeyProvider = serviceProvider.GetService(); var loggerFactory = serviceProvider.GetService() ?? NullLoggerFactory.Instance; - ITaskStore taskStore = serviceProvider.GetKeyedService(agent.Name) ?? new InMemoryTaskStore(); + ITaskStore taskStore = serviceProvider.GetKeyedService(agentName) ?? new InMemoryTaskStore(); - // Wrap the task store with isolation key scoping, same as the session store above. + // Wrap the task store with isolation key scoping, same as the session store. if (taskStore is not IsolationKeyScopedTaskStore) { taskStore = new IsolationKeyScopedTaskStore(taskStore, isolationKeyProvider, strict: isolationKeyProvider != null); @@ -223,4 +257,29 @@ private static A2AServer CreateA2AServer(IServiceProvider serviceProvider, AIAge loggerFactory.CreateLogger(), options?.ServerOptions); } + + /// + /// Creates the hosting wrapper that persists the sessions of an agent served over A2A. + /// + /// The service provider that owns the agent's session services. + /// The agent to host. + /// The name that keys the agent's session store. + /// + /// The stable logical identity that partitions persisted sessions, or to use the agent instance identity. + /// + /// The hosting wrapper for . + internal static AIHostAgent CreateHostAgent(IServiceProvider services, AIAgent agent, string agentName, string? sessionStorageIdentity) + { + var isolationKeyProvider = services.GetService(); + var agentSessionStore = services.GetKeyedService(agentName); + + // Ensure that we have an IsolationKeyScopedAgentSessionStore registered. + if (agentSessionStore?.GetService() is null) + { + agentSessionStore ??= new NoopAgentSessionStore(); + agentSessionStore = new IsolationKeyScopedAgentSessionStore(agentSessionStore, isolationKeyProvider, new() { Strict = isolationKeyProvider != null }); + } + + return new AIHostAgent(agent, agentSessionStore, sessionStorageIdentity); + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj index 2db2d6b6f6a..627d123bbd5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj @@ -13,6 +13,7 @@ true true true + true @@ -28,6 +29,7 @@ + diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/ScopedA2AAgentHandler.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/ScopedA2AAgentHandler.cs new file mode 100644 index 00000000000..49352b2f8bf --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/ScopedA2AAgentHandler.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using A2A; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI.Hosting.A2A; + +/// +/// An that resolves the agent and its session services from a new service scope +/// for each A2A operation, so the configured DI lifetimes of the hosted registration are honored. +/// +/// +/// A2A operations can continue in the background after the originating HTTP request completes, so each +/// operation owns its scope instead of borrowing the request scope. +/// +[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)] +internal sealed class ScopedA2AAgentHandler : IAgentHandler +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly string _agentName; + private readonly AIAgent? _agent; + private readonly string? _sessionStorageIdentity; + private readonly AgentRunMode _runMode; + + /// + /// Initializes a new instance of the class. + /// + /// The factory used to create a scope for each operation. + /// The name that keys the agent registration and its hosted services. + /// A fixed agent instance, or to resolve the keyed registration in each scope. + /// + /// The stable logical identity that partitions persisted sessions, or to use the agent instance identity. + /// + /// Controls which A2A artifact the agent response is returned as. + public ScopedA2AAgentHandler(IServiceScopeFactory scopeFactory, string agentName, AIAgent? agent, string? sessionStorageIdentity, AgentRunMode runMode) + { + this._scopeFactory = scopeFactory; + this._agentName = agentName; + this._agent = agent; + this._sessionStorageIdentity = sessionStorageIdentity; + this._runMode = runMode; + } + + /// + public async Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) + { + AsyncServiceScope scope = this._scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + await this.CreateHandler(scope.ServiceProvider).ExecuteAsync(context, eventQueue, cancellationToken).ConfigureAwait(false); + } + } + + /// + public async Task CancelAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) + { + AsyncServiceScope scope = this._scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + // Canceling a task does not involve the agent, so only a custom handler needs to be resolved. + Task cancellation = scope.ServiceProvider.GetKeyedService(this._agentName) is { } agentHandler + ? agentHandler.CancelAsync(context, eventQueue, cancellationToken) + : A2AAgentHandler.CancelTaskAsync(context, eventQueue, cancellationToken); + await cancellation.ConfigureAwait(false); + } + } + + private IAgentHandler CreateHandler(IServiceProvider services) + { + if (services.GetKeyedService(this._agentName) is { } agentHandler) + { + return agentHandler; + } + + AIAgent agent = this._agent ?? services.GetRequiredKeyedService(this._agentName); + AIHostAgent hostAgent = A2AServerServiceCollectionExtensions.CreateHostAgent(services, agent, this._agentName, this._sessionStorageIdentity); + return new A2AAgentHandler(hostAgent, this._runMode); + } +} 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..cc2d0fe837a 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,8 @@ 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. /// /// /// Trust model. The AG-UI RunAgentInput.ThreadId arrives @@ -125,18 +208,15 @@ 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); + return MapAGUIServerCore(endpoints, pattern, context => + CreateHostAgent(context.RequestServices, aiAgent, aiAgent.Name, sessionStorageIdentity: null)); + } + private static IEndpointConventionBuilder MapAGUIServerCore( + IEndpointRouteBuilder endpoints, + string pattern, + Func hostAgentFactory) + { IEndpointConventionBuilder endpoint = endpoints.MapPost(pattern, async ( [FromBody] RunAgentInput? input, [FromServices] IOptions jsonOptions, @@ -148,6 +228,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 +276,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.Generated true + true diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.ChatCompletions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.ChatCompletions.cs index 8745d510320..8a9b00eab9a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.ChatCompletions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.ChatCompletions.cs @@ -8,6 +8,8 @@ using Microsoft.Agents.AI.Hosting.OpenAI; using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions; using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; +using Microsoft.Agents.AI.Hosting.OpenAI.Models; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; @@ -36,13 +38,38 @@ public static IEndpointConventionBuilder MapOpenAIChatCompletions(this IEndpoint /// Custom route path for the chat completions endpoint. /// Optional options controlling how incoming requests are mapped onto the agent run. /// + /// Singleton registrations are resolved once when the endpoint is mapped. Scoped and transient registrations are + /// resolved from for each request, so their configured DI lifetime is honored. /// See /// for endpoint authorization and application-owned state requirements. /// public static IEndpointConventionBuilder MapOpenAIChatCompletions(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string? path, OpenAIChatCompletionsMapOptions? mapOptions = null) { - var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentBuilder.Name); - return MapOpenAIChatCompletions(endpoints, agent, path, mapOptions); + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentNullException.ThrowIfNull(agentBuilder); + + string agentName = agentBuilder.Name; + ValidateAgentName(agentName); + HostedAgentResolution.EnsureAgentRegistered(endpoints.ServiceProvider, agentName); + + Func agentResolver; + if (agentBuilder.Lifetime == ServiceLifetime.Singleton) + { + var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); + agentResolver = _ => agent; + } + else + { + agentResolver = context => context.RequestServices.GetRequiredKeyedService(agentName); + } + + path ??= $"/{agentName}/v1/chat/completions"; + return MapOpenAIChatCompletionsCore( + endpoints, + path, + agentName + "/CreateChatCompletion", + agentResolver, + mapOptions); } /// @@ -84,12 +111,86 @@ public static IEndpointConventionBuilder MapOpenAIChatCompletions( ValidateAgentName(agent.Name); path ??= $"/{agent.Name}/v1/chat/completions"; + return MapOpenAIChatCompletionsCore(endpoints, path, agent.Name + "/CreateChatCompletion", _ => agent, mapOptions); + } + + /// + /// Maps an OpenAI ChatCompletions API endpoint that selects the to invoke for each request. + /// + /// The to add the OpenAI ChatCompletions endpoint to. + /// The route path for the chat completions 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. + /// + /// Optional options controlling how incoming requests are mapped onto the agent run. + /// An for further endpoint configuration. + /// + /// When returns , the endpoint responds with + /// 404 Not Found. The selected agent must have a non-empty , which is its + /// stable logical identity. Selection is not an authorization boundary; see + /// + /// for endpoint authorization and application-owned state requirements. + /// + public static IEndpointConventionBuilder MapOpenAIChatCompletions( + this IEndpointRouteBuilder endpoints, + [StringSyntax("Route")] string path, + Func agentSelector, + OpenAIChatCompletionsMapOptions? mapOptions = null) + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentException.ThrowIfNullOrWhiteSpace(path); + ArgumentNullException.ThrowIfNull(agentSelector); + + return MapOpenAIChatCompletionsCore( + endpoints, + path, + endpointName: null, + context => + { + if (agentSelector(context) is not { } agent) + { + return null; + } + + _ = HostedAgentResolution.GetSelectedAgentName(agent); + return agent; + }, + mapOptions); + } + + private static RouteGroupBuilder MapOpenAIChatCompletionsCore( + IEndpointRouteBuilder endpoints, + string path, + string? endpointName, + Func agentResolver, + OpenAIChatCompletionsMapOptions? mapOptions) + { var group = endpoints.MapGroup(path); - var endpointAgentName = agent.Name ?? agent.Id; - group.MapPost("/", async ([FromBody] CreateChatCompletion request, CancellationToken cancellationToken) - => await AIAgentChatCompletionsProcessor.CreateChatCompletionAsync(agent, request, mapOptions, cancellationToken).ConfigureAwait(false)) - .WithName(endpointAgentName + "/CreateChatCompletion"); + var endpoint = group.MapPost("/", async ([FromBody] CreateChatCompletion request, HttpContext context, CancellationToken cancellationToken) => + { + if (agentResolver(context) is not { } agent) + { + return Results.NotFound(new ErrorResponse + { + Error = new ErrorDetails + { + Message = "No agent matches the request.", + Type = "invalid_request_error", + Code = "agent_not_found" + } + }); + } + + return await AIAgentChatCompletionsProcessor.CreateChatCompletionAsync(agent, request, mapOptions, cancellationToken).ConfigureAwait(false); + }); + + if (endpointName is not null) + { + endpoint.WithName(endpointName); + } MarkFeatureUsed(); return group; diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs index 0403d1eb172..a79cc9d5a02 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs @@ -38,6 +38,9 @@ public static IEndpointConventionBuilder MapOpenAIResponses(this IEndpointRouteB /// Custom route path for the OpenAI Responses endpoint. /// Optional options controlling how incoming requests are mapped onto the agent run. /// + /// Singleton registrations are resolved once when the endpoint is mapped. Scoped and transient registrations are + /// resolved for each validation or execution operation. The keyed session store is always resolved for each operation, + /// and persisted sessions are partitioned by the registration name. /// See for endpoint authorization /// and caller isolation requirements. /// @@ -46,21 +49,24 @@ public static IEndpointConventionBuilder MapOpenAIResponses(this IEndpointRouteB ArgumentNullException.ThrowIfNull(endpoints); ArgumentNullException.ThrowIfNull(agentBuilder); - ValidateAgentName(agentBuilder.Name); - path ??= $"/{agentBuilder.Name}/v1/responses"; + string agentName = agentBuilder.Name; + ValidateAgentName(agentName); + path ??= $"/{agentName}/v1/responses"; - // Defer agent and session-store resolution so their registered lifetimes are owned by - // each validation or execution operation rather than by the application root. var scopeFactory = endpoints.ServiceProvider.GetRequiredService(); - var executor = new AIAgentResponseExecutor( - agentBuilder.Name, - scopeFactory, - mapOptions); + var executor = agentBuilder.Lifetime == ServiceLifetime.Singleton + ? new AIAgentResponseExecutor( + endpoints.ServiceProvider.GetRequiredKeyedService(agentName), + agentName, + scopeFactory, + mapOptions, + sessionStorageIdentity: agentName) + : new AIAgentResponseExecutor(agentName, scopeFactory, mapOptions); return MapOpenAIResponses( endpoints, executor, path, - agentBuilder.Name); + agentName); } /// @@ -114,11 +120,70 @@ public static IEndpointConventionBuilder MapOpenAIResponses( agent.Name); } + /// + /// Maps OpenAI Responses API endpoints that select the to invoke for each create request. + /// + /// The to add the OpenAI Responses endpoints to. + /// The route path for the responses endpoint. + /// + /// A delegate invoked once per create 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. + /// + /// Optional options controlling how incoming requests are mapped onto the agent run. + /// An for further endpoint configuration. + /// + /// + /// When returns , the create operation responds with + /// 404 Not Found before any session or conversation state is loaded. Retrieval, cancellation, deletion, + /// and input item operations address stored responses and do not invoke the selector. + /// + /// + /// The selected agent's is its stable logical identity: it is used to resolve the + /// keyed 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. Background responses keep running the selected agent after the HTTP request completes, + /// so agents used for background responses must not depend on request-scoped services. + /// + /// + /// Selection is not an authorization boundary. See + /// for endpoint authorization and caller isolation requirements. + /// + /// + public static IEndpointConventionBuilder MapOpenAIResponses( + this IEndpointRouteBuilder endpoints, + [StringSyntax("Route")] string responsesPath, + Func agentSelector, + OpenAIResponsesMapOptions? mapOptions = null) + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentException.ThrowIfNullOrWhiteSpace(responsesPath); + ArgumentNullException.ThrowIfNull(agentSelector); + + var scopeFactory = endpoints.ServiceProvider.GetRequiredService(); + return MapOpenAIResponses( + endpoints, + executor: null, + responsesPath, + endpointAgentName: null, + context => + { + if (agentSelector(context) is not { } agent) + { + return null; + } + + string agentName = HostedAgentResolution.GetSelectedAgentName(agent); + return new AIAgentResponseExecutor(agent, agentName, scopeFactory, mapOptions, sessionStorageIdentity: agentName); + }); + } + private static RouteGroupBuilder MapOpenAIResponses( IEndpointRouteBuilder endpoints, - IResponseExecutor executor, + IResponseExecutor? executor, string responsesPath, - string endpointAgentName) + string? endpointAgentName, + Func? executorSelector = null) { // Resolve the response storage settings and optional conversation storage. var storageOptions = endpoints.ServiceProvider.GetService() ?? new InMemoryStorageOptions(); @@ -132,38 +197,41 @@ private static RouteGroupBuilder MapOpenAIResponses( // Create the response service so response and conversation operations are scoped by the caller's isolation key. var responsesService = new InMemoryResponsesService(executor, storageOptions, conversationStorage, isolationKeyResolver); - var handlers = new ResponsesHttpHandler(responsesService); + var handlers = new ResponsesHttpHandler(responsesService, executorSelector); var group = endpoints.MapGroup(responsesPath); // Create response endpoint group.MapPost("/", handlers.CreateResponseAsync) - .WithName(endpointAgentName + "/CreateResponse") + .WithEndpointName(endpointAgentName, "CreateResponse") .WithSummary("Creates a model response for the given input"); // Get response endpoint group.MapGet("{responseId}", handlers.GetResponseAsync) - .WithName(endpointAgentName + "/GetResponse") + .WithEndpointName(endpointAgentName, "GetResponse") .WithSummary("Retrieves a response by ID"); // Cancel response endpoint group.MapPost("{responseId}/cancel", handlers.CancelResponseAsync) - .WithName(endpointAgentName + "/CancelResponse") + .WithEndpointName(endpointAgentName, "CancelResponse") .WithSummary("Cancels an in-progress response"); // Delete response endpoint group.MapDelete("{responseId}", handlers.DeleteResponseAsync) - .WithName(endpointAgentName + "/DeleteResponse") + .WithEndpointName(endpointAgentName, "DeleteResponse") .WithSummary("Deletes a response"); // List response input items endpoint group.MapGet("{responseId}/input_items", handlers.ListResponseInputItemsAsync) - .WithName(endpointAgentName + "/ListResponseInputItems") + .WithEndpointName(endpointAgentName, "ListResponseInputItems") .WithSummary("Lists the input items for a response"); MarkFeatureUsed(); return group; } + private static RouteHandlerBuilder WithEndpointName(this RouteHandlerBuilder builder, string? endpointAgentName, string operationName) + => endpointAgentName is null ? builder : builder.WithName(endpointAgentName + "/" + operationName); + /// /// Maps OpenAI Responses API endpoints to the specified . /// diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj index baf2152635c..b99905c72e1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj @@ -14,6 +14,7 @@ true true + true diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs index 84e95d7f0d7..92b943ac2d6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs @@ -19,6 +19,7 @@ internal sealed class AIAgentResponseExecutor : IResponseExecutor { private readonly AIAgent? _agent; private readonly string _registrationKey; + private readonly string? _sessionStorageIdentity; private readonly IServiceScopeFactory _scopeFactory; private readonly OpenAIResponsesMapOptions _mapOptions; @@ -26,7 +27,8 @@ public AIAgentResponseExecutor( AIAgent agent, string registrationKey, IServiceScopeFactory scopeFactory, - OpenAIResponsesMapOptions? mapOptions = null) + OpenAIResponsesMapOptions? mapOptions = null, + string? sessionStorageIdentity = null) { ArgumentNullException.ThrowIfNull(agent); ArgumentException.ThrowIfNullOrEmpty(registrationKey); @@ -34,6 +36,7 @@ public AIAgentResponseExecutor( this._agent = agent; this._registrationKey = registrationKey; + this._sessionStorageIdentity = sessionStorageIdentity; this._scopeFactory = scopeFactory; this._mapOptions = mapOptions ?? new OpenAIResponsesMapOptions(); } @@ -47,6 +50,7 @@ public AIAgentResponseExecutor( ArgumentNullException.ThrowIfNull(scopeFactory); this._registrationKey = registrationKey; + this._sessionStorageIdentity = registrationKey; this._scopeFactory = scopeFactory; this._mapOptions = mapOptions ?? new OpenAIResponsesMapOptions(); } @@ -123,7 +127,6 @@ private AIAgent ResolveExecutionAgent(IServiceProvider services) return agent; } - string? sessionStorageIdentity = this._agent is null ? this._registrationKey : null; - return new AIHostAgent(agent, sessionStore, sessionStorageIdentity); + return new AIHostAgent(agent, sessionStore, this._sessionStorageIdentity); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IResponsesService.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IResponsesService.cs index b1676ac99cc..5c5b34192ba 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IResponsesService.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IResponsesService.cs @@ -49,6 +49,42 @@ IAsyncEnumerable CreateResponseStreamingAsync( CreateResponse request, CancellationToken cancellationToken = default); + /// + /// Validates a create response request that will run with the specified executor. + /// + /// The create response request to validate. + /// The executor selected for this request. + /// Cancellation token. + /// A ResponseError if validation fails, null if validation succeeds. + ValueTask ValidateRequestAsync( + CreateResponse request, + IResponseExecutor executor, + CancellationToken cancellationToken = default); + + /// + /// Creates a model response for the given input using the specified executor. + /// + /// The create response request. + /// The executor selected for this request. + /// Cancellation token. + /// The created response. + Task CreateResponseAsync( + CreateResponse request, + IResponseExecutor executor, + CancellationToken cancellationToken = default); + + /// + /// Creates a streaming model response for the given input using the specified executor. + /// + /// The create response request. + /// The executor selected for this request. + /// Cancellation token. + /// An async enumerable of streaming response events. + IAsyncEnumerable CreateResponseStreamingAsync( + CreateResponse request, + IResponseExecutor executor, + CancellationToken cancellationToken = default); + /// /// Retrieves a response by ID. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs index 365851508d0..647388ad1a6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Runtime.CompilerServices; using System.Threading; @@ -19,7 +20,6 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses; /// internal sealed class InMemoryResponsesService : IResponsesService, IDisposable { - private readonly IResponseExecutor _executor; private readonly MemoryCache _cache; private readonly InMemoryStorageOptions _options; private readonly Conversations.IConversationStorage? _conversationStorage; @@ -35,6 +35,7 @@ private sealed class ResponseState public CreateResponse? Request { get; set; } public string? ConversationStorageId { get; set; } public string? IsolationKey { get; set; } + public IResponseExecutor? Executor { get; set; } public List StreamingUpdates { get; } = []; public Task? CompletionTask { get; set; } public CancellationTokenSource? CancellationTokenSource { get; set; } @@ -145,25 +146,37 @@ public InMemoryResponsesService(IResponseExecutor executor, InMemoryStorageOptio { } + // A null executor requires every create operation to supply the executor selected for its request. public InMemoryResponsesService( - IResponseExecutor executor, + IResponseExecutor? executor, InMemoryStorageOptions options, Conversations.IConversationStorage? conversationStorage, IsolationKeyResolver? isolationKeyResolver) { - ArgumentNullException.ThrowIfNull(executor); ArgumentNullException.ThrowIfNull(options); - this._executor = executor; + this.DefaultExecutor = executor; this._options = options; this._cache = new MemoryCache(options.ToMemoryCacheOptions()); this._conversationStorage = conversationStorage; this._isolationKeyResolver = isolationKeyResolver; } + [AllowNull] + private IResponseExecutor DefaultExecutor => + field ?? throw new InvalidOperationException("This responses service has no default executor. Supply the executor selected for the request."); + + public ValueTask ValidateRequestAsync( + CreateResponse request, + CancellationToken cancellationToken = default) + => this.ValidateRequestAsync(request, this.DefaultExecutor, cancellationToken); + public async ValueTask ValidateRequestAsync( CreateResponse request, + IResponseExecutor executor, CancellationToken cancellationToken = default) { + ArgumentNullException.ThrowIfNull(executor); + if (request.Conversation is not null && !string.IsNullOrEmpty(request.Conversation.Id) && !string.IsNullOrEmpty(request.PreviousResponseId)) { @@ -192,13 +205,21 @@ public InMemoryResponsesService( } } - return await this._executor.ValidateRequestAsync(request, cancellationToken).ConfigureAwait(false); + return await executor.ValidateRequestAsync(request, cancellationToken).ConfigureAwait(false); } + public Task CreateResponseAsync( + CreateResponse request, + CancellationToken cancellationToken = default) + => this.CreateResponseAsync(request, this.DefaultExecutor, cancellationToken); + public async Task CreateResponseAsync( CreateResponse request, + IResponseExecutor executor, CancellationToken cancellationToken = default) { + ArgumentNullException.ThrowIfNull(executor); + if (request.Stream == true) { throw new InvalidOperationException("Cannot create a streaming response using CreateResponseAsync. Use CreateResponseStreamingAsync instead."); @@ -222,7 +243,7 @@ public async Task CreateResponseAsync( }; var state = this.InitializeResponse( - responseId, responseStorageId, conversationStorageId, isolationKey, request); + responseId, responseStorageId, conversationStorageId, isolationKey, request, executor); state.CompletionTask = this.ExecuteResponseAsync(responseId, state, ct); // For background responses, start execution and return immediately @@ -236,10 +257,18 @@ public async Task CreateResponseAsync( return state.Response!; } + public IAsyncEnumerable CreateResponseStreamingAsync( + CreateResponse request, + CancellationToken cancellationToken = default) + => this.CreateResponseStreamingAsync(request, this.DefaultExecutor, cancellationToken); + public async IAsyncEnumerable CreateResponseStreamingAsync( CreateResponse request, + IResponseExecutor executor, [EnumeratorCancellation] CancellationToken cancellationToken = default) { + ArgumentNullException.ThrowIfNull(executor); + if (request.Stream == false) { throw new InvalidOperationException("Cannot create a non-streaming response using CreateResponseStreamingAsync. Use CreateResponseAsync instead."); @@ -258,7 +287,7 @@ public async IAsyncEnumerable CreateResponseStreamingAsy // Start execution var state = this.InitializeResponse( - responseId, responseStorageId, conversationStorageId, isolationKey, request); + responseId, responseStorageId, conversationStorageId, isolationKey, request, executor); state.CompletionTask = this.ExecuteResponseAsync(responseId, state, CancellationToken.None); // Stream updates as they become available @@ -425,7 +454,8 @@ private ResponseState InitializeResponse( string responseStorageId, string? conversationStorageId, string? isolationKey, - CreateResponse request) + CreateResponse request, + IResponseExecutor executor) { var metadata = request.Metadata ?? []; @@ -476,6 +506,7 @@ private ResponseState InitializeResponse( Request = request, ConversationStorageId = conversationStorageId, IsolationKey = isolationKey, + Executor = executor, CancellationTokenSource = new CancellationTokenSource() }; @@ -527,7 +558,7 @@ private async Task ExecuteResponseAsync(string responseId, ResponseState state, List outputItems = []; // Execute using the injected executor - await foreach (var streamingEvent in this._executor.ExecuteAsync(context, request, conversationHistory, linkedCts.Token).ConfigureAwait(false)) + await foreach (var streamingEvent in state.Executor!.ExecuteAsync(context, request, conversationHistory, linkedCts.Token).ConfigureAwait(false)) { state.AddStreamingEvent(streamingEvent); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/ResponsesHttpHandler.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/ResponsesHttpHandler.cs index 2c5f705a563..5a49894712b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/ResponsesHttpHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/ResponsesHttpHandler.cs @@ -16,26 +16,54 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses; internal sealed class ResponsesHttpHandler { private readonly IResponsesService _responsesService; + private readonly Func? _executorSelector; /// /// Initializes a new instance of the class. /// /// The responses service. - public ResponsesHttpHandler(IResponsesService responsesService) + /// + /// An optional delegate that selects the executor for each create request, returning + /// when no agent matches the request. When omitted, the service's executor is used. + /// + public ResponsesHttpHandler(IResponsesService responsesService, Func? executorSelector = null) { this._responsesService = responsesService ?? throw new ArgumentNullException(nameof(responsesService)); + this._executorSelector = executorSelector; } /// /// Creates a model response for the given input. /// public async Task CreateResponseAsync( + HttpContext httpContext, [FromBody] CreateResponse request, [FromQuery] bool? stream, CancellationToken cancellationToken) { + // Select the agent before validation so an unmatched request never loads session or conversation state. + IResponseExecutor? executor = null; + if (this._executorSelector is not null) + { + executor = this._executorSelector(httpContext); + if (executor is null) + { + return Results.NotFound(new ErrorResponse + { + Error = new ErrorDetails + { + Message = "No agent matches the request.", + Type = "invalid_request_error", + Code = "agent_not_found" + } + }); + } + } + // Validate the request first - ResponseError? validationError = await this._responsesService.ValidateRequestAsync(request, cancellationToken).ConfigureAwait(false); + ResponseError? validationError = executor is null + ? await this._responsesService.ValidateRequestAsync(request, cancellationToken).ConfigureAwait(false) + : await this._responsesService.ValidateRequestAsync(request, executor, cancellationToken).ConfigureAwait(false); if (validationError is not null) { var (statusCode, wireCode) = ResponseErrorCodes.MapValidationError(validationError.Code); @@ -62,9 +90,9 @@ public async Task CreateResponseAsync( if (shouldStream) { - var streamingResponse = this._responsesService.CreateResponseStreamingAsync( - request, - cancellationToken: cancellationToken); + var streamingResponse = executor is null + ? this._responsesService.CreateResponseStreamingAsync(request, cancellationToken: cancellationToken) + : this._responsesService.CreateResponseStreamingAsync(request, executor, cancellationToken); return new SseJsonResult( streamingResponse, @@ -72,9 +100,9 @@ public async Task CreateResponseAsync( OpenAIHostingJsonContext.Default.StreamingResponseEvent); } - var response = await this._responsesService.CreateResponseAsync( - request, - cancellationToken: cancellationToken).ConfigureAwait(false); + var response = executor is null + ? await this._responsesService.CreateResponseAsync(request, cancellationToken: cancellationToken).ConfigureAwait(false) + : await this._responsesService.CreateResponseAsync(request, executor, cancellationToken).ConfigureAwait(false); return response.Status switch { 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.A2A.UnitTests/A2ARequestScopedAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2ARequestScopedAgentTests.cs new file mode 100644 index 00000000000..3f53c8ebda7 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2ARequestScopedAgentTests.cs @@ -0,0 +1,357 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using A2A; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Moq; + +namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests; + +/// +/// Verifies that A2A hosting honors agent registration lifetimes and selects agents for each request. +/// +public sealed class A2ARequestScopedAgentTests +{ + [Fact] + public async Task AddA2AServer_WithScopedAgent_ResolvesAgentForEachOperationAsync() + { + // Arrange + int createdAgents = 0; + ServiceCollection services = new(); + services.AddLogging(); + services.AddScoped(); + services.AddAIAgent( + "assistant", + (sp, name) => + { + _ = sp.GetRequiredService(); + Interlocked.Increment(ref createdAgents); + return new TurnCountingAgent(name); + }, + ServiceLifetime.Scoped) + .WithInMemorySessionStore(withIsolation: false); + services.AddA2AServer("assistant"); + await using ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); + A2AServer server = provider.GetRequiredKeyedService("assistant"); + + // Act + SendMessageResponse firstResponse = await server.SendMessageAsync(CreateRequest("ctx-1")); + SendMessageResponse secondResponse = await server.SendMessageAsync(CreateRequest("ctx-1")); + + // Assert + Assert.Equal(2, createdAgents); + Assert.Equal("assistant turn 1", GetText(firstResponse)); + Assert.Equal("assistant turn 2", GetText(secondResponse)); + } + + [Fact] + public async Task AddA2AServer_WithSingletonHostedAgentBuilder_ResolvesAgentOnceAsync() + { + // Arrange + int createdAgents = 0; + ServiceCollection services = new(); + services.AddLogging(); + services.AddAIAgent( + "assistant", + (_, name) => + { + Interlocked.Increment(ref createdAgents); + return new TurnCountingAgent(name); + }) + .WithInMemorySessionStore(withIsolation: false) + .AddA2AServer(); + await using ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); + A2AServer server = provider.GetRequiredKeyedService("assistant"); + int createdAtServerCreation = createdAgents; + + // Act + await server.SendMessageAsync(CreateRequest("ctx-1")); + SendMessageResponse secondResponse = await server.SendMessageAsync(CreateRequest("ctx-1")); + + // Assert + Assert.Equal(1, createdAtServerCreation); + Assert.Equal(1, createdAgents); + Assert.Equal("assistant turn 2", GetText(secondResponse)); + } + + [Fact] + public async Task AddA2AServer_WithUnregisteredAgent_ThrowsWhenServerIsResolvedAsync() + { + // Arrange + ServiceCollection services = new(); + services.AddLogging(); + services.AddA2AServer("missing"); + await using ServiceProvider provider = services.BuildServiceProvider(); + + // Act & Assert + InvalidOperationException exception = Assert.Throws(() => provider.GetRequiredKeyedService("missing")); + Assert.Contains("missing", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task AddA2AServer_WithCustomAgentHandler_DoesNotRequireAgentRegistrationAsync() + { + // Arrange + ServiceCollection services = new(); + services.AddLogging(); + services.AddKeyedSingleton("custom", Mock.Of()); + services.AddA2AServer("custom"); + await using ServiceProvider provider = services.BuildServiceProvider(); + + // Act + A2AServer? server = provider.GetKeyedService("custom"); + + // Assert + Assert.NotNull(server); + } + + [Fact] + public async Task MapA2AHttpJson_WithAgentSelector_SelectsAgentFromRouteValueAsync() + { + // Arrange + await using WebApplication app = await StartAsync( + services => + { + services.AddAIAgent("alpha", (_, name) => new TurnCountingAgent(name), ServiceLifetime.Scoped) + .WithInMemorySessionStore(withIsolation: false); + services.AddAIAgent("beta", (_, name) => new TurnCountingAgent(name), ServiceLifetime.Transient) + .WithInMemorySessionStore(withIsolation: false); + }, + endpoints => endpoints.MapA2AHttpJson("/agents/{agentId}/a2a", SelectAgentFromRoute)); + using HttpClient httpClient = app.GetTestClient(); + using A2AHttpJsonClient alphaClient = new(new Uri(httpClient.BaseAddress!, "/agents/alpha/a2a"), httpClient); + using A2AHttpJsonClient betaClient = new(new Uri(httpClient.BaseAddress!, "/agents/beta/a2a"), httpClient); + + // Act + SendMessageResponse firstAlphaResponse = await alphaClient.SendMessageAsync(CreateRequest("ctx-1")); + SendMessageResponse secondAlphaResponse = await alphaClient.SendMessageAsync(CreateRequest("ctx-1")); + SendMessageResponse betaResponse = await betaClient.SendMessageAsync(CreateRequest("ctx-1")); + + // Assert + Assert.Equal("alpha turn 1", GetText(firstAlphaResponse)); + Assert.Equal("alpha turn 2", GetText(secondAlphaResponse)); + Assert.Equal("beta turn 1", GetText(betaResponse)); + } + + [Fact] + public async Task MapA2AJsonRpc_WithAgentSelector_PersistsSessionsAcrossNewAgentInstancesAsync() + { + // Arrange + await using WebApplication app = await StartAsync( + services => services.AddKeyedSingleton("dynamic", new InMemoryAgentSessionStore()), + endpoints => endpoints.MapA2AJsonRpc("/a2a", _ => new TurnCountingAgent("dynamic"))); + using HttpClient httpClient = app.GetTestClient(); + using A2AClient client = new(new Uri(httpClient.BaseAddress!, "/a2a"), httpClient); + + // Act + SendMessageResponse firstResponse = await client.SendMessageAsync(CreateRequest("ctx-1")); + SendMessageResponse secondResponse = await client.SendMessageAsync(CreateRequest("ctx-1")); + SendMessageResponse otherContextResponse = await client.SendMessageAsync(CreateRequest("ctx-2")); + + // Assert + Assert.Equal("dynamic turn 1", GetText(firstResponse)); + Assert.Equal("dynamic turn 2", GetText(secondResponse)); + Assert.Equal("dynamic turn 1", GetText(otherContextResponse)); + } + + [Fact] + public async Task MapA2AHttpJson_WithAgentSelector_StreamsSelectedAgentAsync() + { + // Arrange + await using WebApplication app = await StartAsync( + _ => { }, + endpoints => endpoints.MapA2AHttpJson("/a2a", _ => new TurnCountingAgent("streaming"))); + using HttpClient httpClient = app.GetTestClient(); + using A2AHttpJsonClient client = new(new Uri(httpClient.BaseAddress!, "/a2a"), httpClient); + + // Act + List responses = []; + await foreach (StreamResponse response in client.SendStreamingMessageAsync(CreateRequest("ctx-1"))) + { + responses.Add(response); + } + + // Assert + Message message = Assert.IsType(Assert.Single(responses).Message); + Assert.Equal("streaming turn 1", string.Concat(message.Parts.Select(part => part.Text))); + } + + [Fact] + public async Task MapA2AHttpJson_WithAgentSelector_SharesTaskStateAcrossRequestsAsync() + { + // Arrange + await using WebApplication app = await StartAsync( + _ => { }, + endpoints => endpoints.MapA2AHttpJson( + "/a2a", + _ => new TurnCountingAgent("tasks"), + options => options.AgentRunMode = AgentRunMode.ReturnTask)); + using HttpClient httpClient = app.GetTestClient(); + using A2AHttpJsonClient client = new(new Uri(httpClient.BaseAddress!, "/a2a"), httpClient); + + // Act + SendMessageResponse response = await client.SendMessageAsync(CreateRequest("ctx-1")); + AgentTask task = await client.GetTaskAsync(new GetTaskRequest { Id = response.Task!.Id }); + + // Assert + Assert.Equal(response.Task.Id, task.Id); + Assert.Equal(TaskState.Completed, task.Status.State); + } + + [Fact] + public async Task MapA2AHttpJson_WithAgentSelectorReturningNull_ReturnsNotFoundAsync() + { + // Arrange + int selections = 0; + await using WebApplication app = await StartAsync( + _ => { }, + endpoints => endpoints.MapA2AHttpJson("/agents/{agentId}/a2a", _ => + { + Interlocked.Increment(ref selections); + return null; + })); + using HttpClient httpClient = app.GetTestClient(); + + // Act + using HttpResponseMessage response = await httpClient.GetAsync(new Uri("/agents/unknown/a2a/tasks/task-1", UriKind.Relative)); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal(1, selections); + } + + [Fact] + public async Task MapA2AJsonRpc_WithAgentSelectorReturningUnnamedAgent_FailsRequestAsync() + { + // Arrange + await using WebApplication app = await StartAsync( + _ => { }, + endpoints => endpoints.MapA2AJsonRpc("/a2a", _ => new TurnCountingAgent(name: null))); + using HttpClient httpClient = app.GetTestClient(); + using A2AClient client = new(new Uri(httpClient.BaseAddress!, "/a2a"), httpClient); + + // Act & Assert + InvalidOperationException exception = await Assert.ThrowsAsync(() => client.SendMessageAsync(CreateRequest("ctx-1"))); + Assert.Contains("stable name", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void MapA2AHttpJson_WithNullAgentSelector_ThrowsArgumentNullException() + { + // Arrange + Mock endpoints = new(); + + // Act & Assert + Assert.Throws(() => + endpoints.Object.MapA2AHttpJson("/a2a", (Func)null!)); + } + + private static AIAgent? SelectAgentFromRoute(HttpContext context) => + context.GetRouteValue("agentId") is string agentId + ? context.RequestServices.GetKeyedService(agentId) + : null; + + private static async Task StartAsync(Action configureServices, Action mapEndpoints) + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Host.UseDefaultServiceProvider(options => options.ValidateScopes = true); + configureServices(builder.Services); + + WebApplication app = builder.Build(); + mapEndpoints(app); + await app.StartAsync(); + return app; + } + + private static SendMessageRequest CreateRequest(string contextId) => new() + { + Message = new Message + { + MessageId = Guid.NewGuid().ToString("N"), + ContextId = contextId, + Role = Role.User, + Parts = [Part.FromText("hi")] + } + }; + + private static string? GetText(SendMessageResponse response) + { + Message message = Assert.IsType(response.Message); + return string.Concat(message.Parts.Select(part => part.Text)); + } + + [SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated via dependency injection")] + private sealed class RequestDependency; + + private sealed class TurnCountingAgent(string? name) : AIAgent + { + public override string? Name => name; + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => + new(new TurnCountingSession()); + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + new(session.StateBag.Serialize()); + + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + new(new TurnCountingSession(AgentSessionStateBag.Deserialize(serializedState))); + + protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + int turn = 1; + if (session is not null) + { + if (session.StateBag.TryGetValue("turn", out string? previousTurn)) + { + turn = int.Parse(previousTurn!, CultureInfo.InvariantCulture) + 1; + } + + session.StateBag.SetValue("turn", turn.ToString(CultureInfo.InvariantCulture)); + } + + await Task.Yield(); + yield return new AgentResponseUpdate + { + MessageId = "message-1", + Role = ChatRole.Assistant, + Contents = [new TextContent($"{name} turn {turn}")] + }; + } + } + + private sealed class TurnCountingSession : AgentSession + { + public TurnCountingSession() + { + } + + public TurnCountingSession(AgentSessionStateBag stateBag) + : base(stateBag) + { + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs index fb35a49abce..7129e42531d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs @@ -206,7 +206,9 @@ public async Task AddA2AServer_WithCustomAgentSessionStore_ResolvesSuccessfullyA await using var provider = services.BuildServiceProvider(); var server = provider.GetKeyedService(AgentName); Assert.NotNull(server); - mockSessionStore.Verify(s => s.GetService(typeof(IsolationKeyScopedAgentSessionStore), null), Times.Once); + + // The session store is resolved for each A2A operation rather than when the server is created. + mockSessionStore.Verify(s => s.GetService(typeof(IsolationKeyScopedAgentSessionStore), null), Times.Never); } /// 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()), Times.Never); } - [Fact] - public void MapAGUIServer_WithHostedAgentBuilder_ResolvesAgentByBuilderName() + [Theory] + [InlineData(ServiceLifetime.Scoped)] + [InlineData(ServiceLifetime.Transient)] + public void MapAGUIServer_WithNonSingletonHostedAgentBuilder_DoesNotResolveAgentAtMapTime(ServiceLifetime lifetime) { // Arrange Mock endpointsMock = new(); Mock serviceProviderMock = new(); Mock agentBuilderMock = new(); - AIAgent agent = new NamedTestAgent(); + serviceProviderMock.As(); agentBuilderMock.Setup(b => b.Name).Returns("test-agent"); + agentBuilderMock.Setup(b => b.Lifetime).Returns(lifetime); + + endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); + endpointsMock.Setup(e => e.DataSources).Returns([]); + + // Act + IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUIServer(agentBuilderMock.Object, "/api/agent"); + // Assert + Assert.NotNull(result); + serviceProviderMock.As() + .Verify(sp => sp.GetRequiredKeyedService(typeof(AIAgent), It.IsAny()), Times.Never); + } + + [Fact] + public void MapAGUIServer_WithSingletonHostedAgentBuilder_ResolvesAgentOnceAtMapTime() + { + // Arrange + Mock endpointsMock = new(); + Mock serviceProviderMock = new(); + Mock agentBuilderMock = new(); serviceProviderMock.As() .Setup(sp => sp.GetRequiredKeyedService(typeof(AIAgent), "test-agent")) - .Returns(agent); + .Returns(new NamedTestAgent()); + + agentBuilderMock.Setup(b => b.Name).Returns("test-agent"); + agentBuilderMock.Setup(b => b.Lifetime).Returns(ServiceLifetime.Singleton); endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); endpointsMock.Setup(e => e.DataSources).Returns([]); @@ -125,17 +147,13 @@ public void MapAGUIServer_WithHostedAgentBuilder_ResolvesAgentByBuilderName() } [Fact] - public void MapAGUIServer_WithAgent_ResolvesSessionStoreFromDI() + public void MapAGUIServer_WithAgent_DoesNotResolveSessionStoreAtMapTime() { // Arrange Mock endpointsMock = new(); Mock serviceProviderMock = new(); - Mock sessionStoreMock = new(); AIAgent agent = new NamedTestAgent(); - - serviceProviderMock.As() - .Setup(sp => sp.GetKeyedService(typeof(AgentSessionStore), "test-agent")) - .Returns(sessionStoreMock.Object); + serviceProviderMock.As(); endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); endpointsMock.Setup(e => e.DataSources).Returns([]); @@ -146,8 +164,43 @@ public void MapAGUIServer_WithAgent_ResolvesSessionStoreFromDI() // Assert Assert.NotNull(result); serviceProviderMock.As() - .Verify(sp => sp.GetKeyedService(typeof(AgentSessionStore), "test-agent"), Times.Once); - sessionStoreMock.Verify(s => s.GetService(typeof(IsolationKeyScopedAgentSessionStore), null), Times.Once); + .Verify(sp => sp.GetKeyedService(typeof(AgentSessionStore), It.IsAny()), Times.Never); + } + + [Fact] + public void MapAGUIServer_WithAgentSelector_DoesNotInvokeSelectorAtMapTime() + { + // Arrange + Mock endpointsMock = new(); + Mock serviceProviderMock = new(); + serviceProviderMock.As(); + endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); + endpointsMock.Setup(e => e.DataSources).Returns([]); + int selectorInvocations = 0; + + // Act + IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUIServer("/api/{agentId}", _ => + { + selectorInvocations++; + return new NamedTestAgent(); + }); + + // Assert + Assert.NotNull(result); + Assert.Equal(0, selectorInvocations); + } + + [Fact] + public void MapAGUIServer_WithNullAgentSelector_ThrowsArgumentNullException() + { + // Arrange + Mock endpointsMock = new(); + Mock serviceProviderMock = new(); + endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); + + // Act & Assert + Assert.Throws(() => + endpointsMock.Object.MapAGUIServer("/api/agent", (Func)null!)); } [Fact] diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIRequestScopedAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIRequestScopedAgentTests.cs new file mode 100644 index 00000000000..c863abed3c4 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIRequestScopedAgentTests.cs @@ -0,0 +1,357 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Net; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests; + +/// +/// Verifies that AG-UI endpoints resolve hosted agents and their session services for each request. +/// +public sealed class AGUIRequestScopedAgentTests : IAsyncDisposable +{ + private WebApplication? _app; + private HttpClient? _client; + + [Fact] + public async Task MapAGUIServer_WithScopedAgent_ResolvesAgentFromRequestScopeAsync() + { + // Arrange + ConcurrentQueue createdAgents = new(); + await this.StartAsync( + services => + { + services.AddScoped(); + services.AddAIAgent( + "assistant", + (sp, name) => + { + TurnCountingAgent agent = new(name, sp.GetRequiredService().Id); + createdAgents.Enqueue(agent); + return agent; + }, + ServiceLifetime.Scoped) + .WithInMemorySessionStore(withIsolation: false); + }, + app => app.MapAGUIServer("assistant", "/ag-ui")); + + // Act + string firstResponse = await this.PostRunAsync("/ag-ui", "thread-1"); + string secondResponse = await this.PostRunAsync("/ag-ui", "thread-1"); + + // Assert + TurnCountingAgent[] agents = [.. createdAgents]; + Assert.Equal(2, agents.Length); + Assert.NotEqual(agents[0].Id, agents[1].Id); + Assert.NotEqual(agents[0].Label, agents[1].Label); + Assert.Contains("assistant turn 1", firstResponse); + Assert.Contains("assistant turn 2", secondResponse); + } + + [Fact] + public async Task MapAGUIServer_WithHostedAgentBuilder_ResolvesTransientAgentPerRequestAsync() + { + // Arrange + int createdAgents = 0; + IHostedAgentBuilder? agentBuilder = null; + await this.StartAsync( + services => + { + agentBuilder = services.AddAIAgent( + "assistant", + (_, name) => + { + Interlocked.Increment(ref createdAgents); + return new TurnCountingAgent(name); + }, + ServiceLifetime.Transient) + .WithInMemorySessionStore(withIsolation: false); + }, + app => app.MapAGUIServer(agentBuilder!, "/ag-ui")); + + // Act + await this.PostRunAsync("/ag-ui", "thread-1"); + string secondResponse = await this.PostRunAsync("/ag-ui", "thread-1"); + + // Assert + Assert.Equal(2, createdAgents); + Assert.Contains("assistant turn 2", secondResponse); + } + + [Fact] + public async Task MapAGUIServer_WithSingletonHostedAgentBuilder_ResolvesAgentOnceAtMapTimeAsync() + { + // Arrange + int createdAgents = 0; + IHostedAgentBuilder? agentBuilder = null; + await this.StartAsync( + services => + { + agentBuilder = services.AddAIAgent( + "assistant", + (_, name) => + { + Interlocked.Increment(ref createdAgents); + return new TurnCountingAgent(name); + }) + .WithInMemorySessionStore(withIsolation: false); + }, + app => + { + app.MapAGUIServer(agentBuilder!, "/ag-ui"); + Assert.Equal(1, createdAgents); + }); + + // Act + await this.PostRunAsync("/ag-ui", "thread-1"); + string secondResponse = await this.PostRunAsync("/ag-ui", "thread-1"); + + // Assert + Assert.Equal(1, createdAgents); + Assert.Contains("assistant turn 2", secondResponse); + } + + [Fact] + public async Task MapAGUIServer_WithAgent_ResolvesScopedSessionStoreFromRequestScopeAsync() + { + // Arrange + InMemoryAgentSessionStore sharedStore = new(); + int createdStores = 0; + TurnCountingAgent agent = new("assistant"); + await this.StartAsync( + services => services.AddKeyedScoped("assistant", (_, _) => + { + Interlocked.Increment(ref createdStores); + return sharedStore; + }), + app => app.MapAGUIServer("/ag-ui", agent)); + + // Act + await this.PostRunAsync("/ag-ui", "thread-1"); + string secondResponse = await this.PostRunAsync("/ag-ui", "thread-1"); + + // Assert + Assert.Equal(2, createdStores); + Assert.Contains("assistant turn 2", secondResponse); + } + + [Fact] + public async Task MapAGUIServer_WithUnregisteredAgentName_ThrowsAtMapTimeAsync() + { + // Arrange + await using WebApplication app = CreateBuilder().Build(); + + // Act & Assert + InvalidOperationException exception = Assert.Throws(() => app.MapAGUIServer("missing", "/ag-ui")); + Assert.Contains("missing", exception.Message); + } + + [Fact] + public async Task MapAGUIServer_WithAgentSelector_SelectsAgentFromRouteValueAsync() + { + // Arrange + await this.StartAsync( + services => + { + services.AddAIAgent("alpha", (_, name) => new TurnCountingAgent(name), ServiceLifetime.Scoped); + services.AddAIAgent("beta", (_, name) => new TurnCountingAgent(name), ServiceLifetime.Scoped); + }, + app => app.MapAGUIServer("/agents/{agentId}/ag-ui", SelectAgentFromRoute)); + + // Act + string alphaResponse = await this.PostRunAsync("/agents/alpha/ag-ui", "thread-1"); + string betaResponse = await this.PostRunAsync("/agents/beta/ag-ui", "thread-1"); + + // Assert + Assert.Contains("alpha turn 1", alphaResponse); + Assert.Contains("beta turn 1", betaResponse); + } + + [Fact] + public async Task MapAGUIServer_WithAgentSelectorReturningNull_ReturnsNotFoundWithoutLoadingSessionAsync() + { + // Arrange + int sessionStoreResolutions = 0; + await this.StartAsync( + services => services.AddKeyedScoped(KeyedService.AnyKey, (_, _) => + { + Interlocked.Increment(ref sessionStoreResolutions); + return new InMemoryAgentSessionStore(); + }), + app => app.MapAGUIServer("/agents/{agentId}/ag-ui", _ => null)); + + // Act + using HttpResponseMessage response = await this.SendRunAsync("/agents/unknown/ag-ui", "thread-1"); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal(0, sessionStoreResolutions); + } + + [Fact] + public async Task MapAGUIServer_WithAgentSelector_PersistsSessionsAcrossNewAgentInstancesAsync() + { + // Arrange + await this.StartAsync( + services => services.AddKeyedSingleton("dynamic", new InMemoryAgentSessionStore()), + app => app.MapAGUIServer("/ag-ui", _ => new TurnCountingAgent("dynamic"))); + + // Act + string firstResponse = await this.PostRunAsync("/ag-ui", "thread-1"); + string secondResponse = await this.PostRunAsync("/ag-ui", "thread-1"); + string otherThreadResponse = await this.PostRunAsync("/ag-ui", "thread-2"); + + // Assert + Assert.Contains("dynamic turn 1", firstResponse); + Assert.Contains("dynamic turn 2", secondResponse); + Assert.Contains("dynamic turn 1", otherThreadResponse); + } + + [Fact] + public async Task MapAGUIServer_WithAgentSelectorReturningUnnamedAgent_FailsRequestAsync() + { + // Arrange + await this.StartAsync( + _ => { }, + app => app.MapAGUIServer("/ag-ui", _ => new TurnCountingAgent(name: null))); + + // Act & Assert + InvalidOperationException exception = await Assert.ThrowsAsync(() => this.SendRunAsync("/ag-ui", "thread-1")); + Assert.Contains("stable name", exception.Message); + } + + public async ValueTask DisposeAsync() + { + this._client?.Dispose(); + if (this._app is not null) + { + await this._app.DisposeAsync(); + } + } + + private static AIAgent? SelectAgentFromRoute(HttpContext context) => + context.GetRouteValue("agentId") is string agentId + ? context.RequestServices.GetKeyedService(agentId) + : null; + + private static WebApplicationBuilder CreateBuilder() + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Host.UseDefaultServiceProvider(options => + { + options.ValidateScopes = true; + options.ValidateOnBuild = true; + }); + builder.Services.AddAGUIServer(); + return builder; + } + + private async Task StartAsync(Action configureServices, Action mapEndpoints) + { + WebApplicationBuilder builder = CreateBuilder(); + configureServices(builder.Services); + + this._app = builder.Build(); + mapEndpoints(this._app); + await this._app.StartAsync(); + + this._client = this._app.GetTestClient(); + } + + private async Task PostRunAsync(string path, string threadId) + { + using HttpResponseMessage response = await this.SendRunAsync(path, threadId); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync(); + } + + private async Task SendRunAsync(string path, string threadId) + { + string json = $$$""" + {"threadId":"{{{threadId}}}","runId":"{{{Guid.NewGuid():N}}}","messages":[{"id":"m1","role":"user","content":"hi"}],"tools":[],"context":[],"state":{}} + """; + using StringContent content = new(json, Encoding.UTF8, "application/json"); + return await this._client!.PostAsync(new Uri(path, UriKind.Relative), content); + } + + [SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated via dependency injection")] + private sealed class RequestDependency + { + public string Id { get; } = Guid.NewGuid().ToString("N"); + } + + private sealed class TurnCountingAgent(string? name, string? label = null) : AIAgent + { + public override string? Name => name; + + public string? Label => label; + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => + new(new TurnCountingSession()); + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + new(session.StateBag.Serialize()); + + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + new(new TurnCountingSession(AgentSessionStateBag.Deserialize(serializedState))); + + protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + int turn = 1; + if (session is not null) + { + if (session.StateBag.TryGetValue("turn", out string? previousTurn)) + { + turn = int.Parse(previousTurn!, CultureInfo.InvariantCulture) + 1; + } + + session.StateBag.SetValue("turn", turn.ToString(CultureInfo.InvariantCulture)); + } + + await Task.Yield(); + yield return new AgentResponseUpdate + { + MessageId = "message-1", + Role = ChatRole.Assistant, + Contents = [new TextContent($"{name} turn {turn}")] + }; + } + } + + private sealed class TurnCountingSession : AgentSession + { + public TurnCountingSession() + { + } + + public TurnCountingSession(AgentSessionStateBag stateBag) + : base(stateBag) + { + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj index 8497c2d761d..d3918e551ad 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj @@ -5,6 +5,7 @@ + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsAgentResolutionTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsAgentResolutionTests.cs new file mode 100644 index 00000000000..3b6fceb9f25 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsAgentResolutionTests.cs @@ -0,0 +1,196 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Moq; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests; + +/// +/// Verifies how OpenAI Chat Completions endpoints resolve and select agents for each request. +/// +public sealed class OpenAIChatCompletionsAgentResolutionTests +{ + private const string RequestJson = """{"model":"test-model","messages":[{"role":"user","content":"hello"}]}"""; + + [Fact] + public async Task MapOpenAIChatCompletions_WithScopedHostedAgent_ResolvesAgentPerRequestAsync() + { + // Arrange + int createdAgents = 0; + WebApplicationBuilder builder = CreateBuilder(); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent( + "assistant", + (_, name) => + { + Interlocked.Increment(ref createdAgents); + return new ChatClientAgent(new TestHelpers.SimpleMockChatClient("Scoped response"), name: name); + }, + ServiceLifetime.Scoped); + + await using WebApplication app = builder.Build(); + app.MapOpenAIChatCompletions(agentBuilder); + await app.StartAsync(); + using HttpClient client = app.GetTestClient(); + + // Act + string firstResponse = await PostAsync(client, "/assistant/v1/chat/completions"); + string secondResponse = await PostAsync(client, "/assistant/v1/chat/completions"); + + // Assert + Assert.Equal(2, createdAgents); + Assert.Contains("Scoped response", firstResponse, StringComparison.Ordinal); + Assert.Contains("Scoped response", secondResponse, StringComparison.Ordinal); + } + + [Fact] + public async Task MapOpenAIChatCompletions_WithSingletonHostedAgent_ResolvesAgentOnceAtMapTimeAsync() + { + // Arrange + int createdAgents = 0; + WebApplicationBuilder builder = CreateBuilder(); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent( + "assistant", + (_, name) => + { + Interlocked.Increment(ref createdAgents); + return new ChatClientAgent(new TestHelpers.SimpleMockChatClient("Singleton response"), name: name); + }); + + await using WebApplication app = builder.Build(); + app.MapOpenAIChatCompletions(agentBuilder); + int createdAtMapTime = createdAgents; + await app.StartAsync(); + using HttpClient client = app.GetTestClient(); + + // Act + await PostAsync(client, "/assistant/v1/chat/completions"); + string secondResponse = await PostAsync(client, "/assistant/v1/chat/completions"); + + // Assert + Assert.Equal(1, createdAtMapTime); + Assert.Equal(1, createdAgents); + Assert.Contains("Singleton response", secondResponse, StringComparison.Ordinal); + } + + [Fact] + public async Task MapOpenAIChatCompletions_WithUnregisteredHostedAgent_ThrowsAtMapTimeAsync() + { + // Arrange + Mock agentBuilder = new(); + agentBuilder.Setup(b => b.Name).Returns("missing"); + await using WebApplication app = CreateBuilder().Build(); + + // Act & Assert + InvalidOperationException exception = Assert.Throws(() => app.MapOpenAIChatCompletions(agentBuilder.Object)); + Assert.Contains("missing", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task MapOpenAIChatCompletions_WithAgentSelector_SelectsAgentFromRouteValueAsync() + { + // Arrange + WebApplicationBuilder builder = CreateBuilder(); + builder.AddAIAgent("alpha", (_, name) => new ChatClientAgent(new TestHelpers.SimpleMockChatClient("Alpha response"), name: name), ServiceLifetime.Scoped); + builder.AddAIAgent("beta", (_, name) => new ChatClientAgent(new TestHelpers.SimpleMockChatClient("Beta response"), name: name), ServiceLifetime.Transient); + + await using WebApplication app = builder.Build(); + app.MapOpenAIChatCompletions("/agents/{agentId}/v1/chat/completions", SelectAgentFromRoute); + await app.StartAsync(); + using HttpClient client = app.GetTestClient(); + + // Act + string alphaResponse = await PostAsync(client, "/agents/alpha/v1/chat/completions"); + string betaResponse = await PostAsync(client, "/agents/beta/v1/chat/completions"); + + // Assert + Assert.Contains("Alpha response", alphaResponse, StringComparison.Ordinal); + Assert.Contains("Beta response", betaResponse, StringComparison.Ordinal); + } + + [Fact] + public async Task MapOpenAIChatCompletions_WithAgentSelectorReturningNull_ReturnsNotFoundAsync() + { + // Arrange + WebApplicationBuilder builder = CreateBuilder(); + await using WebApplication app = builder.Build(); + app.MapOpenAIChatCompletions("/agents/{agentId}/v1/chat/completions", SelectAgentFromRoute); + await app.StartAsync(); + using HttpClient client = app.GetTestClient(); + + // Act + using HttpResponseMessage response = await SendAsync(client, "/agents/unknown/v1/chat/completions"); + string responseBody = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Contains("agent_not_found", responseBody, StringComparison.Ordinal); + } + + [Fact] + public async Task MapOpenAIChatCompletions_WithAgentSelectorReturningUnnamedAgent_FailsRequestAsync() + { + // Arrange + WebApplicationBuilder builder = CreateBuilder(); + await using WebApplication app = builder.Build(); + app.MapOpenAIChatCompletions("/v1/chat/completions", _ => new ChatClientAgent(new TestHelpers.SimpleMockChatClient())); + await app.StartAsync(); + using HttpClient client = app.GetTestClient(); + + // Act + using HttpResponseMessage response = await SendAsync(client, "/v1/chat/completions"); + string responseBody = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + Assert.Contains("stable name", responseBody, StringComparison.Ordinal); + } + + [Fact] + public void MapOpenAIChatCompletions_WithNullAgentSelector_ThrowsArgumentNullException() + { + // Arrange + Mock endpoints = new(); + + // Act & Assert + Assert.Throws(() => + endpoints.Object.MapOpenAIChatCompletions("/v1/chat/completions", null!)); + } + + private static AIAgent? SelectAgentFromRoute(HttpContext context) => + context.GetRouteValue("agentId") is string agentId + ? context.RequestServices.GetKeyedService(agentId) + : null; + + private static WebApplicationBuilder CreateBuilder() + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = Environments.Development }); + builder.WebHost.UseTestServer(); + builder.AddOpenAIChatCompletions(); + return builder; + } + + private static async Task PostAsync(HttpClient client, string path) + { + using HttpResponseMessage response = await SendAsync(client, path); + string responseBody = await response.Content.ReadAsStringAsync(); + Assert.True(response.IsSuccessStatusCode, responseBody); + return responseBody; + } + + private static async Task SendAsync(HttpClient client, string path) + { + using StringContent content = new(RequestJson, Encoding.UTF8, "application/json"); + return await client.PostAsync(new Uri(path, UriKind.Relative), content); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ResponseSessionRegressionTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ResponseSessionRegressionTests.cs index d68adfe50af..fb3a2350f1d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ResponseSessionRegressionTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ResponseSessionRegressionTests.cs @@ -11,6 +11,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; @@ -76,6 +77,114 @@ public async Task ApprovalContinuation_TransientAgent_UsesStableRegistrationIden Assert.Equal(2, modelCalls); } + [Fact] + public async Task ApprovalContinuation_AgentSelectorReturningNewInstances_UsesStableAgentNameAsync() + { + // Arrange + int modelCalls = 0; + int toolCalls = 0; + int selectedAgents = 0; + AIFunction function = new ApprovalRequiredAIFunction(AIFunctionFactory.Create( + () => + { + Interlocked.Increment(ref toolCalls); + return "Sunny"; + }, + ToolName)); + Mock chatClient = CreateApprovalChatClient(() => Interlocked.Increment(ref modelCalls)); + + WebApplicationBuilder builder = CreateBuilder(validateScopes: true); + builder.Services.AddKeyedSingleton(AgentName, new InMemoryAgentSessionStore()); + builder.AddOpenAIResponses(); + + await using WebApplication app = builder.Build(); + app.MapOpenAIResponses("/agents/{agentId}/v1/responses", context => + { + Interlocked.Increment(ref selectedAgents); + return context.GetRouteValue("agentId") as string == AgentName + ? new ChatClientAgent(chatClient.Object, name: AgentName, tools: [function]) + : null; + }); + await app.StartAsync(); + using HttpClient client = app.GetTestClient(); + string path = $"/agents/{AgentName}/v1/responses"; + + (string responseId, JsonElement approvalEvent) = await CreatePendingApprovalAsync( + client, + path, + includeAgentName: false); + using StringContent approvalContent = JsonContent(CreateApprovalResponseJson( + responseId, + approvalEvent, + includeAgentName: false)); + + // Act + using HttpResponseMessage response = await client.PostAsync( + new Uri(path, UriKind.Relative), + approvalContent); + string responseBody = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.True(response.IsSuccessStatusCode, responseBody); + Assert.Equal(2, selectedAgents); + Assert.Equal(1, toolCalls); + Assert.Equal(2, modelCalls); + } + + [Fact] + public async Task AgentSelector_ReturningNull_ReturnsNotFoundAsync() + { + // Arrange + WebApplicationBuilder builder = CreateBuilder(); + builder.AddOpenAIResponses(); + + await using WebApplication app = builder.Build(); + app.MapOpenAIResponses("/agents/{agentId}/v1/responses", _ => null); + await app.StartAsync(); + using HttpClient client = app.GetTestClient(); + + // Act + using HttpResponseMessage response = await PostJsonAsync( + client, + "/agents/unknown/v1/responses", + """{"input":"hello"}"""); + string responseBody = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(System.Net.HttpStatusCode.NotFound, response.StatusCode); + Assert.Contains("agent_not_found", responseBody, StringComparison.Ordinal); + } + + [Fact] + public async Task AgentSelector_ResolvesScopedAgentFromRequestServicesAsync() + { + // Arrange + WebApplicationBuilder builder = CreateBuilder(validateScopes: true); + builder.AddAIAgent( + AgentName, + (_, name) => new ChatClientAgent(new TestHelpers.SimpleMockChatClient("Scoped response"), name: name), + ServiceLifetime.Scoped); + builder.AddOpenAIResponses(); + + await using WebApplication app = builder.Build(); + app.MapOpenAIResponses( + "/agents/{agentId}/v1/responses", + context => context.RequestServices.GetKeyedService(context.GetRouteValue("agentId"))); + await app.StartAsync(); + using HttpClient client = app.GetTestClient(); + + // Act + using HttpResponseMessage response = await PostJsonAsync( + client, + $"/agents/{AgentName}/v1/responses", + """{"input":"hello"}"""); + string responseBody = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.True(response.IsSuccessStatusCode, responseBody); + Assert.Contains("Scoped response", responseBody, StringComparison.Ordinal); + } + [Theory] [InlineData(false)] [InlineData(true)] From 4f07bccc78dbf94d950ed8b9cc6d22af93ed6613 Mon Sep 17 00:00:00 2001 From: Marco Minerva Date: Wed, 14 Oct 2026 15:04:40 +0000 Subject: [PATCH 2/6] .NET: Reject ReturnImmediately task runs on A2A agent selector endpoints The selected agent and its session store are bound to HttpContext.RequestServices, so a run returned immediately as a task would outlive the request scope. Reject non-streaming requests with ReturnImmediately when AgentRunMode returns a task. --- .../A2AEndpointRouteBuilderExtensions.cs | 5 ++- .../SelectedAgentA2ARequestHandler.cs | 2 +- .../A2AAgentHandler.cs | 16 ++++++- .../A2ARequestScopedAgentTests.cs | 43 ++++++++++++++++++- 4 files changed, 60 insertions(+), 6 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs index 72ba452191b..9488ff9a595 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs @@ -124,8 +124,9 @@ public static IEndpointConventionBuilder MapA2AHttpJson(this IEndpointRouteBuild /// identifies the A2A task state shared by the requests that select it through this endpoint, so conversations /// and tasks continue across requests even when the selector returns a new agent instance each time. The selected /// agent must therefore have a non-empty name. The session store is resolved from - /// . Tasks returned immediately keep running the selected agent after the - /// HTTP request completes, so agents used for such tasks must not depend on request-scoped services. + /// , so the run cannot outlive the request: when the configured + /// returns a task, non-streaming requests with ReturnImmediately enabled are + /// rejected with . /// /// /// Selection is not an authorization boundary. See diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/SelectedAgentA2ARequestHandler.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/SelectedAgentA2ARequestHandler.cs index c21bf1a9c16..366b088e5e0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/SelectedAgentA2ARequestHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/SelectedAgentA2ARequestHandler.cs @@ -123,7 +123,7 @@ private sealed class Selection( public void BindAgent(Message message) { AIHostAgent hostAgent = A2AServerServiceCollectionExtensions.CreateHostAgent(requestServices, agent, agentName, sessionStorageIdentity: agentName); - server.Bind(message, new A2AAgentHandler(hostAgent, runMode)); + server.Bind(message, new A2AAgentHandler(hostAgent, runMode, allowReturnImmediately: false)); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs index c14b9854084..293dfe42a9a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs @@ -28,21 +28,28 @@ internal sealed class A2AAgentHandler : IAgentHandler private readonly AIHostAgent _hostAgent; private readonly AgentRunMode _runMode; + private readonly bool _allowReturnImmediately; /// /// Initializes a new instance of the class. /// /// The hosted agent that provides the execution logic. /// Controls which A2A artifact the agent response is returned as. + /// + /// to reject non-streaming requests with ReturnImmediately = true when + /// returns a task, because the run would continue after the request completes. + /// public A2AAgentHandler( AIHostAgent hostAgent, - AgentRunMode runMode) + AgentRunMode runMode, + bool allowReturnImmediately = true) { ArgumentNullException.ThrowIfNull(hostAgent); ArgumentNullException.ThrowIfNull(runMode); this._hostAgent = hostAgent; this._runMode = runMode; + this._allowReturnImmediately = allowReturnImmediately; } /// @@ -150,6 +157,13 @@ private async Task HandleNewMessageAsync(RequestContext context, AgentEventQueue var decisionContext = new A2ARunDecisionContext(context); var returnTask = await this._runMode.ShouldReturnTaskAsync(decisionContext, cancellationToken).ConfigureAwait(false); + if (returnTask && !aggregateTaskUpdates && !context.StreamingResponse && !this._allowReturnImmediately) + { + throw new A2AException( + "ReturnImmediately is not supported when the agent is selected for each request, because the selected agent and its services are bound to the HTTP request. Send the message with ReturnImmediately disabled.", + A2AErrorCode.UnsupportedOperation); + } + var options = CreateRunOptions(context); var updates = this._hostAgent.RunStreamingAsync(chatMessages, session, options, cancellationToken); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2ARequestScopedAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2ARequestScopedAgentTests.cs index 3f53c8ebda7..05afb712c60 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2ARequestScopedAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2ARequestScopedAgentTests.cs @@ -214,6 +214,44 @@ public async Task MapA2AHttpJson_WithAgentSelector_SharesTaskStateAcrossRequests Assert.Equal(TaskState.Completed, task.Status.State); } + [Fact] + public async Task MapA2AJsonRpc_WithAgentSelectorReturningTask_RejectsReturnImmediatelyAsync() + { + // Arrange + await using WebApplication app = await StartAsync( + _ => { }, + endpoints => endpoints.MapA2AJsonRpc( + "/a2a", + _ => new TurnCountingAgent("tasks"), + options => options.AgentRunMode = AgentRunMode.ReturnTask)); + using HttpClient httpClient = app.GetTestClient(); + using A2AClient client = new(new Uri(httpClient.BaseAddress!, "/a2a"), httpClient); + + // Act + A2AException exception = await Assert.ThrowsAsync( + () => client.SendMessageAsync(CreateRequest("ctx-1", returnImmediately: true))); + + // Assert + Assert.Equal(A2AErrorCode.UnsupportedOperation, exception.ErrorCode); + } + + [Fact] + public async Task MapA2AHttpJson_WithAgentSelectorReturningMessage_IgnoresReturnImmediatelyAsync() + { + // Arrange + await using WebApplication app = await StartAsync( + _ => { }, + endpoints => endpoints.MapA2AHttpJson("/a2a", _ => new TurnCountingAgent("messages"))); + using HttpClient httpClient = app.GetTestClient(); + using A2AHttpJsonClient client = new(new Uri(httpClient.BaseAddress!, "/a2a"), httpClient); + + // Act + SendMessageResponse response = await client.SendMessageAsync(CreateRequest("ctx-1", returnImmediately: true)); + + // Assert + Assert.Equal("messages turn 1", GetText(response)); + } + [Fact] public async Task MapA2AHttpJson_WithAgentSelectorReturningNull_ReturnsNotFoundAsync() { @@ -280,7 +318,7 @@ private static async Task StartAsync(Action return app; } - private static SendMessageRequest CreateRequest(string contextId) => new() + private static SendMessageRequest CreateRequest(string contextId, bool returnImmediately = false) => new() { Message = new Message { @@ -288,7 +326,8 @@ private static async Task StartAsync(Action ContextId = contextId, Role = Role.User, Parts = [Part.FromText("hi")] - } + }, + Configuration = returnImmediately ? new SendMessageConfiguration { ReturnImmediately = true } : null }; private static string? GetText(SendMessageResponse response) From 9f02a06ff37cd1b70235651c71d9383cedbfe026 Mon Sep 17 00:00:00 2001 From: Marco Minerva Date: Wed, 14 Oct 2026 15:22:10 +0000 Subject: [PATCH 3/6] .NET: Apply OpenAI Responses agent selection to every operation The agent selector now runs for retrieval, cancellation, deletion, and input item listing as well as creation, returning 404 before any state is loaded when no agent matches. Stored responses are bound to the selected agent name, so a response is only visible through requests that select the agent that created it. --- ...ndpointRouteBuilderExtensions.Responses.cs | 31 +++-- .../Responses/InMemoryResponsesService.cs | 48 ++++++-- .../Responses/ResponsesHttpHandler.cs | 107 ++++++++++++------ .../ResponseSessionRegressionTests.cs | 74 ++++++++++++ 4 files changed, 208 insertions(+), 52 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs index a79cc9d5a02..63c66399bc6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs @@ -121,12 +121,12 @@ public static IEndpointConventionBuilder MapOpenAIResponses( } /// - /// Maps OpenAI Responses API endpoints that select the to invoke for each create request. + /// Maps OpenAI Responses API endpoints that select the for each request. /// /// The to add the OpenAI Responses endpoints to. /// The route path for the responses endpoint. /// - /// A delegate invoked once per create request that returns the agent to invoke, or when + /// A delegate invoked once per request that returns the agent to serve, or when /// no agent matches the request. The delegate can use route values, claims, headers, and /// to select or resolve the agent. /// @@ -134,16 +134,18 @@ public static IEndpointConventionBuilder MapOpenAIResponses( /// An for further endpoint configuration. /// /// - /// When returns , the create operation responds with - /// 404 Not Found before any session or conversation state is loaded. Retrieval, cancellation, deletion, - /// and input item operations address stored responses and do not invoke the selector. + /// The selector applies to every operation: creation, retrieval, cancellation, deletion, and input item listing. + /// When returns , the operation responds with + /// 404 Not Found before any response, session, or conversation state is loaded. /// /// /// The selected agent's is its stable logical identity: it is used to resolve the - /// keyed 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. Background responses keep running the selected agent after the HTTP request completes, - /// so agents used for background responses must not depend on request-scoped services. + /// keyed , to partition persisted sessions, and to bind stored responses to the + /// agent that created them, so a response can only be retrieved, canceled, or deleted through requests that select + /// the same agent name. 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. Background responses keep running the + /// selected agent after the HTTP request completes, so agents used for background responses must not depend on + /// request-scoped services. /// /// /// Selection is not an authorization boundary. See @@ -174,7 +176,7 @@ public static IEndpointConventionBuilder MapOpenAIResponses( } string agentName = HostedAgentResolution.GetSelectedAgentName(agent); - return new AIAgentResponseExecutor(agent, agentName, scopeFactory, mapOptions, sessionStorageIdentity: agentName); + return (agentName, new AIAgentResponseExecutor(agent, agentName, scopeFactory, mapOptions, sessionStorageIdentity: agentName)); }); } @@ -183,7 +185,7 @@ private static RouteGroupBuilder MapOpenAIResponses( IResponseExecutor? executor, string responsesPath, string? endpointAgentName, - Func? executorSelector = null) + Func? agentSelector = null) { // Resolve the response storage settings and optional conversation storage. var storageOptions = endpoints.ServiceProvider.GetService() ?? new InMemoryStorageOptions(); @@ -197,7 +199,7 @@ private static RouteGroupBuilder MapOpenAIResponses( // Create the response service so response and conversation operations are scoped by the caller's isolation key. var responsesService = new InMemoryResponsesService(executor, storageOptions, conversationStorage, isolationKeyResolver); - var handlers = new ResponsesHttpHandler(responsesService, executorSelector); + var handlers = new ResponsesHttpHandler(responsesService, agentSelector is null ? null : SelectAgent); var group = endpoints.MapGroup(responsesPath); // Create response endpoint @@ -227,6 +229,11 @@ private static RouteGroupBuilder MapOpenAIResponses( MarkFeatureUsed(); return group; + + (IResponsesService, IResponseExecutor)? SelectAgent(HttpContext context) => + agentSelector(context) is { } selection + ? (responsesService.ForAgent(selection.AgentName), selection.Executor) + : null; } private static RouteHandlerBuilder WithEndpointName(this RouteHandlerBuilder builder, string? endpointAgentName, string operationName) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs index 65f5017d599..911fceaab44 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs @@ -21,9 +21,11 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses; internal sealed class InMemoryResponsesService : IResponsesService, IDisposable { private readonly MemoryCache _cache; + private readonly bool _ownsCache; private readonly InMemoryStorageOptions _options; private readonly Conversations.IConversationStorage? _conversationStorage; private readonly IsolationKeyResolver? _isolationKeyResolver; + private readonly string? _agentName; private sealed class ResponseState { @@ -157,10 +159,31 @@ public InMemoryResponsesService( this.DefaultExecutor = executor; this._options = options; this._cache = new MemoryCache(options.ToMemoryCacheOptions()); + this._ownsCache = true; this._conversationStorage = conversationStorage; this._isolationKeyResolver = isolationKeyResolver; } + private InMemoryResponsesService(InMemoryResponsesService owner, string agentName) + { + this._options = owner._options; + this._cache = owner._cache; + this._conversationStorage = owner._conversationStorage; + this._isolationKeyResolver = owner._isolationKeyResolver; + this._agentName = agentName; + } + + /// + /// Creates a view that shares this service's storage but only exposes the responses created through it. + /// + /// The stable name of the agent that owns the responses. + /// A responses service bound to . + public InMemoryResponsesService ForAgent(string agentName) + { + ArgumentException.ThrowIfNullOrEmpty(agentName); + return new InMemoryResponsesService(this, agentName); + } + [AllowNull] private IResponseExecutor DefaultExecutor => field ?? throw new InvalidOperationException("This responses service has no default executor. Supply the executor selected for the request."); @@ -230,7 +253,7 @@ public async Task CreateResponseAsync( // Resolve ambient caller identity before a background response detaches from the HTTP request. string? isolationKey = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false); - string responseStorageId = IsolationKeyResolver.ScopeId(responseId, isolationKey); + string responseStorageId = this.ScopeToAgent(IsolationKeyResolver.ScopeId(responseId, isolationKey)); var conversationStorageId = request.Conversation?.Id is { } conversationId ? IsolationKeyResolver.ScopeId(conversationId, isolationKey) @@ -279,7 +302,7 @@ public async IAsyncEnumerable CreateResponseStreamingAsy // Streaming execution is also detached from the request cancellation token, so capture identity now. string? isolationKey = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false); - string responseStorageId = IsolationKeyResolver.ScopeId(responseId, isolationKey); + string responseStorageId = this.ScopeToAgent(IsolationKeyResolver.ScopeId(responseId, isolationKey)); var conversationStorageId = request.Conversation?.Id is { } conversationId ? IsolationKeyResolver.ScopeId(conversationId, isolationKey) @@ -299,7 +322,7 @@ public async IAsyncEnumerable CreateResponseStreamingAsy public async Task GetResponseAsync(string responseId, CancellationToken cancellationToken = default) { - var responseStorageId = await this.GetStorageIdAsync(responseId, cancellationToken).ConfigureAwait(false); + var responseStorageId = await this.GetResponseStorageIdAsync(responseId, cancellationToken).ConfigureAwait(false); this._cache.TryGetValue(responseStorageId, out ResponseState? state); return state?.Response; } @@ -309,7 +332,7 @@ public async IAsyncEnumerable GetResponseStreamingAsync( int? startingAfter = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - var responseStorageId = await this.GetStorageIdAsync(responseId, cancellationToken).ConfigureAwait(false); + var responseStorageId = await this.GetResponseStorageIdAsync(responseId, cancellationToken).ConfigureAwait(false); if (!this._cache.TryGetValue(responseStorageId, out ResponseState? state) || state is null) { yield break; @@ -324,7 +347,7 @@ public async IAsyncEnumerable GetResponseStreamingAsync( public async Task CancelResponseAsync(string responseId, CancellationToken cancellationToken = default) { - var responseStorageId = await this.GetStorageIdAsync(responseId, cancellationToken).ConfigureAwait(false); + var responseStorageId = await this.GetResponseStorageIdAsync(responseId, cancellationToken).ConfigureAwait(false); if (!this._cache.TryGetValue(responseStorageId, out ResponseState? state) || state is null) { throw new InvalidOperationException($"Response '{responseId}' not found."); @@ -353,7 +376,7 @@ public async Task CancelResponseAsync(string responseId, CancellationT public async Task DeleteResponseAsync(string responseId, CancellationToken cancellationToken = default) { - var responseStorageId = await this.GetStorageIdAsync(responseId, cancellationToken).ConfigureAwait(false); + var responseStorageId = await this.GetResponseStorageIdAsync(responseId, cancellationToken).ConfigureAwait(false); if (!this._cache.TryGetValue(responseStorageId, out ResponseState? state)) { return false; @@ -378,7 +401,7 @@ public async Task> ListResponseInputItemsAsync( int effectiveLimit = Math.Clamp(limit ?? IResponsesService.DefaultListLimit, 1, 100); SortOrder effectiveOrder = order ?? SortOrder.Descending; - var responseStorageId = await this.GetStorageIdAsync(responseId, cancellationToken).ConfigureAwait(false); + var responseStorageId = await this.GetResponseStorageIdAsync(responseId, cancellationToken).ConfigureAwait(false); if (!this._cache.TryGetValue(responseStorageId, out ResponseState? state)) { throw new InvalidOperationException($"Response '{responseId}' not found."); @@ -438,6 +461,12 @@ public async Task> ListResponseInputItemsAsync( }; } + private async ValueTask GetResponseStorageIdAsync(string responseId, CancellationToken cancellationToken) + => this.ScopeToAgent(await this.GetStorageIdAsync(responseId, cancellationToken).ConfigureAwait(false)); + + // Reuses the isolation key escaping so agent names cannot collide with isolation-scoped identifiers. + private string ScopeToAgent(string storageId) => IsolationKeyResolver.ScopeId(storageId, this._agentName); + private ValueTask GetStorageIdAsync(string id, CancellationToken cancellationToken) { if (this._isolationKeyResolver is null) @@ -671,6 +700,9 @@ private static List GetInputItems(string responseId, ResponseState public void Dispose() { - this._cache.Dispose(); + if (this._ownsCache) + { + this._cache.Dispose(); + } } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/ResponsesHttpHandler.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/ResponsesHttpHandler.cs index 5a49894712b..266835e5fc3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/ResponsesHttpHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/ResponsesHttpHandler.cs @@ -16,20 +16,23 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses; internal sealed class ResponsesHttpHandler { private readonly IResponsesService _responsesService; - private readonly Func? _executorSelector; + private readonly Func? _agentSelector; /// /// Initializes a new instance of the class. /// /// The responses service. - /// - /// An optional delegate that selects the executor for each create request, returning - /// when no agent matches the request. When omitted, the service's executor is used. + /// + /// An optional delegate that selects, for every operation, the responses service bound to the selected agent and + /// the executor that runs it, returning when no agent matches the request. When omitted, + /// and its executor are used. /// - public ResponsesHttpHandler(IResponsesService responsesService, Func? executorSelector = null) + public ResponsesHttpHandler( + IResponsesService responsesService, + Func? agentSelector = null) { this._responsesService = responsesService ?? throw new ArgumentNullException(nameof(responsesService)); - this._executorSelector = executorSelector; + this._agentSelector = agentSelector; } /// @@ -42,28 +45,15 @@ public async Task CreateResponseAsync( CancellationToken cancellationToken) { // Select the agent before validation so an unmatched request never loads session or conversation state. - IResponseExecutor? executor = null; - if (this._executorSelector is not null) + if (!this.TrySelectAgent(httpContext, out IResponsesService responsesService, out IResponseExecutor? executor)) { - executor = this._executorSelector(httpContext); - if (executor is null) - { - return Results.NotFound(new ErrorResponse - { - Error = new ErrorDetails - { - Message = "No agent matches the request.", - Type = "invalid_request_error", - Code = "agent_not_found" - } - }); - } + return AgentNotFound(); } // Validate the request first ResponseError? validationError = executor is null - ? await this._responsesService.ValidateRequestAsync(request, cancellationToken).ConfigureAwait(false) - : await this._responsesService.ValidateRequestAsync(request, executor, cancellationToken).ConfigureAwait(false); + ? await responsesService.ValidateRequestAsync(request, cancellationToken).ConfigureAwait(false) + : await responsesService.ValidateRequestAsync(request, executor, cancellationToken).ConfigureAwait(false); if (validationError is not null) { var (statusCode, wireCode) = ResponseErrorCodes.MapValidationError(validationError.Code); @@ -91,8 +81,8 @@ public async Task CreateResponseAsync( if (shouldStream) { var streamingResponse = executor is null - ? this._responsesService.CreateResponseStreamingAsync(request, cancellationToken: cancellationToken) - : this._responsesService.CreateResponseStreamingAsync(request, executor, cancellationToken); + ? responsesService.CreateResponseStreamingAsync(request, cancellationToken: cancellationToken) + : responsesService.CreateResponseStreamingAsync(request, executor, cancellationToken); return new SseJsonResult( streamingResponse, @@ -101,8 +91,8 @@ public async Task CreateResponseAsync( } var response = executor is null - ? await this._responsesService.CreateResponseAsync(request, cancellationToken: cancellationToken).ConfigureAwait(false) - : await this._responsesService.CreateResponseAsync(request, executor, cancellationToken).ConfigureAwait(false); + ? await responsesService.CreateResponseAsync(request, cancellationToken: cancellationToken).ConfigureAwait(false) + : await responsesService.CreateResponseAsync(request, executor, cancellationToken).ConfigureAwait(false); return response.Status switch { @@ -129,16 +119,22 @@ public async Task CreateResponseAsync( /// Retrieves a response by ID. /// public async Task GetResponseAsync( + HttpContext httpContext, string responseId, [FromQuery] string[]? include, [FromQuery] bool? stream, [FromQuery] int? starting_after, CancellationToken cancellationToken) { + if (!this.TrySelectAgent(httpContext, out IResponsesService responsesService, out _)) + { + return AgentNotFound(); + } + // If streaming is requested, return SSE stream if (stream == true) { - var streamingResponse = this._responsesService.GetResponseStreamingAsync( + var streamingResponse = responsesService.GetResponseStreamingAsync( responseId, startingAfter: starting_after, cancellationToken: cancellationToken); @@ -150,7 +146,7 @@ public async Task GetResponseAsync( } // Non-streaming: return the response object - var response = await this._responsesService.GetResponseAsync(responseId, cancellationToken).ConfigureAwait(false); + var response = await responsesService.GetResponseAsync(responseId, cancellationToken).ConfigureAwait(false); return response is not null ? Results.Ok(response) : Results.NotFound(new ErrorResponse @@ -167,12 +163,18 @@ public async Task GetResponseAsync( /// Cancels an in-progress response. /// public async Task CancelResponseAsync( + HttpContext httpContext, string responseId, CancellationToken cancellationToken) { + if (!this.TrySelectAgent(httpContext, out IResponsesService responsesService, out _)) + { + return AgentNotFound(); + } + try { - var response = await this._responsesService.CancelResponseAsync(responseId, cancellationToken).ConfigureAwait(false); + var response = await responsesService.CancelResponseAsync(responseId, cancellationToken).ConfigureAwait(false); return Results.Ok(response); } catch (InvalidOperationException ex) @@ -192,10 +194,16 @@ public async Task CancelResponseAsync( /// Deletes a response. /// public async Task DeleteResponseAsync( + HttpContext httpContext, string responseId, CancellationToken cancellationToken) { - var deleted = await this._responsesService.DeleteResponseAsync(responseId, cancellationToken).ConfigureAwait(false); + if (!this.TrySelectAgent(httpContext, out IResponsesService responsesService, out _)) + { + return AgentNotFound(); + } + + var deleted = await responsesService.DeleteResponseAsync(responseId, cancellationToken).ConfigureAwait(false); return deleted ? Results.Ok(new DeleteResponse { Id = responseId, Object = "response", Deleted = true }) : Results.NotFound(new ErrorResponse @@ -212,6 +220,7 @@ public async Task DeleteResponseAsync( /// Lists the input items for a response. /// public async Task ListResponseInputItemsAsync( + HttpContext httpContext, string responseId, [FromQuery] int? limit, [FromQuery] string? order, @@ -219,6 +228,11 @@ public async Task ListResponseInputItemsAsync( [FromQuery] string? before, CancellationToken cancellationToken) { + if (!this.TrySelectAgent(httpContext, out IResponsesService responsesService, out _)) + { + return AgentNotFound(); + } + try { // Convert string order to SortOrder enum @@ -230,7 +244,7 @@ string s when s.Equals("desc", StringComparison.OrdinalIgnoreCase) => SortOrder. _ => throw new InvalidOperationException($"Invalid order value: {order}. Must be 'asc' or 'desc'.") }; - var result = await this._responsesService.ListResponseInputItemsAsync( + var result = await responsesService.ListResponseInputItemsAsync( responseId, limit, sortOrder, @@ -252,4 +266,33 @@ string s when s.Equals("desc", StringComparison.OrdinalIgnoreCase) => SortOrder. }); } } + + private bool TrySelectAgent(HttpContext httpContext, out IResponsesService responsesService, out IResponseExecutor? executor) + { + responsesService = this._responsesService; + executor = null; + if (this._agentSelector is null) + { + return true; + } + + if (this._agentSelector(httpContext) is not { } selection) + { + return false; + } + + (responsesService, executor) = selection; + return true; + } + + private static IResult AgentNotFound() => + Results.NotFound(new ErrorResponse + { + Error = new ErrorDetails + { + Message = "No agent matches the request.", + Type = "invalid_request_error", + Code = "agent_not_found" + } + }); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ResponseSessionRegressionTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ResponseSessionRegressionTests.cs index fb3a2350f1d..ad09d8dfa5c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ResponseSessionRegressionTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ResponseSessionRegressionTests.cs @@ -155,6 +155,80 @@ public async Task AgentSelector_ReturningNull_ReturnsNotFoundAsync() Assert.Contains("agent_not_found", responseBody, StringComparison.Ordinal); } + [Theory] + [InlineData("GET", "/agents/unknown/v1/responses/resp_1")] + [InlineData("POST", "/agents/unknown/v1/responses/resp_1/cancel")] + [InlineData("DELETE", "/agents/unknown/v1/responses/resp_1")] + [InlineData("GET", "/agents/unknown/v1/responses/resp_1/input_items")] + public async Task AgentSelector_ReturningNull_ReturnsNotFoundForStoredResponseOperationsAsync(string method, string path) + { + // Arrange + int selections = 0; + WebApplicationBuilder builder = CreateBuilder(); + builder.AddOpenAIResponses(); + + await using WebApplication app = builder.Build(); + app.MapOpenAIResponses("/agents/{agentId}/v1/responses", _ => + { + Interlocked.Increment(ref selections); + return null; + }); + await app.StartAsync(); + using HttpClient client = app.GetTestClient(); + + // Act + using HttpRequestMessage request = new(new HttpMethod(method), new Uri(path, UriKind.Relative)); + using HttpResponseMessage response = await client.SendAsync(request); + string responseBody = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(System.Net.HttpStatusCode.NotFound, response.StatusCode); + Assert.Contains("agent_not_found", responseBody, StringComparison.Ordinal); + Assert.Equal(1, selections); + } + + [Fact] + public async Task AgentSelector_BindsStoredResponsesToSelectedAgentAsync() + { + // Arrange + Dictionary agents = new(StringComparer.Ordinal) + { + ["alpha"] = new ChatClientAgent(new TestHelpers.SimpleMockChatClient("Alpha response"), name: "alpha"), + ["beta"] = new ChatClientAgent(new TestHelpers.SimpleMockChatClient("Beta response"), name: "beta"), + }; + WebApplicationBuilder builder = CreateBuilder(); + builder.AddOpenAIResponses(); + + await using WebApplication app = builder.Build(); + app.MapOpenAIResponses( + "/agents/{agentId}/v1/responses", + context => context.GetRouteValue("agentId") is string agentId ? agents.GetValueOrDefault(agentId) : null); + await app.StartAsync(); + using HttpClient client = app.GetTestClient(); + + using HttpResponseMessage createResponse = await PostJsonAsync( + client, + "/agents/alpha/v1/responses", + """{"input":"hello"}"""); + string createBody = await createResponse.Content.ReadAsStringAsync(); + Assert.True(createResponse.IsSuccessStatusCode, createBody); + string responseId = JsonDocument.Parse(createBody).RootElement.GetProperty("id").GetString()!; + + // Act + using HttpResponseMessage otherAgentGet = await client.GetAsync(new Uri($"/agents/beta/v1/responses/{responseId}", UriKind.Relative)); + using HttpResponseMessage otherAgentInputItems = await client.GetAsync(new Uri($"/agents/beta/v1/responses/{responseId}/input_items", UriKind.Relative)); + using HttpResponseMessage otherAgentDelete = await client.DeleteAsync(new Uri($"/agents/beta/v1/responses/{responseId}", UriKind.Relative)); + using HttpResponseMessage ownerGet = await client.GetAsync(new Uri($"/agents/alpha/v1/responses/{responseId}", UriKind.Relative)); + string ownerBody = await ownerGet.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(System.Net.HttpStatusCode.NotFound, otherAgentGet.StatusCode); + Assert.Equal(System.Net.HttpStatusCode.NotFound, otherAgentInputItems.StatusCode); + Assert.Equal(System.Net.HttpStatusCode.NotFound, otherAgentDelete.StatusCode); + Assert.True(ownerGet.IsSuccessStatusCode, ownerBody); + Assert.Contains("Alpha response", ownerBody, StringComparison.Ordinal); + } + [Fact] public async Task AgentSelector_ResolvesScopedAgentFromRequestServicesAsync() { From a3c1ee07bb32ed3daed4044995c76f7cc32304ab Mon Sep 17 00:00:00 2001 From: Marco Minerva Date: Wed, 14 Oct 2026 15:51:47 +0000 Subject: [PATCH 4/6] .NET: Release the response executor after OpenAI Responses execution Stored response states outlive execution under a sliding expiration, so clear the executor, and the selected agent graph it references, once execution ends, including on failure or cancellation. --- .../Responses/InMemoryResponsesService.cs | 3 ++ .../InMemoryResponsesServiceTests.cs | 32 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs index 911fceaab44..4b7981c0996 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs @@ -676,6 +676,9 @@ private async Task ExecuteResponseAsync(string responseId, ResponseState state, } finally { + // Stored responses outlive execution, so release the executor and the agent graph it references. + state.Executor = null; + // Signal one final time to unblock any waiting consumers state.SignalUpdate(); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/InMemoryResponsesServiceTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/InMemoryResponsesServiceTests.cs index 1827d852330..e25d1a855f5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/InMemoryResponsesServiceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/InMemoryResponsesServiceTests.cs @@ -138,6 +138,38 @@ public async Task ListResponseInputItemsAsync_ReturnsTheItemsTheCursorsSelectAsy }; } + [Fact] + public async Task CreateResponseAsync_ReleasesExecutorAfterExecutionCompletesAsync() + { + // Arrange + using var service = new InMemoryResponsesService( + executor: null, new InMemoryStorageOptions(), conversationStorage: null, isolationKeyResolver: null); + + // Act + (WeakReference executor, string responseId) = await CreateResponseWithCollectableExecutorAsync(service); + + // Leave the completion stack so the finished execution state machine has released its locals. + await Task.Yield(); + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + // Assert + Assert.False(executor.IsAlive); + Assert.NotNull(await service.GetResponseAsync(responseId)); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static async Task<(WeakReference Executor, string ResponseId)> CreateResponseWithCollectableExecutorAsync( + InMemoryResponsesService service) + { + var executor = new StubResponseExecutor(); + Response response = await service.CreateResponseAsync( + new CreateResponse { Input = ResponseInput.FromText("hello") }, + executor); + return (new WeakReference(executor), response.Id); + } + private sealed class StubResponseExecutor : IResponseExecutor { public ValueTask ValidateRequestAsync(CreateResponse request, CancellationToken cancellationToken = default) From 2c66b602dcccba7e0ba97c2214df0892067dcc10 Mon Sep 17 00:00:00 2001 From: Marco Minerva Date: Mon, 19 Oct 2026 08:40:02 +0000 Subject: [PATCH 5/6] .NET: Scope request-time agent selection PR to AG-UI hosting Move the A2A, OpenAI Chat Completions and OpenAI Responses changes to separate branches so this PR only covers MapAGUIServer and the shared hosted agent resolution helper. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../A2AEndpointRouteBuilderExtensions.cs | 121 ------ ...ft.Agents.AI.Hosting.A2A.AspNetCore.csproj | 4 +- .../SelectedAgentA2ARequestHandler.cs | 160 ------- .../A2AAgentHandler.cs | 28 +- .../A2AServerServiceCollectionExtensions.cs | 113 ++--- .../Microsoft.Agents.AI.Hosting.A2A.csproj | 2 - .../ScopedA2AAgentHandler.cs | 84 ---- ...tRouteBuilderExtensions.ChatCompletions.cs | 113 +---- ...ndpointRouteBuilderExtensions.Responses.cs | 109 +---- .../Microsoft.Agents.AI.Hosting.OpenAI.csproj | 1 - .../Responses/AIAgentResponseExecutor.cs | 9 +- .../Responses/IResponsesService.cs | 36 -- .../Responses/InMemoryResponsesService.cs | 100 +---- .../Responses/ResponsesHttpHandler.cs | 97 +---- .../A2ARequestScopedAgentTests.cs | 396 ------------------ ...AServerServiceCollectionExtensionsTests.cs | 4 +- .../InMemoryResponsesServiceTests.cs | 32 -- ...enAIChatCompletionsAgentResolutionTests.cs | 196 --------- .../ResponseSessionRegressionTests.cs | 183 -------- 19 files changed, 88 insertions(+), 1700 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/SelectedAgentA2ARequestHandler.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.A2A/ScopedA2AAgentHandler.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2ARequestScopedAgentTests.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsAgentResolutionTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs index 975a7d6e1dc..6dad579786e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs @@ -6,8 +6,6 @@ using A2A.AspNetCore; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Hosting; -using Microsoft.Agents.AI.Hosting.A2A; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Shared.DiagnosticIds; @@ -95,56 +93,6 @@ public static IEndpointConventionBuilder MapA2AHttpJson(this IEndpointRouteBuild return endpoint; } - /// - /// Maps A2A HTTP+JSON endpoints that select the agent to serve for each request. - /// - /// The to add the A2A endpoints to. - /// The route path prefix for A2A endpoints. - /// - /// A delegate invoked once per request that returns the agent to serve, or when - /// no agent matches the request. The delegate can use route values, claims, headers, and - /// to select or resolve the agent. - /// - /// An optional callback to configure the applied to every selected agent. - /// An for further endpoint configuration. - /// - /// - /// When returns , the endpoint responds with - /// 404 Not Found before any session or task state is loaded. No AddA2AServer registration is required. - /// - /// - /// The selected agent's is its stable logical identity. It keys the agent's - /// and registrations, partitions persisted sessions, and - /// identifies the A2A task state shared by the requests that select it through this endpoint, so conversations - /// and tasks continue across requests even when the selector returns a new agent instance each time. The selected - /// agent must therefore have a non-empty name. The session store is resolved from - /// , so the run cannot outlive the request: when the configured - /// returns a task, non-streaming requests with ReturnImmediately enabled are - /// rejected with . - /// - /// - /// Selection is not an authorization boundary. See - /// for endpoint authorization and caller isolation requirements. - /// - /// - public static IEndpointConventionBuilder MapA2AHttpJson( - this IEndpointRouteBuilder endpoints, - string path, - Func agentSelector, - Action? configureOptions = null) - { - ArgumentNullException.ThrowIfNull(endpoints); - ArgumentException.ThrowIfNullOrWhiteSpace(path); - ArgumentNullException.ThrowIfNull(agentSelector); - - var requestHandler = CreateSelectedAgentRequestHandler(endpoints, configureOptions); - - IEndpointConventionBuilder endpoint = endpoints.MapHttpA2A(requestHandler, path); - AddAgentSelection(endpoint, requestHandler, agentSelector); - MarkFeatureUsed(); - return endpoint; - } - /// /// Maps A2A JSON-RPC endpoints for the specified agent to the given path. /// An for the agent must be registered first by calling @@ -215,75 +163,6 @@ public static IEndpointConventionBuilder MapA2AJsonRpc(this IEndpointRouteBuilde return endpoint; } - /// - /// Maps A2A JSON-RPC endpoints that select the agent to serve for each request. - /// - /// The to add the A2A endpoints to. - /// The route path prefix for A2A endpoints. - /// - /// A delegate invoked once per request that returns the agent to serve, or when - /// no agent matches the request. The delegate can use route values, claims, headers, and - /// to select or resolve the agent. - /// - /// An optional callback to configure the applied to every selected agent. - /// An for further endpoint configuration. - /// - /// See - /// for selection, identity, and lifetime behavior, and - /// for endpoint authorization and caller isolation requirements, which also apply to this JSON-RPC binding. - /// - public static IEndpointConventionBuilder MapA2AJsonRpc( - this IEndpointRouteBuilder endpoints, - string path, - Func agentSelector, - Action? configureOptions = null) - { - ArgumentNullException.ThrowIfNull(endpoints); - ArgumentException.ThrowIfNullOrWhiteSpace(path); - ArgumentNullException.ThrowIfNull(agentSelector); - - var requestHandler = CreateSelectedAgentRequestHandler(endpoints, configureOptions); - - IEndpointConventionBuilder endpoint = endpoints.MapA2A(requestHandler, path); - AddAgentSelection(endpoint, requestHandler, agentSelector); - MarkFeatureUsed(); - return endpoint; - } - - private static SelectedAgentA2ARequestHandler CreateSelectedAgentRequestHandler( - IEndpointRouteBuilder endpoints, - Action? configureOptions) - { - A2AServerRegistrationOptions? options = null; - if (configureOptions is not null) - { - options = new A2AServerRegistrationOptions(); - configureOptions(options); - } - - return new SelectedAgentA2ARequestHandler(endpoints.ServiceProvider, options); - } - - private static void AddAgentSelection( - IEndpointConventionBuilder endpoint, - SelectedAgentA2ARequestHandler requestHandler, - Func agentSelector) - { - // The A2A endpoints call the request handler without request context, so select the agent for the - // asynchronous flow of the request before the endpoint runs. - endpoint.AddEndpointFilter(async (invocationContext, next) => - { - HttpContext httpContext = invocationContext.HttpContext; - if (agentSelector(httpContext) is not { } agent) - { - return Results.NotFound(); - } - - requestHandler.Select(agent, httpContext.RequestServices); - return await next(invocationContext).ConfigureAwait(false); - }); - } - private static void MarkFeatureUsed() { #pragma warning disable MAAI001 diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj index 0333f13f23e..b91ea40baa7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj @@ -12,8 +12,8 @@ - + true + true true diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/SelectedAgentA2ARequestHandler.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/SelectedAgentA2ARequestHandler.cs deleted file mode 100644 index d29d81ec256..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/SelectedAgentA2ARequestHandler.cs +++ /dev/null @@ -1,160 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; -using A2A; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Shared.DiagnosticIds; - -namespace Microsoft.Agents.AI.Hosting.A2A; - -/// -/// An that dispatches each A2A operation to the agent selected for the current request. -/// -/// -/// The A2A endpoints invoke this handler without request context, so the endpoint filter records the selected agent -/// for the asynchronous flow of the request before the endpoint runs. Each selected agent name owns an -/// whose task state is shared by all requests that select that name. -/// -[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)] -internal sealed class SelectedAgentA2ARequestHandler : IA2ARequestHandler -{ - private readonly AsyncLocal _selection = new(); - private readonly ConcurrentDictionary _servers = new(StringComparer.Ordinal); - private readonly IServiceProvider _applicationServices; - private readonly A2AServerRegistrationOptions? _options; - - /// - /// Initializes a new instance of the class. - /// - /// The application service provider that owns the shared task state. - /// The optional registration options applied to every selected agent. - public SelectedAgentA2ARequestHandler(IServiceProvider applicationServices, A2AServerRegistrationOptions? options) - { - this._applicationServices = applicationServices; - this._options = options; - } - - /// - /// Makes the target of the A2A operations dispatched in the current asynchronous flow. - /// - /// The agent selected for the request. - /// The request services that own the agent's session services. - public void Select(AIAgent agent, IServiceProvider requestServices) - { - string agentName = HostedAgentResolution.GetSelectedAgentName(agent); - SelectedAgentServer server = this._servers.GetOrAdd( - agentName, - static (name, state) => new SelectedAgentServer(state._applicationServices, name, state._options), - this); - - this._selection.Value = new Selection(agent, agentName, requestServices, server, this._options?.AgentRunMode ?? AgentRunMode.ReturnMessage); - } - - /// - public Task SendMessageAsync(SendMessageRequest request, CancellationToken cancellationToken = default) - { - Selection selection = this.GetSelection(); - selection.BindAgent(request.Message); - return selection.A2AServer.SendMessageAsync(request, cancellationToken); - } - - /// - public IAsyncEnumerable SendStreamingMessageAsync(SendMessageRequest request, CancellationToken cancellationToken = default) - { - Selection selection = this.GetSelection(); - selection.BindAgent(request.Message); - return selection.A2AServer.SendStreamingMessageAsync(request, cancellationToken); - } - - /// - public Task GetTaskAsync(GetTaskRequest request, CancellationToken cancellationToken = default) - => this.GetSelection().A2AServer.GetTaskAsync(request, cancellationToken); - - /// - public Task ListTasksAsync(ListTasksRequest request, CancellationToken cancellationToken = default) - => this.GetSelection().A2AServer.ListTasksAsync(request, cancellationToken); - - /// - public Task CancelTaskAsync(CancelTaskRequest request, CancellationToken cancellationToken = default) - => this.GetSelection().A2AServer.CancelTaskAsync(request, cancellationToken); - - /// - public IAsyncEnumerable SubscribeToTaskAsync(SubscribeToTaskRequest request, CancellationToken cancellationToken = default) - => this.GetSelection().A2AServer.SubscribeToTaskAsync(request, cancellationToken); - - /// - public Task CreateTaskPushNotificationConfigAsync(TaskPushNotificationConfig config, CancellationToken cancellationToken = default) - => this.GetSelection().A2AServer.CreateTaskPushNotificationConfigAsync(config, cancellationToken); - - /// - public Task GetTaskPushNotificationConfigAsync(GetTaskPushNotificationConfigRequest request, CancellationToken cancellationToken = default) - => this.GetSelection().A2AServer.GetTaskPushNotificationConfigAsync(request, cancellationToken); - - /// - public Task ListTaskPushNotificationConfigsAsync(ListTaskPushNotificationConfigsRequest request, CancellationToken cancellationToken = default) - => this.GetSelection().A2AServer.ListTaskPushNotificationConfigsAsync(request, cancellationToken); - - /// - public Task DeleteTaskPushNotificationConfigAsync(DeleteTaskPushNotificationConfigRequest request, CancellationToken cancellationToken = default) - => this.GetSelection().A2AServer.DeleteTaskPushNotificationConfigAsync(request, cancellationToken); - - /// - public Task GetExtendedAgentCardAsync(GetExtendedAgentCardRequest request, CancellationToken cancellationToken = default) - => this.GetSelection().A2AServer.GetExtendedAgentCardAsync(request, cancellationToken); - - private Selection GetSelection() => - this._selection.Value ?? throw new InvalidOperationException("No agent was selected for the current A2A request."); - - private sealed class Selection( - AIAgent agent, - string agentName, - IServiceProvider requestServices, - SelectedAgentServer server, - AgentRunMode runMode) - { - public A2AServer A2AServer => server.A2AServer; - - public void BindAgent(Message message) - { - AIHostAgent hostAgent = A2AServerServiceCollectionExtensions.CreateHostAgent(requestServices, agent, agentName, sessionStorageIdentity: agentName); - server.Bind(message, new A2AAgentHandler(hostAgent, runMode, allowReturnImmediately: false)); - } - } - - /// - /// Owns the task state of one selected agent name and runs each message with the agent selected by its request. - /// - private sealed class SelectedAgentServer : IAgentHandler - { - // A2AServer passes the incoming message to the handler, including when it runs the handler in the background. - private readonly ConditionalWeakTable _handlers = new(); - - public SelectedAgentServer(IServiceProvider applicationServices, string agentName, A2AServerRegistrationOptions? options) - { - this.A2AServer = A2AServerServiceCollectionExtensions.CreateA2AServer(applicationServices, agentName, this, options); - } - - public A2AServer A2AServer { get; } - - public void Bind(Message message, IAgentHandler handler) => this._handlers.AddOrUpdate(message, handler); - - public Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) - { - if (context.Message is null || !this._handlers.TryGetValue(context.Message, out IAgentHandler? handler)) - { - throw new InvalidOperationException("The A2A message is not associated with the agent selected for its request."); - } - - return handler.ExecuteAsync(context, eventQueue, cancellationToken); - } - - public Task CancelAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) - => A2AAgentHandler.CancelTaskAsync(context, eventQueue, cancellationToken); - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs index b3ec933be67..8baf274020d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs @@ -28,28 +28,21 @@ internal sealed class A2AAgentHandler : IAgentHandler private readonly AIHostAgent _hostAgent; private readonly AgentRunMode _runMode; - private readonly bool _allowReturnImmediately; /// /// Initializes a new instance of the class. /// /// The hosted agent that provides the execution logic. /// Controls which A2A artifact the agent response is returned as. - /// - /// to reject non-streaming requests with ReturnImmediately = true when - /// returns a task, because the run would continue after the request completes. - /// public A2AAgentHandler( AIHostAgent hostAgent, - AgentRunMode runMode, - bool allowReturnImmediately = true) + AgentRunMode runMode) { ArgumentNullException.ThrowIfNull(hostAgent); ArgumentNullException.ThrowIfNull(runMode); this._hostAgent = hostAgent; this._runMode = runMode; - this._allowReturnImmediately = allowReturnImmediately; } /// @@ -74,17 +67,7 @@ public Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, Can } /// - public Task CancelAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) - => CancelTaskAsync(context, eventQueue, cancellationToken); - - /// - /// Transitions the task identified by to the canceled state. - /// - /// The request context of the task to cancel. - /// The queue the cancellation event is written to. - /// A to cancel the operation. - /// A task that represents the asynchronous operation. - internal static async Task CancelTaskAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) + public async Task CancelAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) { var taskUpdater = new TaskUpdater(eventQueue, context.TaskId, context.ContextId); await taskUpdater.CancelAsync(cancellationToken: cancellationToken).ConfigureAwait(false); @@ -157,13 +140,6 @@ private async Task HandleNewMessageAsync(RequestContext context, AgentEventQueue var decisionContext = new A2ARunDecisionContext(context); var returnTask = await this._runMode.ShouldReturnTaskAsync(decisionContext, cancellationToken).ConfigureAwait(false); - if (returnTask && !aggregateTaskUpdates && !context.StreamingResponse && !this._allowReturnImmediately) - { - throw new A2AException( - "ReturnImmediately is not supported when the agent is selected for each request, because the selected agent and its services are bound to the HTTP request. Send the message with ReturnImmediately disabled.", - A2AErrorCode.UnsupportedOperation); - } - var options = CreateRunOptions(context); var updates = this._hostAgent.RunStreamingAsync(chatMessages, session, options, cancellationToken); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs index c466e394de4..b3fc808b213 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs @@ -30,11 +30,6 @@ public static class A2AServerServiceCollectionExtensions /// The for chaining. /// /// - /// Singleton agent registrations are resolved once when the server is created. Scoped and transient registrations - /// are resolved for each A2A operation as described in - /// . - /// - /// /// Trust model. The A2A contextId and taskId arrive /// from the wire and are treated as chain-resume identifiers — not as /// authorization tokens. accepts an explicit user partition, @@ -60,7 +55,7 @@ public static IHostedAgentBuilder AddA2AServer(this IHostedAgentBuilder agentBui { ArgumentNullException.ThrowIfNull(agentBuilder); - AddA2AServer(agentBuilder.ServiceCollection, agentBuilder.Name, agentBuilder.Lifetime, configureOptions); + agentBuilder.ServiceCollection.AddA2AServer(agentBuilder.Name, configureOptions); return agentBuilder; } @@ -126,25 +121,13 @@ public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder /// An optional callback to configure . /// The for chaining. /// - /// - /// The server and its task store are singletons shared by all requests. The keyed , its keyed - /// , and an optional keyed are resolved from a new - /// service scope for each A2A operation, so the configured DI lifetimes of these registrations are honored and - /// operations that continue in the background keep their services alive until they complete. Persisted sessions - /// are partitioned by , which remains stable across scoped or transient agent instances. - /// - /// /// See the trust-model remarks on /// for guidance on multi-user hosts (the wire contextId and taskId /// are chain-resume identifiers, not authorization tokens; multi-user hosts must /// supply a trusted user partition via UseClaimsBasedAgentIsolation(...) or /// a custom ). - /// /// public static IServiceCollection AddA2AServer(this IServiceCollection services, string agentName, Action? configureOptions = null) - => AddA2AServer(services, agentName, agentLifetime: null, configureOptions); - - private static IServiceCollection AddA2AServer(IServiceCollection services, string agentName, ServiceLifetime? agentLifetime, Action? configureOptions) { ArgumentNullException.ThrowIfNull(services); ArgumentException.ThrowIfNullOrWhiteSpace(agentName); @@ -158,19 +141,8 @@ private static IServiceCollection AddA2AServer(IServiceCollection services, stri services.AddKeyedSingleton(agentName, (sp, _) => { - // Custom handlers replace the agent, so the agent registration is only required without one. - bool hasCustomHandler = sp.GetService()?.IsKeyedService(typeof(IAgentHandler), agentName) ?? false; - AIAgent? agent = null; - if (!hasCustomHandler) - { - HostedAgentResolution.EnsureAgentRegistered(sp, agentName); - if (agentLifetime == ServiceLifetime.Singleton) - { - agent = sp.GetRequiredKeyedService(agentName); - } - } - - return CreateA2AServer(sp, agentName, agent, sessionStorageIdentity: agentName, options); + var agent = sp.GetRequiredKeyedService(agentName); + return CreateA2AServer(sp, agent, options); }); return services; @@ -206,45 +178,39 @@ public static IServiceCollection AddA2AServer(this IServiceCollection services, configureOptions(options); } - services.AddKeyedSingleton(agent.Name, (sp, _) => CreateA2AServer(sp, agent.Name, agent, sessionStorageIdentity: null, options)); + services.AddKeyedSingleton(agent.Name, (sp, _) => CreateA2AServer(sp, agent, options)); return services; } - private static A2AServer CreateA2AServer( - IServiceProvider serviceProvider, - string agentName, - AIAgent? agent, - string? sessionStorageIdentity, - A2AServerRegistrationOptions? options) + private static A2AServer CreateA2AServer(IServiceProvider serviceProvider, AIAgent agent, A2AServerRegistrationOptions? options) { - // The server owns task state shared across requests, while the agent and its session services are - // resolved for each operation so that scoped and transient registrations keep their lifetimes. - var agentHandler = new ScopedA2AAgentHandler( - serviceProvider.GetRequiredService(), - agentName, - agent, - sessionStorageIdentity, - options?.AgentRunMode ?? AgentRunMode.ReturnMessage); + var isolationKeyProvider = serviceProvider.GetService(); - return CreateA2AServer(serviceProvider, agentName, agentHandler, options); - } + var agentHandler = serviceProvider.GetKeyedService(agent.Name); + if (agentHandler is null) + { + var agentSessionStore = serviceProvider.GetKeyedService(agent.Name); + var runMode = options?.AgentRunMode ?? AgentRunMode.ReturnMessage; + + // Ensure that we have an IsolationKeyScopedAgentSessionStore registered. + if (agentSessionStore?.GetService() is null) + { + agentSessionStore ??= new NoopAgentSessionStore(); + agentSessionStore = new IsolationKeyScopedAgentSessionStore(agentSessionStore, isolationKeyProvider, new() { Strict = isolationKeyProvider != null }); + } + + var hostAgent = new AIHostAgent( + innerAgent: agent, + sessionStore: agentSessionStore); + + agentHandler = new A2AAgentHandler(hostAgent, runMode); + } - /// - /// Creates an for the specified agent name with its keyed or default task store. - /// - /// The application service provider. - /// The name that keys the agent's hosted services. - /// The handler that executes A2A operations. - /// The optional registration options. - /// The created server. - internal static A2AServer CreateA2AServer(IServiceProvider serviceProvider, string agentName, IAgentHandler agentHandler, A2AServerRegistrationOptions? options) - { - var isolationKeyProvider = serviceProvider.GetService(); var loggerFactory = serviceProvider.GetService() ?? NullLoggerFactory.Instance; - ITaskStore taskStore = serviceProvider.GetKeyedService(agentName) ?? new InMemoryTaskStore(); + ITaskStore taskStore = serviceProvider.GetKeyedService(agent.Name) ?? new InMemoryTaskStore(); - // Wrap the task store with isolation key scoping, same as the session store. + // Wrap the task store with isolation key scoping, same as the session store above. if (taskStore is not IsolationKeyScopedTaskStore) { taskStore = new IsolationKeyScopedTaskStore(taskStore, isolationKeyProvider, strict: isolationKeyProvider != null); @@ -257,29 +223,4 @@ internal static A2AServer CreateA2AServer(IServiceProvider serviceProvider, stri loggerFactory.CreateLogger(), options?.ServerOptions); } - - /// - /// Creates the hosting wrapper that persists the sessions of an agent served over A2A. - /// - /// The service provider that owns the agent's session services. - /// The agent to host. - /// The name that keys the agent's session store. - /// - /// The stable logical identity that partitions persisted sessions, or to use the agent instance identity. - /// - /// The hosting wrapper for . - internal static AIHostAgent CreateHostAgent(IServiceProvider services, AIAgent agent, string agentName, string? sessionStorageIdentity) - { - var isolationKeyProvider = services.GetService(); - var agentSessionStore = services.GetKeyedService(agentName); - - // Ensure that we have an IsolationKeyScopedAgentSessionStore registered. - if (agentSessionStore?.GetService() is null) - { - agentSessionStore ??= new NoopAgentSessionStore(); - agentSessionStore = new IsolationKeyScopedAgentSessionStore(agentSessionStore, isolationKeyProvider, new() { Strict = isolationKeyProvider != null }); - } - - return new AIHostAgent(agent, agentSessionStore, sessionStorageIdentity); - } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj index 627d123bbd5..2db2d6b6f6a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj @@ -13,7 +13,6 @@ true true true - true @@ -29,7 +28,6 @@ - diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/ScopedA2AAgentHandler.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/ScopedA2AAgentHandler.cs deleted file mode 100644 index 49352b2f8bf..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/ScopedA2AAgentHandler.cs +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Diagnostics.CodeAnalysis; -using System.Threading; -using System.Threading.Tasks; -using A2A; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Shared.DiagnosticIds; - -namespace Microsoft.Agents.AI.Hosting.A2A; - -/// -/// An that resolves the agent and its session services from a new service scope -/// for each A2A operation, so the configured DI lifetimes of the hosted registration are honored. -/// -/// -/// A2A operations can continue in the background after the originating HTTP request completes, so each -/// operation owns its scope instead of borrowing the request scope. -/// -[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)] -internal sealed class ScopedA2AAgentHandler : IAgentHandler -{ - private readonly IServiceScopeFactory _scopeFactory; - private readonly string _agentName; - private readonly AIAgent? _agent; - private readonly string? _sessionStorageIdentity; - private readonly AgentRunMode _runMode; - - /// - /// Initializes a new instance of the class. - /// - /// The factory used to create a scope for each operation. - /// The name that keys the agent registration and its hosted services. - /// A fixed agent instance, or to resolve the keyed registration in each scope. - /// - /// The stable logical identity that partitions persisted sessions, or to use the agent instance identity. - /// - /// Controls which A2A artifact the agent response is returned as. - public ScopedA2AAgentHandler(IServiceScopeFactory scopeFactory, string agentName, AIAgent? agent, string? sessionStorageIdentity, AgentRunMode runMode) - { - this._scopeFactory = scopeFactory; - this._agentName = agentName; - this._agent = agent; - this._sessionStorageIdentity = sessionStorageIdentity; - this._runMode = runMode; - } - - /// - public async Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) - { - AsyncServiceScope scope = this._scopeFactory.CreateAsyncScope(); - await using (scope.ConfigureAwait(false)) - { - await this.CreateHandler(scope.ServiceProvider).ExecuteAsync(context, eventQueue, cancellationToken).ConfigureAwait(false); - } - } - - /// - public async Task CancelAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) - { - AsyncServiceScope scope = this._scopeFactory.CreateAsyncScope(); - await using (scope.ConfigureAwait(false)) - { - // Canceling a task does not involve the agent, so only a custom handler needs to be resolved. - Task cancellation = scope.ServiceProvider.GetKeyedService(this._agentName) is { } agentHandler - ? agentHandler.CancelAsync(context, eventQueue, cancellationToken) - : A2AAgentHandler.CancelTaskAsync(context, eventQueue, cancellationToken); - await cancellation.ConfigureAwait(false); - } - } - - private IAgentHandler CreateHandler(IServiceProvider services) - { - if (services.GetKeyedService(this._agentName) is { } agentHandler) - { - return agentHandler; - } - - AIAgent agent = this._agent ?? services.GetRequiredKeyedService(this._agentName); - AIHostAgent hostAgent = A2AServerServiceCollectionExtensions.CreateHostAgent(services, agent, this._agentName, this._sessionStorageIdentity); - return new A2AAgentHandler(hostAgent, this._runMode); - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.ChatCompletions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.ChatCompletions.cs index 8a9b00eab9a..8745d510320 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.ChatCompletions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.ChatCompletions.cs @@ -8,8 +8,6 @@ using Microsoft.Agents.AI.Hosting.OpenAI; using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions; using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; -using Microsoft.Agents.AI.Hosting.OpenAI.Models; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; @@ -38,38 +36,13 @@ public static IEndpointConventionBuilder MapOpenAIChatCompletions(this IEndpoint /// Custom route path for the chat completions endpoint. /// Optional options controlling how incoming requests are mapped onto the agent run. /// - /// Singleton registrations are resolved once when the endpoint is mapped. Scoped and transient registrations are - /// resolved from for each request, so their configured DI lifetime is honored. /// See /// for endpoint authorization and application-owned state requirements. /// public static IEndpointConventionBuilder MapOpenAIChatCompletions(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string? path, OpenAIChatCompletionsMapOptions? mapOptions = null) { - ArgumentNullException.ThrowIfNull(endpoints); - ArgumentNullException.ThrowIfNull(agentBuilder); - - string agentName = agentBuilder.Name; - ValidateAgentName(agentName); - HostedAgentResolution.EnsureAgentRegistered(endpoints.ServiceProvider, agentName); - - Func agentResolver; - if (agentBuilder.Lifetime == ServiceLifetime.Singleton) - { - var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); - agentResolver = _ => agent; - } - else - { - agentResolver = context => context.RequestServices.GetRequiredKeyedService(agentName); - } - - path ??= $"/{agentName}/v1/chat/completions"; - return MapOpenAIChatCompletionsCore( - endpoints, - path, - agentName + "/CreateChatCompletion", - agentResolver, - mapOptions); + var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentBuilder.Name); + return MapOpenAIChatCompletions(endpoints, agent, path, mapOptions); } /// @@ -111,86 +84,12 @@ public static IEndpointConventionBuilder MapOpenAIChatCompletions( ValidateAgentName(agent.Name); path ??= $"/{agent.Name}/v1/chat/completions"; - return MapOpenAIChatCompletionsCore(endpoints, path, agent.Name + "/CreateChatCompletion", _ => agent, mapOptions); - } - - /// - /// Maps an OpenAI ChatCompletions API endpoint that selects the to invoke for each request. - /// - /// The to add the OpenAI ChatCompletions endpoint to. - /// The route path for the chat completions 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. - /// - /// Optional options controlling how incoming requests are mapped onto the agent run. - /// An for further endpoint configuration. - /// - /// When returns , the endpoint responds with - /// 404 Not Found. The selected agent must have a non-empty , which is its - /// stable logical identity. Selection is not an authorization boundary; see - /// - /// for endpoint authorization and application-owned state requirements. - /// - public static IEndpointConventionBuilder MapOpenAIChatCompletions( - this IEndpointRouteBuilder endpoints, - [StringSyntax("Route")] string path, - Func agentSelector, - OpenAIChatCompletionsMapOptions? mapOptions = null) - { - ArgumentNullException.ThrowIfNull(endpoints); - ArgumentException.ThrowIfNullOrWhiteSpace(path); - ArgumentNullException.ThrowIfNull(agentSelector); - - return MapOpenAIChatCompletionsCore( - endpoints, - path, - endpointName: null, - context => - { - if (agentSelector(context) is not { } agent) - { - return null; - } - - _ = HostedAgentResolution.GetSelectedAgentName(agent); - return agent; - }, - mapOptions); - } - - private static RouteGroupBuilder MapOpenAIChatCompletionsCore( - IEndpointRouteBuilder endpoints, - string path, - string? endpointName, - Func agentResolver, - OpenAIChatCompletionsMapOptions? mapOptions) - { var group = endpoints.MapGroup(path); + var endpointAgentName = agent.Name ?? agent.Id; - var endpoint = group.MapPost("/", async ([FromBody] CreateChatCompletion request, HttpContext context, CancellationToken cancellationToken) => - { - if (agentResolver(context) is not { } agent) - { - return Results.NotFound(new ErrorResponse - { - Error = new ErrorDetails - { - Message = "No agent matches the request.", - Type = "invalid_request_error", - Code = "agent_not_found" - } - }); - } - - return await AIAgentChatCompletionsProcessor.CreateChatCompletionAsync(agent, request, mapOptions, cancellationToken).ConfigureAwait(false); - }); - - if (endpointName is not null) - { - endpoint.WithName(endpointName); - } + group.MapPost("/", async ([FromBody] CreateChatCompletion request, CancellationToken cancellationToken) + => await AIAgentChatCompletionsProcessor.CreateChatCompletionAsync(agent, request, mapOptions, cancellationToken).ConfigureAwait(false)) + .WithName(endpointAgentName + "/CreateChatCompletion"); MarkFeatureUsed(); return group; diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs index 63c66399bc6..0403d1eb172 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs @@ -38,9 +38,6 @@ public static IEndpointConventionBuilder MapOpenAIResponses(this IEndpointRouteB /// Custom route path for the OpenAI Responses endpoint. /// Optional options controlling how incoming requests are mapped onto the agent run. /// - /// Singleton registrations are resolved once when the endpoint is mapped. Scoped and transient registrations are - /// resolved for each validation or execution operation. The keyed session store is always resolved for each operation, - /// and persisted sessions are partitioned by the registration name. /// See for endpoint authorization /// and caller isolation requirements. /// @@ -49,24 +46,21 @@ public static IEndpointConventionBuilder MapOpenAIResponses(this IEndpointRouteB ArgumentNullException.ThrowIfNull(endpoints); ArgumentNullException.ThrowIfNull(agentBuilder); - string agentName = agentBuilder.Name; - ValidateAgentName(agentName); - path ??= $"/{agentName}/v1/responses"; + ValidateAgentName(agentBuilder.Name); + path ??= $"/{agentBuilder.Name}/v1/responses"; + // Defer agent and session-store resolution so their registered lifetimes are owned by + // each validation or execution operation rather than by the application root. var scopeFactory = endpoints.ServiceProvider.GetRequiredService(); - var executor = agentBuilder.Lifetime == ServiceLifetime.Singleton - ? new AIAgentResponseExecutor( - endpoints.ServiceProvider.GetRequiredKeyedService(agentName), - agentName, - scopeFactory, - mapOptions, - sessionStorageIdentity: agentName) - : new AIAgentResponseExecutor(agentName, scopeFactory, mapOptions); + var executor = new AIAgentResponseExecutor( + agentBuilder.Name, + scopeFactory, + mapOptions); return MapOpenAIResponses( endpoints, executor, path, - agentName); + agentBuilder.Name); } /// @@ -120,72 +114,11 @@ public static IEndpointConventionBuilder MapOpenAIResponses( agent.Name); } - /// - /// Maps OpenAI Responses API endpoints that select the for each request. - /// - /// The to add the OpenAI Responses endpoints to. - /// The route path for the responses endpoint. - /// - /// A delegate invoked once per request that returns the agent to serve, or when - /// no agent matches the request. The delegate can use route values, claims, headers, and - /// to select or resolve the agent. - /// - /// Optional options controlling how incoming requests are mapped onto the agent run. - /// An for further endpoint configuration. - /// - /// - /// The selector applies to every operation: creation, retrieval, cancellation, deletion, and input item listing. - /// When returns , the operation responds with - /// 404 Not Found before any response, session, or conversation state is loaded. - /// - /// - /// The selected agent's is its stable logical identity: it is used to resolve the - /// keyed , to partition persisted sessions, and to bind stored responses to the - /// agent that created them, so a response can only be retrieved, canceled, or deleted through requests that select - /// the same agent name. 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. Background responses keep running the - /// selected agent after the HTTP request completes, so agents used for background responses must not depend on - /// request-scoped services. - /// - /// - /// Selection is not an authorization boundary. See - /// for endpoint authorization and caller isolation requirements. - /// - /// - public static IEndpointConventionBuilder MapOpenAIResponses( - this IEndpointRouteBuilder endpoints, - [StringSyntax("Route")] string responsesPath, - Func agentSelector, - OpenAIResponsesMapOptions? mapOptions = null) - { - ArgumentNullException.ThrowIfNull(endpoints); - ArgumentException.ThrowIfNullOrWhiteSpace(responsesPath); - ArgumentNullException.ThrowIfNull(agentSelector); - - var scopeFactory = endpoints.ServiceProvider.GetRequiredService(); - return MapOpenAIResponses( - endpoints, - executor: null, - responsesPath, - endpointAgentName: null, - context => - { - if (agentSelector(context) is not { } agent) - { - return null; - } - - string agentName = HostedAgentResolution.GetSelectedAgentName(agent); - return (agentName, new AIAgentResponseExecutor(agent, agentName, scopeFactory, mapOptions, sessionStorageIdentity: agentName)); - }); - } - private static RouteGroupBuilder MapOpenAIResponses( IEndpointRouteBuilder endpoints, - IResponseExecutor? executor, + IResponseExecutor executor, string responsesPath, - string? endpointAgentName, - Func? agentSelector = null) + string endpointAgentName) { // Resolve the response storage settings and optional conversation storage. var storageOptions = endpoints.ServiceProvider.GetService() ?? new InMemoryStorageOptions(); @@ -199,46 +132,38 @@ private static RouteGroupBuilder MapOpenAIResponses( // Create the response service so response and conversation operations are scoped by the caller's isolation key. var responsesService = new InMemoryResponsesService(executor, storageOptions, conversationStorage, isolationKeyResolver); - var handlers = new ResponsesHttpHandler(responsesService, agentSelector is null ? null : SelectAgent); + var handlers = new ResponsesHttpHandler(responsesService); var group = endpoints.MapGroup(responsesPath); // Create response endpoint group.MapPost("/", handlers.CreateResponseAsync) - .WithEndpointName(endpointAgentName, "CreateResponse") + .WithName(endpointAgentName + "/CreateResponse") .WithSummary("Creates a model response for the given input"); // Get response endpoint group.MapGet("{responseId}", handlers.GetResponseAsync) - .WithEndpointName(endpointAgentName, "GetResponse") + .WithName(endpointAgentName + "/GetResponse") .WithSummary("Retrieves a response by ID"); // Cancel response endpoint group.MapPost("{responseId}/cancel", handlers.CancelResponseAsync) - .WithEndpointName(endpointAgentName, "CancelResponse") + .WithName(endpointAgentName + "/CancelResponse") .WithSummary("Cancels an in-progress response"); // Delete response endpoint group.MapDelete("{responseId}", handlers.DeleteResponseAsync) - .WithEndpointName(endpointAgentName, "DeleteResponse") + .WithName(endpointAgentName + "/DeleteResponse") .WithSummary("Deletes a response"); // List response input items endpoint group.MapGet("{responseId}/input_items", handlers.ListResponseInputItemsAsync) - .WithEndpointName(endpointAgentName, "ListResponseInputItems") + .WithName(endpointAgentName + "/ListResponseInputItems") .WithSummary("Lists the input items for a response"); MarkFeatureUsed(); return group; - - (IResponsesService, IResponseExecutor)? SelectAgent(HttpContext context) => - agentSelector(context) is { } selection - ? (responsesService.ForAgent(selection.AgentName), selection.Executor) - : null; } - private static RouteHandlerBuilder WithEndpointName(this RouteHandlerBuilder builder, string? endpointAgentName, string operationName) - => endpointAgentName is null ? builder : builder.WithName(endpointAgentName + "/" + operationName); - /// /// Maps OpenAI Responses API endpoints to the specified . /// diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj index b99905c72e1..baf2152635c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj @@ -14,7 +14,6 @@ true true - true diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs index 92b943ac2d6..84e95d7f0d7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs @@ -19,7 +19,6 @@ internal sealed class AIAgentResponseExecutor : IResponseExecutor { private readonly AIAgent? _agent; private readonly string _registrationKey; - private readonly string? _sessionStorageIdentity; private readonly IServiceScopeFactory _scopeFactory; private readonly OpenAIResponsesMapOptions _mapOptions; @@ -27,8 +26,7 @@ public AIAgentResponseExecutor( AIAgent agent, string registrationKey, IServiceScopeFactory scopeFactory, - OpenAIResponsesMapOptions? mapOptions = null, - string? sessionStorageIdentity = null) + OpenAIResponsesMapOptions? mapOptions = null) { ArgumentNullException.ThrowIfNull(agent); ArgumentException.ThrowIfNullOrEmpty(registrationKey); @@ -36,7 +34,6 @@ public AIAgentResponseExecutor( this._agent = agent; this._registrationKey = registrationKey; - this._sessionStorageIdentity = sessionStorageIdentity; this._scopeFactory = scopeFactory; this._mapOptions = mapOptions ?? new OpenAIResponsesMapOptions(); } @@ -50,7 +47,6 @@ public AIAgentResponseExecutor( ArgumentNullException.ThrowIfNull(scopeFactory); this._registrationKey = registrationKey; - this._sessionStorageIdentity = registrationKey; this._scopeFactory = scopeFactory; this._mapOptions = mapOptions ?? new OpenAIResponsesMapOptions(); } @@ -127,6 +123,7 @@ private AIAgent ResolveExecutionAgent(IServiceProvider services) return agent; } - return new AIHostAgent(agent, sessionStore, this._sessionStorageIdentity); + string? sessionStorageIdentity = this._agent is null ? this._registrationKey : null; + return new AIHostAgent(agent, sessionStore, sessionStorageIdentity); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IResponsesService.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IResponsesService.cs index 5c5b34192ba..b1676ac99cc 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IResponsesService.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IResponsesService.cs @@ -49,42 +49,6 @@ IAsyncEnumerable CreateResponseStreamingAsync( CreateResponse request, CancellationToken cancellationToken = default); - /// - /// Validates a create response request that will run with the specified executor. - /// - /// The create response request to validate. - /// The executor selected for this request. - /// Cancellation token. - /// A ResponseError if validation fails, null if validation succeeds. - ValueTask ValidateRequestAsync( - CreateResponse request, - IResponseExecutor executor, - CancellationToken cancellationToken = default); - - /// - /// Creates a model response for the given input using the specified executor. - /// - /// The create response request. - /// The executor selected for this request. - /// Cancellation token. - /// The created response. - Task CreateResponseAsync( - CreateResponse request, - IResponseExecutor executor, - CancellationToken cancellationToken = default); - - /// - /// Creates a streaming model response for the given input using the specified executor. - /// - /// The create response request. - /// The executor selected for this request. - /// Cancellation token. - /// An async enumerable of streaming response events. - IAsyncEnumerable CreateResponseStreamingAsync( - CreateResponse request, - IResponseExecutor executor, - CancellationToken cancellationToken = default); - /// /// Retrieves a response by ID. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs index 4b7981c0996..c696c406817 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Runtime.CompilerServices; using System.Threading; @@ -20,12 +19,11 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses; /// internal sealed class InMemoryResponsesService : IResponsesService, IDisposable { + private readonly IResponseExecutor _executor; private readonly MemoryCache _cache; - private readonly bool _ownsCache; private readonly InMemoryStorageOptions _options; private readonly Conversations.IConversationStorage? _conversationStorage; private readonly IsolationKeyResolver? _isolationKeyResolver; - private readonly string? _agentName; private sealed class ResponseState { @@ -37,7 +35,6 @@ private sealed class ResponseState public CreateResponse? Request { get; set; } public string? ConversationStorageId { get; set; } public string? IsolationKey { get; set; } - public IResponseExecutor? Executor { get; set; } public List StreamingUpdates { get; } = []; public Task? CompletionTask { get; set; } public CancellationTokenSource? CancellationTokenSource { get; set; } @@ -148,58 +145,25 @@ public InMemoryResponsesService(IResponseExecutor executor, InMemoryStorageOptio { } - // A null executor requires every create operation to supply the executor selected for its request. public InMemoryResponsesService( - IResponseExecutor? executor, + IResponseExecutor executor, InMemoryStorageOptions options, Conversations.IConversationStorage? conversationStorage, IsolationKeyResolver? isolationKeyResolver) { + ArgumentNullException.ThrowIfNull(executor); ArgumentNullException.ThrowIfNull(options); - this.DefaultExecutor = executor; + this._executor = executor; this._options = options; this._cache = new MemoryCache(options.ToMemoryCacheOptions()); - this._ownsCache = true; this._conversationStorage = conversationStorage; this._isolationKeyResolver = isolationKeyResolver; } - private InMemoryResponsesService(InMemoryResponsesService owner, string agentName) - { - this._options = owner._options; - this._cache = owner._cache; - this._conversationStorage = owner._conversationStorage; - this._isolationKeyResolver = owner._isolationKeyResolver; - this._agentName = agentName; - } - - /// - /// Creates a view that shares this service's storage but only exposes the responses created through it. - /// - /// The stable name of the agent that owns the responses. - /// A responses service bound to . - public InMemoryResponsesService ForAgent(string agentName) - { - ArgumentException.ThrowIfNullOrEmpty(agentName); - return new InMemoryResponsesService(this, agentName); - } - - [AllowNull] - private IResponseExecutor DefaultExecutor => - field ?? throw new InvalidOperationException("This responses service has no default executor. Supply the executor selected for the request."); - - public ValueTask ValidateRequestAsync( - CreateResponse request, - CancellationToken cancellationToken = default) - => this.ValidateRequestAsync(request, this.DefaultExecutor, cancellationToken); - public async ValueTask ValidateRequestAsync( CreateResponse request, - IResponseExecutor executor, CancellationToken cancellationToken = default) { - ArgumentNullException.ThrowIfNull(executor); - if (request.Conversation is not null && !string.IsNullOrEmpty(request.Conversation.Id) && !string.IsNullOrEmpty(request.PreviousResponseId)) { @@ -228,21 +192,13 @@ public InMemoryResponsesService ForAgent(string agentName) } } - return await executor.ValidateRequestAsync(request, cancellationToken).ConfigureAwait(false); + return await this._executor.ValidateRequestAsync(request, cancellationToken).ConfigureAwait(false); } - public Task CreateResponseAsync( - CreateResponse request, - CancellationToken cancellationToken = default) - => this.CreateResponseAsync(request, this.DefaultExecutor, cancellationToken); - public async Task CreateResponseAsync( CreateResponse request, - IResponseExecutor executor, CancellationToken cancellationToken = default) { - ArgumentNullException.ThrowIfNull(executor); - if (request.Stream == true) { throw new InvalidOperationException("Cannot create a streaming response using CreateResponseAsync. Use CreateResponseStreamingAsync instead."); @@ -253,7 +209,7 @@ public async Task CreateResponseAsync( // Resolve ambient caller identity before a background response detaches from the HTTP request. string? isolationKey = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false); - string responseStorageId = this.ScopeToAgent(IsolationKeyResolver.ScopeId(responseId, isolationKey)); + string responseStorageId = IsolationKeyResolver.ScopeId(responseId, isolationKey); var conversationStorageId = request.Conversation?.Id is { } conversationId ? IsolationKeyResolver.ScopeId(conversationId, isolationKey) @@ -266,7 +222,7 @@ public async Task CreateResponseAsync( }; var state = this.InitializeResponse( - responseId, responseStorageId, conversationStorageId, isolationKey, request, executor); + responseId, responseStorageId, conversationStorageId, isolationKey, request); state.CompletionTask = this.ExecuteResponseAsync(responseId, state, ct); // For background responses, start execution and return immediately @@ -280,18 +236,10 @@ public async Task CreateResponseAsync( return state.Response!; } - public IAsyncEnumerable CreateResponseStreamingAsync( - CreateResponse request, - CancellationToken cancellationToken = default) - => this.CreateResponseStreamingAsync(request, this.DefaultExecutor, cancellationToken); - public async IAsyncEnumerable CreateResponseStreamingAsync( CreateResponse request, - IResponseExecutor executor, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - ArgumentNullException.ThrowIfNull(executor); - if (request.Stream == false) { throw new InvalidOperationException("Cannot create a non-streaming response using CreateResponseStreamingAsync. Use CreateResponseAsync instead."); @@ -302,7 +250,7 @@ public async IAsyncEnumerable CreateResponseStreamingAsy // Streaming execution is also detached from the request cancellation token, so capture identity now. string? isolationKey = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false); - string responseStorageId = this.ScopeToAgent(IsolationKeyResolver.ScopeId(responseId, isolationKey)); + string responseStorageId = IsolationKeyResolver.ScopeId(responseId, isolationKey); var conversationStorageId = request.Conversation?.Id is { } conversationId ? IsolationKeyResolver.ScopeId(conversationId, isolationKey) @@ -310,7 +258,7 @@ public async IAsyncEnumerable CreateResponseStreamingAsy // Start execution var state = this.InitializeResponse( - responseId, responseStorageId, conversationStorageId, isolationKey, request, executor); + responseId, responseStorageId, conversationStorageId, isolationKey, request); state.CompletionTask = this.ExecuteResponseAsync(responseId, state, CancellationToken.None); // Stream updates as they become available @@ -322,7 +270,7 @@ public async IAsyncEnumerable CreateResponseStreamingAsy public async Task GetResponseAsync(string responseId, CancellationToken cancellationToken = default) { - var responseStorageId = await this.GetResponseStorageIdAsync(responseId, cancellationToken).ConfigureAwait(false); + var responseStorageId = await this.GetStorageIdAsync(responseId, cancellationToken).ConfigureAwait(false); this._cache.TryGetValue(responseStorageId, out ResponseState? state); return state?.Response; } @@ -332,7 +280,7 @@ public async IAsyncEnumerable GetResponseStreamingAsync( int? startingAfter = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - var responseStorageId = await this.GetResponseStorageIdAsync(responseId, cancellationToken).ConfigureAwait(false); + var responseStorageId = await this.GetStorageIdAsync(responseId, cancellationToken).ConfigureAwait(false); if (!this._cache.TryGetValue(responseStorageId, out ResponseState? state) || state is null) { yield break; @@ -347,7 +295,7 @@ public async IAsyncEnumerable GetResponseStreamingAsync( public async Task CancelResponseAsync(string responseId, CancellationToken cancellationToken = default) { - var responseStorageId = await this.GetResponseStorageIdAsync(responseId, cancellationToken).ConfigureAwait(false); + var responseStorageId = await this.GetStorageIdAsync(responseId, cancellationToken).ConfigureAwait(false); if (!this._cache.TryGetValue(responseStorageId, out ResponseState? state) || state is null) { throw new InvalidOperationException($"Response '{responseId}' not found."); @@ -376,7 +324,7 @@ public async Task CancelResponseAsync(string responseId, CancellationT public async Task DeleteResponseAsync(string responseId, CancellationToken cancellationToken = default) { - var responseStorageId = await this.GetResponseStorageIdAsync(responseId, cancellationToken).ConfigureAwait(false); + var responseStorageId = await this.GetStorageIdAsync(responseId, cancellationToken).ConfigureAwait(false); if (!this._cache.TryGetValue(responseStorageId, out ResponseState? state)) { return false; @@ -401,7 +349,7 @@ public async Task> ListResponseInputItemsAsync( int effectiveLimit = Math.Clamp(limit ?? IResponsesService.DefaultListLimit, 1, 100); SortOrder effectiveOrder = order ?? SortOrder.Descending; - var responseStorageId = await this.GetResponseStorageIdAsync(responseId, cancellationToken).ConfigureAwait(false); + var responseStorageId = await this.GetStorageIdAsync(responseId, cancellationToken).ConfigureAwait(false); if (!this._cache.TryGetValue(responseStorageId, out ResponseState? state)) { throw new InvalidOperationException($"Response '{responseId}' not found."); @@ -461,12 +409,6 @@ public async Task> ListResponseInputItemsAsync( }; } - private async ValueTask GetResponseStorageIdAsync(string responseId, CancellationToken cancellationToken) - => this.ScopeToAgent(await this.GetStorageIdAsync(responseId, cancellationToken).ConfigureAwait(false)); - - // Reuses the isolation key escaping so agent names cannot collide with isolation-scoped identifiers. - private string ScopeToAgent(string storageId) => IsolationKeyResolver.ScopeId(storageId, this._agentName); - private ValueTask GetStorageIdAsync(string id, CancellationToken cancellationToken) { if (this._isolationKeyResolver is null) @@ -487,8 +429,7 @@ private ResponseState InitializeResponse( string responseStorageId, string? conversationStorageId, string? isolationKey, - CreateResponse request, - IResponseExecutor executor) + CreateResponse request) { var metadata = request.Metadata ?? []; @@ -539,7 +480,6 @@ private ResponseState InitializeResponse( Request = request, ConversationStorageId = conversationStorageId, IsolationKey = isolationKey, - Executor = executor, CancellationTokenSource = new CancellationTokenSource() }; @@ -591,7 +531,7 @@ private async Task ExecuteResponseAsync(string responseId, ResponseState state, List outputItems = []; // Execute using the injected executor - await foreach (var streamingEvent in state.Executor!.ExecuteAsync(context, request, conversationHistory, linkedCts.Token).ConfigureAwait(false)) + await foreach (var streamingEvent in this._executor.ExecuteAsync(context, request, conversationHistory, linkedCts.Token).ConfigureAwait(false)) { state.AddStreamingEvent(streamingEvent); @@ -676,9 +616,6 @@ private async Task ExecuteResponseAsync(string responseId, ResponseState state, } finally { - // Stored responses outlive execution, so release the executor and the agent graph it references. - state.Executor = null; - // Signal one final time to unblock any waiting consumers state.SignalUpdate(); } @@ -703,9 +640,6 @@ private static List GetInputItems(string responseId, ResponseState public void Dispose() { - if (this._ownsCache) - { - this._cache.Dispose(); - } + this._cache.Dispose(); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/ResponsesHttpHandler.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/ResponsesHttpHandler.cs index 266835e5fc3..2c5f705a563 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/ResponsesHttpHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/ResponsesHttpHandler.cs @@ -16,44 +16,26 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses; internal sealed class ResponsesHttpHandler { private readonly IResponsesService _responsesService; - private readonly Func? _agentSelector; /// /// Initializes a new instance of the class. /// /// The responses service. - /// - /// An optional delegate that selects, for every operation, the responses service bound to the selected agent and - /// the executor that runs it, returning when no agent matches the request. When omitted, - /// and its executor are used. - /// - public ResponsesHttpHandler( - IResponsesService responsesService, - Func? agentSelector = null) + public ResponsesHttpHandler(IResponsesService responsesService) { this._responsesService = responsesService ?? throw new ArgumentNullException(nameof(responsesService)); - this._agentSelector = agentSelector; } /// /// Creates a model response for the given input. /// public async Task CreateResponseAsync( - HttpContext httpContext, [FromBody] CreateResponse request, [FromQuery] bool? stream, CancellationToken cancellationToken) { - // Select the agent before validation so an unmatched request never loads session or conversation state. - if (!this.TrySelectAgent(httpContext, out IResponsesService responsesService, out IResponseExecutor? executor)) - { - return AgentNotFound(); - } - // Validate the request first - ResponseError? validationError = executor is null - ? await responsesService.ValidateRequestAsync(request, cancellationToken).ConfigureAwait(false) - : await responsesService.ValidateRequestAsync(request, executor, cancellationToken).ConfigureAwait(false); + ResponseError? validationError = await this._responsesService.ValidateRequestAsync(request, cancellationToken).ConfigureAwait(false); if (validationError is not null) { var (statusCode, wireCode) = ResponseErrorCodes.MapValidationError(validationError.Code); @@ -80,9 +62,9 @@ public async Task CreateResponseAsync( if (shouldStream) { - var streamingResponse = executor is null - ? responsesService.CreateResponseStreamingAsync(request, cancellationToken: cancellationToken) - : responsesService.CreateResponseStreamingAsync(request, executor, cancellationToken); + var streamingResponse = this._responsesService.CreateResponseStreamingAsync( + request, + cancellationToken: cancellationToken); return new SseJsonResult( streamingResponse, @@ -90,9 +72,9 @@ public async Task CreateResponseAsync( OpenAIHostingJsonContext.Default.StreamingResponseEvent); } - var response = executor is null - ? await responsesService.CreateResponseAsync(request, cancellationToken: cancellationToken).ConfigureAwait(false) - : await responsesService.CreateResponseAsync(request, executor, cancellationToken).ConfigureAwait(false); + var response = await this._responsesService.CreateResponseAsync( + request, + cancellationToken: cancellationToken).ConfigureAwait(false); return response.Status switch { @@ -119,22 +101,16 @@ public async Task CreateResponseAsync( /// Retrieves a response by ID. /// public async Task GetResponseAsync( - HttpContext httpContext, string responseId, [FromQuery] string[]? include, [FromQuery] bool? stream, [FromQuery] int? starting_after, CancellationToken cancellationToken) { - if (!this.TrySelectAgent(httpContext, out IResponsesService responsesService, out _)) - { - return AgentNotFound(); - } - // If streaming is requested, return SSE stream if (stream == true) { - var streamingResponse = responsesService.GetResponseStreamingAsync( + var streamingResponse = this._responsesService.GetResponseStreamingAsync( responseId, startingAfter: starting_after, cancellationToken: cancellationToken); @@ -146,7 +122,7 @@ public async Task GetResponseAsync( } // Non-streaming: return the response object - var response = await responsesService.GetResponseAsync(responseId, cancellationToken).ConfigureAwait(false); + var response = await this._responsesService.GetResponseAsync(responseId, cancellationToken).ConfigureAwait(false); return response is not null ? Results.Ok(response) : Results.NotFound(new ErrorResponse @@ -163,18 +139,12 @@ public async Task GetResponseAsync( /// Cancels an in-progress response. /// public async Task CancelResponseAsync( - HttpContext httpContext, string responseId, CancellationToken cancellationToken) { - if (!this.TrySelectAgent(httpContext, out IResponsesService responsesService, out _)) - { - return AgentNotFound(); - } - try { - var response = await responsesService.CancelResponseAsync(responseId, cancellationToken).ConfigureAwait(false); + var response = await this._responsesService.CancelResponseAsync(responseId, cancellationToken).ConfigureAwait(false); return Results.Ok(response); } catch (InvalidOperationException ex) @@ -194,16 +164,10 @@ public async Task CancelResponseAsync( /// Deletes a response. /// public async Task DeleteResponseAsync( - HttpContext httpContext, string responseId, CancellationToken cancellationToken) { - if (!this.TrySelectAgent(httpContext, out IResponsesService responsesService, out _)) - { - return AgentNotFound(); - } - - var deleted = await responsesService.DeleteResponseAsync(responseId, cancellationToken).ConfigureAwait(false); + var deleted = await this._responsesService.DeleteResponseAsync(responseId, cancellationToken).ConfigureAwait(false); return deleted ? Results.Ok(new DeleteResponse { Id = responseId, Object = "response", Deleted = true }) : Results.NotFound(new ErrorResponse @@ -220,7 +184,6 @@ public async Task DeleteResponseAsync( /// Lists the input items for a response. /// public async Task ListResponseInputItemsAsync( - HttpContext httpContext, string responseId, [FromQuery] int? limit, [FromQuery] string? order, @@ -228,11 +191,6 @@ public async Task ListResponseInputItemsAsync( [FromQuery] string? before, CancellationToken cancellationToken) { - if (!this.TrySelectAgent(httpContext, out IResponsesService responsesService, out _)) - { - return AgentNotFound(); - } - try { // Convert string order to SortOrder enum @@ -244,7 +202,7 @@ string s when s.Equals("desc", StringComparison.OrdinalIgnoreCase) => SortOrder. _ => throw new InvalidOperationException($"Invalid order value: {order}. Must be 'asc' or 'desc'.") }; - var result = await responsesService.ListResponseInputItemsAsync( + var result = await this._responsesService.ListResponseInputItemsAsync( responseId, limit, sortOrder, @@ -266,33 +224,4 @@ string s when s.Equals("desc", StringComparison.OrdinalIgnoreCase) => SortOrder. }); } } - - private bool TrySelectAgent(HttpContext httpContext, out IResponsesService responsesService, out IResponseExecutor? executor) - { - responsesService = this._responsesService; - executor = null; - if (this._agentSelector is null) - { - return true; - } - - if (this._agentSelector(httpContext) is not { } selection) - { - return false; - } - - (responsesService, executor) = selection; - return true; - } - - private static IResult AgentNotFound() => - Results.NotFound(new ErrorResponse - { - Error = new ErrorDetails - { - Message = "No agent matches the request.", - Type = "invalid_request_error", - Code = "agent_not_found" - } - }); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2ARequestScopedAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2ARequestScopedAgentTests.cs deleted file mode 100644 index 05afb712c60..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2ARequestScopedAgentTests.cs +++ /dev/null @@ -1,396 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Runtime.CompilerServices; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using A2A; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Moq; - -namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests; - -/// -/// Verifies that A2A hosting honors agent registration lifetimes and selects agents for each request. -/// -public sealed class A2ARequestScopedAgentTests -{ - [Fact] - public async Task AddA2AServer_WithScopedAgent_ResolvesAgentForEachOperationAsync() - { - // Arrange - int createdAgents = 0; - ServiceCollection services = new(); - services.AddLogging(); - services.AddScoped(); - services.AddAIAgent( - "assistant", - (sp, name) => - { - _ = sp.GetRequiredService(); - Interlocked.Increment(ref createdAgents); - return new TurnCountingAgent(name); - }, - ServiceLifetime.Scoped) - .WithInMemorySessionStore(withIsolation: false); - services.AddA2AServer("assistant"); - await using ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); - A2AServer server = provider.GetRequiredKeyedService("assistant"); - - // Act - SendMessageResponse firstResponse = await server.SendMessageAsync(CreateRequest("ctx-1")); - SendMessageResponse secondResponse = await server.SendMessageAsync(CreateRequest("ctx-1")); - - // Assert - Assert.Equal(2, createdAgents); - Assert.Equal("assistant turn 1", GetText(firstResponse)); - Assert.Equal("assistant turn 2", GetText(secondResponse)); - } - - [Fact] - public async Task AddA2AServer_WithSingletonHostedAgentBuilder_ResolvesAgentOnceAsync() - { - // Arrange - int createdAgents = 0; - ServiceCollection services = new(); - services.AddLogging(); - services.AddAIAgent( - "assistant", - (_, name) => - { - Interlocked.Increment(ref createdAgents); - return new TurnCountingAgent(name); - }) - .WithInMemorySessionStore(withIsolation: false) - .AddA2AServer(); - await using ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); - A2AServer server = provider.GetRequiredKeyedService("assistant"); - int createdAtServerCreation = createdAgents; - - // Act - await server.SendMessageAsync(CreateRequest("ctx-1")); - SendMessageResponse secondResponse = await server.SendMessageAsync(CreateRequest("ctx-1")); - - // Assert - Assert.Equal(1, createdAtServerCreation); - Assert.Equal(1, createdAgents); - Assert.Equal("assistant turn 2", GetText(secondResponse)); - } - - [Fact] - public async Task AddA2AServer_WithUnregisteredAgent_ThrowsWhenServerIsResolvedAsync() - { - // Arrange - ServiceCollection services = new(); - services.AddLogging(); - services.AddA2AServer("missing"); - await using ServiceProvider provider = services.BuildServiceProvider(); - - // Act & Assert - InvalidOperationException exception = Assert.Throws(() => provider.GetRequiredKeyedService("missing")); - Assert.Contains("missing", exception.Message, StringComparison.Ordinal); - } - - [Fact] - public async Task AddA2AServer_WithCustomAgentHandler_DoesNotRequireAgentRegistrationAsync() - { - // Arrange - ServiceCollection services = new(); - services.AddLogging(); - services.AddKeyedSingleton("custom", Mock.Of()); - services.AddA2AServer("custom"); - await using ServiceProvider provider = services.BuildServiceProvider(); - - // Act - A2AServer? server = provider.GetKeyedService("custom"); - - // Assert - Assert.NotNull(server); - } - - [Fact] - public async Task MapA2AHttpJson_WithAgentSelector_SelectsAgentFromRouteValueAsync() - { - // Arrange - await using WebApplication app = await StartAsync( - services => - { - services.AddAIAgent("alpha", (_, name) => new TurnCountingAgent(name), ServiceLifetime.Scoped) - .WithInMemorySessionStore(withIsolation: false); - services.AddAIAgent("beta", (_, name) => new TurnCountingAgent(name), ServiceLifetime.Transient) - .WithInMemorySessionStore(withIsolation: false); - }, - endpoints => endpoints.MapA2AHttpJson("/agents/{agentId}/a2a", SelectAgentFromRoute)); - using HttpClient httpClient = app.GetTestClient(); - using A2AHttpJsonClient alphaClient = new(new Uri(httpClient.BaseAddress!, "/agents/alpha/a2a"), httpClient); - using A2AHttpJsonClient betaClient = new(new Uri(httpClient.BaseAddress!, "/agents/beta/a2a"), httpClient); - - // Act - SendMessageResponse firstAlphaResponse = await alphaClient.SendMessageAsync(CreateRequest("ctx-1")); - SendMessageResponse secondAlphaResponse = await alphaClient.SendMessageAsync(CreateRequest("ctx-1")); - SendMessageResponse betaResponse = await betaClient.SendMessageAsync(CreateRequest("ctx-1")); - - // Assert - Assert.Equal("alpha turn 1", GetText(firstAlphaResponse)); - Assert.Equal("alpha turn 2", GetText(secondAlphaResponse)); - Assert.Equal("beta turn 1", GetText(betaResponse)); - } - - [Fact] - public async Task MapA2AJsonRpc_WithAgentSelector_PersistsSessionsAcrossNewAgentInstancesAsync() - { - // Arrange - await using WebApplication app = await StartAsync( - services => services.AddKeyedSingleton("dynamic", new InMemoryAgentSessionStore()), - endpoints => endpoints.MapA2AJsonRpc("/a2a", _ => new TurnCountingAgent("dynamic"))); - using HttpClient httpClient = app.GetTestClient(); - using A2AClient client = new(new Uri(httpClient.BaseAddress!, "/a2a"), httpClient); - - // Act - SendMessageResponse firstResponse = await client.SendMessageAsync(CreateRequest("ctx-1")); - SendMessageResponse secondResponse = await client.SendMessageAsync(CreateRequest("ctx-1")); - SendMessageResponse otherContextResponse = await client.SendMessageAsync(CreateRequest("ctx-2")); - - // Assert - Assert.Equal("dynamic turn 1", GetText(firstResponse)); - Assert.Equal("dynamic turn 2", GetText(secondResponse)); - Assert.Equal("dynamic turn 1", GetText(otherContextResponse)); - } - - [Fact] - public async Task MapA2AHttpJson_WithAgentSelector_StreamsSelectedAgentAsync() - { - // Arrange - await using WebApplication app = await StartAsync( - _ => { }, - endpoints => endpoints.MapA2AHttpJson("/a2a", _ => new TurnCountingAgent("streaming"))); - using HttpClient httpClient = app.GetTestClient(); - using A2AHttpJsonClient client = new(new Uri(httpClient.BaseAddress!, "/a2a"), httpClient); - - // Act - List responses = []; - await foreach (StreamResponse response in client.SendStreamingMessageAsync(CreateRequest("ctx-1"))) - { - responses.Add(response); - } - - // Assert - Message message = Assert.IsType(Assert.Single(responses).Message); - Assert.Equal("streaming turn 1", string.Concat(message.Parts.Select(part => part.Text))); - } - - [Fact] - public async Task MapA2AHttpJson_WithAgentSelector_SharesTaskStateAcrossRequestsAsync() - { - // Arrange - await using WebApplication app = await StartAsync( - _ => { }, - endpoints => endpoints.MapA2AHttpJson( - "/a2a", - _ => new TurnCountingAgent("tasks"), - options => options.AgentRunMode = AgentRunMode.ReturnTask)); - using HttpClient httpClient = app.GetTestClient(); - using A2AHttpJsonClient client = new(new Uri(httpClient.BaseAddress!, "/a2a"), httpClient); - - // Act - SendMessageResponse response = await client.SendMessageAsync(CreateRequest("ctx-1")); - AgentTask task = await client.GetTaskAsync(new GetTaskRequest { Id = response.Task!.Id }); - - // Assert - Assert.Equal(response.Task.Id, task.Id); - Assert.Equal(TaskState.Completed, task.Status.State); - } - - [Fact] - public async Task MapA2AJsonRpc_WithAgentSelectorReturningTask_RejectsReturnImmediatelyAsync() - { - // Arrange - await using WebApplication app = await StartAsync( - _ => { }, - endpoints => endpoints.MapA2AJsonRpc( - "/a2a", - _ => new TurnCountingAgent("tasks"), - options => options.AgentRunMode = AgentRunMode.ReturnTask)); - using HttpClient httpClient = app.GetTestClient(); - using A2AClient client = new(new Uri(httpClient.BaseAddress!, "/a2a"), httpClient); - - // Act - A2AException exception = await Assert.ThrowsAsync( - () => client.SendMessageAsync(CreateRequest("ctx-1", returnImmediately: true))); - - // Assert - Assert.Equal(A2AErrorCode.UnsupportedOperation, exception.ErrorCode); - } - - [Fact] - public async Task MapA2AHttpJson_WithAgentSelectorReturningMessage_IgnoresReturnImmediatelyAsync() - { - // Arrange - await using WebApplication app = await StartAsync( - _ => { }, - endpoints => endpoints.MapA2AHttpJson("/a2a", _ => new TurnCountingAgent("messages"))); - using HttpClient httpClient = app.GetTestClient(); - using A2AHttpJsonClient client = new(new Uri(httpClient.BaseAddress!, "/a2a"), httpClient); - - // Act - SendMessageResponse response = await client.SendMessageAsync(CreateRequest("ctx-1", returnImmediately: true)); - - // Assert - Assert.Equal("messages turn 1", GetText(response)); - } - - [Fact] - public async Task MapA2AHttpJson_WithAgentSelectorReturningNull_ReturnsNotFoundAsync() - { - // Arrange - int selections = 0; - await using WebApplication app = await StartAsync( - _ => { }, - endpoints => endpoints.MapA2AHttpJson("/agents/{agentId}/a2a", _ => - { - Interlocked.Increment(ref selections); - return null; - })); - using HttpClient httpClient = app.GetTestClient(); - - // Act - using HttpResponseMessage response = await httpClient.GetAsync(new Uri("/agents/unknown/a2a/tasks/task-1", UriKind.Relative)); - - // Assert - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - Assert.Equal(1, selections); - } - - [Fact] - public async Task MapA2AJsonRpc_WithAgentSelectorReturningUnnamedAgent_FailsRequestAsync() - { - // Arrange - await using WebApplication app = await StartAsync( - _ => { }, - endpoints => endpoints.MapA2AJsonRpc("/a2a", _ => new TurnCountingAgent(name: null))); - using HttpClient httpClient = app.GetTestClient(); - using A2AClient client = new(new Uri(httpClient.BaseAddress!, "/a2a"), httpClient); - - // Act & Assert - InvalidOperationException exception = await Assert.ThrowsAsync(() => client.SendMessageAsync(CreateRequest("ctx-1"))); - Assert.Contains("stable name", exception.Message, StringComparison.Ordinal); - } - - [Fact] - public void MapA2AHttpJson_WithNullAgentSelector_ThrowsArgumentNullException() - { - // Arrange - Mock endpoints = new(); - - // Act & Assert - Assert.Throws(() => - endpoints.Object.MapA2AHttpJson("/a2a", (Func)null!)); - } - - private static AIAgent? SelectAgentFromRoute(HttpContext context) => - context.GetRouteValue("agentId") is string agentId - ? context.RequestServices.GetKeyedService(agentId) - : null; - - private static async Task StartAsync(Action configureServices, Action mapEndpoints) - { - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Host.UseDefaultServiceProvider(options => options.ValidateScopes = true); - configureServices(builder.Services); - - WebApplication app = builder.Build(); - mapEndpoints(app); - await app.StartAsync(); - return app; - } - - private static SendMessageRequest CreateRequest(string contextId, bool returnImmediately = false) => new() - { - Message = new Message - { - MessageId = Guid.NewGuid().ToString("N"), - ContextId = contextId, - Role = Role.User, - Parts = [Part.FromText("hi")] - }, - Configuration = returnImmediately ? new SendMessageConfiguration { ReturnImmediately = true } : null - }; - - private static string? GetText(SendMessageResponse response) - { - Message message = Assert.IsType(response.Message); - return string.Concat(message.Parts.Select(part => part.Text)); - } - - [SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated via dependency injection")] - private sealed class RequestDependency; - - private sealed class TurnCountingAgent(string? name) : AIAgent - { - public override string? Name => name; - - protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => - new(new TurnCountingSession()); - - protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => - new(session.StateBag.Serialize()); - - protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => - new(new TurnCountingSession(AgentSessionStateBag.Deserialize(serializedState))); - - protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - - protected override async IAsyncEnumerable RunCoreStreamingAsync( - IEnumerable messages, - AgentSession? session = null, - AgentRunOptions? options = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - int turn = 1; - if (session is not null) - { - if (session.StateBag.TryGetValue("turn", out string? previousTurn)) - { - turn = int.Parse(previousTurn!, CultureInfo.InvariantCulture) + 1; - } - - session.StateBag.SetValue("turn", turn.ToString(CultureInfo.InvariantCulture)); - } - - await Task.Yield(); - yield return new AgentResponseUpdate - { - MessageId = "message-1", - Role = ChatRole.Assistant, - Contents = [new TextContent($"{name} turn {turn}")] - }; - } - } - - private sealed class TurnCountingSession : AgentSession - { - public TurnCountingSession() - { - } - - public TurnCountingSession(AgentSessionStateBag stateBag) - : base(stateBag) - { - } - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs index a66234f7804..1b16307e9ea 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs @@ -206,9 +206,7 @@ public async Task AddA2AServer_WithCustomAgentSessionStore_ResolvesSuccessfullyA await using var provider = services.BuildServiceProvider(); var server = provider.GetKeyedService(AgentName); Assert.NotNull(server); - - // The session store is resolved for each A2A operation rather than when the server is created. - mockSessionStore.Verify(s => s.GetService(typeof(IsolationKeyScopedAgentSessionStore), null), Times.Never); + mockSessionStore.Verify(s => s.GetService(typeof(IsolationKeyScopedAgentSessionStore), null), Times.Once); } /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/InMemoryResponsesServiceTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/InMemoryResponsesServiceTests.cs index e25d1a855f5..1827d852330 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/InMemoryResponsesServiceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/InMemoryResponsesServiceTests.cs @@ -138,38 +138,6 @@ public async Task ListResponseInputItemsAsync_ReturnsTheItemsTheCursorsSelectAsy }; } - [Fact] - public async Task CreateResponseAsync_ReleasesExecutorAfterExecutionCompletesAsync() - { - // Arrange - using var service = new InMemoryResponsesService( - executor: null, new InMemoryStorageOptions(), conversationStorage: null, isolationKeyResolver: null); - - // Act - (WeakReference executor, string responseId) = await CreateResponseWithCollectableExecutorAsync(service); - - // Leave the completion stack so the finished execution state machine has released its locals. - await Task.Yield(); - GC.Collect(); - GC.WaitForPendingFinalizers(); - GC.Collect(); - - // Assert - Assert.False(executor.IsAlive); - Assert.NotNull(await service.GetResponseAsync(responseId)); - } - - [MethodImpl(MethodImplOptions.NoInlining)] - private static async Task<(WeakReference Executor, string ResponseId)> CreateResponseWithCollectableExecutorAsync( - InMemoryResponsesService service) - { - var executor = new StubResponseExecutor(); - Response response = await service.CreateResponseAsync( - new CreateResponse { Input = ResponseInput.FromText("hello") }, - executor); - return (new WeakReference(executor), response.Id); - } - private sealed class StubResponseExecutor : IResponseExecutor { public ValueTask ValidateRequestAsync(CreateResponse request, CancellationToken cancellationToken = default) diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsAgentResolutionTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsAgentResolutionTests.cs deleted file mode 100644 index 3b6fceb9f25..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsAgentResolutionTests.cs +++ /dev/null @@ -1,196 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Net; -using System.Net.Http; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Moq; - -namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests; - -/// -/// Verifies how OpenAI Chat Completions endpoints resolve and select agents for each request. -/// -public sealed class OpenAIChatCompletionsAgentResolutionTests -{ - private const string RequestJson = """{"model":"test-model","messages":[{"role":"user","content":"hello"}]}"""; - - [Fact] - public async Task MapOpenAIChatCompletions_WithScopedHostedAgent_ResolvesAgentPerRequestAsync() - { - // Arrange - int createdAgents = 0; - WebApplicationBuilder builder = CreateBuilder(); - IHostedAgentBuilder agentBuilder = builder.AddAIAgent( - "assistant", - (_, name) => - { - Interlocked.Increment(ref createdAgents); - return new ChatClientAgent(new TestHelpers.SimpleMockChatClient("Scoped response"), name: name); - }, - ServiceLifetime.Scoped); - - await using WebApplication app = builder.Build(); - app.MapOpenAIChatCompletions(agentBuilder); - await app.StartAsync(); - using HttpClient client = app.GetTestClient(); - - // Act - string firstResponse = await PostAsync(client, "/assistant/v1/chat/completions"); - string secondResponse = await PostAsync(client, "/assistant/v1/chat/completions"); - - // Assert - Assert.Equal(2, createdAgents); - Assert.Contains("Scoped response", firstResponse, StringComparison.Ordinal); - Assert.Contains("Scoped response", secondResponse, StringComparison.Ordinal); - } - - [Fact] - public async Task MapOpenAIChatCompletions_WithSingletonHostedAgent_ResolvesAgentOnceAtMapTimeAsync() - { - // Arrange - int createdAgents = 0; - WebApplicationBuilder builder = CreateBuilder(); - IHostedAgentBuilder agentBuilder = builder.AddAIAgent( - "assistant", - (_, name) => - { - Interlocked.Increment(ref createdAgents); - return new ChatClientAgent(new TestHelpers.SimpleMockChatClient("Singleton response"), name: name); - }); - - await using WebApplication app = builder.Build(); - app.MapOpenAIChatCompletions(agentBuilder); - int createdAtMapTime = createdAgents; - await app.StartAsync(); - using HttpClient client = app.GetTestClient(); - - // Act - await PostAsync(client, "/assistant/v1/chat/completions"); - string secondResponse = await PostAsync(client, "/assistant/v1/chat/completions"); - - // Assert - Assert.Equal(1, createdAtMapTime); - Assert.Equal(1, createdAgents); - Assert.Contains("Singleton response", secondResponse, StringComparison.Ordinal); - } - - [Fact] - public async Task MapOpenAIChatCompletions_WithUnregisteredHostedAgent_ThrowsAtMapTimeAsync() - { - // Arrange - Mock agentBuilder = new(); - agentBuilder.Setup(b => b.Name).Returns("missing"); - await using WebApplication app = CreateBuilder().Build(); - - // Act & Assert - InvalidOperationException exception = Assert.Throws(() => app.MapOpenAIChatCompletions(agentBuilder.Object)); - Assert.Contains("missing", exception.Message, StringComparison.Ordinal); - } - - [Fact] - public async Task MapOpenAIChatCompletions_WithAgentSelector_SelectsAgentFromRouteValueAsync() - { - // Arrange - WebApplicationBuilder builder = CreateBuilder(); - builder.AddAIAgent("alpha", (_, name) => new ChatClientAgent(new TestHelpers.SimpleMockChatClient("Alpha response"), name: name), ServiceLifetime.Scoped); - builder.AddAIAgent("beta", (_, name) => new ChatClientAgent(new TestHelpers.SimpleMockChatClient("Beta response"), name: name), ServiceLifetime.Transient); - - await using WebApplication app = builder.Build(); - app.MapOpenAIChatCompletions("/agents/{agentId}/v1/chat/completions", SelectAgentFromRoute); - await app.StartAsync(); - using HttpClient client = app.GetTestClient(); - - // Act - string alphaResponse = await PostAsync(client, "/agents/alpha/v1/chat/completions"); - string betaResponse = await PostAsync(client, "/agents/beta/v1/chat/completions"); - - // Assert - Assert.Contains("Alpha response", alphaResponse, StringComparison.Ordinal); - Assert.Contains("Beta response", betaResponse, StringComparison.Ordinal); - } - - [Fact] - public async Task MapOpenAIChatCompletions_WithAgentSelectorReturningNull_ReturnsNotFoundAsync() - { - // Arrange - WebApplicationBuilder builder = CreateBuilder(); - await using WebApplication app = builder.Build(); - app.MapOpenAIChatCompletions("/agents/{agentId}/v1/chat/completions", SelectAgentFromRoute); - await app.StartAsync(); - using HttpClient client = app.GetTestClient(); - - // Act - using HttpResponseMessage response = await SendAsync(client, "/agents/unknown/v1/chat/completions"); - string responseBody = await response.Content.ReadAsStringAsync(); - - // Assert - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - Assert.Contains("agent_not_found", responseBody, StringComparison.Ordinal); - } - - [Fact] - public async Task MapOpenAIChatCompletions_WithAgentSelectorReturningUnnamedAgent_FailsRequestAsync() - { - // Arrange - WebApplicationBuilder builder = CreateBuilder(); - await using WebApplication app = builder.Build(); - app.MapOpenAIChatCompletions("/v1/chat/completions", _ => new ChatClientAgent(new TestHelpers.SimpleMockChatClient())); - await app.StartAsync(); - using HttpClient client = app.GetTestClient(); - - // Act - using HttpResponseMessage response = await SendAsync(client, "/v1/chat/completions"); - string responseBody = await response.Content.ReadAsStringAsync(); - - // Assert - Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); - Assert.Contains("stable name", responseBody, StringComparison.Ordinal); - } - - [Fact] - public void MapOpenAIChatCompletions_WithNullAgentSelector_ThrowsArgumentNullException() - { - // Arrange - Mock endpoints = new(); - - // Act & Assert - Assert.Throws(() => - endpoints.Object.MapOpenAIChatCompletions("/v1/chat/completions", null!)); - } - - private static AIAgent? SelectAgentFromRoute(HttpContext context) => - context.GetRouteValue("agentId") is string agentId - ? context.RequestServices.GetKeyedService(agentId) - : null; - - private static WebApplicationBuilder CreateBuilder() - { - WebApplicationBuilder builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = Environments.Development }); - builder.WebHost.UseTestServer(); - builder.AddOpenAIChatCompletions(); - return builder; - } - - private static async Task PostAsync(HttpClient client, string path) - { - using HttpResponseMessage response = await SendAsync(client, path); - string responseBody = await response.Content.ReadAsStringAsync(); - Assert.True(response.IsSuccessStatusCode, responseBody); - return responseBody; - } - - private static async Task SendAsync(HttpClient client, string path) - { - using StringContent content = new(RequestJson, Encoding.UTF8, "application/json"); - return await client.PostAsync(new Uri(path, UriKind.Relative), content); - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ResponseSessionRegressionTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ResponseSessionRegressionTests.cs index ad09d8dfa5c..d68adfe50af 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ResponseSessionRegressionTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ResponseSessionRegressionTests.cs @@ -11,7 +11,6 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; @@ -77,188 +76,6 @@ public async Task ApprovalContinuation_TransientAgent_UsesStableRegistrationIden Assert.Equal(2, modelCalls); } - [Fact] - public async Task ApprovalContinuation_AgentSelectorReturningNewInstances_UsesStableAgentNameAsync() - { - // Arrange - int modelCalls = 0; - int toolCalls = 0; - int selectedAgents = 0; - AIFunction function = new ApprovalRequiredAIFunction(AIFunctionFactory.Create( - () => - { - Interlocked.Increment(ref toolCalls); - return "Sunny"; - }, - ToolName)); - Mock chatClient = CreateApprovalChatClient(() => Interlocked.Increment(ref modelCalls)); - - WebApplicationBuilder builder = CreateBuilder(validateScopes: true); - builder.Services.AddKeyedSingleton(AgentName, new InMemoryAgentSessionStore()); - builder.AddOpenAIResponses(); - - await using WebApplication app = builder.Build(); - app.MapOpenAIResponses("/agents/{agentId}/v1/responses", context => - { - Interlocked.Increment(ref selectedAgents); - return context.GetRouteValue("agentId") as string == AgentName - ? new ChatClientAgent(chatClient.Object, name: AgentName, tools: [function]) - : null; - }); - await app.StartAsync(); - using HttpClient client = app.GetTestClient(); - string path = $"/agents/{AgentName}/v1/responses"; - - (string responseId, JsonElement approvalEvent) = await CreatePendingApprovalAsync( - client, - path, - includeAgentName: false); - using StringContent approvalContent = JsonContent(CreateApprovalResponseJson( - responseId, - approvalEvent, - includeAgentName: false)); - - // Act - using HttpResponseMessage response = await client.PostAsync( - new Uri(path, UriKind.Relative), - approvalContent); - string responseBody = await response.Content.ReadAsStringAsync(); - - // Assert - Assert.True(response.IsSuccessStatusCode, responseBody); - Assert.Equal(2, selectedAgents); - Assert.Equal(1, toolCalls); - Assert.Equal(2, modelCalls); - } - - [Fact] - public async Task AgentSelector_ReturningNull_ReturnsNotFoundAsync() - { - // Arrange - WebApplicationBuilder builder = CreateBuilder(); - builder.AddOpenAIResponses(); - - await using WebApplication app = builder.Build(); - app.MapOpenAIResponses("/agents/{agentId}/v1/responses", _ => null); - await app.StartAsync(); - using HttpClient client = app.GetTestClient(); - - // Act - using HttpResponseMessage response = await PostJsonAsync( - client, - "/agents/unknown/v1/responses", - """{"input":"hello"}"""); - string responseBody = await response.Content.ReadAsStringAsync(); - - // Assert - Assert.Equal(System.Net.HttpStatusCode.NotFound, response.StatusCode); - Assert.Contains("agent_not_found", responseBody, StringComparison.Ordinal); - } - - [Theory] - [InlineData("GET", "/agents/unknown/v1/responses/resp_1")] - [InlineData("POST", "/agents/unknown/v1/responses/resp_1/cancel")] - [InlineData("DELETE", "/agents/unknown/v1/responses/resp_1")] - [InlineData("GET", "/agents/unknown/v1/responses/resp_1/input_items")] - public async Task AgentSelector_ReturningNull_ReturnsNotFoundForStoredResponseOperationsAsync(string method, string path) - { - // Arrange - int selections = 0; - WebApplicationBuilder builder = CreateBuilder(); - builder.AddOpenAIResponses(); - - await using WebApplication app = builder.Build(); - app.MapOpenAIResponses("/agents/{agentId}/v1/responses", _ => - { - Interlocked.Increment(ref selections); - return null; - }); - await app.StartAsync(); - using HttpClient client = app.GetTestClient(); - - // Act - using HttpRequestMessage request = new(new HttpMethod(method), new Uri(path, UriKind.Relative)); - using HttpResponseMessage response = await client.SendAsync(request); - string responseBody = await response.Content.ReadAsStringAsync(); - - // Assert - Assert.Equal(System.Net.HttpStatusCode.NotFound, response.StatusCode); - Assert.Contains("agent_not_found", responseBody, StringComparison.Ordinal); - Assert.Equal(1, selections); - } - - [Fact] - public async Task AgentSelector_BindsStoredResponsesToSelectedAgentAsync() - { - // Arrange - Dictionary agents = new(StringComparer.Ordinal) - { - ["alpha"] = new ChatClientAgent(new TestHelpers.SimpleMockChatClient("Alpha response"), name: "alpha"), - ["beta"] = new ChatClientAgent(new TestHelpers.SimpleMockChatClient("Beta response"), name: "beta"), - }; - WebApplicationBuilder builder = CreateBuilder(); - builder.AddOpenAIResponses(); - - await using WebApplication app = builder.Build(); - app.MapOpenAIResponses( - "/agents/{agentId}/v1/responses", - context => context.GetRouteValue("agentId") is string agentId ? agents.GetValueOrDefault(agentId) : null); - await app.StartAsync(); - using HttpClient client = app.GetTestClient(); - - using HttpResponseMessage createResponse = await PostJsonAsync( - client, - "/agents/alpha/v1/responses", - """{"input":"hello"}"""); - string createBody = await createResponse.Content.ReadAsStringAsync(); - Assert.True(createResponse.IsSuccessStatusCode, createBody); - string responseId = JsonDocument.Parse(createBody).RootElement.GetProperty("id").GetString()!; - - // Act - using HttpResponseMessage otherAgentGet = await client.GetAsync(new Uri($"/agents/beta/v1/responses/{responseId}", UriKind.Relative)); - using HttpResponseMessage otherAgentInputItems = await client.GetAsync(new Uri($"/agents/beta/v1/responses/{responseId}/input_items", UriKind.Relative)); - using HttpResponseMessage otherAgentDelete = await client.DeleteAsync(new Uri($"/agents/beta/v1/responses/{responseId}", UriKind.Relative)); - using HttpResponseMessage ownerGet = await client.GetAsync(new Uri($"/agents/alpha/v1/responses/{responseId}", UriKind.Relative)); - string ownerBody = await ownerGet.Content.ReadAsStringAsync(); - - // Assert - Assert.Equal(System.Net.HttpStatusCode.NotFound, otherAgentGet.StatusCode); - Assert.Equal(System.Net.HttpStatusCode.NotFound, otherAgentInputItems.StatusCode); - Assert.Equal(System.Net.HttpStatusCode.NotFound, otherAgentDelete.StatusCode); - Assert.True(ownerGet.IsSuccessStatusCode, ownerBody); - Assert.Contains("Alpha response", ownerBody, StringComparison.Ordinal); - } - - [Fact] - public async Task AgentSelector_ResolvesScopedAgentFromRequestServicesAsync() - { - // Arrange - WebApplicationBuilder builder = CreateBuilder(validateScopes: true); - builder.AddAIAgent( - AgentName, - (_, name) => new ChatClientAgent(new TestHelpers.SimpleMockChatClient("Scoped response"), name: name), - ServiceLifetime.Scoped); - builder.AddOpenAIResponses(); - - await using WebApplication app = builder.Build(); - app.MapOpenAIResponses( - "/agents/{agentId}/v1/responses", - context => context.RequestServices.GetKeyedService(context.GetRouteValue("agentId"))); - await app.StartAsync(); - using HttpClient client = app.GetTestClient(); - - // Act - using HttpResponseMessage response = await PostJsonAsync( - client, - $"/agents/{AgentName}/v1/responses", - """{"input":"hello"}"""); - string responseBody = await response.Content.ReadAsStringAsync(); - - // Assert - Assert.True(response.IsSuccessStatusCode, responseBody); - Assert.Contains("Scoped response", responseBody, StringComparison.Ordinal); - } - [Theory] [InlineData(false)] [InlineData(true)] From fb90fb6fce23468ed9c422d7062f3e27c04d9c79 Mon Sep 17 00:00:00 2001 From: Marco Minerva Date: Tue, 20 Oct 2026 08:37:11 +0000 Subject: [PATCH 6/6] .NET: Identify AG-UI agent instance sessions by agent name The explicit-instance MapAGUIServer overload partitioned persisted sessions by the agent instance, while the named, hosted, and selector mappings partition them by the agent name, so the same agent exposed through a named and an explicit-instance mapping no longer shared conversations. Named agents mapped by instance are now identified by their name as well; unnamed agents keep their instance identity. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AGUIEndpointRouteBuilderExtensions.cs | 12 +++- .../AGUIRequestScopedAgentTests.cs | 71 +++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) 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 cc2d0fe837a..2dc62f20800 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs @@ -173,6 +173,13 @@ public static IEndpointConventionBuilder MapAGUIServer( /// 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 /// from the wire and is treated as a chain-resume identifier, not as an authorization /// token. Multi-user hosts must register an that @@ -208,8 +215,11 @@ public static IEndpointConventionBuilder MapAGUIServer( ArgumentNullException.ThrowIfNull(endpoints); ArgumentNullException.ThrowIfNull(aiAgent); + // 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: null)); + CreateHostAgent(context.RequestServices, aiAgent, aiAgent.Name, sessionStorageIdentity)); } private static IEndpointConventionBuilder MapAGUIServerCore( diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIRequestScopedAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIRequestScopedAgentTests.cs index c863abed3c4..18f750a05e0 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIRequestScopedAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIRequestScopedAgentTests.cs @@ -152,6 +152,77 @@ await this.StartAsync( Assert.Contains("assistant turn 2", secondResponse); } + [Fact] + public async Task MapAGUIServer_WithSameAgentMappedByNameAndInstance_SharesConversationAsync() + { + // Arrange + TurnCountingAgent agent = new("assistant"); + await this.StartAsync( + services => services.AddAIAgent("assistant", (_, _) => agent).WithInMemorySessionStore(withIsolation: false), + app => + { + app.MapAGUIServer(agentName: "assistant", pattern: "/chat"); + app.MapAGUIServer(pattern: "/chat-alias", aiAgent: agent); + }); + + // Act + string namedResponse = await this.PostRunAsync("/chat", "thread-1"); + string instanceResponse = await this.PostRunAsync("/chat-alias", "thread-1"); + string namedAgainResponse = await this.PostRunAsync("/chat", "thread-1"); + + // Assert + Assert.Contains("assistant turn 1", namedResponse); + Assert.Contains("assistant turn 2", instanceResponse); + Assert.Contains("assistant turn 3", namedAgainResponse); + } + + [Fact] + public async Task MapAGUIServer_WithAgentInstances_PartitionsSessionsByNameAsync() + { + // Arrange + await this.StartAsync( + services => services.AddKeyedSingleton(KeyedService.AnyKey, new InMemoryAgentSessionStore()), + app => + { + app.MapAGUIServer("/first", new TurnCountingAgent("assistant")); + app.MapAGUIServer("/second", new TurnCountingAgent("assistant")); + app.MapAGUIServer("/other", new TurnCountingAgent("other")); + }); + + // Act + string firstResponse = await this.PostRunAsync("/first", "thread-1"); + string sameNameResponse = await this.PostRunAsync("/second", "thread-1"); + string otherNameResponse = await this.PostRunAsync("/other", "thread-1"); + + // Assert + Assert.Contains("assistant turn 1", firstResponse); + Assert.Contains("assistant turn 2", sameNameResponse); + Assert.Contains("other turn 1", otherNameResponse); + } + + [Fact] + public async Task MapAGUIServer_WithUnnamedAgentInstances_PartitionsSessionsByInstanceAsync() + { + // Arrange + TurnCountingAgent agent = new(name: null); + await this.StartAsync( + services => services.AddSingleton(new InMemoryAgentSessionStore()), + app => + { + app.MapAGUIServer("/first", agent); + app.MapAGUIServer("/second", new TurnCountingAgent(name: null)); + }); + + // Act + await this.PostRunAsync("/first", "thread-1"); + string sameInstanceResponse = await this.PostRunAsync("/first", "thread-1"); + string otherInstanceResponse = await this.PostRunAsync("/second", "thread-1"); + + // Assert + Assert.Contains(" turn 2", sameInstanceResponse); + Assert.Contains(" turn 1", otherInstanceResponse); + } + [Fact] public async Task MapAGUIServer_WithUnregisteredAgentName_ThrowsAtMapTimeAsync() {