.NET: Add AIContextProvider support (#691)

* Add AIContextProvider support

* Address feedback.

* Address PR comments.

* Switch to valuetask and remove parallel calls for AIContextProvider

* Remove Model from ModelInvokingAsync method name

* Remove agent thread id again and remove it from context provider interface

* Add AIContextProvider serialization support to AgentThread and update sample to show this feature

* Address PR comments

* Improve memory sample

* Update sample comment.

* Remove AggregateAIContextProvider for now since it makes too many assumptions.  We can include it later as a sample if needed.

* Update AIContextProviders to have an Invoked method instead of MessagesAddingAsync.

* Remove unused using.

* Address PR comments.

* Address PR comment.

* Update comment.

* Update comment

* Address PR comments.
This commit is contained in:
westey
2025-09-16 10:54:18 +01:00
committed by GitHub
Unverified
parent 89cb94b5c2
commit 66fe1c957c
25 changed files with 981 additions and 32 deletions
@@ -0,0 +1,37 @@
// Copyright (c) Microsoft. All rights reserved.
#pragma warning disable SA1623 // Property summary documentation should match accessors
namespace System.Runtime.CompilerServices;
/// <summary>
/// Indicates that compiler support for a particular feature is required for the location where this attribute is applied.
/// </summary>
[AttributeUsage(AttributeTargets.All, AllowMultiple = true, Inherited = false)]
internal sealed class CompilerFeatureRequiredAttribute : Attribute
{
public CompilerFeatureRequiredAttribute(string featureName)
{
FeatureName = featureName;
}
/// <summary>
/// The name of the compiler feature.
/// </summary>
public string FeatureName { get; }
/// <summary>
/// If true, the compiler can choose to allow access to the location where this attribute is applied if it does not understand <see cref="FeatureName"/>.
/// </summary>
public bool IsOptional { get; init; }
/// <summary>
/// The <see cref="FeatureName"/> used for the ref structs C# feature.
/// </summary>
public const string RefStructs = nameof(RefStructs);
/// <summary>
/// The <see cref="FeatureName"/> used for the required members C# feature.
/// </summary>
public const string RequiredMembers = nameof(RequiredMembers);
}
@@ -0,0 +1,9 @@
Enables use of C# required members on older frameworks.
To use this source in your project, add the following to your `.csproj` file:
```xml
<PropertyGroup>
<InjectCompilerFeatureRequiredOnLegacy>true</InjectCompilerFeatureRequiredOnLegacy>
</PropertyGroup>
```
@@ -0,0 +1,9 @@
Enables use of C# required members on older frameworks.
To use this source in your project, add the following to your `.csproj` file:
```xml
<PropertyGroup>
<InjectRequiredMemberOnLegacy>true</InjectRequiredMemberOnLegacy>
</PropertyGroup>
```
@@ -0,0 +1,10 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel;
namespace System.Runtime.CompilerServices;
/// <summary>Specifies that a type has required members or that a member is required.</summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false, Inherited = false)]
[EditorBrowsable(EditorBrowsableState.Never)]
internal sealed class RequiredMemberAttribute : Attribute;
@@ -290,6 +290,6 @@ public abstract class AIAgent
_ = Throw.IfNull(thread);
_ = Throw.IfNull(messages);
await thread.OnNewMessagesAsync(messages, cancellationToken).ConfigureAwait(false);
await thread.MessagesReceivedAsync(messages, cancellationToken).ConfigureAwait(false);
}
}
@@ -0,0 +1,43 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
namespace Microsoft.Extensions.AI.Agents;
/// <summary>
/// A class containing any context that should be provided to the AI model
/// as supplied by an <see cref="AIContextProvider"/>.
/// </summary>
/// <remarks>
/// Each <see cref="AIContextProvider"/> has the ability to provide its own context for each invocation.
/// The <see cref="AIContext"/> class contains the additional context supplied by the <see cref="AIContextProvider"/>.
/// This context will be combined with context supplied by other providers before being passed to the AI model.
/// </remarks>
public sealed class AIContext
{
/// <summary>
/// Gets or sets any instructions to pass to the AI model in addition to any other prompts
/// that it may already have (in the case of an agent), or chat history that may
/// already exist.
/// </summary>
/// <remarks>
/// These instructions will be transient and only apply to the current invocation.
/// </remarks>
public string? Instructions { get; set; }
/// <summary>
/// Gets or sets a list of messages to add to the chat history.
/// </summary>
/// <remarks>
/// These messages will permanently be added to the chat history.
/// </remarks>
public IList<ChatMessage>? Messages { get; set; }
/// <summary>
/// Gets or sets a list of functions/tools to make available to the AI model for the current invocation.
/// </summary>
/// <remarks>
/// These functions/tools will be transient and only apply to the current invocation.
/// </remarks>
public IList<AITool>? Tools { get; set; }
}
@@ -0,0 +1,118 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Extensions.AI.Agents;
/// <summary>
/// Base class for all AI context providers.
/// </summary>
/// <remarks>
/// An AI context provider is a component that can be used to enhance the AI's context management.
/// It can listen to changes in the conversation, provide additional context to
/// the Model/Agent/etc. just before invocation and supply additional function tools.
/// </remarks>
public abstract class AIContextProvider
{
/// <summary>
/// Called just before the Model/Agent/etc. is invoked
/// Implementers can load any additional context required at this time,
/// and they should return any context that should be passed to the Model/Agent/etc.
/// </summary>
/// <param name="context">Contains the event context.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that completes when the context has been rendered and returned.</returns>
public abstract ValueTask<AIContext> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default);
/// <summary>
/// Called just before the Model/Agent/etc. is invoked
/// Implementers can load any additional context required at this time,
/// and they should return any context that should be passed to the Model/Agent/etc.
/// </summary>
/// <param name="context">Contains the event context.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that completes when the context has been rendered and returned.</returns>
public virtual ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default)
{
return default;
}
/// <summary>
/// Serializes the current object's state to a <see cref="JsonElement"/> using the specified serialization options.
/// </summary>
/// <param name="jsonSerializerOptions">The JSON serialization options to use.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="JsonElement"/> representation of the object's state.</returns>
public virtual ValueTask<JsonElement?> SerializeAsync(JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
{
return default;
}
/// <summary>
/// Deserializes the state contained in the provided <see cref="JsonElement"/> into the properties on this object.
/// </summary>
/// <param name="serializedState">A <see cref="JsonElement"/> representing the state of the object.</param>
/// <param name="jsonSerializerOptions">Optional settings for customizing the JSON deserialization process.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ValueTask"/> that completes when the state has been deserialized.</returns>
public virtual ValueTask DeserializeAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
{
return default;
}
/// <summary>
/// Contains the event context provided to <see cref="AIContextProvider.InvokingAsync(InvokingContext, CancellationToken)"/>.
/// </summary>
public class InvokingContext
{
/// <summary>
/// Initializes a new instance of the <see cref="InvokingContext"/> class.
/// </summary>
/// <param name="requestMessages">The messages to be sent to the Model/Agent/etc. for this invocation.</param>
/// <exception cref="ArgumentNullException">Thrown if <paramref name="requestMessages"/> is <see langword="null"/>.</exception>
public InvokingContext(IEnumerable<ChatMessage> requestMessages)
{
RequestMessages = requestMessages ?? throw new ArgumentNullException(nameof(requestMessages));
}
/// <summary>
/// Gets the messages that will be sent to the Model/Agent/etc. for this invocation.
/// </summary>
public IEnumerable<ChatMessage> RequestMessages { get; private set; }
}
/// <summary>
/// Contains the event conext provided to <see cref="AIContextProvider.InvokedAsync(InvokedContext, CancellationToken)"/>.
/// </summary>
public class InvokedContext
{
/// <summary>
/// Initializes a new instance of the <see cref="InvokedContext"/> class with the specified request messages.
/// </summary>
/// <param name="requestMessages">The messages that were sent to the Model/Agent/etc. for this invocation.</param>
/// <exception cref="ArgumentNullException">Thrown if <paramref name="requestMessages"/> is <see langword="null"/>.</exception>
public InvokedContext(IEnumerable<ChatMessage> requestMessages)
{
RequestMessages = requestMessages ?? throw new ArgumentNullException(nameof(requestMessages));
}
/// <summary>
/// Gets the messages that were sent to the Model/Agent/etc. for this invocation.
/// </summary>
public IEnumerable<ChatMessage> RequestMessages { get; private set; }
/// <summary>
/// Gets the collection of response messages generated by Model/Agent/etc. if the invocation succeeded.
/// </summary>
public IEnumerable<ChatMessage>? ResponseMessages { get; init; }
/// <summary>
/// Gets the <see cref="Exception"/> that was thrown during the invocation, if the invocation failed.
/// </summary>
public Exception? InvokeException { get; init; }
}
}
@@ -27,7 +27,7 @@ public class AgentThread
}
/// <summary>
/// Gets or sets the id of the current thread to support cases where the thread is owned by the agent service.
/// Gets or sets the ID of the underlying service thread to support cases where the chat history is stored by the agent service.
/// </summary>
/// <remarks>
/// <para>
@@ -108,6 +108,11 @@ public class AgentThread
}
}
/// <summary>
/// Gets or sets the <see cref="AIContextProvider"/> used by this thread to provide additional context to the AI model before each invocation.
/// </summary>
public AIContextProvider? AIContextProvider { get; set; }
/// <summary>
/// Serializes the current object's state to a <see cref="JsonElement"/> using the specified serialization options.
/// </summary>
@@ -120,10 +125,15 @@ public class AgentThread
null :
await this._messageStore.SerializeStateAsync(jsonSerializerOptions, cancellationToken).ConfigureAwait(false);
var aiContextProviderState = this.AIContextProvider is null ?
null :
await this.AIContextProvider.SerializeAsync(jsonSerializerOptions, cancellationToken).ConfigureAwait(false);
var state = new ThreadState
{
ConversationId = this.ConversationId,
StoreState = storeState
StoreState = storeState,
AIContextProviderState = aiContextProviderState
};
return JsonSerializer.SerializeToElement(state, AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ThreadState)));
@@ -139,7 +149,7 @@ public class AgentThread
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that completes when the context has been updated.</returns>
/// <exception cref="InvalidOperationException">The thread has been deleted.</exception>
protected internal virtual async Task OnNewMessagesAsync(IEnumerable<ChatMessage> newMessages, CancellationToken cancellationToken = default)
protected internal virtual async Task MessagesReceivedAsync(IEnumerable<ChatMessage> newMessages, CancellationToken cancellationToken = default)
{
switch (this)
{
@@ -186,6 +196,11 @@ public class AgentThread
return;
}
if (state?.AIContextProviderState.HasValue is true && this.AIContextProvider is not null)
{
await this.AIContextProvider.DeserializeAsync(state.AIContextProviderState.Value, jsonSerializerOptions, cancellationToken).ConfigureAwait(false);
}
// If we don't have any IChatMessageStore state return here.
if (state?.StoreState is null || state?.StoreState.Value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
{
@@ -206,5 +221,7 @@ public class AgentThread
public string? ConversationId { get; set; }
public JsonElement? StoreState { get; set; }
public JsonElement? AIContextProviderState { get; set; }
}
}
@@ -12,6 +12,9 @@
<InjectSharedThrow>true</InjectSharedThrow>
<InjectDiagnosticClassesOnLegacy>true</InjectDiagnosticClassesOnLegacy>
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
<InjectRequiredMemberOnLegacy>true</InjectRequiredMemberOnLegacy>
<InjectCompilerFeatureRequiredOnLegacy>true</InjectCompilerFeatureRequiredOnLegacy>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
@@ -114,7 +114,17 @@ public sealed class ChatClientAgent : AIAgent
this._logger.LogAgentChatClientInvokingAgent(nameof(RunAsync), this.Id, agentName, this._chatClientType);
ChatResponse chatResponse = await this.ChatClient.GetResponseAsync(threadMessages, chatOptions, cancellationToken).ConfigureAwait(false);
// Call the IChatClient and notify the AIContextProvider of any failures.
ChatResponse chatResponse;
try
{
chatResponse = await this.ChatClient.GetResponseAsync(threadMessages, chatOptions, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, cancellationToken).ConfigureAwait(false);
throw;
}
this._logger.LogAgentChatClientInvokedAgent(nameof(RunAsync), this.Id, agentName, this._chatClientType, inputMessages.Count);
@@ -122,19 +132,17 @@ public sealed class ChatClientAgent : AIAgent
// so let's update it and set the conversation id for the service thread case.
this.UpdateThreadWithTypeAndConversationId(safeThread, chatResponse.ConversationId);
// Only notify the thread of new messages if the chatResponse was successful to avoid inconsistent messages state in the thread.
await NotifyThreadOfNewMessagesAsync(safeThread, inputMessages, cancellationToken).ConfigureAwait(false);
// Ensure that the author name is set for each message in the response.
foreach (ChatMessage chatResponseMessage in chatResponse.Messages)
{
chatResponseMessage.AuthorName ??= agentName;
}
// Convert the chat response messages to a valid IReadOnlyCollection for notification signatures below.
var chatResponseMessages = chatResponse.Messages as IReadOnlyCollection<ChatMessage> ?? [.. chatResponse.Messages];
// Only notify the thread of new messages if the chatResponse was successful to avoid inconsistent message state in the thread.
await NotifyThreadOfNewMessagesAsync(safeThread, inputMessages.Concat(chatResponse.Messages), cancellationToken).ConfigureAwait(false);
await NotifyThreadOfNewMessagesAsync(safeThread, chatResponseMessages, cancellationToken).ConfigureAwait(false);
// Notify the AIContextProvider of all new messages.
await NotifyAIContextProviderOfSuccessAsync(safeThread, inputMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false);
return new(chatResponse) { AgentId = this.Id };
}
@@ -156,15 +164,34 @@ public sealed class ChatClientAgent : AIAgent
this._logger.LogAgentChatClientInvokingAgent(nameof(RunStreamingAsync), this.Id, loggingAgentName, this._chatClientType);
// Using the enumerator to ensure we consider the case where no updates are returned for notification.
var responseUpdatesEnumerator = this.ChatClient.GetStreamingResponseAsync(threadMessages, chatOptions, cancellationToken).GetAsyncEnumerator(cancellationToken);
List<ChatResponseUpdate> responseUpdates = [];
IAsyncEnumerator<ChatResponseUpdate> responseUpdatesEnumerator;
try
{
// Using the enumerator to ensure we consider the case where no updates are returned for notification.
responseUpdatesEnumerator = this.ChatClient.GetStreamingResponseAsync(threadMessages, chatOptions, cancellationToken).GetAsyncEnumerator(cancellationToken);
}
catch (Exception ex)
{
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, cancellationToken).ConfigureAwait(false);
throw;
}
this._logger.LogAgentChatClientInvokedStreamingAgent(nameof(RunStreamingAsync), this.Id, loggingAgentName, this._chatClientType);
List<ChatResponseUpdate> responseUpdates = [];
// Ensure we start the streaming request
var hasUpdates = await responseUpdatesEnumerator.MoveNextAsync().ConfigureAwait(false);
bool hasUpdates;
try
{
// Ensure we start the streaming request
hasUpdates = await responseUpdatesEnumerator.MoveNextAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, cancellationToken).ConfigureAwait(false);
throw;
}
while (hasUpdates)
{
@@ -176,20 +203,28 @@ public sealed class ChatClientAgent : AIAgent
yield return new(update) { AgentId = this.Id };
}
hasUpdates = await responseUpdatesEnumerator.MoveNextAsync().ConfigureAwait(false);
try
{
hasUpdates = await responseUpdatesEnumerator.MoveNextAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, cancellationToken).ConfigureAwait(false);
throw;
}
}
var chatResponse = responseUpdates.ToChatResponse();
var chatResponseMessages = chatResponse.Messages as IReadOnlyCollection<ChatMessage> ?? [.. chatResponse.Messages];
// We can derive the type of supported thread from whether we have a conversation id,
// so let's update it and set the conversation id for the service thread case.
this.UpdateThreadWithTypeAndConversationId(safeThread, chatResponse.ConversationId);
// To avoid inconsistent state we only notify the thread of the input messages if no error occurs after the initial request.
await NotifyThreadOfNewMessagesAsync(safeThread, inputMessages, cancellationToken).ConfigureAwait(false);
await NotifyThreadOfNewMessagesAsync(safeThread, inputMessages.Concat(chatResponse.Messages), cancellationToken).ConfigureAwait(false);
await NotifyThreadOfNewMessagesAsync(safeThread, chatResponseMessages, cancellationToken).ConfigureAwait(false);
// Notify the AIContextProvider of all new messages.
await NotifyAIContextProviderOfSuccessAsync(safeThread, inputMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
@@ -204,12 +239,40 @@ public sealed class ChatClientAgent : AIAgent
/// <inheritdoc/>
public override AgentThread GetNewThread()
{
var thread = new AgentThread { MessageStore = this._agentOptions?.ChatMessageStoreFactory?.Invoke() };
var thread = new AgentThread
{
MessageStore = this._agentOptions?.ChatMessageStoreFactory?.Invoke(),
AIContextProvider = this._agentOptions?.AIContextProviderFactory?.Invoke()
};
return thread;
}
#region Private
/// <summary>
/// Notify the <see cref="AIContextProvider"/> when an agent run succeeded, if there is an <see cref="AIContextProvider"/>.
/// </summary>
private static async Task NotifyAIContextProviderOfSuccessAsync(AgentThread thread, IEnumerable<ChatMessage> inputMessages, IEnumerable<ChatMessage> responseMessages, CancellationToken cancellationToken)
{
if (thread.AIContextProvider is not null)
{
await thread.AIContextProvider.InvokedAsync(new(inputMessages) { ResponseMessages = responseMessages },
cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// Notify the <see cref="AIContextProvider"/> of any failure during an agent run, if there is an <see cref="AIContextProvider"/>.
/// </summary>
private static async Task NotifyAIContextProviderOfFailureAsync(AgentThread thread, Exception ex, IEnumerable<ChatMessage> inputMessages, CancellationToken cancellationToken)
{
if (thread.AIContextProvider is not null)
{
await thread.AIContextProvider.InvokedAsync(new(inputMessages) { InvokeException = ex },
cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// Configures and returns chat options by merging the provided run options with the agent's default chat options.
/// </summary>
@@ -350,6 +413,34 @@ public sealed class ChatClientAgent : AIAgent
threadMessages.AddRange(await thread.MessageStore.GetMessagesAsync(cancellationToken).ConfigureAwait(false));
}
// If we have an AIContextProvider, we should get context from it, and update our
// messages and options with the additional context.
if (thread.AIContextProvider is not null)
{
var invokingContext = new AIContextProvider.InvokingContext(inputMessages);
var aiContext = await thread.AIContextProvider.InvokingAsync(invokingContext, cancellationToken).ConfigureAwait(false);
if (aiContext.Messages is { Count: > 0 })
{
threadMessages.AddRange(aiContext.Messages);
}
if (aiContext.Tools is { Count: > 0 })
{
chatOptions ??= new();
chatOptions.Tools ??= [];
foreach (AITool tool in aiContext.Tools)
{
chatOptions.Tools.Add(tool);
}
}
if (aiContext.Instructions is not null)
{
chatOptions ??= new();
chatOptions.Instructions = string.IsNullOrWhiteSpace(chatOptions.Instructions) ? aiContext.Instructions : $"{chatOptions.Instructions}\n{aiContext.Instructions}";
}
}
// Add the input messages to the end of thread messages.
threadMessages.AddRange(inputMessages);
@@ -80,6 +80,13 @@ public class ChatClientAgentOptions
/// </summary>
public Func<IChatMessageStore>? ChatMessageStoreFactory { get; set; }
/// <summary>
/// Gets or sets a factory function to create an instance of <see cref="AIContextProvider"/>
/// which will be used to create a context provider for each new thread, and can then
/// provide additional context for each agent run.
/// </summary>
public Func<AIContextProvider>? AIContextProviderFactory { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to use the provided <see cref="IChatClient"/> instance as is,
/// without applying any default decorators.
@@ -105,6 +112,7 @@ public class ChatClientAgentOptions
Instructions = this.Instructions,
Description = this.Description,
ChatOptions = this.ChatOptions?.Clone(),
ChatMessageStoreFactory = this.ChatMessageStoreFactory
ChatMessageStoreFactory = this.ChatMessageStoreFactory,
AIContextProviderFactory = this.AIContextProviderFactory,
};
}