From 8e1998ddcb46d75cf3cc277bc74d2ea87b0e035a Mon Sep 17 00:00:00 2001 From: Matthias Howell <106839070+MatthiasHowellYopp@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:18:00 -0400 Subject: [PATCH] .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 * 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 * Apply suggestion from @westey-m * Fix formatting (dotnet format) Signed-off-by: Matthias Howell * 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 Co-authored-by: Matthias Howell Co-authored-by: westey <164392973+westey-m@users.noreply.github.com> Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> --- .gitignore | 1 + dotnet/Directory.Packages.props | 5 + dotnet/agent-framework-dotnet.slnx | 4 + ...WithMemory_Step03_MemoryUsingValkey.csproj | 22 ++ .../Program.cs | 55 ++++ .../README.md | 30 +++ ...ry_Step03_MemoryUsingValkey_Bedrock.csproj | 20 ++ .../Program.cs | 57 ++++ .../README.md | 41 +++ .../Microsoft.Agents.AI.Valkey.csproj | 40 +++ .../ValkeyChatHistoryProvider.cs | 225 ++++++++++++++++ .../ValkeyChatHistoryProviderOptions.cs | 56 ++++ ...icrosoft.Agents.AI.Valkey.UnitTests.csproj | 11 + .../TestHelpers.cs | 44 ++++ .../ValkeyChatHistoryProviderTests.cs | 249 ++++++++++++++++++ 15 files changed, 860 insertions(+) create mode 100644 dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey/AgentWithMemory_Step03_MemoryUsingValkey.csproj create mode 100644 dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey/Program.cs create mode 100644 dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey/README.md create mode 100644 dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock.csproj create mode 100644 dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock/Program.cs create mode 100644 dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock/README.md create mode 100644 dotnet/src/Microsoft.Agents.AI.Valkey/Microsoft.Agents.AI.Valkey.csproj create mode 100644 dotnet/src/Microsoft.Agents.AI.Valkey/ValkeyChatHistoryProvider.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Valkey/ValkeyChatHistoryProviderOptions.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Valkey.UnitTests/Microsoft.Agents.AI.Valkey.UnitTests.csproj create mode 100644 dotnet/tests/Microsoft.Agents.AI.Valkey.UnitTests/TestHelpers.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Valkey.UnitTests/ValkeyChatHistoryProviderTests.cs diff --git a/.gitignore b/.gitignore index e6b9efb40e..258a8c0704 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index fc57fdea08..76a4444bc1 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -138,10 +138,15 @@ + + + + + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 98c364cd53..6afa318012 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -194,6 +194,8 @@ + + @@ -625,6 +627,7 @@ + @@ -678,5 +681,6 @@ + diff --git a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey/AgentWithMemory_Step03_MemoryUsingValkey.csproj b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey/AgentWithMemory_Step03_MemoryUsingValkey.csproj new file mode 100644 index 0000000000..1217591bc1 --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey/AgentWithMemory_Step03_MemoryUsingValkey.csproj @@ -0,0 +1,22 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey/Program.cs b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey/Program.cs new file mode 100644 index 0000000000..6faa02a0f3 --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey/Program.cs @@ -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!"); diff --git a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey/README.md b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey/README.md new file mode 100644 index 0000000000..08f65ecffa --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey/README.md @@ -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 +``` diff --git a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock.csproj b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock.csproj new file mode 100644 index 0000000000..274ac15c97 --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock.csproj @@ -0,0 +1,20 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock/Program.cs b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock/Program.cs new file mode 100644 index 0000000000..6f3027681f --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock/Program.cs @@ -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!"); diff --git a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock/README.md b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock/README.md new file mode 100644 index 0000000000..06d4012bd9 --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock/README.md @@ -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 +``` diff --git a/dotnet/src/Microsoft.Agents.AI.Valkey/Microsoft.Agents.AI.Valkey.csproj b/dotnet/src/Microsoft.Agents.AI.Valkey/Microsoft.Agents.AI.Valkey.csproj new file mode 100644 index 0000000000..e819c3f51c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Valkey/Microsoft.Agents.AI.Valkey.csproj @@ -0,0 +1,40 @@ + + + + $(TargetFrameworksCore) + Microsoft.Agents.AI.Valkey + alpha + $(NoWarn);CA1873 + + + + true + true + + + + + + false + + + + + Microsoft Agent Framework - Valkey integration + Provides Valkey integration for Microsoft Agent Framework, including chat history persistence and context provider with full-text search. + + + + + + + + + + + + + + + + diff --git a/dotnet/src/Microsoft.Agents.AI.Valkey/ValkeyChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Valkey/ValkeyChatHistoryProvider.cs new file mode 100644 index 0000000000..088d66ed47 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Valkey/ValkeyChatHistoryProvider.cs @@ -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; + +/// +/// Provides a Valkey-backed implementation of for persistent chat history storage. +/// +/// +/// +/// Uses basic Valkey list operations via Valkey.Glide. +/// No search module is required — this provider works with any Valkey server. +/// +/// +/// Data retention: Stored messages have no TTL and persist indefinitely. +/// Use to limit per-conversation storage, and +/// for explicit cleanup. Callers are responsible for implementing data retention policies. +/// +/// +/// Security considerations: +/// +/// PII and sensitive data: 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 property can limit stored messages per conversation. +/// Compromised store risks: 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. +/// +/// +/// +public sealed class ValkeyChatHistoryProvider : ChatHistoryProvider +{ + private readonly ProviderSessionState _sessionState; + private IReadOnlyList? _stateKeys; + private readonly IConnectionMultiplexer _connection; + private readonly string _keyPrefix; + private readonly int? _maxMessages; + private readonly int? _maxMessagesToRetrieve; + private readonly JsonSerializerOptions _jsonSerializerOptions; + private readonly ILogger? _logger; + + /// + /// Initializes a new instance of the class. + /// + /// An existing instance. + /// A delegate that initializes the provider state on the first invocation. + /// Optional configuration options. + /// Optional logger factory. + public ValkeyChatHistoryProvider( + IConnectionMultiplexer connection, + Func stateInitializer, + ValkeyChatHistoryProviderOptions? options = null, + ILoggerFactory? loggerFactory = null) + : base(options?.ProvideOutputMessageFilter, options?.StoreInputRequestMessageFilter, options?.StoreInputResponseMessageFilter) + { + this._sessionState = new ProviderSessionState( + 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(); + } + + /// + public override IReadOnlyList StateKeys => this._stateKeys ??= [this._sessionState.StateKey]; + + /// + protected override async ValueTask> 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(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; + } + + /// + 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); + } + + /// + /// Clears all messages for the specified session's conversation. + /// + /// The session containing the conversation state. + /// Cancellation token. + /// A task representing the asynchronous operation. + 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); + } + + /// + /// Gets the count of stored messages for the specified session's conversation. + /// + /// The session containing the conversation state. + /// Cancellation token. + /// The number of stored messages. + public async Task 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}"; + + /// + /// Represents the per-session state of a . + /// + public sealed class State + { + /// + /// Initializes a new instance of the class. + /// + /// The unique identifier for this conversation thread. + [JsonConstructor] + public State(string conversationId) + { + this.ConversationId = Throw.IfNullOrWhitespace(conversationId); + } + + /// + /// Gets the conversation ID associated with this state. + /// + public string ConversationId { get; } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Valkey/ValkeyChatHistoryProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI.Valkey/ValkeyChatHistoryProviderOptions.cs new file mode 100644 index 0000000000..eabe4680cf --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Valkey/ValkeyChatHistoryProviderOptions.cs @@ -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; + +/// +/// Options for configuring . +/// +public sealed class ValkeyChatHistoryProviderOptions +{ + /// + /// Gets or sets the prefix for Valkey keys. Defaults to "chat_history". + /// + public string KeyPrefix { get; set; } = "chat_history"; + + /// + /// Gets or sets the maximum number of messages to retain per conversation. + /// When exceeded, oldest messages are automatically trimmed. Null means unlimited. + /// + public int? MaxMessages { get; set; } + + /// + /// Gets or sets the maximum number of messages to retrieve from the provider. + /// Null means no limit. + /// + public int? MaxMessagesToRetrieve { get; set; } + + /// + /// Gets or sets an optional key for storing state in the session's StateBag. + /// + public string? StateKey { get; set; } + + /// + /// Gets or sets optional JSON serializer options for serializing the state of this provider. + /// + public JsonSerializerOptions? JsonSerializerOptions { get; set; } + + /// + /// Gets or sets an optional filter for messages when retrieving from history. + /// + public Func, IEnumerable>? ProvideOutputMessageFilter { get; set; } + + /// + /// Gets or sets an optional filter for request messages before storing. + /// + public Func, IEnumerable>? StoreInputRequestMessageFilter { get; set; } + + /// + /// Gets or sets an optional filter for response messages before storing. + /// + public Func, IEnumerable>? StoreInputResponseMessageFilter { get; set; } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Valkey.UnitTests/Microsoft.Agents.AI.Valkey.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Valkey.UnitTests/Microsoft.Agents.AI.Valkey.UnitTests.csproj new file mode 100644 index 0000000000..4dd4ff1f6c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Valkey.UnitTests/Microsoft.Agents.AI.Valkey.UnitTests.csproj @@ -0,0 +1,11 @@ + + + + net10.0 + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Valkey.UnitTests/TestHelpers.cs b/dotnet/tests/Microsoft.Agents.AI.Valkey.UnitTests/TestHelpers.cs new file mode 100644 index 0000000000..1f320ec34f --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Valkey.UnitTests/TestHelpers.cs @@ -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().Object; + + internal static ChatHistoryProvider.InvokingContext CreateChatHistoryInvokingContext( + IEnumerable? 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 requestMessages, + IEnumerable responseMessages) + { +#pragma warning disable MAAI001 + return new ChatHistoryProvider.InvokedContext( + MockAgent, + new TestAgentSession(), + requestMessages, + responseMessages); +#pragma warning restore MAAI001 + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Valkey.UnitTests/ValkeyChatHistoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Valkey.UnitTests/ValkeyChatHistoryProviderTests.cs new file mode 100644 index 0000000000..d624b58fa1 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Valkey.UnitTests/ValkeyChatHistoryProviderTests.cs @@ -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; + +/// +/// Unit tests for . +/// +public sealed class ValkeyChatHistoryProviderTests +{ + private static Mock CreateMockConnection(Mock? dbMock = null) + { + var mockConnection = new Mock(); + dbMock ??= new Mock(); + 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(() => + new ValkeyChatHistoryProvider( + null!, + static (_) => new ValkeyChatHistoryProvider.State("conv-1"))); + } + + [Fact] + public void Constructor_WithConnection_NullStateInitializer_Throws() + { + // Act & Assert + Assert.Throws(() => + new ValkeyChatHistoryProvider( + CreateMockConnection().Object, + null!)); + } + + // --- State tests --- + + [Fact] + public void State_NullConversationId_Throws() + { + Assert.Throws(() => new ValkeyChatHistoryProvider.State(null!)); + } + + [Fact] + public void State_EmptyConversationId_Throws() + { + Assert.Throws(() => 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(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(); + 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(), It.IsAny(), It.IsAny())) + .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(); + dbMock.Setup(d => d.ListRangeAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .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(), -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(() => + provider.InvokingAsync(context, cts.Token).AsTask()); + } + + // --- StoreChatHistoryAsync tests --- + + [Fact] + public async Task StoreChatHistoryAsync_BatchPushesMessagesAsync() + { + // Arrange + var dbMock = new Mock(); + dbMock.Setup(d => d.ListRightPushAsync(It.IsAny(), It.IsAny())) + .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(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task StoreChatHistoryAsync_WithMaxMessages_TrimsAsync() + { + // Arrange + var dbMock = new Mock(); + dbMock.Setup(d => d.ListRightPushAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(1); + dbMock.Setup(d => d.ListTrimAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .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(), -10, -1), Times.Once); + } +}