Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions dotnet/src/Microsoft.Agents.AI/AgentExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,81 @@ async Task<string> InvokeAgentAsync(
return AIFunctionFactory.Create(InvokeAgentAsync, options);
}

/// <summary>
/// Creates an <see cref="IChatClient"/> that delegates its operations to the provided <see cref="AIAgent"/>.
/// </summary>
/// <param name="agent">The <see cref="AIAgent"/> to be represented as an <see cref="IChatClient"/>.</param>
/// <param name="session">
/// Optional <see cref="AgentSession"/> to use for every request made through the returned client. If not provided,
/// each request is made without a session and the caller is responsible for supplying the conversation history.
/// </param>
/// <returns>
/// An <see cref="IChatClient"/> that can be used anywhere the <see cref="IChatClient"/> abstraction is consumed,
/// such as in a <see cref="ChatClientBuilder"/> pipeline.
/// </returns>
/// <exception cref="ArgumentNullException"><paramref name="agent"/> is <see langword="null"/>.</exception>
/// <remarks>
/// <para>
/// By default the returned client is stateless: no <see cref="AgentSession"/> is used, so every call must supply the
/// full conversation history, just as when calling an <see cref="IChatClient"/> directly.
/// </para>
/// <para>
/// If a <paramref name="session"/> is provided, the returned client is stateful, referencing both the
/// <paramref name="agent"/> and the <paramref name="session"/>. Avoid using such a client concurrently in multiple
/// conversations or in requests where parallel calls may result in concurrent usage of the session, as that could
/// lead to undefined and unpredictable behavior. In particular, do not register a session-bound client as a shared
/// or singleton service that serves multiple users: concurrent use is unsupported, and every caller appends to and
/// reads from the same conversation, so history bleeds across them. Because a bound session already accumulates the
/// conversation history, callers should send only the new messages on each call rather than the full history, which
/// would otherwise be duplicated.
/// </para>
/// <para>
/// Any <see cref="ChatOptions"/> supplied to the returned client are passed to the agent as
/// <see cref="ChatClientAgentRunOptions"/>. Agents that understand that type, such as <see cref="ChatClientAgent"/>,
/// honor those options; other agent implementations may ignore them. The exception is
/// <see cref="ChatOptions.ResponseFormat"/>, which is additionally copied to <see cref="AgentRunOptions.ResponseFormat"/>
/// and so may be honored by any agent implementation.
/// </para>
/// <para>
/// For agents that honor <see cref="ChatClientAgentRunOptions"/>, this means a caller supplying
/// <see cref="ChatOptions"/> can add tools to those configured on the agent and append to its instructions; the
/// collections are unioned and the instructions concatenated rather than replaced. When requests originate from an
/// untrusted caller, do not pass caller-supplied <see cref="ChatOptions"/> through unfiltered. Follow the
/// default-closed pattern used by the <c>Microsoft.Agents.AI.Hosting.OpenAI</c> package, whose
/// <c>RunOptionsFactory</c> defaults to <c>RejectRequestSettings</c> and rejects caller-supplied settings unless the
/// host explicitly maps the ones it chooses to honor.
/// </para>
/// <para>
/// Background and continuation responses are not supported through the returned client. A continuation token
/// obtained from a <see cref="ChatResponse"/> is the underlying service's raw token rather than the agent's wrapped
/// token, so it does not round-trip: passing it back via <see cref="ChatOptions.ContinuationToken"/> causes
/// <see cref="ChatClientAgent"/> to reject it during token validation.
/// </para>
/// <para>
/// Some option combinations cause the underlying agent to throw an <see cref="InvalidOperationException"/> at run
/// time. With <see cref="ChatClientAgent"/>, requesting <see cref="ChatOptions.AllowBackgroundResponses"/> without a
/// bound <paramref name="session"/> throws, as does supplying a <see cref="ChatOptions.ConversationId"/> that
/// differs from the conversation id already held by a bound <paramref name="session"/>.
/// </para>
/// <para>
/// Calling this method on a <see cref="ChatClientAgent"/> returns an adapter over the full agent pipeline, including
/// its instructions, tools, chat history management, and any middleware. An unkeyed
/// <see cref="IChatClient.GetService"/> request for <see cref="IChatClient"/> returns the adapter itself, not the
/// agent's inner client. Keyed requests, and requests for other service types, are forwarded to
/// <see cref="AIAgent.GetService(Type, object?)"/> and may therefore return the inner client.
/// </para>
/// <para>
/// The returned client does not own the lifetime of the <paramref name="agent"/> or the <paramref name="session"/>;
/// disposing it does not dispose either of them.
/// </para>
/// </remarks>
public static IChatClient AsIChatClient(this AIAgent agent, AgentSession? session = null)
{
Throw.IfNull(agent);

return new AIAgentChatClient(agent, session);
}

/// <summary>
/// Removes characters from AI agent name that shouldn't be used in an AI function name.
/// </summary>
Expand Down
165 changes: 165 additions & 0 deletions dotnet/src/Microsoft.Agents.AI/ChatClient/AIAgentChatClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;

namespace Microsoft.Agents.AI;

/// <summary>
/// Provides an <see cref="IChatClient"/> implementation that delegates all of its operations to an <see cref="AIAgent"/>.
/// </summary>
/// <remarks>
/// <para>
/// This adapter is the inverse of <see cref="ChatClientAgent"/>: rather than building an agent on top of a chat client,
/// it exposes an existing agent to any component that consumes the <see cref="IChatClient"/> abstraction, such as
/// <see cref="ChatClientBuilder"/> pipelines or <see cref="ChatClientExtensions"/> helpers.
/// </para>
/// <para>
/// The adapter does not own the lifetime of the wrapped agent or session, so <see cref="Dispose"/> is a no-op.
/// </para>
/// </remarks>
internal sealed class AIAgentChatClient : IChatClient
{
/// <summary>The agent to which all operations are delegated.</summary>
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;

/// <summary>Lazily-created metadata synthesized from the agent's <see cref="AIAgentMetadata"/>.</summary>
private ChatClientMetadata? _metadata;

/// <summary>
/// Initializes a new instance of the <see cref="AIAgentChatClient"/> class.
/// </summary>
/// <param name="agent">The agent to which all operations are delegated. Must not be <see langword="null"/>.</param>
/// <param name="session">
/// The optional <see cref="AgentSession"/> to use for every request. If <see langword="null"/>, each request is
/// made without a session, and the caller is responsible for supplying the full conversation history.
/// </param>
/// <remarks>
/// <paramref name="agent"/> is validated by <see cref="AIAgentExtensions.AsIChatClient"/>, the only entry point
/// through which this internal type is constructed.
/// </remarks>
public AIAgentChatClient(AIAgent agent, AgentSession? session)
{
this._agent = agent;
this._session = session;
}

/// <inheritdoc/>
public async Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(messages);

var response = await this._agent.RunAsync(messages, this._session, ToAgentRunOptions(options), cancellationToken).ConfigureAwait(false);

return response.AsChatResponse();
}

/// <inheritdoc/>
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
// This method is deliberately not an iterator so that argument validation happens
// when the method is called rather than when the resulting sequence is enumerated.
_ = Throw.IfNull(messages);

return this.GetStreamingResponseCoreAsync(messages, options, cancellationToken);
}

/// <summary>
/// Streams the agent's response, converting each <see cref="AgentResponseUpdate"/> to a <see cref="ChatResponseUpdate"/>.
/// </summary>
/// <param name="messages">The messages to send to the agent.</param>
/// <param name="options">The chat options to apply to the run, if any.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>An asynchronous sequence of <see cref="ChatResponseUpdate"/> instances.</returns>
/// <remarks>
/// <paramref name="cancellationToken"/> is annotated with <see cref="EnumeratorCancellationAttribute"/> so that a
/// token supplied by the consumer at enumeration time, via <c>WithCancellation</c>, also reaches the agent. Without
/// the annotation such a token would be silently dropped.
/// </remarks>
private async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseCoreAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
var updates = this._agent.RunStreamingAsync(messages, this._session, ToAgentRunOptions(options), cancellationToken);

await foreach (var update in updates.ConfigureAwait(false))
{
yield return update.AsChatResponseUpdate();
}
}

/// <inheritdoc/>
public object? GetService(Type serviceType, object? serviceKey = null)
{
_ = Throw.IfNull(serviceType);

if (serviceKey is null && serviceType.IsInstanceOfType(this))
{
return this;
}

if (this._agent.GetService(serviceType, serviceKey) is { } service)
{
return service;
}

if (serviceKey is null && serviceType == typeof(ChatClientMetadata))
{
// A race here is benign: concurrent callers may each build an equivalent instance, and the
// reference assignment is atomic, so every caller still observes a fully-constructed object.
return this._metadata ??= new ChatClientMetadata(this._agent.GetService<AIAgentMetadata>()?.ProviderName);
}

return null;
}

/// <inheritdoc/>
/// <remarks>
/// This adapter does not own the lifetime of the underlying <see cref="AIAgent"/> or <see cref="AgentSession"/>,
/// so disposing it has no effect and is safe to perform any number of times.
/// </remarks>
public void Dispose()
{
// Intentionally a no-op: the adapter does not own the agent or session it wraps.
}

/// <summary>
/// Converts <see cref="ChatOptions"/> into the agent run options understood by agents that support chat options.
/// </summary>
/// <param name="options">The chat options to convert, or <see langword="null"/> if none were supplied.</param>
/// <returns>
/// A <see cref="ChatClientAgentRunOptions"/> carrying <paramref name="options"/>, or <see langword="null"/> if
/// <paramref name="options"/> is <see langword="null"/>.
/// </returns>
/// <remarks>
/// <para>
/// <see cref="ChatOptions.ResponseFormat"/> is additionally surfaced on the base <see cref="AgentRunOptions"/>
/// so that agents which do not understand <see cref="ChatClientAgentRunOptions"/> can still honor it.
/// </para>
/// <para>
/// It is deliberately the only option copied to the base type. <see cref="AgentRunOptions.ResponseFormat"/> is the
/// single member whose <see cref="ChatOptions"/> counterpart any agent implementation can meaningfully act on.
/// <see cref="AgentRunOptions.AllowBackgroundResponses"/> and <see cref="AgentRunOptions.AdditionalProperties"/>
/// are not mapped: background responses require a session and continuation tokens that do not round-trip through
/// the <see cref="IChatClient"/> abstraction, and additional properties carry agent-specific semantics that a
/// caller supplying <see cref="ChatOptions"/> is not expressing.
/// </para>
/// </remarks>
private static ChatClientAgentRunOptions? ToAgentRunOptions(ChatOptions? options) =>
options is null ? null : new ChatClientAgentRunOptions(options) { ResponseFormat = options.ResponseFormat };
}
Loading
Loading