Skip to content

.NET: Add AsIChatClient extension to expose any AIAgent as an IChatClient - #7687

Open
Tomas Rampas (tomas-rampas) wants to merge 3 commits into
microsoft:mainfrom
tomas-rampas:issue-3496-asichatclient
Open

.NET: Add AsIChatClient extension to expose any AIAgent as an IChatClient#7687
Tomas Rampas (tomas-rampas) wants to merge 3 commits into
microsoft:mainfrom
tomas-rampas:issue-3496-asichatclient

Conversation

@tomas-rampas

@tomas-rampas Tomas Rampas (tomas-rampas) commented Aug 16, 2026

Copy link
Copy Markdown

Motivation & Context

More and more .NET APIs accept Microsoft.Extensions.AI.IChatClient. This change lets any AIAgent be used wherever an IChatClient is accepted — the motivating scenario from the issue thread is using an agent as the LLM behind Microsoft.Extensions.AI.Evaluation judges. Implements the proposal I claimed on #3496 (API shape posted there for early feedback).

Description & Review Guide

  • What are the major changes?

    • New extension method AIAgentExtensions.AsIChatClient(this AIAgent agent, AgentSession? session = null) in Microsoft.Agents.AI — mirrors the sibling AsAIFunction(..., AgentSession?) and the AsIChatClient naming already used by the provider-client adapters in this repo.
    • New internal sealed class AIAgentChatClient : IChatClient (src/Microsoft.Agents.AI/ChatClient/AIAgentChatClient.cs):
      • GetResponseAsyncagent.RunAsync(...) → the existing AgentResponse.AsChatResponse() converter (raw ChatResponse pass-through preserved, so ConversationId/usage survive for ChatClientAgent).
      • GetStreamingResponseAsync validates eagerly (throws before enumeration), then streams via a private [EnumeratorCancellation] iterator using the singular AsChatResponseUpdate() converter, so WithCancellation(...) tokens are honored.
      • ChatOptions are carried through ChatClientAgentRunOptions (honored by ChatClientAgent, gracefully ignored by agents that don't understand them); ResponseFormat is additionally copied onto the base AgentRunOptions so structured output (GetResponseAsync<T>) works for every agent type.
      • GetService: unkeyed IChatClient requests return the adapter (preserving the full agent pipeline — instructions, tools, context providers); everything else forwards to the agent; ChatClientMetadata is synthesized as a last-resort fallback.
      • Dispose is a no-op; the caller owns the agent lifetime.
    • 24 unit tests (tests/Microsoft.Agents.AI.UnitTests/AIAgentChatClientTests.cs), including ChatClientAgent end-to-end (instructions/tools merge, ConversationId round-trip), M.E.AI structured-output through the adapter, GetService precedence pinned against a real ChatClientAgent, 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?

    1. Should this API carry [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]? Microsoft.Agents.AI is GA-validated (baseline 1.0.0), and the adapter has documented sharp edges (background/continuation responses unsupported; non-ChatClientAgent agents ignore most ChatOptions). The closest structural analogue (ChatStrategyExtensions.AsChatReducer) is gated; the sibling AsAIFunction is not. Happy to add the attribute if you prefer to let the shape settle — please advise.
    2. ChatOptions.ContinuationToken is passed through rather than rejected up front; raw tokens don't round-trip for ChatClientAgent (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.
    3. ChatOptions pass-through means callers can add tools / append instructions for agents honoring ChatClientAgentRunOptions — same capability the agent holder already has via RunAsync; the remarks point untrusted-caller scenarios at the RejectRequestSettings/RunOptionsFactory pattern from Microsoft.Agents.AI.Hosting.OpenAI.

    Offered as follow-ups (kept out to keep this PR small): a sample mirroring Agent_Step09_AsFunctionTool showing an agent as an M.E.AI.Evaluation judge; additional tests (cancelled-token → OperationCanceledException end-to-end, exception propagation unwrapped, caller ChatOptions non-mutation regression).

Related Issue

Fixes #3496

No other open PR exists for this issue.

Contribution Checklist

  • The code builds clean without any errors or warnings
  • All unit tests pass, and I have added new tests where possible
  • The PR follows the Contribution Guidelines
  • This PR is linked to an issue and there is no other open PR for this issue (see Related Issue above).
  • This is not a breaking change. If it is a breaking change, add the breaking change label (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.

Copilot AI left a comment

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.

Pull request overview

Adds an adapter allowing any .NET AIAgent to be consumed as an IChatClient.

Changes:

  • Adds the AsIChatClient extension 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.

@tomas-rampas

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

@microsoft-github-policy-service agree

@rogerbarreto

Copy link
Copy Markdown
Member

Not all AIAgents may be compatible with a IChatClient contract, ideally this should be limited to ChatClientAgents that already expose its internal IChatClient via the GetService<IChatClient>() method.

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.

@tomas-rampas

Copy link
Copy Markdown
Author

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 [Experimental].

But I don't think GetService<IChatClient>() can cover what #3496 asks for, for two reasons:

  1. For agents other than ChatClientAgent it just returns null - base AIAgent.GetService only returns the agent itself (AIAgent.cs:118-124), and for example GitHubCopilotAgent does not expose any inner IChatClient. The motivating scenario in the issue is using Copilot SDK agents as Microsoft.Extensions.AI.Evaluation judges, so exactly these agents need it most.

  2. For ChatClientAgent it returns the inner client (ChatClientAgent.cs:408), so agent instructions, agent-level tools, context providers and session handling are all skipped. It is a useful escape hatch, but it is a different operation - GetService unwraps the agent, while AsIChatClient() runs the whole agent behind the IChatClient interface, same way as AsAIFunction() does it for tools.

If you prefer to keep the surface constrained while the shape settles, I can add [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)], or narrow the extension to ChatClientAgent only - but the second option would not cover the Copilot SDK case. Shyam N (@shyamnamboodiripad) does your evaluation scenario need also non-ChatClientAgent agents? Whatever direction the area owners prefer, the change is small on my side.

…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.
@tomas-rampas

Copy link
Copy Markdown
Author

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;

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 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.

  1. 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.

  2. 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:

  1. Only support one or zero sessions at a time (like now).
    1. This would mean returning a conversation id if a session is used.
    2. This could be a fake const conversation id, or an id supplied by the dev who constructs the ChatClient.
    3. 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.
  2. Support multiple sessions
    1. We would need a session store (could default to InMemory).
    2. Hosting already has the session store we could move down the stack.
    3. 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.
    4. 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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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

Suggested change
Throw.IfNull(agent);
Throw.IfNull(agent);
Throw.IfNull(agent.GetService<ChatClientAgent>());
// OR
Throw.IfNull(agent.GetService<IChatClient>());

@shyamnamboodiripad

Copy link
Copy Markdown

If you prefer to keep the surface constrained while the shape settles, I can add [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)], or narrow the extension to ChatClientAgent only - but the second option would not cover the Copilot SDK case. Shyam N (@shyamnamboodiripad) does your evaluation scenario need also non-ChatClientAgent agents? Whatever direction the area owners prefer, the change is small on my side.

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 AIAgent - and especially Copilot SDK. So, from the point of view of the evaluation SDK, it would be great if the API were open ended and available uniformly on all underlying agent kinds to make it possible to use any agent instance that is already available to the caller in their evaluation environment.

One use case where this would be helpful is for the Microsoft.Extensions.AI.Evaluation framework which defines .NET abstractions for (AI) evaluation along with a set of in-built evaluators. These abstractions currently rely on IChatClient for all AI / LLM interactions. It would be great to have the ability to leverage agents (e.g., Copilot SDK) to perform these evaluations the same way one would otherwise use an LLM (with or without tools). (You can read more about the above framework and the functionality it supports in this blog post.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

.NET Usage: [Issues, PRs], Target: .Net

Projects

None yet

Development

Successfully merging this pull request may close these issues.

.NET: [Feature]: AIAgent.AsIChatClient

5 participants