.NET: Add AsIChatClient extension to expose any AIAgent as an IChatClient - #7687
.NET: Add AsIChatClient extension to expose any AIAgent as an IChatClient#7687Tomas Rampas (tomas-rampas) wants to merge 3 commits into
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
|
Roger Barreto (@rogerbarreto) small ask - the workflows on the PR are waiting for maintainer approval to run (new head after I updated the branch + fixed the formatter finding from CI). Could you approve them when you have a minute? All checks were green on the previous run except the format one, which is what the last commit fixes |
| private readonly AIAgent _agent; | ||
|
|
||
| /// <summary>The optional session to use for every request, or <see langword="null"/> to operate statelessly.</summary> | ||
| private readonly AgentSession? _session; |
There was a problem hiding this comment.
This AIAgentChatClient implementation doesn't currently conform to the IChatClient specification when it comes to ChatHistory management. Specifically when we use an AgentSession, it means that ChatHistory is maintained, via the session, in the AIAgentChatClient.
Signaling this behavior is supported by IChatClient today via the ConversationId property on ChatOptions and ChatResponse.
-
When a service that is behind IChatClient stores ChatHistory, the ChatClient sets the ConversationId property on the ChatResponse. This signals to callers that they need to provide the same id again on the next request, to continue the conversation, and that they only need to supply new messages.
-
When no ConversationId is returned by an IChatClient, it means that the caller should aggregate ChatHistory and resupply it with any new messages on the next turn.
When we support AgentSession, we are in scenario 1. Callers shouldn't resupply chat history, however, we do not signal this to callers here, since we are not setting the ConversationId property.
To support this correctly, we have a couple of options:
- Only support one or zero sessions at a time (like now).
- This would mean returning a conversation id if a session is used.
- This could be a fake const conversation id, or an id supplied by the dev who constructs the ChatClient.
- If the caller supplies anything other than the fake const or dev supplied id, we would need to throw an exception, to indicate that the id was not found. Similar to how, e.g. OpenAIResponsesChatClient deals with the caller supplying a responseId that doesn't exist.
- Support multiple sessions
- We would need a session store (could default to InMemory).
- Hosting already has the session store we could move down the stack.
- If we want to support both storing and not storing, we could mimic the Responses approach, by having a bool store param on the ChatClient, allowing devs to choose. store=true would result in a session being created on any request without a conversationId, with the session stored under a new id that is returned on the response.
- Any conversationId provided by a caller that isn't in the store results in an exception.
| /// </remarks> | ||
| public static IChatClient AsIChatClient(this AIAgent agent, AgentSession? session = null) | ||
| { | ||
| Throw.IfNull(agent); |
There was a problem hiding this comment.
To correctly set the expectations that an AIAgent is capable of fulfilling most of the IChatClient contract requirements we should either ensure the Agent is a ChatClientAgent or is a decorator of a ChatClientAgent and or have a IChatClient based implementation internally.
This is necessary to avoid using this API for purposes that it is outside of our control on customized AIAgent as well to avoid pit of failure using this API incorrectly.
IE: a2aAgentXYZ.AsIChatClient().AsAIAgent() and expect it to behave correctly as a ChatClientAgent for example.
Additionally to this change we should ensure this usage
| Throw.IfNull(agent); | |
| Throw.IfNull(agent); | |
| Throw.IfNull(agent.GetService<ChatClientAgent>()); | |
| // OR | |
| Throw.IfNull(agent.GetService<IChatClient>()); |
Thank you for taking this on Tomas Rampas (@tomas-rampas)! I have been out of the loop on the evaluation SDK side for the past few months. However, the main ask as I had mentioned in #3496 (comment) (also pasted below for reference) was to be able to support any
|
Motivation & Context
More and more .NET APIs accept
Microsoft.Extensions.AI.IChatClient. This change lets anyAIAgentbe 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)inMicrosoft.Agents.AI— mirrors the siblingAsAIFunction(..., AgentSession?)and theAsIChatClientnaming already used by the provider-client adapters in this repo.internal sealed class AIAgentChatClient : IChatClient(src/Microsoft.Agents.AI/ChatClient/AIAgentChatClient.cs):GetResponseAsync→agent.RunAsync(...)→ the existingAgentResponse.AsChatResponse()converter (rawChatResponsepass-through preserved, soConversationId/usage survive forChatClientAgent).GetStreamingResponseAsyncvalidates eagerly (throws before enumeration), then streams via a private[EnumeratorCancellation]iterator using the singularAsChatResponseUpdate()converter, soWithCancellation(...)tokens are honored.ChatOptionsare carried throughChatClientAgentRunOptions(honored byChatClientAgent, gracefully 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), includingChatClientAgentend-to-end (instructions/tools merge,ConversationIdround-trip), M.E.AI structured-output through the adapter,GetServiceprecedence pinned against a realChatClientAgent, and cancellation propagation on both paths (mutation-tested: breaking token forwarding fails 4 tests).What is the impact of these changes? Purely additive: one new public method, no modified lines in existing code. Release build passes Package Validation with zero CP diagnostics. Default usage is stateless per call (full history each request); an optional bound session enables stateful use with documented caveats (no concurrent use, don't share across users).
What do you want reviewers to focus on?
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]?Microsoft.Agents.AIis GA-validated (baseline 1.0.0), and the adapter has documented sharp edges (background/continuation responses unsupported; non-ChatClientAgentagents ignore mostChatOptions). The closest structural analogue (ChatStrategyExtensions.AsChatReducer) is gated; the siblingAsAIFunctionis not. Happy to add the attribute if you prefer to let the shape settle — please advise.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, callerChatOptionsnon-mutation regression).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.