Skip to content

Commit c8750cb

Browse files
authored
.NET: Create a sample to show bounded chat history with overflow into chat history memory (#4136)
* Create a sample to show bounded chat history with overflow into chat history memory * Address PR comments. * Address PR comment and fix bug
1 parent 394e9c1 commit c8750cb

7 files changed

Lines changed: 341 additions & 0 deletions

File tree

dotnet/agent-framework-dotnet.slnx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@
103103
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/AgentWithMemory_Step01_ChatHistoryMemory.csproj" />
104104
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0/AgentWithMemory_Step02_MemoryUsingMem0.csproj" />
105105
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj" />
106+
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step05_BoundedChatHistory/AgentWithMemory_Step05_BoundedChatHistory.csproj" />
106107
</Folder>
107108
<Folder Name="/Samples/02-agents/AgentWithOpenAI/">
108109
<File Path="samples/02-agents/AgentWithOpenAI/README.md" />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
5+
<TargetFrameworks>net10.0</TargetFrameworks>
6+
7+
<Nullable>enable</Nullable>
8+
<ImplicitUsings>enable</ImplicitUsings>
9+
</PropertyGroup>
10+
11+
<ItemGroup>
12+
<PackageReference Include="Azure.AI.OpenAI" />
13+
<PackageReference Include="Azure.Identity" />
14+
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
15+
<PackageReference Include="Microsoft.SemanticKernel.Connectors.InMemory" />
16+
</ItemGroup>
17+
18+
<ItemGroup>
19+
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
20+
</ItemGroup>
21+
22+
</Project>
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using Microsoft.Agents.AI;
4+
using Microsoft.Extensions.AI;
5+
using Microsoft.Extensions.VectorData;
6+
7+
namespace SampleApp;
8+
9+
/// <summary>
10+
/// A <see cref="ChatHistoryProvider"/> that keeps a bounded window of recent messages in session state
11+
/// (via <see cref="InMemoryChatHistoryProvider"/>) and overflows older messages to a vector store
12+
/// (via <see cref="ChatHistoryMemoryProvider"/>). When providing chat history, it searches the vector
13+
/// store for relevant older messages and prepends them as a memory context message.
14+
/// </summary>
15+
/// <remarks>
16+
/// Only non-system messages are counted towards the session state limit and overflow mechanism. System messages are always retained in session state and are not included in the vector store.
17+
/// Function calls and function results are also dropped when truncation happens, both from in-memory state, and they are also not persisted to the vector store.
18+
/// </remarks>
19+
internal sealed class BoundedChatHistoryProvider : ChatHistoryProvider, IDisposable
20+
{
21+
private readonly InMemoryChatHistoryProvider _chatHistoryProvider;
22+
private readonly ChatHistoryMemoryProvider _memoryProvider;
23+
private readonly TruncatingChatReducer _reducer;
24+
private readonly string _contextPrompt;
25+
private IReadOnlyList<string>? _stateKeys;
26+
27+
/// <summary>
28+
/// Initializes a new instance of the <see cref="BoundedChatHistoryProvider"/> class.
29+
/// </summary>
30+
/// <param name="maxSessionMessages">The maximum number of non-system messages to keep in session state before overflowing to the vector store.</param>
31+
/// <param name="vectorStore">The vector store to use for storing and retrieving overflow chat history.</param>
32+
/// <param name="collectionName">The name of the collection for storing overflow chat history in the vector store.</param>
33+
/// <param name="vectorDimensions">The number of dimensions to use for the chat history vector store embeddings.</param>
34+
/// <param name="stateInitializer">A delegate that initializes the memory provider state, providing the storage and search scopes.</param>
35+
/// <param name="contextPrompt">Optional prompt to prefix memory search results. Defaults to a standard memory context prompt.</param>
36+
public BoundedChatHistoryProvider(
37+
int maxSessionMessages,
38+
VectorStore vectorStore,
39+
string collectionName,
40+
int vectorDimensions,
41+
Func<AgentSession?, ChatHistoryMemoryProvider.State> stateInitializer,
42+
string? contextPrompt = null)
43+
{
44+
if (maxSessionMessages < 0)
45+
{
46+
throw new ArgumentOutOfRangeException(nameof(maxSessionMessages), "maxSessionMessages must be non-negative.");
47+
}
48+
49+
this._reducer = new TruncatingChatReducer(maxSessionMessages);
50+
this._chatHistoryProvider = new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions
51+
{
52+
ChatReducer = this._reducer,
53+
ReducerTriggerEvent = InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded,
54+
StorageInputRequestMessageFilter = msgs => msgs,
55+
});
56+
this._memoryProvider = new ChatHistoryMemoryProvider(
57+
vectorStore,
58+
collectionName,
59+
vectorDimensions,
60+
stateInitializer,
61+
options: new ChatHistoryMemoryProviderOptions
62+
{
63+
SearchInputMessageFilter = msgs => msgs,
64+
StorageInputRequestMessageFilter = msgs => msgs,
65+
});
66+
this._contextPrompt = contextPrompt
67+
?? "The following are memories from earlier in this conversation. Use them to inform your responses:";
68+
}
69+
70+
/// <inheritdoc />
71+
public override IReadOnlyList<string> StateKeys => this._stateKeys ??= this._chatHistoryProvider.StateKeys.Concat(this._memoryProvider.StateKeys).ToArray();
72+
73+
/// <inheritdoc />
74+
protected override async ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(
75+
InvokingContext context,
76+
CancellationToken cancellationToken = default)
77+
{
78+
// Delegate to the inner provider's full lifecycle (retrieve, filter, stamp, merge with request messages).
79+
var chatHistoryProviderInputContext = new InvokingContext(context.Agent, context.Session, []);
80+
var allMessages = await this._chatHistoryProvider.InvokingAsync(chatHistoryProviderInputContext, cancellationToken).ConfigureAwait(false);
81+
82+
// Search the vector store for relevant older messages.
83+
var aiContext = new AIContext { Messages = context.RequestMessages.ToList() };
84+
var invokingContext = new AIContextProvider.InvokingContext(
85+
context.Agent, context.Session, aiContext);
86+
87+
var result = await this._memoryProvider.InvokingAsync(invokingContext, cancellationToken).ConfigureAwait(false);
88+
89+
// Extract only the messages added by the memory provider (stamped with AIContextProvider source type).
90+
var memoryMessages = result.Messages?
91+
.Where(m => m.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.AIContextProvider)
92+
.ToList();
93+
94+
if (memoryMessages is { Count: > 0 })
95+
{
96+
var memoryText = string.Join("\n", memoryMessages.Select(m => m.Text).Where(t => !string.IsNullOrWhiteSpace(t)));
97+
98+
if (!string.IsNullOrWhiteSpace(memoryText))
99+
{
100+
var contextMessage = new ChatMessage(ChatRole.User, $"{this._contextPrompt}\n{memoryText}");
101+
return new[] { contextMessage }.Concat(allMessages);
102+
}
103+
}
104+
105+
return allMessages;
106+
}
107+
108+
/// <inheritdoc />
109+
protected override async ValueTask StoreChatHistoryAsync(
110+
InvokedContext context,
111+
CancellationToken cancellationToken = default)
112+
{
113+
// Delegate storage to the in-memory provider. Its TruncatingChatReducer (AfterMessageAdded trigger)
114+
// will automatically truncate to the configured maximum and expose any removed messages.
115+
var innerContext = new InvokedContext(
116+
context.Agent, context.Session, context.RequestMessages, context.ResponseMessages!);
117+
await this._chatHistoryProvider.InvokedAsync(innerContext, cancellationToken).ConfigureAwait(false);
118+
119+
// Archive any messages that the reducer removed to the vector store.
120+
if (this._reducer.RemovedMessages is { Count: > 0 })
121+
{
122+
var overflowContext = new AIContextProvider.InvokedContext(
123+
context.Agent, context.Session, this._reducer.RemovedMessages, []);
124+
await this._memoryProvider.InvokedAsync(overflowContext, cancellationToken).ConfigureAwait(false);
125+
}
126+
}
127+
128+
/// <inheritdoc/>
129+
public void Dispose()
130+
{
131+
this._memoryProvider.Dispose();
132+
}
133+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
// This sample shows how to create a bounded chat history provider that keeps a configurable number of
4+
// recent messages in session state and automatically overflows older messages to a vector store.
5+
// When the agent is invoked, it searches the vector store for relevant older messages and
6+
// prepends them as a "memory" context message before the recent session history.
7+
8+
using Azure.AI.OpenAI;
9+
using Azure.Identity;
10+
using Microsoft.Agents.AI;
11+
using Microsoft.Extensions.AI;
12+
using Microsoft.Extensions.VectorData;
13+
using Microsoft.SemanticKernel.Connectors.InMemory;
14+
using OpenAI.Chat;
15+
using SampleApp;
16+
17+
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
18+
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
19+
var embeddingDeploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-3-large";
20+
21+
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
22+
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
23+
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
24+
var credential = new DefaultAzureCredential();
25+
26+
// Create a vector store to store overflow chat messages.
27+
// For demonstration purposes, we are using an in-memory vector store.
28+
// Replace this with a persistent vector store implementation for production scenarios.
29+
VectorStore vectorStore = new InMemoryVectorStore(new InMemoryVectorStoreOptions()
30+
{
31+
EmbeddingGenerator = new AzureOpenAIClient(new Uri(endpoint), credential)
32+
.GetEmbeddingClient(embeddingDeploymentName)
33+
.AsIEmbeddingGenerator()
34+
});
35+
36+
var sessionId = Guid.NewGuid().ToString();
37+
38+
// Create the BoundedChatHistoryProvider with a maximum of 4 non-system messages in session state.
39+
// It internally creates an InMemoryChatHistoryProvider with a TruncatingChatReducer and a
40+
// ChatHistoryMemoryProvider with the correct configuration to ensure overflow messages are
41+
// automatically archived to the vector store and recalled via semantic search.
42+
var boundedProvider = new BoundedChatHistoryProvider(
43+
maxSessionMessages: 4,
44+
vectorStore,
45+
collectionName: "chathistory-overflow",
46+
vectorDimensions: 3072,
47+
session => new ChatHistoryMemoryProvider.State(
48+
storageScope: new() { UserId = "UID1", SessionId = sessionId },
49+
searchScope: new() { UserId = "UID1" }));
50+
51+
// Create the agent with the bounded chat history provider.
52+
AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), credential)
53+
.GetChatClient(deploymentName)
54+
.AsAIAgent(new ChatClientAgentOptions
55+
{
56+
ChatOptions = new() { Instructions = "You are a helpful assistant. Answer questions concisely." },
57+
Name = "Assistant",
58+
ChatHistoryProvider = boundedProvider,
59+
});
60+
61+
// Start a conversation. The first several exchanges will fill up the session state window.
62+
AgentSession session = await agent.CreateSessionAsync();
63+
64+
Console.WriteLine("--- Filling the session window (4 messages max) ---\n");
65+
66+
Console.WriteLine(await agent.RunAsync("My favorite color is blue.", session));
67+
Console.WriteLine(await agent.RunAsync("I have a dog named Max.", session));
68+
69+
// At this point the session state holds 4 messages (2 user + 2 assistant).
70+
// The next exchange will push the oldest messages into the vector store.
71+
Console.WriteLine("\n--- Next exchange will trigger overflow to vector store ---\n");
72+
73+
Console.WriteLine(await agent.RunAsync("What is the capital of France?", session));
74+
75+
// The oldest messages about favorite color have now been archived to the vector store.
76+
// Ask the agent something that requires recalling the overflowed information.
77+
Console.WriteLine("\n--- Asking about overflowed information (should recall from vector store) ---\n");
78+
79+
Console.WriteLine(await agent.RunAsync("What is my favorite color?", session));
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Bounded Chat History with Vector Store Overflow
2+
3+
This sample demonstrates how to create a custom `ChatHistoryProvider` that keeps a bounded window of recent messages in session state and automatically overflows older messages to a vector store. When the agent is invoked, it searches the vector store for relevant older messages and prepends them as memory context.
4+
5+
## Concepts
6+
7+
- **`TruncatingChatReducer`**: A custom `IChatReducer` that keeps the most recent N messages and exposes removed messages via a `RemovedMessages` property.
8+
- **`BoundedChatHistoryProvider`**: A custom `ChatHistoryProvider` that composes:
9+
- `InMemoryChatHistoryProvider` for fast session-state storage (bounded by the reducer)
10+
- `ChatHistoryMemoryProvider` for vector-store overflow and semantic search of older messages
11+
12+
## Prerequisites
13+
14+
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
15+
- An Azure OpenAI resource with:
16+
- A chat deployment (e.g., `gpt-4o-mini`)
17+
- An embedding deployment (e.g., `text-embedding-3-large`)
18+
19+
## Configuration
20+
21+
Set the following environment variables:
22+
23+
| Variable | Description | Default |
24+
|---|---|---|
25+
| `AZURE_OPENAI_ENDPOINT` | Your Azure OpenAI endpoint URL | *(required)* |
26+
| `AZURE_OPENAI_DEPLOYMENT_NAME` | Chat model deployment name | `gpt-4o-mini` |
27+
| `AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME` | Embedding model deployment name | `text-embedding-3-large` |
28+
29+
## Running the Sample
30+
31+
```bash
32+
dotnet run
33+
```
34+
35+
## How it Works
36+
37+
1. The agent starts a conversation with a bounded session window of 4 non-system, non-function messages (i.e., user/assistant turns). System messages are always preserved, and function call/result messages are truncated and not preserved.
38+
2. As messages accumulate beyond the limit, the `TruncatingChatReducer` removes the oldest messages.
39+
3. The `BoundedChatHistoryProvider` detects the removed messages and stores them in a vector store via `ChatHistoryMemoryProvider`.
40+
4. On subsequent invocations, the provider searches the vector store for relevant older messages and prepends them as memory context, allowing the agent to recall information from earlier in the conversation.
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using Microsoft.Extensions.AI;
4+
5+
namespace SampleApp;
6+
7+
/// <summary>
8+
/// A truncating chat reducer that keeps the most recent messages up to a configured maximum,
9+
/// preserving any leading system message. Removed messages are exposed via <see cref="RemovedMessages"/>
10+
/// so that a caller can archive them (e.g. to a vector store).
11+
/// </summary>
12+
internal sealed class TruncatingChatReducer : IChatReducer
13+
{
14+
private readonly int _maxMessages;
15+
16+
/// <summary>
17+
/// Initializes a new instance of the <see cref="TruncatingChatReducer"/> class.
18+
/// </summary>
19+
/// <param name="maxMessages">The maximum number of non-system messages to retain.</param>
20+
public TruncatingChatReducer(int maxMessages)
21+
{
22+
this._maxMessages = maxMessages > 0 ? maxMessages : throw new ArgumentOutOfRangeException(nameof(maxMessages));
23+
}
24+
25+
/// <summary>
26+
/// Gets the messages that were removed during the most recent call to <see cref="ReduceAsync"/>.
27+
/// </summary>
28+
public IReadOnlyList<ChatMessage> RemovedMessages { get; private set; } = [];
29+
30+
/// <inheritdoc />
31+
public Task<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken)
32+
{
33+
_ = messages ?? throw new ArgumentNullException(nameof(messages));
34+
35+
ChatMessage? systemMessage = null;
36+
Queue<ChatMessage> retained = new(capacity: this._maxMessages);
37+
List<ChatMessage> removed = [];
38+
39+
foreach (var message in messages)
40+
{
41+
if (message.Role == ChatRole.System)
42+
{
43+
// Preserve the first system message outside the counting window.
44+
systemMessage ??= message;
45+
}
46+
else if (!message.Contents.Any(c => c is FunctionCallContent or FunctionResultContent))
47+
{
48+
if (retained.Count >= this._maxMessages)
49+
{
50+
removed.Add(retained.Dequeue());
51+
}
52+
53+
retained.Enqueue(message);
54+
}
55+
}
56+
57+
this.RemovedMessages = removed;
58+
59+
IEnumerable<ChatMessage> result = systemMessage is not null
60+
? new[] { systemMessage }.Concat(retained)
61+
: retained;
62+
63+
return Task.FromResult(result);
64+
}
65+
}

dotnet/samples/02-agents/AgentWithMemory/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@ These samples show how to create an agent with the Agent Framework that uses Mem
88
|[Memory with MemoryStore](./AgentWithMemory_Step02_MemoryUsingMem0/)|This sample demonstrates how to create and run an agent that uses the Mem0 service to extract and retrieve individual memories.|
99
|[Custom Memory Implementation](../../01-get-started/04_memory/)|This sample demonstrates how to create a custom memory component and attach it to an agent.|
1010
|[Memory with Azure AI Foundry](./AgentWithMemory_Step04_MemoryUsingFoundry/)|This sample demonstrates how to create and run an agent that uses Azure AI Foundry's managed memory service to extract and retrieve individual memories.|
11+
|[Bounded Chat History with Overflow](./AgentWithMemory_Step05_BoundedChatHistory/)|This sample demonstrates how to create a bounded chat history provider that overflows older messages to a vector store and recalls them as memories.|
1112

1213
> **See also**: [Memory Search with Foundry Agents](../FoundryAgents/FoundryAgents_Step22_MemorySearch/) - demonstrates using the built-in Memory Search tool with Azure Foundry Agents.

0 commit comments

Comments
 (0)