mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.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:
committed by
GitHub
Unverified
parent
4149f24791
commit
8e1998ddcb
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
+22
@@ -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>
|
||||
+55
@@ -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!");
|
||||
+30
@@ -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
|
||||
```
|
||||
+20
@@ -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>
|
||||
+57
@@ -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!");
|
||||
+41
@@ -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; }
|
||||
}
|
||||
+11
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user