.NET: Add AsIChatClient extension to expose an AIAgent as an IChatClient - #7687
Conversation
There was a problem hiding this comment.
Pull request overview
Adds an adapter allowing any .NET AIAgent to be consumed as an IChatClient.
Changes:
- Adds the
AsIChatClientextension with session and usage guidance. - Implements response conversion, streaming, cancellation, options, metadata, and service forwarding.
- Adds comprehensive unit and integration-style coverage.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
dotnet/src/Microsoft.Agents.AI/AgentExtensions.cs |
Exposes the new public extension method. |
dotnet/src/Microsoft.Agents.AI/ChatClient/AIAgentChatClient.cs |
Implements the agent-to-chat-client adapter. |
dotnet/tests/Microsoft.Agents.AI.UnitTests/AIAgentChatClientTests.cs |
Tests adapter behavior and integration. |
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
d009b77 to
23aca4f
Compare
@microsoft-github-policy-service agree |
|
Not all So, given the requirement in the PR, the proper way would be. var anyAiAgent = ...
var chatClient = anyAIAgent.GetService<IChatClient>();
// use the chat client from here. |
|
Thanks Roger Barreto (@rogerbarreto), the compatibility concern is fair - that's also why the remarks document what each agent type honors, and why I asked in the PR description if this should go under But I don't think
If you prefer to keep the surface constrained while the shape settles, I can add |
…ient Adds AIAgentExtensions.AsIChatClient(this AIAgent, AgentSession? = null), backed by an internal AIAgentChatClient adapter, so any agent can be used where Microsoft.Extensions.AI.IChatClient is accepted (e.g. as an evaluation judge in Microsoft.Extensions.AI.Evaluation). - Maps GetResponseAsync/GetStreamingResponseAsync to RunAsync/ RunStreamingAsync, reusing the AgentResponse converters; streaming honors WithCancellation via EnumeratorCancellation. - Carries ChatOptions through ChatClientAgentRunOptions; ResponseFormat is also copied to the base AgentRunOptions so structured output works for non-ChatClient agents. - GetService returns the adapter for unkeyed IChatClient requests (preserving the full agent pipeline), forwards everything else to the agent, and synthesizes ChatClientMetadata as a fallback. - Stateless per call by default; optional bound session mirrors AsAIFunction semantics. - 24 unit tests incl. ChatClientAgent end-to-end, structured output, GetService precedence, and cancellation propagation. Addresses microsoft#3496
The repo's own ChatClientExtensions is declared in the Microsoft.Extensions.AI namespace, so the fully-qualified cref never disambiguated anything; CI's dotnet format (SDK 10.0.400) flags it.
33329bb to
03e9539
Compare
…icit opt-out By default AsIChatClient now throws InvalidOperationException unless the agent is a ChatClientAgent or returns one from an unkeyed GetService request, which every DelegatingAIAgent-based decorator forwards. The probe is ChatClientAgent rather than IChatClient because the adapter's value rests on the agent honoring ChatClientAgentRunOptions. Passing allowNonChatClientAgents: true wraps any other agent; the parameter doc spells out what is lost (only ResponseFormat of the supplied ChatOptions can take effect) and that the untrusted-caller guidance still applies. The flag is evaluated before the probe so an opted-in call never queries the agent, and argument validation runs before the capability check. Tests: the fake-agent call sites opt in, the real ChatClientAgent sites prove the default, and new tests pin the probe type, the unkeyed request, the short-circuit, decorators over both kinds of agent, and the validation order. The five PublicAPI.Unshipped.txt baselines carry the new parameter.
…onversation id Without a bound session the adapter has no conversation to name, so it now hands out no id and takes none back: a non-blank ChatOptions.ConversationId is rejected with InvalidOperationException before the agent runs, on both entry points, and an id carried by the agent's raw response or streamed update is cleared on a copy. A raw response that carries no id is still returned as the same instance. Bound mode is unchanged apart from one shared rule: a blank incoming id is normalized to null on a copy instead of being forwarded, so downstream IsNullOrEmpty checks cannot read whitespace as a service-managed conversation and an empty string cannot suppress the agent's configured id. Clearing is what keeps the adapter's rule consistent: it must never report an id it would reject, and in-the-box callers echo reported ids on the next call. A ChatClientAgent built over the adapter with AsAIAgent stores the reported id on its session and sends it back on the second turn, and an agent using per-service-call history persistence stamps the local-history sentinel on every response; both now round-trip cleanly instead of throwing on turn two. The trade-off is documented: a session-less client does not continue a service conversation by id; callers bind a session obtained from ChatClientAgent.CreateSessionAsync(conversationId) for that. Tests cover the rejection on both paths, blank normalization in both modes, clearing on responses and updates with identity preserved otherwise, service-managed and per-service-call agents without a session, and the AsAIAgent round trip over two turns.
…trip Correct the session parameter doc: a session-less client carries nothing across calls, but state the agent holds independently of a session (a configured service conversation id, an AIContextProvider scoped to a user or application) still persists. State precisely which GetService requests the adapter answers itself, which are forwarded, and when ChatClientMetadata is synthesized. Say plainly that only the conversation id is withheld and that RawRepresentation, ResponseId and AdditionalProperties pass through as the agent produced them. Trim the adapter's class remarks to implementation rationale and point at the public contract instead of restating it. Also hedge the guard message to match its doc, use the idiomatic agent.AsIChatClient(session) form in the stateless rejection, share the options-without-id copy between the blank-normalization and echo-stripping paths, and add tests for the bound AsAIAgent round trip across two turns and for blank-id normalization on the streaming path.
State the blank-id normalization rationale once, in ResolveRequestOptions, and point at it from the class remarks and the normalization site. Scope the per-instance id remark to the generated id, since a caller-supplied id is whatever the caller chose. Condition the stateless identity guarantee on the raw conversation id already being null, which is what the code checks. Name the test that pins the hand-written response copy.
|
Both comments addressed, 0d5b90c (guard with opt-out) and a098681 (conversation id without session), plus 559d898 and 7f02639 with doc corrections from my own review pass. Merged main as well, the new public API analyzers need AsIChatClient in PublicAPI.Unshipped.txt. Title adjusted, it is not "any AIAgent" by default anymore. |
|
Thanks for the contribution Tomas Rampas (@tomas-rampas). This looks good! |
Motivation & Context
More and more .NET APIs accept
Microsoft.Extensions.AI.IChatClient. This change lets anAIAgentbe used wherever anIChatClientis accepted — the motivating scenario from the issue thread is using an agent as the LLM behindMicrosoft.Extensions.AI.Evaluationjudges. Implements the proposal I claimed on #3496 (API shape posted there for early feedback).Description & Review Guide
What are the major changes?
AIAgentExtensions.AsIChatClient(this AIAgent agent, AgentSession? session = null, string? conversationId = null, bool allowNonChatClientAgents = false)inMicrosoft.Agents.AI, marked[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]per review — mirrors the siblingAsAIFunction(..., AgentSession?)and theAsIChatClientnaming already used by the provider-client adapters in this repo. The optionalconversationIdsets the id a session-bound client reports (validated non-blank, non-reserved); otherwise a per-instance id is generated.InvalidOperationExceptionunless the agent is aChatClientAgentor exposes one through an unkeyedGetService<ChatClientAgent>()request, which is exactly the "honorsChatClientAgentRunOptions" question the adapter depends on. Decorators built onDelegatingAIAgentforward the request, so they pass.allowNonChatClientAgents: truewraps any other agent (A2A, Copilot Studio, GitHub Copilot, workflow-hosted, custom) with the limitations spelled out in the parameter doc: onlyChatOptions.ResponseFormatsurvives, the rest ofChatOptionsis ignored, and the client is only as faithful as the agent's ownRunAsync. The Purview middleware agent does not forwardGetService, so it needs the opt-in for now.internal sealed class AIAgentChatClient : IChatClient(src/Microsoft.Agents.AI/ChatClient/AIAgentChatClient.cs):GetResponseAsync→agent.RunAsync(...)→ the existingAgentResponse.AsChatResponse()converter (rawChatResponsepass-through preserved in stateless mode when the raw response carries no conversation id, so usage and identity survive forChatClientAgent).GetStreamingResponseAsyncvalidates eagerly (throws before enumeration), then streams via a private[EnumeratorCancellation]iterator using the singularAsChatResponseUpdate()converter, soWithCancellation(...)tokens are honored.ChatClientAgent.CreateSessionAsync(conversationId).ChatOptions.ConversationIdthrowsInvalidOperationException, a blank one is treated as absent, and an id carried by the raw response is cleared on a copy. The client therefore never hands out an id it would reject, which matters becauseChatClientAgent(viaAsAIAgent()),FunctionInvokingChatClientandMessageInjectingChatClientall send a reported id back on the next call.ChatOptionsare carried throughChatClientAgentRunOptions(honored byChatClientAgent, ignored by agents that don't understand them);ResponseFormatis additionally copied onto the baseAgentRunOptionsso structured output (GetResponseAsync<T>) works for every agent type.GetService: unkeyedIChatClientrequests return the adapter (preserving the full agent pipeline — instructions, tools, context providers); everything else forwards to the agent;ChatClientMetadatais synthesized as a last-resort fallback.Disposeis a no-op; the caller owns the agent lifetime.tests/Microsoft.Agents.AI.UnitTests/AIAgentChatClientTests.cs, run on net10.0 and net472), includingChatClientAgentend-to-end (instructions/tools merge, the configured id reported over a service-managed conversation), the guard against real, decorated and fake agents, the stateless id rule on both paths including theAsIChatClient().AsAIAgent()two-turn round trip, M.E.AI structured-output through the adapter,GetServiceprecedence pinned against a realChatClientAgent, cancellation propagation on both paths, and a reflection guard that fails the build if a future M.E.AIChatResponsemember is missed by the copy used for id stamping (key guards mutation-tested).What is the impact of these changes? Purely additive: one new public method (gated
[Experimental]), declared in the fivePublicAPI.Unshipped.txtbaselines, no modified lines in existing code. The parameter added in the latest revision is source-compatible; it is binary-breaking only for code compiled against an earlier revision of this PR, which nothing shipped is, so no breaking-change label. Release build passes Package Validation with zero CP diagnostics. By default only chat-client-backed agents are accepted; other agents need an explicit opt-in. Default usage is stateless per call (full history each request); an optional bound session enables stateful use that signals history storage per theIChatClientcontract, with documented caveats (one in-flight request at a time, don't share across users).What do you want reviewers to focus on?
Should this API carryResolved — marked[Experimental]?[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]per westey (@westey-m)'s review.Guard for non-chat-client agents?Resolved — on by default withallowNonChatClientAgents: trueopt-out per Roger Barreto (@rogerbarreto)'s review; the probe isGetService<ChatClientAgent>().ChatOptions.ContinuationTokenis passed through rather than rejected up front; raw tokens don't round-trip forChatClientAgent(its token validation fails loudly). This is a deliberate choice — documented as unsupported in the remarks — so a future agent that accepts raw tokens isn't blocked. Can switch to fail-fast if preferred.ChatOptionspass-through means callers can add tools / append instructions for agents honoringChatClientAgentRunOptions— same capability the agent holder already has viaRunAsync; the remarks point untrusted-caller scenarios at theRejectRequestSettings/RunOptionsFactorypattern fromMicrosoft.Agents.AI.Hosting.OpenAI.Offered as follow-ups (kept out to keep this PR small): a sample mirroring
Agent_Step09_AsFunctionToolshowing an agent as an M.E.AI.Evaluation judge; additional tests (cancelled-token →OperationCanceledExceptionend-to-end, exception propagation unwrapped); a small issue forPurviewAgentnot forwardingGetService.Related Issue
Fixes #3496
No other open PR exists for this issue.
Contribution Checklist
breaking changelabel (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.