Skip to content

Commit 3ef67ef

Browse files
westey-mCopilotSergeyMenshykh
authored
.NET: [BREAKING] Refactor ChatMessageStore methods to be similar to AIContextProvider and add filtering support (#2604)
* Refactor ChatMessageStore methods to be similar to AIContextProvider * Fix file encoding * Ensure that AIContextProvider messages area also persisted. * Update formatting and seal context classes * Improve formatting * Remove optional messages from constructor and add unit test * Add ChatMessageStore filtering via a decorator * Update sample and cosmos message store to store AIContextProvider messages in right order. Fix unit tests. * Update Workflowmessage store to use aicontext provider messages. * Apply suggestions from code review Co-authored-by: Copilot <[email protected]> * Apply suggestions from code review Co-authored-by: SergeyMenshykh <[email protected]> * Improve xml docs messaging * Address code review comments. * Also notify message store on failure --------- Co-authored-by: Copilot <[email protected]> Co-authored-by: SergeyMenshykh <[email protected]>
1 parent deea844 commit 3ef67ef

File tree

21 files changed

+838
-139
lines changed

21 files changed

+838
-139
lines changed

dotnet/samples/GettingStarted/AgentProviders/Agent_With_CustomImplementation/Program.cs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,19 @@ protected override async Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMes
4444
throw new ArgumentException($"The provided thread is not of type {nameof(CustomAgentThread)}.", nameof(thread));
4545
}
4646

47+
// Get existing messages from the store
48+
var invokingContext = new ChatMessageStore.InvokingContext(messages);
49+
var storeMessages = await typedThread.MessageStore.InvokingAsync(invokingContext, cancellationToken);
50+
4751
// Clone the input messages and turn them into response messages with upper case text.
4852
List<ChatMessage> responseMessages = CloneAndToUpperCase(messages, this.Name).ToList();
4953

5054
// Notify the thread of the input and output messages.
51-
await typedThread.MessageStore.AddMessagesAsync(messages.Concat(responseMessages), cancellationToken);
55+
var invokedContext = new ChatMessageStore.InvokedContext(messages, storeMessages)
56+
{
57+
ResponseMessages = responseMessages
58+
};
59+
await typedThread.MessageStore.InvokedAsync(invokedContext, cancellationToken);
5260

5361
return new AgentRunResponse
5462
{
@@ -68,11 +76,19 @@ protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreami
6876
throw new ArgumentException($"The provided thread is not of type {nameof(CustomAgentThread)}.", nameof(thread));
6977
}
7078

79+
// Get existing messages from the store
80+
var invokingContext = new ChatMessageStore.InvokingContext(messages);
81+
var storeMessages = await typedThread.MessageStore.InvokingAsync(invokingContext, cancellationToken);
82+
7183
// Clone the input messages and turn them into response messages with upper case text.
7284
List<ChatMessage> responseMessages = CloneAndToUpperCase(messages, this.Name).ToList();
7385

7486
// Notify the thread of the input and output messages.
75-
await typedThread.MessageStore.AddMessagesAsync(messages.Concat(responseMessages), cancellationToken);
87+
var invokedContext = new ChatMessageStore.InvokedContext(messages, storeMessages)
88+
{
89+
ResponseMessages = responseMessages
90+
};
91+
await typedThread.MessageStore.InvokedAsync(invokedContext, cancellationToken);
7692

7793
foreach (var message in responseMessages)
7894
{

dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/Program.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,12 @@
6262
.CreateAIAgent(new ChatClientAgentOptions
6363
{
6464
ChatOptions = new() { Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available." },
65-
AIContextProviderFactory = ctx => new TextSearchProvider(SearchAdapter, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
65+
AIContextProviderFactory = ctx => new TextSearchProvider(SearchAdapter, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions),
66+
// Since we are using ChatCompletion which stores chat history locally, we can also add a message removal policy
67+
// that removes messages produced by the TextSearchProvider before they are added to the chat history, so that
68+
// we don't bloat chat history with all the search result messages.
69+
ChatMessageStoreFactory = ctx => new InMemoryChatMessageStore(ctx.SerializedState, ctx.JsonSerializerOptions)
70+
.WithAIContextProviderMessageRemoval(),
6671
});
6772

6873
AgentThread thread = agent.GetNewThread();

dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Program.cs

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -89,24 +89,7 @@ public VectorChatMessageStore(VectorStore vectorStore, JsonElement serializedSto
8989

9090
public string? ThreadDbKey { get; private set; }
9191

92-
public override async Task AddMessagesAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
93-
{
94-
this.ThreadDbKey ??= Guid.NewGuid().ToString("N");
95-
96-
var collection = this._vectorStore.GetCollection<string, ChatHistoryItem>("ChatHistory");
97-
await collection.EnsureCollectionExistsAsync(cancellationToken);
98-
99-
await collection.UpsertAsync(messages.Select(x => new ChatHistoryItem()
100-
{
101-
Key = this.ThreadDbKey + x.MessageId,
102-
Timestamp = DateTimeOffset.UtcNow,
103-
ThreadId = this.ThreadDbKey,
104-
SerializedMessage = JsonSerializer.Serialize(x),
105-
MessageText = x.Text
106-
}), cancellationToken);
107-
}
108-
109-
public override async Task<IEnumerable<ChatMessage>> GetMessagesAsync(CancellationToken cancellationToken = default)
92+
public override async ValueTask<IEnumerable<ChatMessage>> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
11093
{
11194
var collection = this._vectorStore.GetCollection<string, ChatHistoryItem>("ChatHistory");
11295
await collection.EnsureCollectionExistsAsync(cancellationToken);
@@ -124,6 +107,33 @@ public override async Task<IEnumerable<ChatMessage>> GetMessagesAsync(Cancellati
124107
return messages;
125108
}
126109

110+
public override async ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default)
111+
{
112+
// Don't store messages if the request failed.
113+
if (context.InvokeException is not null)
114+
{
115+
return;
116+
}
117+
118+
this.ThreadDbKey ??= Guid.NewGuid().ToString("N");
119+
120+
var collection = this._vectorStore.GetCollection<string, ChatHistoryItem>("ChatHistory");
121+
await collection.EnsureCollectionExistsAsync(cancellationToken);
122+
123+
// Add both request and response messages to the store
124+
// Optionally messages produced by the AIContextProvider can also be persisted (not shown).
125+
var allNewMessages = context.RequestMessages.Concat(context.AIContextProviderMessages ?? []).Concat(context.ResponseMessages ?? []);
126+
127+
await collection.UpsertAsync(allNewMessages.Select(x => new ChatHistoryItem()
128+
{
129+
Key = this.ThreadDbKey + x.MessageId,
130+
Timestamp = DateTimeOffset.UtcNow,
131+
ThreadId = this.ThreadDbKey,
132+
SerializedMessage = JsonSerializer.Serialize(x),
133+
MessageText = x.Text
134+
}), cancellationToken);
135+
}
136+
127137
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null) =>
128138
// We have to serialize the thread id, so that on deserialization we can retrieve the messages using the same thread id.
129139
JsonSerializer.SerializeToElement(this.ThreadDbKey);

dotnet/src/Microsoft.Agents.AI.Abstractions/ChatMessageStore.cs

Lines changed: 107 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,9 @@ namespace Microsoft.Agents.AI;
3232
public abstract class ChatMessageStore
3333
{
3434
/// <summary>
35-
/// Asynchronously retrieves all messages from the store that should be provided as context for the next agent invocation.
35+
/// Called at the start of agent invocation to retrieve all messages from the store that should be provided as context for the next agent invocation.
3636
/// </summary>
37+
/// <param name="context">Contains the request context including the caller provided messages that will be used by the agent for this invocation.</param>
3738
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
3839
/// <returns>
3940
/// A task that represents the asynchronous operation. The task result contains a collection of <see cref="ChatMessage"/>
@@ -59,20 +60,19 @@ public abstract class ChatMessageStore
5960
/// and context management.
6061
/// </para>
6162
/// </remarks>
62-
public abstract Task<IEnumerable<ChatMessage>> GetMessagesAsync(CancellationToken cancellationToken = default);
63+
public abstract ValueTask<IEnumerable<ChatMessage>> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default);
6364

6465
/// <summary>
65-
/// Asynchronously adds new messages to the store.
66+
/// Called at the end of the agent invocation to add new messages to the store.
6667
/// </summary>
67-
/// <param name="messages">The collection of chat messages to add to the store.</param>
68+
/// <param name="context">Contains the invocation context including request messages, response messages, and any exception that occurred.</param>
6869
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
6970
/// <returns>A task that represents the asynchronous add operation.</returns>
70-
/// <exception cref="ArgumentNullException"><paramref name="messages"/> is <see langword="null"/>.</exception>
7171
/// <remarks>
7272
/// <para>
7373
/// Messages should be added in the order they were generated to maintain proper chronological sequence.
7474
/// The store is responsible for preserving message ordering and ensuring that subsequent calls to
75-
/// <see cref="GetMessagesAsync"/> return messages in the correct chronological order.
75+
/// <see cref="InvokingAsync"/> return messages in the correct chronological order.
7676
/// </para>
7777
/// <para>
7878
/// Implementations may perform additional processing during message addition, such as:
@@ -83,8 +83,12 @@ public abstract class ChatMessageStore
8383
/// <item><description>Updating indices or search capabilities</description></item>
8484
/// </list>
8585
/// </para>
86+
/// <para>
87+
/// This method is called regardless of whether the invocation succeeded or failed.
88+
/// To check if the invocation was successful, inspect the <see cref="InvokedContext.InvokeException"/> property.
89+
/// </para>
8690
/// </remarks>
87-
public abstract Task AddMessagesAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default);
91+
public abstract ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default);
8892

8993
/// <summary>
9094
/// Serializes the current object's state to a <see cref="JsonElement"/> using the specified serialization options.
@@ -121,4 +125,100 @@ public abstract class ChatMessageStore
121125
/// </remarks>
122126
public TService? GetService<TService>(object? serviceKey = null)
123127
=> this.GetService(typeof(TService), serviceKey) is TService service ? service : default;
128+
129+
/// <summary>
130+
/// Contains the context information provided to <see cref="InvokingAsync(InvokingContext, CancellationToken)"/>.
131+
/// </summary>
132+
/// <remarks>
133+
/// This class provides context about the invocation before the messages are retrieved from the store,
134+
/// including the new messages that will be used. Stores can use this information to determine what
135+
/// messages should be retrieved for the invocation.
136+
/// </remarks>
137+
public sealed class InvokingContext
138+
{
139+
/// <summary>
140+
/// Initializes a new instance of the <see cref="InvokingContext"/> class with the specified request messages.
141+
/// </summary>
142+
/// <param name="requestMessages">The new messages to be used by the agent for this invocation.</param>
143+
/// <exception cref="ArgumentNullException"><paramref name="requestMessages"/> is <see langword="null"/>.</exception>
144+
public InvokingContext(IEnumerable<ChatMessage> requestMessages)
145+
{
146+
this.RequestMessages = requestMessages ?? throw new ArgumentNullException(nameof(requestMessages));
147+
}
148+
149+
/// <summary>
150+
/// Gets the caller provided messages that will be used by the agent for this invocation.
151+
/// </summary>
152+
/// <value>
153+
/// A collection of <see cref="ChatMessage"/> instances representing new messages that were provided by the caller.
154+
/// </value>
155+
public IEnumerable<ChatMessage> RequestMessages { get; }
156+
}
157+
158+
/// <summary>
159+
/// Contains the context information provided to <see cref="InvokedAsync(InvokedContext, CancellationToken)"/>.
160+
/// </summary>
161+
/// <remarks>
162+
/// This class provides context about a completed agent invocation, including both the
163+
/// request messages that were used and the response messages that were generated. It also indicates
164+
/// whether the invocation succeeded or failed.
165+
/// </remarks>
166+
public sealed class InvokedContext
167+
{
168+
/// <summary>
169+
/// Initializes a new instance of the <see cref="InvokedContext"/> class with the specified request messages.
170+
/// </summary>
171+
/// <param name="requestMessages">The caller provided messages that were used by the agent for this invocation.</param>
172+
/// <param name="chatMessageStoreMessages">The messages retrieved from the <see cref="ChatMessageStore"/> for this invocation.</param>
173+
/// <exception cref="ArgumentNullException"><paramref name="requestMessages"/> is <see langword="null"/>.</exception>
174+
public InvokedContext(IEnumerable<ChatMessage> requestMessages, IEnumerable<ChatMessage> chatMessageStoreMessages)
175+
{
176+
this.RequestMessages = Throw.IfNull(requestMessages);
177+
this.ChatMessageStoreMessages = chatMessageStoreMessages;
178+
}
179+
180+
/// <summary>
181+
/// Gets the caller provided messages that were used by the agent for this invocation.
182+
/// </summary>
183+
/// <value>
184+
/// A collection of <see cref="ChatMessage"/> instances representing new messages that were provided by the caller.
185+
/// This does not include any <see cref="ChatMessageStore"/> supplied messages.
186+
/// </value>
187+
public IEnumerable<ChatMessage> RequestMessages { get; }
188+
189+
/// <summary>
190+
/// Gets the messages retrieved from the <see cref="ChatMessageStore"/> for this invocation, if any.
191+
/// </summary>
192+
/// <value>
193+
/// A collection of <see cref="ChatMessage"/> instances that were retrieved from the <see cref="ChatMessageStore"/>,
194+
/// and were used by the agent as part of the invocation.
195+
/// </value>
196+
public IEnumerable<ChatMessage> ChatMessageStoreMessages { get; }
197+
198+
/// <summary>
199+
/// Gets or sets the messages provided by the <see cref="AIContextProvider"/> for this invocation, if any.
200+
/// </summary>
201+
/// <value>
202+
/// A collection of <see cref="ChatMessage"/> instances that were provided by the <see cref="AIContextProvider"/>,
203+
/// and were used by the agent as part of the invocation.
204+
/// </value>
205+
public IEnumerable<ChatMessage>? AIContextProviderMessages { get; set; }
206+
207+
/// <summary>
208+
/// Gets the collection of response messages generated during this invocation if the invocation succeeded.
209+
/// </summary>
210+
/// <value>
211+
/// A collection of <see cref="ChatMessage"/> instances representing the response,
212+
/// or <see langword="null"/> if the invocation failed or did not produce response messages.
213+
/// </value>
214+
public IEnumerable<ChatMessage>? ResponseMessages { get; set; }
215+
216+
/// <summary>
217+
/// Gets the <see cref="Exception"/> that was thrown during the invocation, if the invocation failed.
218+
/// </summary>
219+
/// <value>
220+
/// The exception that caused the invocation to fail, or <see langword="null"/> if the invocation succeeded.
221+
/// </value>
222+
public Exception? InvokeException { get; set; }
223+
}
124224
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System;
4+
using System.Collections.Generic;
5+
using Microsoft.Extensions.AI;
6+
7+
namespace Microsoft.Agents.AI;
8+
9+
/// <summary>
10+
/// Contains extension methods for the <see cref="ChatMessageStore"/> class.
11+
/// </summary>
12+
public static class ChatMessageStoreExtensions
13+
{
14+
/// <summary>
15+
/// Adds message filtering to an existing store, so that messages passed to the store and messages produced by the store
16+
/// can be filtered, updated or replaced.
17+
/// </summary>
18+
/// <param name="store">The store to add the message filter to.</param>
19+
/// <param name="invokingMessagesFilter">An optional filter function to apply to messages produced by the store. If null, no filter is applied at this
20+
/// stage.</param>
21+
/// <param name="invokedMessagesFilter">An optional filter function to apply to the invoked context messages before they are passed to the store. If null, no
22+
/// filter is applied at this stage.</param>
23+
/// <returns>The <see cref="ChatMessageStore"/> with filtering applied.</returns>
24+
public static ChatMessageStore WithMessageFilters(
25+
this ChatMessageStore store,
26+
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? invokingMessagesFilter = null,
27+
Func<ChatMessageStore.InvokedContext, ChatMessageStore.InvokedContext>? invokedMessagesFilter = null)
28+
{
29+
return new ChatMessageStoreMessageFilter(
30+
innerChatMessageStore: store,
31+
invokingMessagesFilter: invokingMessagesFilter,
32+
invokedMessagesFilter: invokedMessagesFilter);
33+
}
34+
35+
/// <summary>
36+
/// Decorates the provided chat message store so that it does not store messages produced by any <see cref="AIContextProvider"/>.
37+
/// </summary>
38+
/// <param name="store">The store to add the message filter to.</param>
39+
/// <returns>A new <see cref="ChatMessageStore"/> instance that filters out <see cref="AIContextProvider"/> messages so they do not get stored.</returns>
40+
public static ChatMessageStore WithAIContextProviderMessageRemoval(this ChatMessageStore store)
41+
{
42+
return new ChatMessageStoreMessageFilter(
43+
innerChatMessageStore: store,
44+
invokedMessagesFilter: (ctx) =>
45+
{
46+
ctx.AIContextProviderMessages = null;
47+
return ctx;
48+
});
49+
}
50+
}

0 commit comments

Comments
 (0)