-
Notifications
You must be signed in to change notification settings - Fork 64
feat: add per-request handler factory overloads for multi-agent hosting #382
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -53,6 +53,78 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo | |
| return routeGroup; | ||
| } | ||
|
|
||
| /// <summary>Enables JSON-RPC A2A endpoints with per-request handler resolution.</summary> | ||
| /// <param name="endpoints">The endpoint route builder.</param> | ||
| /// <param name="handlerFactory">Factory that resolves an <see cref="IA2ARequestHandler"/> per request from the <see cref="HttpContext"/>.</param> | ||
| /// <param name="path">The route path for the A2A endpoint.</param> | ||
| /// <returns>An endpoint convention builder for further configuration.</returns> | ||
| public static IEndpointConventionBuilder MapA2A( | ||
| this IEndpointRouteBuilder endpoints, | ||
| Func<HttpContext, IA2ARequestHandler> handlerFactory, | ||
| [StringSyntax("Route")] string path) | ||
| { | ||
| ArgumentNullException.ThrowIfNull(endpoints); | ||
| ArgumentNullException.ThrowIfNull(handlerFactory); | ||
| ArgumentException.ThrowIfNullOrEmpty(path); | ||
|
|
||
| var routeGroup = endpoints.MapGroup(""); | ||
| routeGroup.MapPost(path, async (HttpContext httpContext, CancellationToken cancellationToken) => | ||
| { | ||
| IA2ARequestHandler handler; | ||
| try | ||
| { | ||
| handler = handlerFactory(httpContext); | ||
| } | ||
| catch (A2AException ex) | ||
| { | ||
| return new JsonRpcResponseResult(JsonRpcResponse.CreateJsonRpcErrorResponse(new JsonRpcId((string?)null), ex)); | ||
| } | ||
|
|
||
| return await A2AJsonRpcProcessor.ProcessRequestAsync(handler, httpContext.Request, cancellationToken); | ||
| }); | ||
|
|
||
| return routeGroup; | ||
| } | ||
|
|
||
| /// <summary>Enables JSON-RPC A2A endpoints and well-known agent card with per-request resolution.</summary> | ||
| /// <param name="endpoints">The endpoint route builder.</param> | ||
| /// <param name="handlerFactory">Factory that resolves an <see cref="IA2ARequestHandler"/> per request from the <see cref="HttpContext"/>.</param> | ||
| /// <param name="agentCardFactory">Factory that resolves an <see cref="AgentCard"/> per request from the <see cref="HttpContext"/>.</param> | ||
| /// <param name="path">The route path for the A2A JSON-RPC endpoint.</param> | ||
| /// <returns>An endpoint convention builder for further configuration.</returns> | ||
| public static IEndpointConventionBuilder MapA2A( | ||
| this IEndpointRouteBuilder endpoints, | ||
| Func<HttpContext, IA2ARequestHandler> handlerFactory, | ||
| Func<HttpContext, AgentCard> agentCardFactory, | ||
| [StringSyntax("Route")] string path) | ||
| { | ||
| ArgumentNullException.ThrowIfNull(endpoints); | ||
| ArgumentNullException.ThrowIfNull(handlerFactory); | ||
| ArgumentNullException.ThrowIfNull(agentCardFactory); | ||
| ArgumentException.ThrowIfNullOrEmpty(path); | ||
|
|
||
| var routeGroup = endpoints.MapGroup(""); | ||
|
|
||
| routeGroup.MapPost(path, async (HttpContext httpContext, CancellationToken cancellationToken) => | ||
| { | ||
| IA2ARequestHandler handler; | ||
| try | ||
| { | ||
| handler = handlerFactory(httpContext); | ||
| } | ||
| catch (A2AException ex) | ||
| { | ||
| return new JsonRpcResponseResult(JsonRpcResponse.CreateJsonRpcErrorResponse(new JsonRpcId((string?)null), ex)); | ||
| } | ||
|
|
||
| return await A2AJsonRpcProcessor.ProcessRequestAsync(handler, httpContext.Request, cancellationToken); | ||
| }); | ||
|
|
||
| routeGroup.MapGet(".well-known/agent-card.json", (HttpContext httpContext) => Results.Ok(agentCardFactory(httpContext))); | ||
|
|
||
| return routeGroup; | ||
| } | ||
|
|
||
| /// <summary>Enables the well-known agent card endpoint for agent discovery.</summary> | ||
| /// <param name="endpoints">The endpoint route builder.</param> | ||
| /// <param name="agentCard">The agent card to serve.</param> | ||
|
|
@@ -70,6 +142,25 @@ public static IEndpointConventionBuilder MapWellKnownAgentCard(this IEndpointRou | |
| return routeGroup; | ||
| } | ||
|
|
||
| /// <summary>Enables the well-known agent card endpoint with per-request card resolution.</summary> | ||
| /// <param name="endpoints">The endpoint route builder.</param> | ||
| /// <param name="agentCardFactory">Factory that resolves an <see cref="AgentCard"/> per request from the <see cref="HttpContext"/>.</param> | ||
| /// <param name="path">An optional route prefix.</param> | ||
| /// <returns>An endpoint convention builder for further configuration.</returns> | ||
| public static IEndpointConventionBuilder MapWellKnownAgentCard( | ||
| this IEndpointRouteBuilder endpoints, | ||
| Func<HttpContext, AgentCard> agentCardFactory, | ||
| [StringSyntax("Route")] string path = "") | ||
| { | ||
| ArgumentNullException.ThrowIfNull(endpoints); | ||
| ArgumentNullException.ThrowIfNull(agentCardFactory); | ||
|
|
||
| var routeGroup = endpoints.MapGroup(path); | ||
| routeGroup.MapGet(".well-known/agent-card.json", (HttpContext httpContext) => Results.Ok(agentCardFactory(httpContext))); | ||
|
|
||
| return routeGroup; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Maps HTTP+JSON REST API endpoints for A2A. | ||
| /// </summary> | ||
|
|
@@ -147,4 +238,118 @@ public static IEndpointConventionBuilder MapHttpA2A( | |
|
|
||
| return routeGroup; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Maps HTTP+JSON REST API endpoints for A2A with per-request handler and agent card resolution. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// <para>Use this overload for multi-agent hosting scenarios where the handler and agent card | ||
| /// vary per request (e.g., based on subdomain, authentication, or tenant context).</para> | ||
| /// <para><strong>Limitation:</strong> Multi-tenant route variants | ||
| /// (<c>/{tenant}/tasks/{id}</c>) defined in the A2A specification are not currently | ||
| /// supported. The <c>Tenant</c> field on request types will always be <c>null</c> | ||
| /// for REST API calls.</para> | ||
| /// </remarks> | ||
| /// <param name="endpoints">The endpoint route builder.</param> | ||
| /// <param name="handlerFactory">Factory that resolves an <see cref="IA2ARequestHandler"/> per request.</param> | ||
| /// <param name="agentCardFactory">Factory that resolves an <see cref="AgentCard"/> per request.</param> | ||
| /// <param name="path">The route prefix for all REST endpoints.</param> | ||
| /// <returns>An endpoint convention builder for further configuration.</returns> | ||
| public static IEndpointConventionBuilder MapHttpA2A( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This overload is the right call for multi-agent hosting. One maintainability thought: it re-declares the full HTTP+JSON route table (tasks / message / pushNotificationConfigs / extendedAgentCard) that the existing fixed-handler Since the fixed-handler case is just "the same routing with a constant handler/card," you could keep both public overloads but have the fixed one delegate to this factory one, so the route table lives in a single place: public static IEndpointConventionBuilder MapHttpA2A(
this IEndpointRouteBuilder endpoints, IA2ARequestHandler requestHandler, AgentCard agentCard, [StringSyntax("Route")] string path = "")
=> endpoints.MapHttpA2A(_ => requestHandler, _ => agentCard, path);To be clear, this keeps the fixed-handler overload exactly as-is for callers (it's the ergonomic single-agent API) — it just routes its body through the factory version instead of duplicating the route registrations. The same pattern applies to the |
||
| this IEndpointRouteBuilder endpoints, | ||
| Func<HttpContext, IA2ARequestHandler> handlerFactory, | ||
| Func<HttpContext, AgentCard> agentCardFactory, | ||
| [StringSyntax("Route")] string path = "") | ||
| { | ||
| ArgumentNullException.ThrowIfNull(endpoints); | ||
| ArgumentNullException.ThrowIfNull(handlerFactory); | ||
| ArgumentNullException.ThrowIfNull(agentCardFactory); | ||
|
|
||
| var routeGroup = endpoints.MapGroup(path); | ||
| var logger = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("A2A.REST"); | ||
|
|
||
| // Agent card | ||
| routeGroup.MapGet("/card", async (HttpContext httpContext, CancellationToken ct) => | ||
| { | ||
| try { return await A2AHttpProcessor.GetAgentCardRestAsync(handlerFactory(httpContext), logger, agentCardFactory(httpContext), ct); } | ||
| catch (A2AException ex) { return A2AHttpProcessor.MapA2AExceptionToHttpResult(ex); } | ||
| }); | ||
|
|
||
| // Task operations | ||
| routeGroup.MapGet("/tasks/{id}", async (HttpContext httpContext, string id, [FromQuery] int? historyLength, CancellationToken ct) => | ||
| { | ||
| try { return await A2AHttpProcessor.GetTaskRestAsync(handlerFactory(httpContext), logger, id, historyLength, ct); } | ||
| catch (A2AException ex) { return A2AHttpProcessor.MapA2AExceptionToHttpResult(ex); } | ||
| }); | ||
|
|
||
| routeGroup.MapPost("/tasks/{id}:cancel", async (HttpContext httpContext, string id, CancellationToken ct) => | ||
| { | ||
| try { return await A2AHttpProcessor.CancelTaskRestAsync(handlerFactory(httpContext), logger, id, ct); } | ||
| catch (A2AException ex) { return A2AHttpProcessor.MapA2AExceptionToHttpResult(ex); } | ||
| }); | ||
|
|
||
| routeGroup.MapPost("/tasks/{id}:subscribe", (HttpContext httpContext, string id, CancellationToken ct) => | ||
| { | ||
| try { return A2AHttpProcessor.SubscribeToTaskRest(handlerFactory(httpContext), logger, id, ct); } | ||
| catch (A2AException ex) { return A2AHttpProcessor.MapA2AExceptionToHttpResult(ex); } | ||
| }); | ||
|
|
||
| routeGroup.MapGet("/tasks", async (HttpContext httpContext, [FromQuery] string? contextId, [FromQuery] string? status, | ||
| [FromQuery] int? pageSize, [FromQuery] string? pageToken, [FromQuery] int? historyLength, CancellationToken ct) => | ||
| { | ||
| try { return await A2AHttpProcessor.ListTasksRestAsync(handlerFactory(httpContext), logger, contextId, status, pageSize, pageToken, historyLength, ct); } | ||
| catch (A2AException ex) { return A2AHttpProcessor.MapA2AExceptionToHttpResult(ex); } | ||
| }); | ||
|
|
||
| // Message operations | ||
| routeGroup.MapPost("/message:send", async (HttpContext httpContext, [FromBody] SendMessageRequest request, CancellationToken ct) => | ||
| { | ||
| try { return await A2AHttpProcessor.SendMessageRestAsync(handlerFactory(httpContext), logger, request, ct); } | ||
| catch (A2AException ex) { return A2AHttpProcessor.MapA2AExceptionToHttpResult(ex); } | ||
| }); | ||
|
|
||
| routeGroup.MapPost("/message:stream", (HttpContext httpContext, [FromBody] SendMessageRequest request, CancellationToken ct) => | ||
| { | ||
| try { return A2AHttpProcessor.SendMessageStreamRest(handlerFactory(httpContext), logger, request, ct); } | ||
| catch (A2AException ex) { return A2AHttpProcessor.MapA2AExceptionToHttpResult(ex); } | ||
| }); | ||
|
|
||
| // Push notification config operations | ||
| routeGroup.MapPost("/tasks/{id}/pushNotificationConfigs", | ||
| async (HttpContext httpContext, string id, [FromBody] PushNotificationConfig config, CancellationToken ct) => | ||
| { | ||
| try { return await A2AHttpProcessor.CreatePushNotificationConfigRestAsync(handlerFactory(httpContext), logger, id, config, ct); } | ||
| catch (A2AException ex) { return A2AHttpProcessor.MapA2AExceptionToHttpResult(ex); } | ||
| }); | ||
|
|
||
| routeGroup.MapGet("/tasks/{id}/pushNotificationConfigs", | ||
| async (HttpContext httpContext, string id, [FromQuery] int? pageSize, [FromQuery] string? pageToken, CancellationToken ct) => | ||
| { | ||
| try { return await A2AHttpProcessor.ListPushNotificationConfigRestAsync(handlerFactory(httpContext), logger, id, pageSize, pageToken, ct); } | ||
| catch (A2AException ex) { return A2AHttpProcessor.MapA2AExceptionToHttpResult(ex); } | ||
| }); | ||
|
|
||
| routeGroup.MapGet("/tasks/{id}/pushNotificationConfigs/{configId}", | ||
| async (HttpContext httpContext, string id, string configId, CancellationToken ct) => | ||
| { | ||
| try { return await A2AHttpProcessor.GetPushNotificationConfigRestAsync(handlerFactory(httpContext), logger, id, configId, ct); } | ||
| catch (A2AException ex) { return A2AHttpProcessor.MapA2AExceptionToHttpResult(ex); } | ||
| }); | ||
|
|
||
| routeGroup.MapDelete("/tasks/{id}/pushNotificationConfigs/{configId}", | ||
| async (HttpContext httpContext, string id, string configId, CancellationToken ct) => | ||
| { | ||
| try { return await A2AHttpProcessor.DeletePushNotificationConfigRestAsync(handlerFactory(httpContext), logger, id, configId, ct); } | ||
| catch (A2AException ex) { return A2AHttpProcessor.MapA2AExceptionToHttpResult(ex); } | ||
| }); | ||
|
|
||
| // Extended agent card | ||
| routeGroup.MapGet("/extendedAgentCard", async (HttpContext httpContext, CancellationToken ct) => | ||
| { | ||
| try { return await A2AHttpProcessor.GetExtendedAgentCardRestAsync(handlerFactory(httpContext), logger, ct); } | ||
| catch (A2AException ex) { return A2AHttpProcessor.MapA2AExceptionToHttpResult(ex); } | ||
| }); | ||
|
|
||
| return routeGroup; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| using Microsoft.AspNetCore.Builder; | ||
| using Microsoft.AspNetCore.Http; | ||
| using Moq; | ||
|
|
||
| namespace A2A.AspNetCore.Tests; | ||
|
|
@@ -72,7 +73,7 @@ public void MapA2A_RequiresNonNullRequestHandler() | |
| var app = WebApplication.CreateBuilder().Build(); | ||
|
|
||
| // Act & Assert | ||
| Assert.Throws<ArgumentNullException>(() => app.MapA2A(null!, "/agent")); | ||
| Assert.Throws<ArgumentNullException>(() => app.MapA2A((IA2ARequestHandler)null!, "/agent")); | ||
| } | ||
|
|
||
| [Fact] | ||
|
|
@@ -82,6 +83,93 @@ public void MapWellKnownAgentCard_RequiresNonNullAgentCard() | |
| var app = WebApplication.CreateBuilder().Build(); | ||
|
|
||
| // Act & Assert | ||
| Assert.Throws<ArgumentNullException>(() => app.MapWellKnownAgentCard(null!)); | ||
| Assert.Throws<ArgumentNullException>(() => app.MapWellKnownAgentCard((AgentCard)null!)); | ||
| } | ||
|
|
||
| // ── Factory overload tests ── | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice to see the factory overloads covered. These tests verify the wiring — the endpoint registers, null args throw — but they don't exercise the behavior the overloads actually add: none of them send a request through the factory. A few behaviors worth a request-level test (e.g. via
As written, a regression in the per-request resolution or the error mapping would still pass these tests. Happy to help add a couple if useful. |
||
|
|
||
| [Fact] | ||
| public void MapA2A_WithFactory_RegistersEndpoint() | ||
| { | ||
| var app = WebApplication.CreateBuilder().Build(); | ||
| Func<HttpContext, IA2ARequestHandler> factory = _ => new Mock<IA2ARequestHandler>().Object; | ||
| var result = app.MapA2A(factory, "/agent"); | ||
| Assert.NotNull(result); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void MapA2A_WithFactory_ThrowsArgumentNullException_WhenFactoryIsNull() | ||
| { | ||
| var app = WebApplication.CreateBuilder().Build(); | ||
| Assert.Throws<ArgumentNullException>(() => | ||
| app.MapA2A((Func<HttpContext, IA2ARequestHandler>)null!, "/agent")); | ||
| } | ||
|
|
||
| [Theory] | ||
| [InlineData(null)] | ||
| [InlineData("")] | ||
| public void MapA2A_WithFactory_ThrowsArgumentException_WhenPathIsNullOrEmpty(string? path) | ||
| { | ||
| var app = WebApplication.CreateBuilder().Build(); | ||
| Func<HttpContext, IA2ARequestHandler> factory = _ => new Mock<IA2ARequestHandler>().Object; | ||
| if (path == null) | ||
| Assert.Throws<ArgumentNullException>(() => app.MapA2A(factory, path!)); | ||
| else | ||
| Assert.Throws<ArgumentException>(() => app.MapA2A(factory, path)); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void MapA2A_WithHandlerAndCardFactories_RegistersEndpoint() | ||
| { | ||
| var app = WebApplication.CreateBuilder().Build(); | ||
| Func<HttpContext, IA2ARequestHandler> handlerFactory = _ => new Mock<IA2ARequestHandler>().Object; | ||
| Func<HttpContext, AgentCard> cardFactory = _ => new AgentCard { Name = "Test", Description = "Test" }; | ||
| var result = app.MapA2A(handlerFactory, cardFactory, "/agent"); | ||
| Assert.NotNull(result); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void MapWellKnownAgentCard_WithFactory_RegistersEndpoint() | ||
| { | ||
| var app = WebApplication.CreateBuilder().Build(); | ||
| Func<HttpContext, AgentCard> factory = _ => new AgentCard { Name = "Test", Description = "Test" }; | ||
| var result = app.MapWellKnownAgentCard(factory); | ||
| Assert.NotNull(result); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void MapWellKnownAgentCard_WithFactory_ThrowsArgumentNullException_WhenFactoryIsNull() | ||
| { | ||
| var app = WebApplication.CreateBuilder().Build(); | ||
| Assert.Throws<ArgumentNullException>(() => | ||
| app.MapWellKnownAgentCard((Func<HttpContext, AgentCard>)null!)); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void MapHttpA2A_WithFactories_RegistersEndpoint() | ||
| { | ||
| var app = WebApplication.CreateBuilder().Build(); | ||
| Func<HttpContext, IA2ARequestHandler> handlerFactory = _ => new Mock<IA2ARequestHandler>().Object; | ||
| Func<HttpContext, AgentCard> cardFactory = _ => new AgentCard { Name = "Test", Description = "Test" }; | ||
| var result = app.MapHttpA2A(handlerFactory, cardFactory); | ||
| Assert.NotNull(result); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void MapHttpA2A_WithFactories_ThrowsArgumentNullException_WhenHandlerFactoryIsNull() | ||
| { | ||
| var app = WebApplication.CreateBuilder().Build(); | ||
| Func<HttpContext, AgentCard> cardFactory = _ => new AgentCard { Name = "Test", Description = "Test" }; | ||
| Assert.Throws<ArgumentNullException>(() => | ||
| app.MapHttpA2A((Func<HttpContext, IA2ARequestHandler>)null!, cardFactory)); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void MapHttpA2A_WithFactories_ThrowsArgumentNullException_WhenCardFactoryIsNull() | ||
| { | ||
| var app = WebApplication.CreateBuilder().Build(); | ||
| Func<HttpContext, IA2ARequestHandler> handlerFactory = _ => new Mock<IA2ARequestHandler>().Object; | ||
| Assert.Throws<ArgumentNullException>(() => | ||
| app.MapHttpA2A(handlerFactory, (Func<HttpContext, AgentCard>)null!)); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This per-request card resolution is exactly what #353 was asking for — nice. One thing worth considering before it locks in: the factory signature is synchronous (
Func<HttpContext, AgentCard>), and it's invoked synchronously in the GET handler just below (Results.Ok(agentCardFactory(httpContext))).In practice, resolving the right agent card per request is often I/O-bound — looking it up from a tenant directory, a database, or a remote agent-definition service keyed off the subdomain/user in
HttpContext. A synchronous signature forces those consumers into sync-over-async (.Result/.GetAwaiter().GetResult()) on the request path, which risks thread-pool starvation / deadlocks under load. #353 itself notes that v0.3'sOnAgentCardQuerywas awaitable, so async card resolution was a supported capability there.Would you consider an async overload —
Func<HttpContext, Task<AgentCard>>(orValueTask<AgentCard>)? The GET handler can then await it cleanly:The same applies to the combined handler + card overloads in
MapA2AandMapHttpA2A. Happy to help with the change if useful.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FWIW the
A2A.V0_3Compatlayer in this same repo already takes an async card factory —Func<Task<AgentCard>>inMapAgentCardGetWithV03Compat(itawaits the factory before serving). Making the v1 overload async would keep it consistent with that existing precedent.