.NET: Adds Valkey to chat message history - issue 5445 (#5542)

* Adds Valkey to chat message history

* Address review: switch to Valkey.Glide, add options class, remove context provider

- Switch from StackExchange.Redis to Valkey.Glide 1.1.0 (official Valkey .NET client)
- Extract optional params into ValkeyChatHistoryProviderOptions
- Add JsonSerializerOptions support, remove [RequiresUnreferencedCode]
- Make MaxMessages/MaxMessagesToRetrieve readonly via options
- Remove ValkeyContextProvider (overlaps with ChatHistoryMemoryProvider + MEVD)
- Remove ValkeyProviderScope (only used by context provider)
- Remove connection string constructors (caller manages IConnectionMultiplexer)
- Update samples to use new API and gpt-5.4-mini

* Use type-safe JsonSerializer overloads, remove suppress attributes

Use JsonSerializerOptions.GetTypeInfo() for Serialize/Deserialize calls
to enable NativeAOT/trimming compatibility without suppress attributes.
Default to AgentAbstractionsJsonUtilities.DefaultOptions when no options provided.

Signed-off-by: Matthias Howell <matthias.howell@improving.com>

* Update READMEs: remove context provider references

Remove ValkeyContextProvider and long-term memory references from sample
READMEs since the context provider was removed from this PR. Simplify
Valkey server requirements (no search module needed for chat history).

Signed-off-by: Matthias Howell <matthias.howell@improving.com>

* Apply suggestion from @westey-m

* Fix formatting (dotnet format)

Signed-off-by: Matthias Howell <matthias.howell@improving.com>

* Update dotnet/src/Microsoft.Agents.AI.Valkey/Microsoft.Agents.AI.Valkey.csproj

Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>

---------

Signed-off-by: Matthias Howell <matthias.howell@improving.com>
Co-authored-by: Matthias Howell <matthias.howell@yoppworks.com>
Co-authored-by: westey <164392973+westey-m@users.noreply.github.com>
Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
This commit is contained in:
Matthias Howell
2026-06-11 09:18:00 -04:00
committed by GitHub
Unverified
parent 4149f24791
commit 8e1998ddcb
15 changed files with 860 additions and 0 deletions
+1
View File
@@ -214,6 +214,7 @@ WARP.md
**/memory-bank/
**/projectBrief.md
**/tmpclaude*
.kiro/
# Dependency-bound validation reports
python/scripts/dependency-*-results.json
python/scripts/dependencies/dependency-*-results.json
+5
View File
@@ -138,10 +138,15 @@
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Mcp" Version="1.0.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.7" />
<!-- Valkey -->
<!-- Redis -->
<PackageVersion Include="StackExchange.Redis" Version="2.10.1" />
<!-- Valkey -->
<PackageVersion Include="Valkey.Glide" Version="1.1.0" />
<!-- Console UX -->
<PackageVersion Include="Spectre.Console" Version="0.49.1" />
<!-- AWS -->
<PackageVersion Include="AWSSDK.Extensions.Bedrock.MEAI" Version="4.0.6.10" />
<!-- Test -->
<PackageVersion Include="FluentAssertions" Version="8.8.0" />
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Condition="'$(TargetFramework)' == 'net8.0'" Version="8.0.22" />
+4
View File
@@ -194,6 +194,8 @@
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0/AgentWithMemory_Step02_MemoryUsingMem0.csproj" />
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj" />
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step05_BoundedChatHistory/AgentWithMemory_Step05_BoundedChatHistory.csproj" />
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey/AgentWithMemory_Step03_MemoryUsingValkey.csproj" />
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/AgentWithOpenAI/">
<File Path="samples/02-agents/AgentWithOpenAI/README.md" />
@@ -625,6 +627,7 @@
<Project Path="src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj" />
<Project Path="src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj" />
<Project Path="src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj" />
<Project Path="src/Microsoft.Agents.AI.Valkey/Microsoft.Agents.AI.Valkey.csproj" />
</Folder>
<Folder Name="/Tests/" />
<Folder Name="/Tests/IntegrationTests/">
@@ -678,5 +681,6 @@
<Project Path="tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/Microsoft.Agents.AI.Workflows.Generators.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Workflows.UnitTests/Microsoft.Agents.AI.Workflows.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Valkey.UnitTests/Microsoft.Agents.AI.Valkey.UnitTests.csproj" />
</Folder>
</Solution>
@@ -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" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Valkey\Microsoft.Agents.AI.Valkey.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,55 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates using Valkey for persistent chat history with the Agent Framework.
// ValkeyChatHistoryProvider persists conversation history across sessions using Valkey lists.
//
// Prerequisites:
// - A running Valkey server (any version):
// docker run -d --name valkey -p 6379:6379 valkey/valkey:latest
// - Azure OpenAI endpoint and deployment configured via environment variables
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Valkey;
using Microsoft.Extensions.AI;
using OpenAI.Chat;
using Valkey.Glide;
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-5.4-mini";
var valkeyConnection = Environment.GetEnvironmentVariable("VALKEY_CONNECTION") ?? "localhost:6379";
var connection = await ConnectionMultiplexer.ConnectAsync(valkeyConnection);
Console.WriteLine("=== ValkeyChatHistoryProvider — Persistent Chat History ===\n");
var historyProvider = new ValkeyChatHistoryProvider(
connection,
_ => new ValkeyChatHistoryProvider.State($"sample-{Guid.NewGuid():N}"),
new ValkeyChatHistoryProviderOptions
{
KeyPrefix = "sample_chat",
MaxMessages = 20
});
AIAgent historyAgent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential())
.GetChatClient(deploymentName)
.AsAIAgent(new ChatClientAgentOptions()
{
ChatOptions = new() { Instructions = "You are a helpful assistant that remembers our conversation." },
ChatHistoryProvider = historyProvider
});
AgentSession session1 = await historyAgent.CreateSessionAsync();
Console.WriteLine(await historyAgent.RunAsync("Hello! My name is Alex and I'm a software engineer.", session1));
Console.WriteLine(await historyAgent.RunAsync("I'm working on a project using Valkey for caching.", session1));
Console.WriteLine(await historyAgent.RunAsync("What do you remember about me?", session1));
var messageCount = await historyProvider.GetMessageCountAsync(session1);
Console.WriteLine($"\n Stored {messageCount} messages in Valkey.\n");
// Clean up
connection.Dispose();
Console.WriteLine("Done!");
@@ -0,0 +1,30 @@
# Agent with Memory Using Valkey
This sample demonstrates using Valkey for persistent chat history with the Agent Framework.
## Components
- **ValkeyChatHistoryProvider** — Persists conversation history across sessions using Valkey lists. Works with any Valkey or Redis OSS server (no search module required).
## Prerequisites
- Azure OpenAI endpoint and deployment
- A running Valkey server (any version):
```bash
docker run -d --name valkey -p 6379:6379 valkey/valkey:latest
```
## Environment Variables
| Variable | Description | Default |
|---|---|---|
| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI endpoint URL | (required) |
| `AZURE_OPENAI_DEPLOYMENT_NAME` | Model deployment name | `gpt-5.4-mini` |
| `VALKEY_CONNECTION` | Valkey connection string | `localhost:6379` |
## Running
```bash
dotnet run
```
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AWSSDK.Extensions.Bedrock.MEAI" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Valkey\Microsoft.Agents.AI.Valkey.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,57 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates using Valkey for persistent chat history with the Agent Framework,
// powered by Amazon Bedrock.
//
// Prerequisites:
// - A running Valkey server (any version):
// docker run -d --name valkey -p 6379:6379 valkey/valkey:latest
// - AWS credentials configured (environment variables, AWS profile, or IAM role)
// - Access to an Amazon Bedrock model (e.g., Anthropic Claude)
using Amazon;
using Amazon.BedrockRuntime;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Valkey;
using Microsoft.Extensions.AI;
using Valkey.Glide;
var awsRegion = Environment.GetEnvironmentVariable("AWS_REGION") ?? "us-east-1";
var modelId = Environment.GetEnvironmentVariable("BEDROCK_MODEL_ID") ?? "anthropic.claude-3-5-sonnet-20241022-v2:0";
var valkeyConnection = Environment.GetEnvironmentVariable("VALKEY_CONNECTION") ?? "localhost:6379";
// Create the Bedrock runtime client.
var bedrockRuntime = new AmazonBedrockRuntimeClient(RegionEndpoint.GetBySystemName(awsRegion));
IChatClient chatClient = bedrockRuntime.AsIChatClient(modelId);
var connection = await ConnectionMultiplexer.ConnectAsync(valkeyConnection);
Console.WriteLine("=== ValkeyChatHistoryProvider — Persistent Chat History (Bedrock) ===\n");
var historyProvider = new ValkeyChatHistoryProvider(
connection,
_ => new ValkeyChatHistoryProvider.State($"bedrock-sample-{Guid.NewGuid():N}"),
new ValkeyChatHistoryProviderOptions
{
KeyPrefix = "bedrock_chat",
MaxMessages = 20
});
AIAgent historyAgent = chatClient.AsAIAgent(new ChatClientAgentOptions()
{
ChatOptions = new() { Instructions = "You are a helpful assistant that remembers our conversation." },
ChatHistoryProvider = historyProvider
});
AgentSession session1 = await historyAgent.CreateSessionAsync();
Console.WriteLine(await historyAgent.RunAsync("Hello! My name is Alex and I'm a software engineer.", session1));
Console.WriteLine(await historyAgent.RunAsync("I'm working on a project using Valkey for caching.", session1));
Console.WriteLine(await historyAgent.RunAsync("What do you remember about me?", session1));
var messageCount = await historyProvider.GetMessageCountAsync(session1);
Console.WriteLine($"\n Stored {messageCount} messages in Valkey.\n");
// Clean up
connection.Dispose();
Console.WriteLine("Done!");
@@ -0,0 +1,41 @@
# Agent with Memory Using Valkey + Amazon Bedrock
This sample demonstrates using Valkey for persistent chat history with the Agent Framework, powered by Amazon Bedrock via the `AWSSDK.Extensions.Bedrock.MEAI` adapter.
## Components
- **ValkeyChatHistoryProvider** — Persists conversation history across sessions using Valkey lists. Works with any Valkey or Redis OSS server (no search module required).
- **Amazon Bedrock** — Provides the LLM via `AWSSDK.Extensions.Bedrock.MEAI`, which implements `IChatClient` from `Microsoft.Extensions.AI`.
## Prerequisites
- AWS credentials configured (environment variables, AWS CLI profile, or IAM role)
- Access to an Amazon Bedrock model (e.g., Anthropic Claude 3.5 Sonnet)
- A running Valkey server (any version):
```bash
docker run -d --name valkey -p 6379:6379 valkey/valkey:latest
```
## Environment Variables
| Variable | Description | Default |
|---|---|---|
| `AWS_REGION` | AWS region for Bedrock | `us-east-1` |
| `BEDROCK_MODEL_ID` | Bedrock model identifier | `anthropic.claude-3-5-sonnet-20241022-v2:0` |
| `VALKEY_CONNECTION` | Valkey connection string | `localhost:6379` |
| `AWS_ACCESS_KEY_ID` | AWS access key (if not using profile/role) | — |
| `AWS_SECRET_ACCESS_KEY` | AWS secret key (if not using profile/role) | — |
## Running
```bash
# Using default AWS credential chain (profile, env vars, or IAM role)
dotnet run
# Or with explicit credentials
export AWS_ACCESS_KEY_ID="your-access-key"
export AWS_SECRET_ACCESS_KEY="your-secret-key"
export AWS_REGION="us-east-1"
dotnet run
```
@@ -0,0 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
<RootNamespace>Microsoft.Agents.AI.Valkey</RootNamespace>
<VersionSuffix>alpha</VersionSuffix>
<NoWarn>$(NoWarn);CA1873</NoWarn>
</PropertyGroup>
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<PropertyGroup>
<!-- Disable packing until we are ready to release this as a nuget -->
<IsPackable>false</IsPackable>
</PropertyGroup>
<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Microsoft Agent Framework - Valkey integration</Title>
<Description>Provides Valkey integration for Microsoft Agent Framework, including chat history persistence and context provider with full-text search.</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Valkey.Glide" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.Valkey.UnitTests" />
</ItemGroup>
</Project>
@@ -0,0 +1,225 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.Diagnostics;
using Valkey.Glide;
namespace Microsoft.Agents.AI.Valkey;
/// <summary>
/// Provides a Valkey-backed implementation of <see cref="ChatHistoryProvider"/> for persistent chat history storage.
/// </summary>
/// <remarks>
/// <para>
/// Uses basic Valkey list operations via Valkey.Glide.
/// No search module is required — this provider works with any Valkey server.
/// </para>
/// <para>
/// <strong>Data retention:</strong> Stored messages have no TTL and persist indefinitely.
/// Use <see cref="ValkeyChatHistoryProviderOptions.MaxMessages"/> to limit per-conversation storage, and <see cref="ClearMessagesAsync"/>
/// for explicit cleanup. Callers are responsible for implementing data retention policies.
/// </para>
/// <para>
/// <strong>Security considerations:</strong>
/// <list type="bullet">
/// <item><description><strong>PII and sensitive data:</strong> Chat history stored in Valkey may contain PII and sensitive
/// conversation content. Ensure the Valkey server is configured with appropriate access controls and encryption in transit
/// (TLS). The <see cref="ValkeyChatHistoryProviderOptions.MaxMessages"/> property can limit stored messages per conversation.</description></item>
/// <item><description><strong>Compromised store risks:</strong> Agent Framework does not validate or filter messages loaded
/// from the store — they are accepted as-is. If the Valkey store is compromised, adversarial content could be injected
/// into the conversation context.</description></item>
/// </list>
/// </para>
/// </remarks>
public sealed class ValkeyChatHistoryProvider : ChatHistoryProvider
{
private readonly ProviderSessionState<State> _sessionState;
private IReadOnlyList<string>? _stateKeys;
private readonly IConnectionMultiplexer _connection;
private readonly string _keyPrefix;
private readonly int? _maxMessages;
private readonly int? _maxMessagesToRetrieve;
private readonly JsonSerializerOptions _jsonSerializerOptions;
private readonly ILogger<ValkeyChatHistoryProvider>? _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ValkeyChatHistoryProvider"/> class.
/// </summary>
/// <param name="connection">An existing <see cref="IConnectionMultiplexer"/> instance.</param>
/// <param name="stateInitializer">A delegate that initializes the provider state on the first invocation.</param>
/// <param name="options">Optional configuration options.</param>
/// <param name="loggerFactory">Optional logger factory.</param>
public ValkeyChatHistoryProvider(
IConnectionMultiplexer connection,
Func<AgentSession?, State> stateInitializer,
ValkeyChatHistoryProviderOptions? options = null,
ILoggerFactory? loggerFactory = null)
: base(options?.ProvideOutputMessageFilter, options?.StoreInputRequestMessageFilter, options?.StoreInputResponseMessageFilter)
{
this._sessionState = new ProviderSessionState<State>(
Throw.IfNull(stateInitializer),
options?.StateKey ?? this.GetType().Name,
options?.JsonSerializerOptions);
this._connection = Throw.IfNull(connection);
this._keyPrefix = options?.KeyPrefix ?? "chat_history";
this._maxMessages = options?.MaxMessages;
this._maxMessagesToRetrieve = options?.MaxMessagesToRetrieve;
this._jsonSerializerOptions = options?.JsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions;
this._logger = loggerFactory?.CreateLogger<ValkeyChatHistoryProvider>();
}
/// <inheritdoc />
public override IReadOnlyList<string> StateKeys => this._stateKeys ??= [this._sessionState.StateKey];
/// <inheritdoc />
protected override async ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
Throw.IfNull(context);
cancellationToken.ThrowIfCancellationRequested();
var state = this._sessionState.GetOrInitializeState(context.Session);
var db = this._connection.GetDatabase();
var key = this.BuildKey(state);
// Fetch only the tail when MaxMessagesToRetrieve is set [Low: avoid fetching all then trimming]
ValkeyValue[] values;
if (this._maxMessagesToRetrieve.HasValue)
{
values = await db.ListRangeAsync(key, -this._maxMessagesToRetrieve.Value, -1).ConfigureAwait(false);
}
else
{
values = await db.ListRangeAsync(key).ConfigureAwait(false);
}
var messages = new List<ChatMessage>(values.Length);
foreach (var value in values)
{
cancellationToken.ThrowIfCancellationRequested();
if (value.IsNullOrEmpty)
{
continue;
}
try
{
var message = JsonSerializer.Deserialize(value.ToString(), this._jsonSerializerOptions.GetTypeInfo(typeof(ChatMessage))) as ChatMessage;
if (message is not null)
{
messages.Add(message);
}
}
catch (JsonException ex)
{
// Skip malformed entries rather than crashing the session [VERIFY-002]
this._logger?.LogWarning(ex, "ValkeyChatHistoryProvider: Skipping malformed message in conversation '{ConversationId}'.", state.ConversationId);
}
}
this._logger?.LogInformation(
"ValkeyChatHistoryProvider: Retrieved {Count} messages for conversation.",
messages.Count);
return messages;
}
/// <inheritdoc />
protected override async ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default)
{
Throw.IfNull(context);
cancellationToken.ThrowIfCancellationRequested();
var state = this._sessionState.GetOrInitializeState(context.Session);
var messageList = context.RequestMessages.Concat(context.ResponseMessages ?? []).ToList();
if (messageList.Count == 0)
{
return;
}
var db = this._connection.GetDatabase();
var key = this.BuildKey(state);
// Batch push — single round-trip [Medium-8]
var serialized = new ValkeyValue[messageList.Count];
for (int i = 0; i < messageList.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
serialized[i] = JsonSerializer.Serialize(messageList[i], this._jsonSerializerOptions.GetTypeInfo(typeof(ChatMessage)));
}
await db.ListRightPushAsync(key, serialized).ConfigureAwait(false);
// Trim to max messages if configured
if (this._maxMessages.HasValue)
{
await db.ListTrimAsync(key, -this._maxMessages.Value, -1).ConfigureAwait(false);
}
this._logger?.LogInformation(
"ValkeyChatHistoryProvider: Stored {Count} messages for conversation.",
messageList.Count);
}
/// <summary>
/// Clears all messages for the specified session's conversation.
/// </summary>
/// <param name="session">The session containing the conversation state.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task representing the asynchronous operation.</returns>
public async Task ClearMessagesAsync(AgentSession? session, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var state = this._sessionState.GetOrInitializeState(session);
var db = this._connection.GetDatabase();
var key = this.BuildKey(state);
await db.KeyDeleteAsync(key).ConfigureAwait(false);
}
/// <summary>
/// Gets the count of stored messages for the specified session's conversation.
/// </summary>
/// <param name="session">The session containing the conversation state.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The number of stored messages.</returns>
public async Task<long> GetMessageCountAsync(AgentSession? session, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var state = this._sessionState.GetOrInitializeState(session);
var db = this._connection.GetDatabase();
var key = this.BuildKey(state);
return await db.ListLengthAsync(key).ConfigureAwait(false);
}
private string BuildKey(State state) => $"{this._keyPrefix}:{state.ConversationId}";
/// <summary>
/// Represents the per-session state of a <see cref="ValkeyChatHistoryProvider"/>.
/// </summary>
public sealed class State
{
/// <summary>
/// Initializes a new instance of the <see cref="State"/> class.
/// </summary>
/// <param name="conversationId">The unique identifier for this conversation thread.</param>
[JsonConstructor]
public State(string conversationId)
{
this.ConversationId = Throw.IfNullOrWhitespace(conversationId);
}
/// <summary>
/// Gets the conversation ID associated with this state.
/// </summary>
public string ConversationId { get; }
}
}
@@ -0,0 +1,56 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.Json;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Valkey;
/// <summary>
/// Options for configuring <see cref="ValkeyChatHistoryProvider"/>.
/// </summary>
public sealed class ValkeyChatHistoryProviderOptions
{
/// <summary>
/// Gets or sets the prefix for Valkey keys. Defaults to "chat_history".
/// </summary>
public string KeyPrefix { get; set; } = "chat_history";
/// <summary>
/// Gets or sets the maximum number of messages to retain per conversation.
/// When exceeded, oldest messages are automatically trimmed. Null means unlimited.
/// </summary>
public int? MaxMessages { get; set; }
/// <summary>
/// Gets or sets the maximum number of messages to retrieve from the provider.
/// Null means no limit.
/// </summary>
public int? MaxMessagesToRetrieve { get; set; }
/// <summary>
/// Gets or sets an optional key for storing state in the session's StateBag.
/// </summary>
public string? StateKey { get; set; }
/// <summary>
/// Gets or sets optional JSON serializer options for serializing the state of this provider.
/// </summary>
public JsonSerializerOptions? JsonSerializerOptions { get; set; }
/// <summary>
/// Gets or sets an optional filter for messages when retrieving from history.
/// </summary>
public Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? ProvideOutputMessageFilter { get; set; }
/// <summary>
/// Gets or sets an optional filter for request messages before storing.
/// </summary>
public Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? StoreInputRequestMessageFilter { get; set; }
/// <summary>
/// Gets or sets an optional filter for response messages before storing.
/// </summary>
public Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? StoreInputResponseMessageFilter { get; set; }
}
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Valkey\Microsoft.Agents.AI.Valkey.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,44 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.Valkey.UnitTests;
internal sealed class TestAgentSession : AgentSession
{
public TestAgentSession()
{
this.StateBag = new AgentSessionStateBag();
}
}
internal static class TestHelpers
{
internal static readonly AIAgent MockAgent = new Mock<AIAgent>().Object;
internal static ChatHistoryProvider.InvokingContext CreateChatHistoryInvokingContext(
IEnumerable<ChatMessage>? requestMessages = null)
{
#pragma warning disable MAAI001
return new ChatHistoryProvider.InvokingContext(
MockAgent,
new TestAgentSession(),
requestMessages ?? [new ChatMessage(ChatRole.User, "test")]);
#pragma warning restore MAAI001
}
internal static ChatHistoryProvider.InvokedContext CreateChatHistoryInvokedContext(
IEnumerable<ChatMessage> requestMessages,
IEnumerable<ChatMessage> responseMessages)
{
#pragma warning disable MAAI001
return new ChatHistoryProvider.InvokedContext(
MockAgent,
new TestAgentSession(),
requestMessages,
responseMessages);
#pragma warning restore MAAI001
}
}
@@ -0,0 +1,249 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
using Valkey.Glide;
namespace Microsoft.Agents.AI.Valkey.UnitTests;
/// <summary>
/// Unit tests for <see cref="ValkeyChatHistoryProvider"/>.
/// </summary>
public sealed class ValkeyChatHistoryProviderTests
{
private static Mock<IConnectionMultiplexer> CreateMockConnection(Mock<IDatabase>? dbMock = null)
{
var mockConnection = new Mock<IConnectionMultiplexer>();
dbMock ??= new Mock<IDatabase>();
mockConnection.Setup(c => c.GetDatabase()).Returns(dbMock.Object);
return mockConnection;
}
// --- Constructor tests ---
[Fact]
public void Constructor_WithConnection_SetsProperties()
{
// Arrange & Act
var provider = new ValkeyChatHistoryProvider(
CreateMockConnection().Object,
static (_) => new ValkeyChatHistoryProvider.State("conv-1"),
new ValkeyChatHistoryProviderOptions { KeyPrefix = "test_prefix" });
// Assert
Assert.NotNull(provider);
}
[Fact]
public void Constructor_WithConnection_NullConnection_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new ValkeyChatHistoryProvider(
null!,
static (_) => new ValkeyChatHistoryProvider.State("conv-1")));
}
[Fact]
public void Constructor_WithConnection_NullStateInitializer_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new ValkeyChatHistoryProvider(
CreateMockConnection().Object,
null!));
}
// --- State tests ---
[Fact]
public void State_NullConversationId_Throws()
{
Assert.Throws<ArgumentNullException>(() => new ValkeyChatHistoryProvider.State(null!));
}
[Fact]
public void State_EmptyConversationId_Throws()
{
Assert.Throws<ArgumentException>(() => new ValkeyChatHistoryProvider.State(""));
}
[Fact]
public void State_ValidConversationId_SetsProperty()
{
var state = new ValkeyChatHistoryProvider.State("my-conversation");
Assert.Equal("my-conversation", state.ConversationId);
}
[Fact]
public void State_JsonConstructor_RoundTrips()
{
// Arrange
var original = new ValkeyChatHistoryProvider.State("test-conv");
// Act
var json = JsonSerializer.Serialize(original);
var deserialized = JsonSerializer.Deserialize<ValkeyChatHistoryProvider.State>(json);
// Assert
Assert.NotNull(deserialized);
Assert.Equal("test-conv", deserialized.ConversationId);
}
// --- StateKeys tests ---
[Fact]
public void StateKeys_ReturnsProviderTypeName()
{
var provider = new ValkeyChatHistoryProvider(
CreateMockConnection().Object,
_ => new ValkeyChatHistoryProvider.State("conv-1"));
var keys = provider.StateKeys;
Assert.Single(keys);
Assert.Equal(nameof(ValkeyChatHistoryProvider), keys[0]);
}
[Fact]
public void StateKeys_WithCustomKey_ReturnsCustomKey()
{
var provider = new ValkeyChatHistoryProvider(
CreateMockConnection().Object,
_ => new ValkeyChatHistoryProvider.State("conv-1"),
new ValkeyChatHistoryProviderOptions { StateKey = "custom_key" });
var keys = provider.StateKeys;
Assert.Single(keys);
Assert.Equal("custom_key", keys[0]);
}
// --- ProvideChatHistoryAsync tests ---
[Fact]
public async Task ProvideChatHistoryAsync_ReturnsDeserializedMessagesAsync()
{
// Arrange
var dbMock = new Mock<IDatabase>();
var msg1 = new ChatMessage(ChatRole.User, "hello");
var msg2 = new ChatMessage(ChatRole.Assistant, "hi there");
var values = new ValkeyValue[]
{
JsonSerializer.Serialize(msg1),
JsonSerializer.Serialize(msg2)
};
dbMock.Setup(d => d.ListRangeAsync(It.IsAny<ValkeyKey>(), It.IsAny<long>(), It.IsAny<long>()))
.ReturnsAsync(values);
var provider = new ValkeyChatHistoryProvider(
CreateMockConnection(dbMock).Object,
_ => new ValkeyChatHistoryProvider.State("conv-1"));
var context = TestHelpers.CreateChatHistoryInvokingContext();
// Act — should not throw
var result = await provider.InvokingAsync(context);
var messages = result.ToList();
// Assert — only the valid message + request message
Assert.True(messages.Count >= 1);
}
[Fact]
public async Task ProvideChatHistoryAsync_WithMaxMessagesToRetrieve_UsesRangeQueryAsync()
{
// Arrange
var dbMock = new Mock<IDatabase>();
dbMock.Setup(d => d.ListRangeAsync(It.IsAny<ValkeyKey>(), It.IsAny<long>(), It.IsAny<long>()))
.ReturnsAsync([]);
var provider = new ValkeyChatHistoryProvider(
CreateMockConnection(dbMock).Object,
_ => new ValkeyChatHistoryProvider.State("conv-1"),
new ValkeyChatHistoryProviderOptions { MaxMessagesToRetrieve = 5 });
var context = TestHelpers.CreateChatHistoryInvokingContext();
// Act
await provider.InvokingAsync(context);
// Assert — should use -5, -1 range
dbMock.Verify(d => d.ListRangeAsync(
It.IsAny<ValkeyKey>(), -5, -1), Times.Once);
}
[Fact]
public async Task ProvideChatHistoryAsync_CancellationToken_ThrowsAsync()
{
// Arrange
var provider = new ValkeyChatHistoryProvider(
CreateMockConnection().Object,
_ => new ValkeyChatHistoryProvider.State("conv-1"));
var cts = new CancellationTokenSource();
cts.Cancel();
var context = TestHelpers.CreateChatHistoryInvokingContext();
// Act & Assert
await Assert.ThrowsAsync<OperationCanceledException>(() =>
provider.InvokingAsync(context, cts.Token).AsTask());
}
// --- StoreChatHistoryAsync tests ---
[Fact]
public async Task StoreChatHistoryAsync_BatchPushesMessagesAsync()
{
// Arrange
var dbMock = new Mock<IDatabase>();
dbMock.Setup(d => d.ListRightPushAsync(It.IsAny<ValkeyKey>(), It.IsAny<ValkeyValue[]>()))
.ReturnsAsync(2);
var provider = new ValkeyChatHistoryProvider(
CreateMockConnection(dbMock).Object,
_ => new ValkeyChatHistoryProvider.State("conv-1"));
var context = TestHelpers.CreateChatHistoryInvokedContext(
[new ChatMessage(ChatRole.User, "hello")],
[new ChatMessage(ChatRole.Assistant, "hi")]);
// Act
await provider.InvokedAsync(context);
// Assert — batch push called once with array
dbMock.Verify(d => d.ListRightPushAsync(
It.IsAny<ValkeyKey>(), It.IsAny<ValkeyValue[]>()), Times.Once);
}
[Fact]
public async Task StoreChatHistoryAsync_WithMaxMessages_TrimsAsync()
{
// Arrange
var dbMock = new Mock<IDatabase>();
dbMock.Setup(d => d.ListRightPushAsync(It.IsAny<ValkeyKey>(), It.IsAny<ValkeyValue[]>()))
.ReturnsAsync(1);
dbMock.Setup(d => d.ListTrimAsync(It.IsAny<ValkeyKey>(), It.IsAny<long>(), It.IsAny<long>()))
.Returns(Task.CompletedTask);
var provider = new ValkeyChatHistoryProvider(
CreateMockConnection(dbMock).Object,
_ => new ValkeyChatHistoryProvider.State("conv-1"),
new ValkeyChatHistoryProviderOptions { MaxMessages = 10 });
var context = TestHelpers.CreateChatHistoryInvokedContext(
[new ChatMessage(ChatRole.User, "hello")],
[new ChatMessage(ChatRole.Assistant, "hi")]);
// Act
await provider.InvokedAsync(context);
// Assert — trim called unconditionally when MaxMessages is set
dbMock.Verify(d => d.ListTrimAsync(
It.IsAny<ValkeyKey>(), -10, -1), Times.Once);
}
}