Skip to content

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

Merged
westey (westey-m) merged 12 commits into
microsoft:mainfrom
tomas-rampas:issue-3496-asichatclient
Sep 16, 2026
Merged

westey (westey-m) merged 12 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
Contributor

Motivation & Context

More and more .NET APIs accept Microsoft.Extensions.AI.IChatClient. This change lets an 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, string? conversationId = null, bool allowNonChatClientAgents = false) in Microsoft.Agents.AI, marked [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] per review — mirrors the sibling AsAIFunction(..., AgentSession?) and the AsIChatClient naming already used by the provider-client adapters in this repo. The optional conversationId sets the id a session-bound client reports (validated non-blank, non-reserved); otherwise a per-instance id is generated.
    • Guard on by default (per Roger Barreto (@rogerbarreto)'s review): the method throws InvalidOperationException unless the agent is a ChatClientAgent or exposes one through an unkeyed GetService<ChatClientAgent>() request, which is exactly the "honors ChatClientAgentRunOptions" question the adapter depends on. Decorators built on DelegatingAIAgent forward the request, so they pass. allowNonChatClientAgents: true wraps any other agent (A2A, Copilot Studio, GitHub Copilot, workflow-hosted, custom) with the limitations spelled out in the parameter doc: only ChatOptions.ResponseFormat survives, the rest of ChatOptions is ignored, and the client is only as faithful as the agent's own RunAsync. The Purview middleware agent does not forward GetService, so it needs the opt-in for now.
    • 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 in stateless mode when the raw response carries no conversation id, so usage and identity 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.
      • Session-bound mode reports one stable conversation id (shape agreed in the review threads): every response and streamed update carries the configured id (or the generated per-instance one), stamped on clones — raw objects are never mutated; a stream that yields no updates still reports the id on a trailing update. Echoing the reported id back behaves as if no id was sent; any other non-blank id throws as not known. Service-side conversation ids stay internal to the session — a caller that needs to address a specific service conversation binds a session obtained from ChatClientAgent.CreateSessionAsync(conversationId).
      • Stateless mode reports no conversation id and accepts none (per westey (@westey-m)'s review): a non-blank ChatOptions.ConversationId throws InvalidOperationException, 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 because ChatClientAgent (via AsAIAgent()), FunctionInvokingChatClient and MessageInjectingChatClient all send a reported id back on the next call.
      • ChatOptions are carried through ChatClientAgentRunOptions (honored by ChatClientAgent, 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.
    • 79 unit test cases (tests/Microsoft.Agents.AI.UnitTests/AIAgentChatClientTests.cs, run on net10.0 and net472), including ChatClientAgent end-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 the AsIChatClient().AsAIAgent() two-turn round trip, M.E.AI structured-output through the adapter, GetService precedence pinned against a real ChatClientAgent, cancellation propagation on both paths, and a reflection guard that fails the build if a future M.E.AI ChatResponse member 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 five PublicAPI.Unshipped.txt baselines, 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 the IChatClient contract, with documented caveats (one in-flight request at a time, don't share across users).

  • What do you want reviewers to focus on?

    1. Should this API carry [Experimental]? Resolved — marked [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] per westey (@westey-m)'s review.
    2. Guard for non-chat-client agents? Resolved — on by default with allowNonChatClientAgents: true opt-out per Roger Barreto (@rogerbarreto)'s review; the probe is GetService<ChatClientAgent>().
    3. 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.
    4. 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); a small issue for PurviewAgent not forwarding GetService.

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
Contributor 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
Contributor 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.
…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.
@tomas-rampas Tomas Rampas (tomas-rampas) changed the title .NET: Add AsIChatClient extension to expose any AIAgent as an IChatClient .NET: Add AsIChatClient extension to expose an AIAgent as an IChatClient Sep 13, 2026
@tomas-rampas

Copy link
Copy Markdown
Contributor Author

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.

@westey-m
westey (westey-m) added this pull request to the merge queue Sep 16, 2026
@westey-m

Copy link
Copy Markdown
Contributor

Thanks for the contribution Tomas Rampas (@tomas-rampas). This looks good!

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