Skip to content
Open
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
223 changes: 223 additions & 0 deletions docs/decisions/0029-unified-dynamic-agent-resolution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
---
status: proposed
contact: Ashutosh0x
date: 2026-06-20
consulted: "@javiercn, @TheEagleByte, @halllo, @Davidlmkh"
---

# Unified Dynamic Agent Resolution Across AG-UI, OpenAI Responses, and A2A Endpoints

## Context and Problem Statement

All three hosting channel endpoint builders (`MapAGUI`, `MapOpenAIResponses`, `MapA2AHttpJson`) currently resolve the `AIAgent` instance and its `AgentSessionStore` **once at endpoint registration time** (app startup). This singleton-capture pattern prevents dynamic per-request agent resolution, which is required for multi-tenant agent platforms where route parameters (e.g., `/agents/{agentId}`) determine which agent handles each request.

Community contributors have proposed factory-delegate overloads for AG-UI (#3162, #2343), but the maintainers (@javiercn) correctly noted that this pattern must be applied consistently across all three channel types simultaneously — not just AG-UI.

### Current singleton-capture pattern (all three channels)

```csharp
// AG-UI — AGUIEndpointRouteBuilderExtensions.cs:105
var agentSessionStore = endpoints.ServiceProvider.GetKeyedService<AgentSessionStore>(aiAgent.Name);
var hostAgent = new AIHostAgent(aiAgent, agentSessionStore);

// Responses — EndpointRouteBuilderExtensions.Responses.cs:71
var executor = new AIAgentResponseExecutor(agent);

// A2A — A2AEndpointRouteBuilderExtensions.cs:69
var a2aServer = endpoints.ServiceProvider.GetKeyedService<A2AServer>(agentName);
```

### Pain points identified by the community

1. **No dynamic routing** — Cannot serve different agents based on route parameters (#2988, #3162)
2. **AgentSessionStore scoping broken** — Even scoped/transient session stores are captured as singletons at startup (@halllo, #3162 comment)
3. **HttpContextRoutingAgent workaround is brittle** — Requires overriding 5+ methods, couples agent logic to HTTP transport, `SerializeSession()` was not async (@halllo, @TheEagleByte)
4. **Forces per-agent infrastructure** — Without dynamic resolution, multi-agent hosts need separate endpoints per agent or fork the repo (@Davidlmkh)

## Decision Drivers

- **Consistency**: The same pattern must work across AG-UI, OpenAI Responses, and A2A
- **ASP.NET Core idiom**: Follow established patterns (`AddDbContext<T>()`, `AddAuthentication<THandler>()`, minimal API delegates)
- **Separation of concerns**: Agent resolution is a routing/infrastructure concern, not an agent behavior concern
- **Backward compatibility**: Existing single-agent `MapXxx(pattern, agent)` overloads must continue to work
- **Session store scoping**: Must support per-request session store resolution, not singleton capture

## Considered Options

### Option 1: HttpContextRoutingAgent (current workaround)

A delegating `AIAgent` subclass that uses `IHttpContextAccessor` to resolve the real agent at runtime.

**Pros**: Works today without framework changes
**Cons**: Couples agents to HTTP, requires overriding all virtual methods, session store still singleton-captured, reduces cohesion

### Option 2: Factory delegate overloads (PR #3162 approach)

Add `MapXxx` overloads accepting `Func<HttpContext, CancellationToken, ValueTask<AIAgent?>>`:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes sense, but ideally for demo-purposes can you please share the sample where overloads today dont allow you to do X, and with the new overload you can do it? That will be a "proof" of why those overloads are important to exist

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Absolutely! Here is a concrete before/after:

Today — you CANNOT do this:
`csharp
// Goal: serve different agents based on URL
// e.g. GET /agents/weather/ag-ui vs GET /agents/search/ag-ui

// This does NOT work — agent is captured at startup:
app.MapAGUI(/agents/{agentId}, weatherAgent);
// ^ weatherAgent is always used, {agentId} route param is ignored
`

Workaround today — brittle HttpContextRoutingAgent:
csharp // You have to create a wrapper agent that overrides every method: public class RoutingAgent : AIAgent { private readonly IHttpContextAccessor _accessor; public override async Task<AgentResponse> RunAsync(...) { var ctx = _accessor.HttpContext; var id = ctx.GetRouteValue(agentId); var real = await _repo.GetAgent(id); return await real.RunAsync(...); // delegate everything } // Must also override: RunStreamingAsync, CreateSessionAsync, // GetSessionAsync, SerializeSession (not async!), etc. }

With factory delegate overload — clean and simple:
csharp app.MapAGUI(/agents/{agentId}, async (context, ct) => { var agentId = context.GetRouteValue(agentId)?.ToString(); return await agentRepo.GetAgentAsync(agentId, ct); }); // Session store also resolved per-request automatically

The core problem: today the agent is a startup-time constant, but multi-tenant platforms need it to be a request-time variable. The workaround requires duplicating the entire AIAgent surface area.


```csharp
app.MapAGUI("/agents/{agentId}", async (context, ct) =>
{
var agentId = context.GetRouteValue("agentId")?.ToString();
return await agentRepository.GetAgentByIdAsync(agentId, ct);
});
```

**Pros**: Familiar minimal API pattern, maximum flexibility
**Cons**: Channel-specific implementation needed for each `MapXxx`

### Option 3: `IAgentResolver` interface with DI registration (recommended)

Define a shared `IAgentResolver` interface in the hosting core:

```csharp
public interface IAgentResolver

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dont see this being much different from resolving AIAgent from the registered instances in the DI container. Default implementation of this will do basically same GetRequiredService(agentname) or similar. Is there any specific use-case where this is helping to solve some problem?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great question @DeagleGross! You are right that a default IAgentResolver would basically do GetRequiredService. The key difference is when and how the agent name is determined:

Today (DI only): The agent is resolved once at startup in MapAGUI(pattern, agent). You can register multiple named agents, but there is no hook to select between them per-request based on route data.

Where dynamic resolution adds value:

  1. Route-based - /agents/{agentId} where agentId comes from the URL at request time
  2. Claims-based - Different tenants get different agents based on JWT claims
  3. Database-driven - Agent definitions stored in a DB, not statically in DI

That said, per @javiercn feedback, I have revised the ADR to deprioritize IAgentResolver in favor of Phase 1: expose HttpContext on options and use Use(...) middleware. This achieves the same result without a new abstraction. IAgentResolver is now Phase 2 only if community demand warrants it. Does that align with your thinking?

{
ValueTask<AIAgent?> ResolveAgentAsync(HttpContext context, CancellationToken cancellationToken = default);
}
```

Registration:
```csharp
builder.Services.AddAgentResolver<RouteBasedAgentResolver>();
```

Endpoint mapping (no agent parameter):
```csharp
app.MapAGUI("/agents/{agentId}");
app.MapOpenAIResponses("/agents/{agentId}/v1/responses");
app.MapA2AHttpJson("/agents/{agentId}/a2a");
```

Each `MapXxx` overload resolves the agent per-request via `IAgentResolver` from DI.

**Pros**: Single interface for all channels, DI-native, testable, session stores resolved per-request
**Cons**: New abstraction to learn

## Decision Outcome

Chosen option: **Option 3 (IAgentResolver) combined with Option 2 (factory delegate)** as the dual-API approach.

### Implementation plan

#### 1. Shared `IAgentResolver` interface (new, in `Microsoft.Agents.AI.Hosting`)

```csharp
namespace Microsoft.Agents.AI.Hosting;

/// <summary>
/// Resolves an <see cref="AIAgent"/> dynamically at request time.
/// Implementations can use route data, headers, claims, or any
/// request-scoped information to select the appropriate agent.
/// </summary>
public interface IAgentResolver
{
/// <summary>
/// Resolves an agent for the current request.
/// Returns null if no agent matches, causing a 404 response.
/// </summary>
ValueTask<AIAgent?> ResolveAgentAsync(
HttpContext context,
CancellationToken cancellationToken = default);
}
```

#### 2. DI registration helper (new, in each hosting package)

```csharp
public static IServiceCollection AddAgentResolver<TResolver>(this IServiceCollection services)
where TResolver : class, IAgentResolver
{
services.AddHttpContextAccessor();
services.AddSingleton<IAgentResolver, TResolver>();
return services;
}
Comment on lines +193 to +199
```

#### 3. Per-request agent + session store resolution

The key fix: resolve `AgentSessionStore` per-request from `HttpContext.RequestServices` instead of capturing at startup:

```csharp
// Inside the endpoint delegate (per-request):
var agent = await resolver.ResolveAgentAsync(context, cancellationToken);
if (agent is null) return Results.NotFound();

// Resolve session store from the request's DI scope (not app-level)
var agentSessionStore = context.RequestServices.GetKeyedService<AgentSessionStore>(agent.Name)
?? new NoopAgentSessionStore();
```

#### 4. Factory delegate overloads (convenience API)

```csharp
// AG-UI
public static IEndpointConventionBuilder MapAGUI(
this IEndpointRouteBuilder endpoints,
string pattern,
Func<HttpContext, CancellationToken, ValueTask<AIAgent?>> agentFactory);

// OpenAI Responses
public static IEndpointConventionBuilder MapOpenAIResponses(
this IEndpointRouteBuilder endpoints,
string? responsesPath,
Func<HttpContext, CancellationToken, ValueTask<AIAgent?>> agentFactory);

// A2A
public static IEndpointConventionBuilder MapA2AHttpJson(
this IEndpointRouteBuilder endpoints,
string path,
Func<HttpContext, CancellationToken, ValueTask<AIAgent?>> agentFactory);
```

#### 5. Error handling

| Scenario | Behavior |
|:---|:---|
| Factory/resolver returns `null` | 404 Not Found |
| Factory/resolver throws | 500 Internal Server Error + log |
| No resolver registered, no agent parameter | `InvalidOperationException` at startup |

### Usage examples

#### Route-based multi-tenant

```csharp
builder.Services.AddAgentResolver<RouteBasedAgentResolver>();

app.MapAGUI("/agents/{agentId}");
app.MapOpenAIResponses("/agents/{agentId}/v1/responses");
app.MapA2AHttpJson("/agents/{agentId}/a2a");

public class RouteBasedAgentResolver(IAgentRepository repo) : IAgentResolver
{
public async ValueTask<AIAgent?> ResolveAgentAsync(HttpContext context, CancellationToken ct)
{
var agentId = context.GetRouteValue("agentId")?.ToString();
return string.IsNullOrEmpty(agentId) ? null : await repo.GetAgentAsync(agentId, ct);
}
}
```

#### Inline factory (quick prototyping)

```csharp
app.MapAGUI("/agents/{agentId}", async (context, ct) =>
{
var agentId = context.GetRouteValue("agentId")?.ToString();
return agentId switch
{
"weather" => weatherAgent,
"search" => searchAgent,
_ => null // 404
};
});
```

## References

- #3162 — .NET: Support dynamic agent resolution in AG-UI endpoints (@TheEagleByte)
- #2343 — Earlier dynamic resolution PR (@halllo)
- #2988 — Original issue requesting dynamic agent selection
- @javiercn's comment: "this needs to be applied not only to AG-UI but to Open AI responses and A2A"
- @halllo's `AgentSessionStore` scoping analysis (Feb 2026)