-
Notifications
You must be signed in to change notification settings - Fork 1.3k
.NET: [BREAKING] Add ChatClient decorator for calling AIContextProviders #4097
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
westey-m
merged 7 commits into
microsoft:main
from
westey-m:chatclient-aicontextprovider
Feb 23, 2026
Merged
Changes from 6 commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
c324338
Add ChatClient decorator for calling AIContextProviders
westey-m 197a867
Format new files
westey-m 8c0203b
Address PR comments
westey-m 45c8911
Merge branch 'main' into chatclient-aicontextprovider
westey-m 4028298
Revert problematic change
westey-m df5db76
Merge branch 'main' into chatclient-aicontextprovider
westey-m 9468623
Rename Use to UseAIContextProvider
westey-m File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
215 changes: 215 additions & 0 deletions
215
dotnet/src/Microsoft.Agents.AI/AIContextProviderDecorators/AIContextProviderChatClient.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,215 @@ | ||
| // Copyright (c) Microsoft. All rights reserved. | ||
|
|
||
| using System; | ||
| using System.Collections.Generic; | ||
| using System.Linq; | ||
| using System.Runtime.CompilerServices; | ||
| using System.Threading; | ||
| using System.Threading.Tasks; | ||
| using Microsoft.Extensions.AI; | ||
| using Microsoft.Shared.Diagnostics; | ||
|
|
||
| namespace Microsoft.Agents.AI; | ||
|
|
||
| /// <summary> | ||
| /// A delegating chat client that enriches input messages, tools, and instructions by invoking a pipeline of | ||
| /// <see cref="AIContextProvider"/> instances before delegating to the inner chat client, and notifies those | ||
| /// providers after the inner client completes. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// <para> | ||
| /// This chat client must be used within the context of a running <see cref="AIAgent"/>. It retrieves the current | ||
| /// agent and session from <see cref="AIAgent.CurrentRunContext"/>, which is set automatically when an agent's | ||
| /// <see cref="AIAgent.RunAsync(IEnumerable{ChatMessage}, AgentSession?, AgentRunOptions?, CancellationToken)"/> or | ||
| /// <see cref="AIAgent.RunStreamingAsync(IEnumerable{ChatMessage}, AgentSession?, AgentRunOptions?, CancellationToken)"/> method is called. | ||
| /// An <see cref="InvalidOperationException"/> is thrown if no run context is available. | ||
| /// </para> | ||
| /// </remarks> | ||
| internal sealed class AIContextProviderChatClient : DelegatingChatClient | ||
| { | ||
| private readonly IReadOnlyList<AIContextProvider> _providers; | ||
|
|
||
| /// <summary> | ||
| /// Initializes a new instance of the <see cref="AIContextProviderChatClient"/> class. | ||
| /// </summary> | ||
| /// <param name="innerClient">The underlying chat client that will handle the core operations.</param> | ||
| /// <param name="providers">The AI context providers to invoke before and after the inner chat client.</param> | ||
| public AIContextProviderChatClient(IChatClient innerClient, IReadOnlyList<AIContextProvider> providers) | ||
| : base(innerClient) | ||
| { | ||
| Throw.IfNull(providers); | ||
|
|
||
| if (providers.Count == 0) | ||
| { | ||
| Throw.ArgumentException(nameof(providers), "At least one AIContextProvider must be provided."); | ||
| } | ||
|
|
||
| this._providers = providers; | ||
| } | ||
|
|
||
| /// <inheritdoc/> | ||
| public override async Task<ChatResponse> GetResponseAsync( | ||
| IEnumerable<ChatMessage> messages, | ||
| ChatOptions? options = null, | ||
| CancellationToken cancellationToken = default) | ||
| { | ||
| var runContext = GetRequiredRunContext(); | ||
| var (enrichedMessages, enrichedOptions) = await this.InvokeProvidersAsync(runContext, messages, options, cancellationToken).ConfigureAwait(false); | ||
|
|
||
| ChatResponse response; | ||
| try | ||
| { | ||
| response = await base.GetResponseAsync(enrichedMessages, enrichedOptions, cancellationToken).ConfigureAwait(false); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| await this.NotifyProvidersOfFailureAsync(runContext, enrichedMessages, ex, cancellationToken).ConfigureAwait(false); | ||
| throw; | ||
| } | ||
|
|
||
| await this.NotifyProvidersOfSuccessAsync(runContext, enrichedMessages, response.Messages, cancellationToken).ConfigureAwait(false); | ||
|
|
||
| return response; | ||
| } | ||
|
|
||
| /// <inheritdoc/> | ||
| public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync( | ||
| IEnumerable<ChatMessage> messages, | ||
| ChatOptions? options = null, | ||
| [EnumeratorCancellation] CancellationToken cancellationToken = default) | ||
| { | ||
| var runContext = GetRequiredRunContext(); | ||
| var (enrichedMessages, enrichedOptions) = await this.InvokeProvidersAsync(runContext, messages, options, cancellationToken).ConfigureAwait(false); | ||
|
|
||
| List<ChatResponseUpdate> responseUpdates = []; | ||
|
|
||
| IAsyncEnumerator<ChatResponseUpdate> enumerator; | ||
| try | ||
| { | ||
| enumerator = base.GetStreamingResponseAsync(enrichedMessages, enrichedOptions, cancellationToken).GetAsyncEnumerator(cancellationToken); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| await this.NotifyProvidersOfFailureAsync(runContext, enrichedMessages, ex, cancellationToken).ConfigureAwait(false); | ||
| throw; | ||
| } | ||
|
|
||
| bool hasUpdates; | ||
| try | ||
| { | ||
| hasUpdates = await enumerator.MoveNextAsync().ConfigureAwait(false); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| await this.NotifyProvidersOfFailureAsync(runContext, enrichedMessages, ex, cancellationToken).ConfigureAwait(false); | ||
| throw; | ||
| } | ||
|
|
||
| while (hasUpdates) | ||
| { | ||
| var update = enumerator.Current; | ||
| responseUpdates.Add(update); | ||
| yield return update; | ||
|
|
||
| try | ||
| { | ||
| hasUpdates = await enumerator.MoveNextAsync().ConfigureAwait(false); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| await this.NotifyProvidersOfFailureAsync(runContext, enrichedMessages, ex, cancellationToken).ConfigureAwait(false); | ||
| throw; | ||
| } | ||
| } | ||
|
|
||
| var chatResponse = responseUpdates.ToChatResponse(); | ||
| await this.NotifyProvidersOfSuccessAsync(runContext, enrichedMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Gets the current <see cref="AgentRunContext"/>, throwing if not available. | ||
| /// </summary> | ||
| private static AgentRunContext GetRequiredRunContext() | ||
| { | ||
| return AIAgent.CurrentRunContext | ||
| ?? throw new InvalidOperationException( | ||
| $"{nameof(AIContextProviderChatClient)} can only be used within the context of a running AIAgent. " + | ||
| "Ensure that the chat client is being invoked as part of an AIAgent.RunAsync or AIAgent.RunStreamingAsync call."); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Invokes each provider's <see cref="AIContextProvider.InvokingAsync"/> in sequence, | ||
| /// accumulating context (messages, tools, instructions) from each. | ||
| /// </summary> | ||
| private async Task<(IEnumerable<ChatMessage> Messages, ChatOptions? Options)> InvokeProvidersAsync( | ||
| AgentRunContext runContext, | ||
| IEnumerable<ChatMessage> messages, | ||
| ChatOptions? options, | ||
| CancellationToken cancellationToken) | ||
| { | ||
| var aiContext = new AIContext | ||
| { | ||
| Instructions = options?.Instructions, | ||
| Messages = messages, | ||
| Tools = options?.Tools | ||
| }; | ||
|
|
||
| foreach (var provider in this._providers) | ||
| { | ||
| var invokingContext = new AIContextProvider.InvokingContext(runContext.Agent, runContext.Session, aiContext); | ||
| aiContext = await provider.InvokingAsync(invokingContext, cancellationToken).ConfigureAwait(false); | ||
| } | ||
|
|
||
| // Materialize the accumulated context back into messages and options. | ||
| var enrichedMessages = aiContext.Messages ?? []; | ||
|
|
||
| var tools = aiContext.Tools as IList<AITool> ?? aiContext.Tools?.ToList(); | ||
| if (options?.Tools is { Count: > 0 } || tools is { Count: > 0 }) | ||
| { | ||
| options ??= new(); | ||
| options.Tools = tools; | ||
| } | ||
|
|
||
| if (options?.Instructions is not null || aiContext.Instructions is not null) | ||
| { | ||
| options ??= new(); | ||
| options.Instructions = aiContext.Instructions; | ||
| } | ||
|
|
||
| return (enrichedMessages, options); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Notifies each provider of a successful invocation. | ||
| /// </summary> | ||
| private async Task NotifyProvidersOfSuccessAsync( | ||
| AgentRunContext runContext, | ||
| IEnumerable<ChatMessage> requestMessages, | ||
| IEnumerable<ChatMessage> responseMessages, | ||
| CancellationToken cancellationToken) | ||
| { | ||
| var invokedContext = new AIContextProvider.InvokedContext(runContext.Agent, runContext.Session, requestMessages, responseMessages); | ||
|
|
||
| foreach (var provider in this._providers) | ||
| { | ||
| await provider.InvokedAsync(invokedContext, cancellationToken).ConfigureAwait(false); | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Notifies each provider of a failed invocation. | ||
| /// </summary> | ||
| private async Task NotifyProvidersOfFailureAsync( | ||
| AgentRunContext runContext, | ||
| IEnumerable<ChatMessage> requestMessages, | ||
| Exception exception, | ||
| CancellationToken cancellationToken) | ||
| { | ||
| var invokedContext = new AIContextProvider.InvokedContext(runContext.Agent, runContext.Session, requestMessages, exception); | ||
|
|
||
| foreach (var provider in this._providers) | ||
| { | ||
| await provider.InvokedAsync(invokedContext, cancellationToken).ConfigureAwait(false); | ||
| } | ||
| } | ||
| } | ||
43 changes: 43 additions & 0 deletions
43
...oft.Agents.AI/AIContextProviderDecorators/AIContextProviderChatClientBuilderExtensions.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| // Copyright (c) Microsoft. All rights reserved. | ||
|
|
||
| using Microsoft.Agents.AI; | ||
| using Microsoft.Shared.Diagnostics; | ||
|
|
||
| namespace Microsoft.Extensions.AI; | ||
|
|
||
| /// <summary> | ||
| /// Provides extension methods for adding <see cref="AIContextProvider"/> support to <see cref="ChatClientBuilder"/> instances. | ||
| /// </summary> | ||
| public static class AIContextProviderChatClientBuilderExtensions | ||
| { | ||
| /// <summary> | ||
| /// Adds one or more <see cref="AIContextProvider"/> instances to the chat client pipeline, enabling context enrichment | ||
| /// (messages, tools, and instructions) for any <see cref="IChatClient"/>. | ||
| /// </summary> | ||
| /// <param name="builder">The <see cref="ChatClientBuilder"/> to which the providers will be added.</param> | ||
| /// <param name="providers"> | ||
| /// The <see cref="AIContextProvider"/> instances to invoke before and after each chat client call. | ||
| /// Providers are called in sequence, with each receiving the accumulated context from the previous provider. | ||
| /// </param> | ||
| /// <returns>The <see cref="ChatClientBuilder"/> with the providers added, enabling method chaining.</returns> | ||
| /// <exception cref="System.ArgumentNullException"><paramref name="builder"/> or <paramref name="providers"/> is <see langword="null"/>.</exception> | ||
| /// <exception cref="System.ArgumentException"><paramref name="providers"/> is empty.</exception> | ||
| /// <remarks> | ||
| /// <para> | ||
| /// This method wraps the inner chat client with a decorator that calls each provider's | ||
| /// <see cref="AIContextProvider.InvokingAsync"/> in sequence before the inner client is called, | ||
| /// and calls <see cref="AIContextProvider.InvokedAsync"/> on each provider after the inner client completes. | ||
| /// </para> | ||
| /// <para> | ||
| /// The chat client must be used within the context of a running <see cref="AIAgent"/>. The agent and session | ||
| /// are retrieved from <see cref="AIAgent.CurrentRunContext"/>. An <see cref="System.InvalidOperationException"/> | ||
| /// is thrown at invocation time if no run context is available. | ||
| /// </para> | ||
| /// </remarks> | ||
| public static ChatClientBuilder Use(this ChatClientBuilder builder, params AIContextProvider[] providers) | ||
westey-m marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| { | ||
| _ = Throw.IfNull(builder); | ||
|
|
||
| return builder.Use(innerClient => new AIContextProviderChatClient(innerClient, providers)); | ||
| } | ||
| } | ||
File renamed without changes.
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.