-
Notifications
You must be signed in to change notification settings - Fork 1.2k
.NET: Create a sample to show bounded chat history with overflow into chat history memory #4136
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
Open
westey-m
wants to merge
3
commits into
microsoft:main
Choose a base branch
from
westey-m:bounded-chat-history-with-overflow-sample
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
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
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
22 changes: 22 additions & 0 deletions
22
...gentWithMemory_Step05_BoundedChatHistory/AgentWithMemory_Step05_BoundedChatHistory.csproj
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,22 @@ | ||
| <Project Sdk="Microsoft.NET.Sdk"> | ||
|
|
||
| <PropertyGroup> | ||
| <OutputType>Exe</OutputType> | ||
| <TargetFrameworks>net10.0</TargetFrameworks> | ||
|
|
||
| <Nullable>enable</Nullable> | ||
| <ImplicitUsings>enable</ImplicitUsings> | ||
| </PropertyGroup> | ||
|
|
||
| <ItemGroup> | ||
| <PackageReference Include="Azure.AI.OpenAI" /> | ||
| <PackageReference Include="Azure.Identity" /> | ||
| <PackageReference Include="Microsoft.Extensions.AI.OpenAI" /> | ||
| <PackageReference Include="Microsoft.SemanticKernel.Connectors.InMemory" /> | ||
| </ItemGroup> | ||
|
|
||
| <ItemGroup> | ||
| <ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" /> | ||
| </ItemGroup> | ||
|
|
||
| </Project> |
128 changes: 128 additions & 0 deletions
128
...d/AgentWithMemory/AgentWithMemory_Step05_BoundedChatHistory/BoundedChatHistoryProvider.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,128 @@ | ||
| // Copyright (c) Microsoft. All rights reserved. | ||
|
|
||
| using Microsoft.Agents.AI; | ||
| using Microsoft.Extensions.AI; | ||
| using Microsoft.Extensions.VectorData; | ||
|
|
||
| namespace SampleApp; | ||
|
|
||
| /// <summary> | ||
| /// A <see cref="ChatHistoryProvider"/> that keeps a bounded window of recent messages in session state | ||
| /// (via <see cref="InMemoryChatHistoryProvider"/>) and overflows older messages to a vector store | ||
| /// (via <see cref="ChatHistoryMemoryProvider"/>). When providing chat history, it searches the vector | ||
| /// store for relevant older messages and prepends them as a memory context message. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// 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. | ||
| /// 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. | ||
| /// </remarks> | ||
| internal sealed class BoundedChatHistoryProvider : ChatHistoryProvider, IDisposable | ||
| { | ||
| private readonly InMemoryChatHistoryProvider _inMemoryProvider; | ||
| private readonly ChatHistoryMemoryProvider _memoryProvider; | ||
| private readonly TruncatingChatReducer _reducer; | ||
| private readonly string _contextPrompt; | ||
|
|
||
| /// <summary> | ||
| /// Initializes a new instance of the <see cref="BoundedChatHistoryProvider"/> class. | ||
| /// </summary> | ||
| /// <param name="maxSessionMessages">The maximum number of non-system messages to keep in session state before overflowing to the vector store.</param> | ||
| /// <param name="vectorStore">The vector store to use for storing and retrieving overflow chat history.</param> | ||
| /// <param name="collectionName">The name of the collection for storing overflow chat history in the vector store.</param> | ||
| /// <param name="vectorDimensions">The number of dimensions to use for the chat history vector store embeddings.</param> | ||
| /// <param name="stateInitializer">A delegate that initializes the memory provider state, providing the storage and search scopes.</param> | ||
| /// <param name="contextPrompt">Optional prompt to prefix memory search results. Defaults to a standard memory context prompt.</param> | ||
| public BoundedChatHistoryProvider( | ||
| int maxSessionMessages, | ||
| VectorStore vectorStore, | ||
| string collectionName, | ||
| int vectorDimensions, | ||
| Func<AgentSession?, ChatHistoryMemoryProvider.State> stateInitializer, | ||
| string? contextPrompt = null) | ||
| { | ||
westey-m marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| if (maxSessionMessages < 0) | ||
| { | ||
| throw new ArgumentOutOfRangeException(nameof(maxSessionMessages), "maxSessionMessages must be non-negative."); | ||
| } | ||
|
|
||
| this._reducer = new TruncatingChatReducer(maxSessionMessages); | ||
| this._inMemoryProvider = new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions | ||
| { | ||
| ChatReducer = this._reducer, | ||
| ReducerTriggerEvent = InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded, | ||
| StorageInputMessageFilter = msgs => msgs, | ||
| }); | ||
| this._memoryProvider = new ChatHistoryMemoryProvider( | ||
| vectorStore, | ||
| collectionName, | ||
| vectorDimensions, | ||
| stateInitializer, | ||
| options: new ChatHistoryMemoryProviderOptions | ||
| { | ||
| SearchInputMessageFilter = msgs => msgs, | ||
| StorageInputMessageFilter = msgs => msgs, | ||
| }); | ||
| this._contextPrompt = contextPrompt | ||
| ?? "The following are memories from earlier in this conversation. Use them to inform your responses:"; | ||
| } | ||
|
|
||
| /// <inheritdoc /> | ||
| protected override async ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync( | ||
| InvokingContext context, | ||
| CancellationToken cancellationToken = default) | ||
| { | ||
| // Delegate to the inner provider's full lifecycle (retrieve, filter, stamp, merge with request messages). | ||
| var allMessages = await this._inMemoryProvider.InvokingAsync(context, cancellationToken).ConfigureAwait(false); | ||
|
|
||
| // Search the vector store for relevant older messages. | ||
| var aiContext = new AIContext { Messages = context.RequestMessages.ToList() }; | ||
| var invokingContext = new AIContextProvider.InvokingContext( | ||
| context.Agent, context.Session, aiContext); | ||
|
|
||
| var result = await this._memoryProvider.InvokingAsync(invokingContext, cancellationToken).ConfigureAwait(false); | ||
|
|
||
| // Extract only the messages added by the memory provider (stamped with AIContextProvider source type). | ||
| var memoryMessages = result.Messages? | ||
| .Where(m => m.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.AIContextProvider) | ||
| .ToList(); | ||
|
|
||
| if (memoryMessages is { Count: > 0 }) | ||
| { | ||
| var memoryText = string.Join("\n", memoryMessages.Select(m => m.Text).Where(t => !string.IsNullOrWhiteSpace(t))); | ||
|
|
||
| if (!string.IsNullOrWhiteSpace(memoryText)) | ||
| { | ||
| var contextMessage = new ChatMessage(ChatRole.User, $"{this._contextPrompt}\n{memoryText}"); | ||
| return new[] { contextMessage }.Concat(allMessages); | ||
| } | ||
| } | ||
|
|
||
| return allMessages; | ||
| } | ||
|
|
||
| /// <inheritdoc /> | ||
| protected override async ValueTask StoreChatHistoryAsync( | ||
| InvokedContext context, | ||
| CancellationToken cancellationToken = default) | ||
| { | ||
| // Delegate storage to the in-memory provider. Its TruncatingChatReducer (AfterMessageAdded trigger) | ||
| // will automatically truncate to the configured maximum and expose any removed messages. | ||
| var innerContext = new InvokedContext( | ||
| context.Agent, context.Session, context.RequestMessages, context.ResponseMessages!); | ||
| await this._inMemoryProvider.InvokedAsync(innerContext, cancellationToken).ConfigureAwait(false); | ||
|
|
||
| // Archive any messages that the reducer removed to the vector store. | ||
| if (this._reducer.RemovedMessages is { Count: > 0 }) | ||
| { | ||
| var overflowContext = new AIContextProvider.InvokedContext( | ||
| context.Agent, context.Session, this._reducer.RemovedMessages, []); | ||
westey-m marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| await this._memoryProvider.InvokedAsync(overflowContext, cancellationToken).ConfigureAwait(false); | ||
| } | ||
| } | ||
|
|
||
| /// <inheritdoc/> | ||
| public void Dispose() | ||
| { | ||
| this._memoryProvider.Dispose(); | ||
westey-m marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| } | ||
79 changes: 79 additions & 0 deletions
79
...mples/GettingStarted/AgentWithMemory/AgentWithMemory_Step05_BoundedChatHistory/Program.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,79 @@ | ||
| // Copyright (c) Microsoft. All rights reserved. | ||
|
|
||
| // This sample shows how to create a bounded chat history provider that keeps a configurable number 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 a "memory" context message before the recent session history. | ||
|
|
||
| using Azure.AI.OpenAI; | ||
| using Azure.Identity; | ||
| using Microsoft.Agents.AI; | ||
| using Microsoft.Extensions.AI; | ||
| using Microsoft.Extensions.VectorData; | ||
| using Microsoft.SemanticKernel.Connectors.InMemory; | ||
| using OpenAI.Chat; | ||
| using SampleApp; | ||
|
|
||
| var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); | ||
| var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; | ||
| var embeddingDeploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-3-large"; | ||
|
|
||
| // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. | ||
| // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid | ||
| // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. | ||
| var credential = new DefaultAzureCredential(); | ||
|
|
||
| // Create a vector store to store overflow chat messages. | ||
| // For demonstration purposes, we are using an in-memory vector store. | ||
| // Replace this with a persistent vector store implementation for production scenarios. | ||
| VectorStore vectorStore = new InMemoryVectorStore(new InMemoryVectorStoreOptions() | ||
| { | ||
| EmbeddingGenerator = new AzureOpenAIClient(new Uri(endpoint), credential) | ||
| .GetEmbeddingClient(embeddingDeploymentName) | ||
| .AsIEmbeddingGenerator() | ||
| }); | ||
|
|
||
| var sessionId = Guid.NewGuid().ToString(); | ||
|
|
||
| // Create the BoundedChatHistoryProvider with a maximum of 4 non-system messages in session state. | ||
| // It internally creates an InMemoryChatHistoryProvider with a TruncatingChatReducer and a | ||
| // ChatHistoryMemoryProvider with the correct configuration to ensure overflow messages are | ||
| // automatically archived to the vector store and recalled via semantic search. | ||
| var boundedProvider = new BoundedChatHistoryProvider( | ||
| maxSessionMessages: 4, | ||
| vectorStore, | ||
| collectionName: "chathistory-overflow", | ||
| vectorDimensions: 3072, | ||
| session => new ChatHistoryMemoryProvider.State( | ||
| storageScope: new() { UserId = "UID1", SessionId = sessionId }, | ||
| searchScope: new() { UserId = "UID1" })); | ||
|
|
||
| // Create the agent with the bounded chat history provider. | ||
| AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), credential) | ||
| .GetChatClient(deploymentName) | ||
| .AsAIAgent(new ChatClientAgentOptions | ||
| { | ||
| ChatOptions = new() { Instructions = "You are a helpful assistant. Answer questions concisely." }, | ||
| Name = "Assistant", | ||
| ChatHistoryProvider = boundedProvider, | ||
| }); | ||
|
|
||
| // Start a conversation. The first several exchanges will fill up the session state window. | ||
| AgentSession session = await agent.CreateSessionAsync(); | ||
|
|
||
| Console.WriteLine("--- Filling the session window (4 messages max) ---\n"); | ||
|
|
||
| Console.WriteLine(await agent.RunAsync("My favorite color is blue.", session)); | ||
| Console.WriteLine(await agent.RunAsync("I have a dog named Max.", session)); | ||
|
|
||
| // At this point the session state holds 4 messages (2 user + 2 assistant). | ||
| // The next exchange will push the oldest messages into the vector store. | ||
| Console.WriteLine("\n--- Next exchange will trigger overflow to vector store ---\n"); | ||
|
|
||
| Console.WriteLine(await agent.RunAsync("What is the capital of France?", session)); | ||
|
|
||
| // The oldest messages about favorite color have now been archived to the vector store. | ||
| // Ask the agent something that requires recalling the overflowed information. | ||
| Console.WriteLine("\n--- Asking about overflowed information (should recall from vector store) ---\n"); | ||
|
|
||
| Console.WriteLine(await agent.RunAsync("What is my favorite color?", session)); |
40 changes: 40 additions & 0 deletions
40
...tingStarted/AgentWithMemory/AgentWithMemory_Step05_BoundedChatHistory/README.md
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,40 @@ | ||
| # Bounded Chat History with Vector Store Overflow | ||
|
|
||
| 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. | ||
|
|
||
| ## Concepts | ||
|
|
||
| - **`TruncatingChatReducer`**: A custom `IChatReducer` that keeps the most recent N messages and exposes removed messages via a `RemovedMessages` property. | ||
| - **`BoundedChatHistoryProvider`**: A custom `ChatHistoryProvider` that composes: | ||
| - `InMemoryChatHistoryProvider` for fast session-state storage (bounded by the reducer) | ||
| - `ChatHistoryMemoryProvider` for vector-store overflow and semantic search of older messages | ||
|
|
||
| ## Prerequisites | ||
|
|
||
| - [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) | ||
| - An Azure OpenAI resource with: | ||
| - A chat deployment (e.g., `gpt-4o-mini`) | ||
| - An embedding deployment (e.g., `text-embedding-3-large`) | ||
|
|
||
| ## Configuration | ||
|
|
||
| Set the following environment variables: | ||
|
|
||
| | Variable | Description | Default | | ||
| |---|---|---| | ||
| | `AZURE_OPENAI_ENDPOINT` | Your Azure OpenAI endpoint URL | *(required)* | | ||
| | `AZURE_OPENAI_DEPLOYMENT_NAME` | Chat model deployment name | `gpt-4o-mini` | | ||
| | `AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME` | Embedding model deployment name | `text-embedding-3-large` | | ||
|
|
||
| ## Running the Sample | ||
|
|
||
| ```bash | ||
| dotnet run | ||
| ``` | ||
|
|
||
| ## How it Works | ||
|
|
||
| 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. | ||
| 2. As messages accumulate beyond the limit, the `TruncatingChatReducer` removes the oldest messages. | ||
| 3. The `BoundedChatHistoryProvider` detects the removed messages and stores them in a vector store via `ChatHistoryMemoryProvider`. | ||
| 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. |
65 changes: 65 additions & 0 deletions
65
...tarted/AgentWithMemory/AgentWithMemory_Step05_BoundedChatHistory/TruncatingChatReducer.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,65 @@ | ||
| // Copyright (c) Microsoft. All rights reserved. | ||
|
|
||
| using Microsoft.Extensions.AI; | ||
|
|
||
| namespace SampleApp; | ||
|
|
||
| /// <summary> | ||
| /// A truncating chat reducer that keeps the most recent messages up to a configured maximum, | ||
| /// preserving any leading system message. Removed messages are exposed via <see cref="RemovedMessages"/> | ||
| /// so that a caller can archive them (e.g. to a vector store). | ||
| /// </summary> | ||
| internal sealed class TruncatingChatReducer : IChatReducer | ||
| { | ||
| private readonly int _maxMessages; | ||
|
|
||
| /// <summary> | ||
| /// Initializes a new instance of the <see cref="TruncatingChatReducer"/> class. | ||
| /// </summary> | ||
| /// <param name="maxMessages">The maximum number of non-system messages to retain.</param> | ||
| public TruncatingChatReducer(int maxMessages) | ||
| { | ||
| this._maxMessages = maxMessages > 0 ? maxMessages : throw new ArgumentOutOfRangeException(nameof(maxMessages)); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Gets the messages that were removed during the most recent call to <see cref="ReduceAsync"/>. | ||
| /// </summary> | ||
| public IReadOnlyList<ChatMessage> RemovedMessages { get; private set; } = []; | ||
westey-m marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| /// <inheritdoc /> | ||
| public Task<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken) | ||
| { | ||
| _ = messages ?? throw new ArgumentNullException(nameof(messages)); | ||
|
|
||
| ChatMessage? systemMessage = null; | ||
| Queue<ChatMessage> retained = new(capacity: this._maxMessages); | ||
| List<ChatMessage> removed = []; | ||
|
|
||
| foreach (var message in messages) | ||
| { | ||
| if (message.Role == ChatRole.System) | ||
| { | ||
| // Preserve the first system message outside the counting window. | ||
| systemMessage ??= message; | ||
westey-m marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| else if (!message.Contents.Any(c => c is FunctionCallContent or FunctionResultContent)) | ||
| { | ||
| if (retained.Count >= this._maxMessages) | ||
| { | ||
| removed.Add(retained.Dequeue()); | ||
| } | ||
|
|
||
| retained.Enqueue(message); | ||
| } | ||
westey-m marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| this.RemovedMessages = removed; | ||
|
|
||
| IEnumerable<ChatMessage> result = systemMessage is not null | ||
| ? new[] { systemMessage }.Concat(retained) | ||
| : retained; | ||
|
|
||
| return Task.FromResult(result); | ||
| } | ||
| } | ||
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
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The BoundedChatHistoryProvider does not override the StateKey property. This means it will default to "BoundedChatHistoryProvider" according to the base class behavior. However, it internally uses InMemoryChatHistoryProvider which will use "InMemoryChatHistoryProvider" as its state key, and ChatHistoryMemoryProvider which uses "ChatHistoryMemoryProvider" by default. Since BoundedChatHistoryProvider doesn't store any state itself, this might not be an issue. However, consider whether overriding StateKey to return the InMemoryChatHistoryProvider's StateKey would be more appropriate, or documenting that this provider composes two others with their own state keys.