Skip to content

Commit 9a38b36

Browse files
authored
Merge pull request #824 from westey-m/chatmessagestore-serialize-updates
Update ChatMessageStore and Serialization related code samples
2 parents 8b22c3a + 34ae321 commit 9a38b36

15 files changed

Lines changed: 141 additions & 88 deletions

File tree

agent-framework/integrations/ag-ui/getting-started.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ AIAgent agent = chatClient.AsAIAgent(
178178
name: "agui-client",
179179
description: "AG-UI Client Agent");
180180

181-
AgentThread thread = agent.GetNewThread();
181+
AgentThread thread = await agent.GetNewThreadAsync();
182182
List<ChatMessage> messages =
183183
[
184184
new(ChatRole.System, "You are a helpful assistant.")

agent-framework/migration-guide/from-semantic-kernel/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ The agent is responsible for creating the thread.
103103

104104
```csharp
105105
// New.
106-
AgentThread thread = agent.GetNewThread();
106+
AgentThread thread = await agent.GetNewThreadAsync();
107107
```
108108

109109
## 4. Hosted Agent Thread Cleanup

agent-framework/tutorials/agents/function-tools-approvals.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ Since you now have a function that requires approval, the agent might respond wi
6666
You can check the response content for any `FunctionApprovalRequestContent` instances, which indicates that the agent requires user approval for a function.
6767

6868
```csharp
69-
AgentThread thread = agent.GetNewThread();
69+
AgentThread thread = await agent.GetNewThreadAsync();
7070
AgentResponse response = await agent.RunAsync("What is the weather like in Amsterdam?", thread);
7171

7272
var functionApprovalRequests = response.Messages

agent-framework/tutorials/agents/memory.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,8 @@ To use the custom `AIContextProvider`, you need to provide an `AIContextProvider
146146

147147
When creating a `ChatClientAgent` it is possible to provide a `ChatClientAgentOptions` object that allows providing the `AIContextProviderFactory` in addition to all other agent options.
148148

149+
The factory is an async function that receives a context object and a cancellation token.
150+
149151
```csharp
150152
using System;
151153
using Azure.AI.OpenAI;
@@ -161,19 +163,20 @@ ChatClient chatClient = new AzureOpenAIClient(
161163
AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions()
162164
{
163165
ChatOptions = new() { Instructions = "You are a friendly assistant. Always address the user by their name." },
164-
AIContextProviderFactory = ctx => new UserInfoMemory(
165-
chatClient.AsIChatClient(),
166-
ctx.SerializedState,
167-
ctx.JsonSerializerOptions)
166+
AIContextProviderFactory = (ctx, ct) => new ValueTask<AIContextProvider>(
167+
new UserInfoMemory(
168+
chatClient.AsIChatClient(),
169+
ctx.SerializedState,
170+
ctx.JsonSerializerOptions))
168171
});
169172
```
170173

171-
When creating a new thread, the `AIContextProvider` will be created by `GetNewThread`
174+
When creating a new thread, the `AIContextProvider` will be created by `GetNewThreadAsync`
172175
and attached to the thread. Once memories are extracted it is therefore possible to access the memory component via the thread's `GetService` method and inspect the memories.
173176

174177
```csharp
175178
// Create a new thread for the conversation.
176-
AgentThread thread = agent.GetNewThread();
179+
AgentThread thread = await agent.GetNewThreadAsync();
177180

178181
Console.WriteLine(await agent.RunAsync("Hello, what is the square root of 9?", thread));
179182
Console.WriteLine(await agent.RunAsync("My name is Ruaidhrí", thread));

agent-framework/tutorials/agents/multi-turn-conversation.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,10 @@ For prerequisites and creating the agent, see the [Create and run a simple agent
2727
Agents are stateless and do not maintain any state internally between calls.
2828
To have a multi-turn conversation with an agent, you need to create an object to hold the conversation state and pass this object to the agent when running it.
2929

30-
To create the conversation state object, call the `GetNewThread` method on the agent instance.
30+
To create the conversation state object, call the `GetNewThreadAsync` method on the agent instance.
3131

3232
```csharp
33-
AgentThread thread = agent.GetNewThread();
33+
AgentThread thread = await agent.GetNewThreadAsync();
3434
```
3535

3636
You can then pass this thread object to the `RunAsync` and `RunStreamingAsync` methods on the agent instance, along with the user input.
@@ -52,8 +52,8 @@ These threads can then be used to maintain separate conversation states for each
5252
The conversations will be fully independent of each other, since the agent does not maintain any state internally.
5353

5454
```csharp
55-
AgentThread thread1 = agent.GetNewThread();
56-
AgentThread thread2 = agent.GetNewThread();
55+
AgentThread thread1 = await agent.GetNewThreadAsync();
56+
AgentThread thread2 = await agent.GetNewThreadAsync();
5757
Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.", thread1));
5858
Console.WriteLine(await agent.RunAsync("Tell me a joke about a robot.", thread2));
5959
Console.WriteLine(await agent.RunAsync("Now add some emojis to the joke and tell it in the voice of a pirate's parrot.", thread1));

agent-framework/tutorials/agents/persisted-conversation.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ AIAgent agent = new AzureOpenAIClient(
3838
.GetChatClient("gpt-4o-mini")
3939
.AsAIAgent(instructions: "You are a helpful assistant.", name: "Assistant");
4040

41-
AgentThread thread = agent.GetNewThread();
41+
AgentThread thread = await agent.GetNewThreadAsync();
4242
```
4343

4444
Run the agent, passing in the thread, so that the `AgentThread` includes this exchange.
@@ -75,7 +75,7 @@ string loadedJson = await File.ReadAllTextAsync(filePath);
7575
JsonElement reloaded = JsonSerializer.Deserialize<JsonElement>(loadedJson, JsonSerializerOptions.Web);
7676

7777
// Deserialize the thread into an AgentThread tied to the same agent type
78-
AgentThread resumedThread = agent.DeserializeThread(reloaded, JsonSerializerOptions.Web);
78+
AgentThread resumedThread = await agent.DeserializeThreadAsync(reloaded, JsonSerializerOptions.Web);
7979
```
8080

8181
Use the resumed thread to continue the conversation.

agent-framework/tutorials/agents/third-party-chat-history-storage.md

Lines changed: 81 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -47,32 +47,32 @@ To create a custom `ChatMessageStore`, you need to implement the abstract `ChatM
4747

4848
The most important methods to implement are:
4949

50-
- `AddMessagesAsync` - called to add new messages to the store.
51-
- `GetMessagesAsync` - called to retrieve the messages from the store.
50+
- `InvokingAsync` - called at the start of agent invocation to retrieve messages from the store that should be provided as context.
51+
- `InvokedAsync` - called at the end of agent invocation to add new messages to the store.
5252

53-
`GetMessagesAsync` should return the messages in ascending chronological order. All messages returned by it will be used by the `ChatClientAgent` when making calls to the underlying <xref:Microsoft.Extensions.AI.IChatClient>. It's therefore important that this method considers the limits of the underlying model, and only returns as many messages as can be handled by the model.
53+
`InvokingAsync` should return the messages in ascending chronological order (oldest first). All messages returned by it will be used by the `ChatClientAgent` when making calls to the underlying <xref:Microsoft.Extensions.AI.IChatClient>. It's therefore important that this method considers the limits of the underlying model, and only returns as many messages as can be handled by the model.
5454

55-
Any chat history reduction logic, such as summarization or trimming, should be done before returning messages from `GetMessagesAsync`.
55+
Any chat history reduction logic, such as summarization or trimming, should be done before returning messages from `InvokingAsync`.
5656

5757
### Serialization
5858

5959
`ChatMessageStore` instances are created and attached to an `AgentThread` when the thread is created, and when a thread is resumed from a serialized state.
6060

6161
While the actual messages making up the chat history are stored externally, the `ChatMessageStore` instance might need to store keys or other state to identify the chat history in the external store.
6262

63-
To allow persisting threads, you need to implement the `SerializeStateAsync` method of the `ChatMessageStore` class. You also need to provide a constructor that takes a <xref:System.Text.Json.JsonElement> parameter, which can be used to deserialize the state when resuming a thread.
63+
To allow persisting threads, you need to implement the `Serialize` method of the `ChatMessageStore` class. This method should return a `JsonElement` containing the state needed to restore the store later. When deserializing, the agent framework will pass this serialized state to the ChatMessageStoreFactory, allowing you to use it to recreate the store.
6464

6565
### Sample ChatMessageStore implementation
6666

6767
The following sample implementation stores chat messages in a vector store.
6868

69-
`AddMessagesAsync` upserts messages into the vector store, using a unique key for each message.
69+
`InvokedAsync` upserts messages into the vector store, using a unique key for each message. It stores both the request messages and response messages from the invocation context.
7070

71-
`GetMessagesAsync` retrieves the messages for the current thread from the vector store, orders them by timestamp, and returns them in ascending order.
71+
`InvokingAsync` retrieves the messages for the current thread from the vector store, orders them by timestamp, and returns them in ascending chronological order (oldest first).
7272

73-
When the first message is received, the store generates a unique key for the thread, which is then used to identify the chat history in the vector store for subsequent calls.
73+
When the first invocation occurs, the store generates a unique key for the thread, which is then used to identify the chat history in the vector store for subsequent calls.
7474

75-
The unique key is stored in the `ThreadDbKey` property, which is serialized and deserialized using the `SerializeStateAsync` method and the constructor that takes a `JsonElement`.
75+
The unique key is stored in the `ThreadDbKey` property, which is serialized using the `Serialize` method and deserialized via the constructor that takes a `JsonElement`.
7676
This key will therefore be persisted as part of the `AgentThread` state, allowing the thread to be resumed later and continue using the same chat history.
7777

7878
```csharp
@@ -105,31 +105,22 @@ internal sealed class VectorChatMessageStore : ChatMessageStore
105105

106106
public string? ThreadDbKey { get; private set; }
107107

108-
public override async Task AddMessagesAsync(
109-
IEnumerable<ChatMessage> messages,
110-
CancellationToken cancellationToken)
108+
public override async ValueTask<IEnumerable<ChatMessage>> InvokingAsync(
109+
InvokingContext context,
110+
CancellationToken cancellationToken = default)
111111
{
112-
this.ThreadDbKey ??= Guid.NewGuid().ToString("N");
113-
var collection = this._vectorStore.GetCollection<string, ChatHistoryItem>("ChatHistory");
114-
await collection.EnsureCollectionExistsAsync(cancellationToken);
115-
await collection.UpsertAsync(messages.Select(x => new ChatHistoryItem()
112+
if (this.ThreadDbKey is null)
116113
{
117-
Key = this.ThreadDbKey + x.MessageId,
118-
Timestamp = DateTimeOffset.UtcNow,
119-
ThreadId = this.ThreadDbKey,
120-
SerializedMessage = JsonSerializer.Serialize(x),
121-
MessageText = x.Text
122-
}), cancellationToken);
123-
}
114+
// No thread key yet, so no messages to retrieve
115+
return [];
116+
}
124117

125-
public override async Task<IEnumerable<ChatMessage>> GetMessagesAsync(
126-
CancellationToken cancellationToken)
127-
{
128118
var collection = this._vectorStore.GetCollection<string, ChatHistoryItem>("ChatHistory");
129119
await collection.EnsureCollectionExistsAsync(cancellationToken);
130120
var records = collection
131121
.GetAsync(
132-
x => x.ThreadId == this.ThreadDbKey, 10,
122+
x => x.ThreadId == this.ThreadDbKey,
123+
10,
133124
new() { OrderBy = x => x.Descending(y => y.Timestamp) },
134125
cancellationToken);
135126

@@ -139,10 +130,41 @@ internal sealed class VectorChatMessageStore : ChatMessageStore
139130
messages.Add(JsonSerializer.Deserialize<ChatMessage>(record.SerializedMessage!)!);
140131
}
141132

133+
// Reverse to return in ascending chronological order (oldest first)
142134
messages.Reverse();
143135
return messages;
144136
}
145137

138+
public override async ValueTask InvokedAsync(
139+
InvokedContext context,
140+
CancellationToken cancellationToken = default)
141+
{
142+
// Don't store messages if the request failed.
143+
if (context.InvokeException is not null)
144+
{
145+
return;
146+
}
147+
148+
this.ThreadDbKey ??= Guid.NewGuid().ToString("N");
149+
150+
var collection = this._vectorStore.GetCollection<string, ChatHistoryItem>("ChatHistory");
151+
await collection.EnsureCollectionExistsAsync(cancellationToken);
152+
153+
// Store request messages, response messages, and optionally AIContextProvider messages
154+
var allNewMessages = context.RequestMessages
155+
.Concat(context.AIContextProviderMessages ?? [])
156+
.Concat(context.ResponseMessages ?? []);
157+
158+
await collection.UpsertAsync(allNewMessages.Select(x => new ChatHistoryItem()
159+
{
160+
Key = this.ThreadDbKey + x.MessageId,
161+
Timestamp = DateTimeOffset.UtcNow,
162+
ThreadId = this.ThreadDbKey,
163+
SerializedMessage = JsonSerializer.Serialize(x),
164+
MessageText = x.Text
165+
}), cancellationToken);
166+
}
167+
146168
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null) =>
147169
// We have to serialize the thread id, so that on deserialization you can retrieve the messages using the same thread id.
148170
JsonSerializer.SerializeToElement(this.ThreadDbKey);
@@ -169,28 +191,46 @@ To use the custom `ChatMessageStore`, you need to provide a `ChatMessageStoreFac
169191

170192
When creating a `ChatClientAgent` it is possible to provide a `ChatClientAgentOptions` object that allows providing the `ChatMessageStoreFactory` in addition to all other agent options.
171193

194+
The factory is an async function that receives a context object and a cancellation token, and returns a `ValueTask<ChatMessageStore>`.
195+
172196
```csharp
173197
using Azure.AI.OpenAI;
174198
using Azure.Identity;
175-
using OpenAI;
199+
using Microsoft.Extensions.VectorData;
200+
using Microsoft.SemanticKernel.Connectors.InMemory;
201+
202+
// Create a vector store to store the chat messages in.
203+
VectorStore vectorStore = new InMemoryVectorStore();
176204

177205
AIAgent agent = new AzureOpenAIClient(
178206
new Uri("https://<myresource>.openai.azure.com"),
179207
new AzureCliCredential())
180-
.GetChatClient("gpt-4o-mini")
181-
.AsAIAgent(new ChatClientAgentOptions
182-
{
183-
Name = "Joker",
184-
ChatOptions = new() { Instructions = "You are good at telling jokes." },
185-
ChatMessageStoreFactory = ctx =>
186-
{
187-
// Create a new chat message store for this agent that stores the messages in a vector store.
188-
return new VectorChatMessageStore(
189-
new InMemoryVectorStore(),
208+
.GetChatClient("gpt-4o-mini")
209+
.AsAIAgent(new ChatClientAgentOptions
210+
{
211+
Name = "Joker",
212+
ChatOptions = new() { Instructions = "You are good at telling jokes." },
213+
ChatMessageStoreFactory = (ctx, ct) => new ValueTask<ChatMessageStore>(
214+
// Create a new chat message store for this agent that stores the messages in a vector store.
215+
// Each thread must get its own copy of the VectorChatMessageStore, since the store
216+
// also contains the id that the thread is stored under.
217+
new VectorChatMessageStore(
218+
vectorStore,
190219
ctx.SerializedState,
191-
ctx.JsonSerializerOptions);
192-
}
193-
});
220+
ctx.JsonSerializerOptions))
221+
});
222+
223+
// Start a new thread for the agent conversation.
224+
AgentThread thread = await agent.GetNewThreadAsync();
225+
226+
// Run the agent with the thread
227+
var response = await agent.RunAsync("Tell me a joke about a pirate.", thread);
228+
229+
// The thread state can be serialized for storage
230+
JsonElement serializedThread = thread.Serialize();
231+
232+
// Later, deserialize the thread to resume the conversation
233+
AgentThread resumedThread = await agent.DeserializeThreadAsync(serializedThread);
194234
```
195235

196236
::: zone-end

agent-framework/user-guide/agents/agent-background-responses.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ AgentRunOptions options = new()
6666
AllowBackgroundResponses = true
6767
};
6868

69-
AgentThread thread = agent.GetNewThread();
69+
AgentThread thread = await agent.GetNewThreadAsync();
7070

7171
// Get initial response - may return with or without a continuation token
7272
AgentResponse response = await agent.RunAsync("Write a very long novel about otters in space.", thread, options);
@@ -108,7 +108,7 @@ AgentRunOptions options = new()
108108
AllowBackgroundResponses = true
109109
};
110110

111-
AgentThread thread = agent.GetNewThread();
111+
AgentThread thread = await agent.GetNewThreadAsync();
112112

113113
AgentResponseUpdate? latestReceivedUpdate = null;
114114

0 commit comments

Comments
 (0)