Add a routing context factory for request-scoped policy state - #7684
Add a routing context factory for request-scoped policy state#7684joshuajyue wants to merge 2 commits into
Conversation
Add a protected virtual CreateContext to FailoverChatClient so a derived class can return its own RoutingContext subclass. One context is already created per request and supplied to every selection and routing update, so state stored on it is scoped to the request and released with it. Previously a policy that needed state across attempts had to keep a side table keyed by the context and remove the entry on the terminal update. That state outlives the request whenever routing ends without a terminal update, such as when selection throws after a nonterminal update or when a streaming enumerator is abandoned without being disposed. Move OrderedFailoverChatClient to the new pattern. Its next-client index is now a field on its own context, which removes the ConcurrentDictionary, the lookups on every selection and update, and the explicit cleanup on termination. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 74d04840-2379-4615-93f7-84f2299ada74
The cast cannot fail: OrderedFailoverChatClient is sealed, FailoverChatClient seals both invocation methods, and the selection and update methods are protected, so the only context they receive is the one CreateContext produced. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 74d04840-2379-4615-93f7-84f2299ada74
There was a problem hiding this comment.
Pull request overview
Adds an extensibility point to chat routing so derived routing clients can create a per-request RoutingContext (including custom subclasses) and have it flow consistently through selection and (for failover) routing updates, enabling request-scoped policy state without side tables.
Changes:
- Add
protected virtual RoutingContext CreateContext(IEnumerable<ChatMessage>, ChatOptions?)toRoutingChatClientand use it in both non-streaming and streaming entry points (with a null-return guard). - Update
FailoverChatClientto useCreateContextin its sealed overrides and document the request-scoped-state pattern. - Refactor
OrderedFailoverChatClientto store its “next client index” on a privateRoutingContextsubclass, removing the per-requestConcurrentDictionaryand related cleanup paths; update tests and API manifests accordingly.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| test/Libraries/Microsoft.Extensions.AI.Tests/ChatRouting/OrderedFailoverChatClientTests.cs | Replaces reflection-based state-leak test with an abandoned-stream scenario to validate state scoping. |
| test/Libraries/Microsoft.Extensions.AI.Tests/ChatRouting/FailoverChatClientTests.cs | Adds coverage ensuring custom contexts flow through selection and updates; adds null-context guard coverage. |
| test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatRouting/RoutingChatClientTests.cs | Adds coverage ensuring custom contexts flow to selection; adds null-context guard coverage. |
| src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.json | Updates API manifest to include OrderedFailoverChatClient.CreateContext override. |
| src/Libraries/Microsoft.Extensions.AI/ChatRouting/OrderedFailoverChatClient.cs | Moves ordered failover request state onto a context subclass; removes side-table state. |
| src/Libraries/Microsoft.Extensions.AI/ChatRouting/FailoverChatClient.cs | Switches to CreateContext for request context creation and documents request-scoped context state. |
| src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json | Updates API manifest to include RoutingChatClient.CreateContext. |
| src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatRouting/RoutingChatClient.cs | Introduces CreateContext factory and uses it in both invocation paths with a null guard. |
|
Closing this for the same reason the Anything a derived context could carry, a side table keyed by the context can carry too — that's what It's additive and purely a virtual with a working default, so it can land later without breaking anyone. If a concrete consumer hits a wall, that's a better time to add it, with the actual requirements in hand. The trade-offs support waiting too: the downcast isn't type-checked, there's one context per request of one type so two layers can't independently attach state, and if Thanks @jozkee for the review. |
Follow-up to #7662.
Summary
Adds one
protected virtualmethod toRoutingChatClient:GetResponseAsyncandGetStreamingResponseAsynccall it instead of constructing aRoutingContextdirectly, so a derived class can return its own subclass and have it flow through selection and, forFailoverChatClient, every routing update for that request.OrderedFailoverChatClientmoves onto it, which removes itsConcurrentDictionary<RoutingContext, int>.Why
Nothing here is newly possible. A router can already keep whatever it wants in a side table keyed by the context — that is what
OrderedFailoverChatClientdoes today for its next-client index, andRoutingContextdoes not overrideEquals/GetHashCode, so reference-keying is sound. The question is only where that state should live.ConcurrentDictionary; every access hashes and may contendThe leak is the substantive row. State must survive from a nonterminal update to the following selection, so it cannot be removed eagerly, and a request can end without a terminal update — an abandoned streaming enumerator, or selection failing after state was already stored. Today the derived class is responsible for cleaning up on those paths. Context-owned state hands that responsibility to the GC.
The rest is ergonomics, and it compounds when a selector needs more inputs rather than more state.
SelectClientAsynctakes aRoutingContext, so a router wanting to hand its callback anything else has to carry a second object and correlate the two. Agent Framework's routing client (microsoft/agent-framework#6932) needs the current agent, the session, its registered destination map, and the session's active destination available to its routing callback, so it declares a standalone context type and copies messages and options into it. That works, and it is whyMicrosoft.Agents.AI.RoutingContextandMicrosoft.Extensions.AI.RoutingContextare currently unrelated types with the same name: two objects per request, and a routing callback written against one does not compose with the other.RoutingContextis a non-sealedpublic classwith a public constructor, so it is already shaped for that subclass to exist. The base class just never lets one through — both invocation methods hardcodenew RoutingContext(...), so whatever a derived class builds cannot reachSelectClientAsync. This finishes that rather than introducing a new concept.Two things worth noting: this is not ambient state, since the context is an explicit parameter to
SelectClientAsyncandOnRoutingUpdateAsyncand nothing depends onExecutionContextflowing — which is what theAsyncLocalsuggestion in #7662 was aimed at, without theSuppressFlowhazard raised against it there. AndRoutingContextstill clones the caller's options in its constructor, which a subclass has to call, so the caller's instance stays protected either way.Trade-offs
OrderedFailoverChatClientis, and weaker when it is not.FailoverChatClientstores nothing on the context, but it does mean that if the base class ever wants its own context state, existing derived contexts would have to re-parent.CreateContextcan returnnull, so both invocation methods guard for it.Changes
RoutingChatClient— addsCreateContext; both invocation methods call it, with anullguard matching the existingSelectClientAsynccheck.FailoverChatClient— no new API. It inheritsCreateContext, and because it declares both invocation methodssealed override, the factory is guaranteed to run for its derived types.OrderedFailoverChatClient— moves its next-client index onto a private context subclass; removes theConcurrentDictionary, the per-call lookups, both cleanup paths, and the_requestStates.Clear()inDispose.CreateContextcoverage at both theRoutingChatClientandFailoverChatClientlevels, including anullreturn;OrderedFailover_AbandonedStreamDoesNotAffectLaterRequestsreplaces the test that used reflection to inspect the removed dictionary.Notes
CreateContextcasts the context back to its own type inSelectClientAsync.OrderedFailoverChatClientasserts the type rather than checking it at runtime: it is sealed,FailoverChatClientseals both invocation methods, and selection and updates are protected, so the only context those methods can receive is the oneCreateContextproduced. Reaching a different type would take a change to the library itself, which the assert catches in Debug.RoutingChatClient<TContext>would remove the downcast, at the cost of a type parameter across the whole hierarchy.Validation
Microsoft.Extensions.AI.Tests— 762 passed.Microsoft.Extensions.AI.Abstractions.Tests— 1646 passed.MakeApiBaselines.ps1output.