From e5d9d74c2da0302d5e2ca01e174a7b1cbe4c03e0 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Fri, 7 Nov 2025 11:46:35 +0000 Subject: [PATCH] .NET: Chathistory memory provider add (#1867) * Add ChatHistoryMemoryProvider with unit tests * Set new project to not packable. * Fix bugs * Add serialization support. * Update dotnet/src/Microsoft.Agents.AI.VectorDataMemory/ChatHistoryMemoryProvider.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Remove unnecessary line * Convert ChatHistoryMemoryProvider to use Dynamic collections. * Sealing options and scope classes. * Add sample, add scope to logs and improve scope validation * Move ChatHistoryMemoryProvider to MAAI project. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- dotnet/agent-framework-dotnet.slnx | 1 + .../Agent_Step18_TextSearchRag/Program.cs | 2 + ...nt_Step21_ChatHistoryMemoryProvider.csproj | 23 + .../Program.cs | 60 +++ .../Microsoft.Agents.AI.Mem0/Mem0Provider.cs | 8 +- .../Microsoft.Agents.AI/AgentJsonUtilities.cs | 1 + .../ChatClient/ChatClientAgentThread.cs | 4 +- .../Memory/ChatHistoryMemoryProvider.cs | 483 ++++++++++++++++++ .../ChatHistoryMemoryProviderOptions.cs | 56 ++ .../Memory/ChatHistoryMemoryProviderScope.cs | 53 ++ .../Memory/ChatHistoryMemoryProviderTests.cs | 396 ++++++++++++++ 11 files changed, 1081 insertions(+), 6 deletions(-) create mode 100644 dotnet/samples/GettingStarted/Agents/Agent_Step21_ChatHistoryMemoryProvider/Agent_Step21_ChatHistoryMemoryProvider.csproj create mode 100644 dotnet/samples/GettingStarted/Agents/Agent_Step21_ChatHistoryMemoryProvider/Program.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProviderOptions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProviderScope.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Memory/ChatHistoryMemoryProviderTests.cs diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 03f8a910d3..56211e457f 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -66,6 +66,7 @@ + diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step18_TextSearchRag/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step18_TextSearchRag/Program.cs index 65f3a9e98f..c56be11fc5 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step18_TextSearchRag/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step18_TextSearchRag/Program.cs @@ -4,6 +4,8 @@ // capabilities to an AI agent. The provider runs a search against an external knowledge base // before each model invocation and injects the results into the model context. +// Also see the AgentWithRAG folder for more advanced RAG scenarios. + using Azure.AI.OpenAI; using Azure.Identity; using Microsoft.Agents.AI; diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step21_ChatHistoryMemoryProvider/Agent_Step21_ChatHistoryMemoryProvider.csproj b/dotnet/samples/GettingStarted/Agents/Agent_Step21_ChatHistoryMemoryProvider/Agent_Step21_ChatHistoryMemoryProvider.csproj new file mode 100644 index 0000000000..1caf270c49 --- /dev/null +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step21_ChatHistoryMemoryProvider/Agent_Step21_ChatHistoryMemoryProvider.csproj @@ -0,0 +1,23 @@ + + + + Exe + net9.0 + + enable + enable + + + + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step21_ChatHistoryMemoryProvider/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step21_ChatHistoryMemoryProvider/Program.cs new file mode 100644 index 0000000000..9e4c27cebb --- /dev/null +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step21_ChatHistoryMemoryProvider/Program.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to create and use a simple AI agent that stores chat messages in a vector store using the ChatHistoryMemoryProvider. +// It can then use the chat history from prior conversations to inform responses in new conversations. + +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.VectorData; +using Microsoft.SemanticKernel.Connectors.InMemory; +using OpenAI; + +var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var embeddingDeploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-3-large"; + +// Create a vector store to store the chat messages in. +// For demonstration purposes, we are using an in-memory vector store. +// Replace this with a vector store implementation of your choice that can persist the chat history long term. +VectorStore vectorStore = new InMemoryVectorStore(new InMemoryVectorStoreOptions() +{ + EmbeddingGenerator = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()) + .GetEmbeddingClient(embeddingDeploymentName) + .AsIEmbeddingGenerator() +}); + +// Create the agent and add the ChatHistoryMemoryProvider to store chat messages in the vector store. +AIAgent agent = new AzureOpenAIClient( + new Uri(endpoint), + new AzureCliCredential()) + .GetChatClient(deploymentName) + .CreateAIAgent(new ChatClientAgentOptions + { + Instructions = "You are good at telling jokes.", + Name = "Joker", + AIContextProviderFactory = (ctx) => new ChatHistoryMemoryProvider( + vectorStore, + collectionName: "chathistory", + vectorDimensions: 3072, + // Configure the scope values under which chat messages will be stored. + // In this case, we are using a fixed user ID and a unique thread ID for each new thread. + storageScope: new() { UserId = "UID1", ThreadId = new Guid().ToString() }, + // Configure the scope which would be used to search for relevant prior messages. + // In this case, we are searching for any messages for the user across all threads. + searchScope: new() { UserId = "UID1" }) + }); + +// Start a new thread for the agent conversation. +AgentThread thread = agent.GetNewThread(); + +// Run the agent with the thread that stores conversation history in the vector store. +Console.WriteLine(await agent.RunAsync("I like jokes about Pirates. Tell me a joke about a pirate.", thread)); + +// Start a second thread. Since we configured the search scope to be across all threads for the user, +// the agent should remember that the user likes pirate jokes. +AgentThread thread2 = agent.GetNewThread(); + +// Run the agent with the second thread. +Console.WriteLine(await agent.RunAsync("Tell me a joke that I might like.", thread2)); diff --git a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs index 4aae5de59b..d18ed2b460 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs @@ -153,7 +153,7 @@ public sealed class Mem0Provider : AIContextProvider if (this._logger is not null) { this._logger.LogInformation( - "Mem0AIContextProvider: Retrieved {Count} memories. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'", + "Mem0AIContextProvider: Retrieved {Count} memories. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'.", memories.Count, this._searchScope.ApplicationId, this._searchScope.AgentId, @@ -162,7 +162,7 @@ public sealed class Mem0Provider : AIContextProvider if (outputMessageText is not null) { this._logger.LogTrace( - "Mem0AIContextProvider: Search Results\nInput:{Input}\nOutput:{MessageText}\nApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'", + "Mem0AIContextProvider: Search Results\nInput:{Input}\nOutput:{MessageText}\nApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'.", queryText, outputMessageText, this._searchScope.ApplicationId, @@ -185,7 +185,7 @@ public sealed class Mem0Provider : AIContextProvider { this._logger?.LogError( ex, - "Mem0AIContextProvider: Failed to search Mem0 for memories due to error. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'", + "Mem0AIContextProvider: Failed to search Mem0 for memories due to error. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'.", this._searchScope.ApplicationId, this._searchScope.AgentId, this._searchScope.ThreadId, @@ -211,7 +211,7 @@ public sealed class Mem0Provider : AIContextProvider { this._logger?.LogError( ex, - "Mem0AIContextProvider: Failed to send messages to Mem0 due to error. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'", + "Mem0AIContextProvider: Failed to send messages to Mem0 due to error. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'.", this._storageScope.ApplicationId, this._storageScope.AgentId, this._storageScope.ThreadId, diff --git a/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs index 36bef4a2af..c400a1cb6c 100644 --- a/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs +++ b/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs @@ -68,6 +68,7 @@ internal static partial class AgentJsonUtilities // Agent abstraction types [JsonSerializable(typeof(ChatClientAgentThread.ThreadState))] [JsonSerializable(typeof(TextSearchProvider.TextSearchProviderState))] + [JsonSerializable(typeof(ChatHistoryMemoryProvider.ChatHistoryMemoryProviderState))] [ExcludeFromCodeCoverage] internal sealed partial class JsonContext : JsonSerializerContext; diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentThread.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentThread.cs index baa36c0054..ad224e6777 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentThread.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentThread.cs @@ -166,8 +166,8 @@ public class ChatClientAgentThread : AgentThread var state = new ThreadState { ConversationId = this.ConversationId, - StoreState = storeState, - AIContextProviderState = aiContextProviderState + StoreState = storeState is { ValueKind: not JsonValueKind.Undefined } ? storeState : null, + AIContextProviderState = aiContextProviderState is { ValueKind: not JsonValueKind.Undefined } ? aiContextProviderState : null, }; return JsonSerializer.SerializeToElement(state, AgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ThreadState))); diff --git a/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs new file mode 100644 index 0000000000..6d90c877e8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs @@ -0,0 +1,483 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.VectorData; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A context provider that stores all chat history in a vector store and is able to +/// retrieve related chat history later to augment the current conversation. +/// +/// +/// +/// This provider stores chat messages in a vector store and retrieves relevant previous messages +/// to provide as context during agent invocations. It uses the VectorStore and VectorStoreCollection +/// abstractions to work with any compatible vector store implementation. +/// +/// +/// Messages are stored during the method and retrieved during the +/// method using semantic similarity search. +/// +/// +/// Behavior is configurable through . When +/// is selected the provider +/// exposes a function tool that the model can invoke to retrieve relevant memories on demand instead of +/// injecting them automatically on each invocation. +/// +/// +public sealed class ChatHistoryMemoryProvider : AIContextProvider, IDisposable +{ + private const string DefaultContextPrompt = "## Memories\nConsider the following memories when answering user questions:"; + private const int DefaultMaxResults = 3; + private const string DefaultFunctionToolName = "Search"; + private const string DefaultFunctionToolDescription = "Allows searching for related previous chat history to help answer the user question."; + + private readonly VectorStore _vectorStore; + private readonly VectorStoreCollection> _collection; + private readonly int _maxResults; + private readonly string _contextPrompt; + private readonly ChatHistoryMemoryProviderOptions.SearchBehavior _searchTime; + private readonly AITool[] _tools; + private readonly ILogger? _logger; + + private readonly ChatHistoryMemoryProviderScope _storageScope; + private readonly ChatHistoryMemoryProviderScope _searchScope; + + private bool _collectionInitialized; + private readonly SemaphoreSlim _initializationLock = new(1, 1); + private bool _disposedValue; + + /// + /// Initializes a new instance of the class. + /// + /// The vector store to use for storing and retrieving chat history. + /// The name of the collection for storing chat history in the vector store. + /// The number of dimensions to use for the chat history vector store embeddings. + /// Optional values to scope the chat history storage with. + /// Optional values to scope the chat history search with. Where values are null, no filtering is done using those values. Defaults to if not provided. + /// Optional configuration options. + /// Optional logger factory. + /// Thrown when is . + public ChatHistoryMemoryProvider( + VectorStore vectorStore, + string collectionName, + int vectorDimensions, + ChatHistoryMemoryProviderScope storageScope, + ChatHistoryMemoryProviderScope? searchScope = null, + ChatHistoryMemoryProviderOptions? options = null, + ILoggerFactory? loggerFactory = null) + : this( + vectorStore, + collectionName, + vectorDimensions, + new ChatHistoryMemoryProviderState + { + StorageScope = new(Throw.IfNull(storageScope)), + SearchScope = searchScope ?? new(storageScope), + }, + options, + loggerFactory) + { + } + + /// + /// Initializes a new instance of the class from previously serialized state. + /// + /// The vector store to use for storing and retrieving chat history. + /// The name of the collection for storing chat history in the vector store. + /// The number of dimensions to use for the chat history vector store embeddings. + /// A representing the serialized state of the provider. + /// Optional settings for customizing the JSON deserialization process. + /// Optional configuration options. + /// Optional logger factory. + public ChatHistoryMemoryProvider( + VectorStore vectorStore, + string collectionName, + int vectorDimensions, + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions = null, + ChatHistoryMemoryProviderOptions? options = null, + ILoggerFactory? loggerFactory = null) + : this( + vectorStore, + collectionName, + vectorDimensions, + DeserializeState(serializedState, jsonSerializerOptions), + options, + loggerFactory) + { + } + + private ChatHistoryMemoryProvider( + VectorStore vectorStore, + string collectionName, + int vectorDimensions, + ChatHistoryMemoryProviderState? state = null, + ChatHistoryMemoryProviderOptions? options = null, + ILoggerFactory? loggerFactory = null) + { + this._vectorStore = vectorStore ?? throw new ArgumentNullException(nameof(vectorStore)); + options ??= new ChatHistoryMemoryProviderOptions(); + this._maxResults = options.MaxResults.HasValue ? Throw.IfLessThanOrEqual(options.MaxResults.Value, 0) : DefaultMaxResults; + this._contextPrompt = options.ContextPrompt ?? DefaultContextPrompt; + this._searchTime = options.SearchTime; + this._logger = loggerFactory?.CreateLogger(); + + if (state == null || state.StorageScope == null || state.SearchScope == null) + { + throw new InvalidOperationException($"The {nameof(ChatHistoryMemoryProvider)} state did not contain the required scope properties."); + } + + this._storageScope = state.StorageScope; + this._searchScope = state.SearchScope; + + // Create on-demand search tool (only used when behavior is OnDemandFunctionCalling) + this._tools = + [ + AIFunctionFactory.Create( + (Func>)this.SearchTextAsync, + name: options.FunctionToolName ?? DefaultFunctionToolName, + description: options.FunctionToolDescription ?? DefaultFunctionToolDescription) + ]; + + // Create a definition so that we can use the dimensions provided at runtime. + var definition = new VectorStoreCollectionDefinition + { + Properties = new List + { + new VectorStoreKeyProperty("Key", typeof(Guid)), + new VectorStoreDataProperty("Role", typeof(string)) { IsIndexed = true }, + new VectorStoreDataProperty("MessageId", typeof(string)) { IsIndexed = true }, + new VectorStoreDataProperty("AuthorName", typeof(string)), + new VectorStoreDataProperty("ApplicationId", typeof(string)) { IsIndexed = true }, + new VectorStoreDataProperty("AgentId", typeof(string)) { IsIndexed = true }, + new VectorStoreDataProperty("UserId", typeof(string)) { IsIndexed = true }, + new VectorStoreDataProperty("ThreadId", typeof(string)) { IsIndexed = true }, + new VectorStoreDataProperty("Content", typeof(string)) { IsFullTextIndexed = true }, + new VectorStoreDataProperty("CreatedAt", typeof(string)) { IsIndexed = true }, + new VectorStoreVectorProperty("ContentEmbedding", typeof(string), Throw.IfLessThan(vectorDimensions, 1)) + } + }; + + this._collection = this._vectorStore.GetDynamicCollection(Throw.IfNullOrWhitespace(collectionName), definition); + } + + /// + public override async ValueTask InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(context); + + if (this._searchTime == ChatHistoryMemoryProviderOptions.SearchBehavior.OnDemandFunctionCalling) + { + // Expose search tool for on-demand invocation by the model + return new AIContext { Tools = this._tools }; + } + + try + { + // Get the text from the current request messages + var requestText = string.Join("\n", context.RequestMessages + .Where(m => m != null && !string.IsNullOrWhiteSpace(m.Text)) + .Select(m => m.Text)); + + if (string.IsNullOrWhiteSpace(requestText)) + { + return new AIContext(); + } + + // Search for relevant chat history + var contextText = await this.SearchTextAsync(requestText, cancellationToken).ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(contextText)) + { + return new AIContext(); + } + + return new AIContext + { + Messages = [new ChatMessage(ChatRole.User, contextText)] + }; + } + catch (Exception ex) + { + this._logger?.LogError( + ex, + "ChatHistoryMemoryProvider: Failed to search for chat history due to error. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'.", + this._searchScope.ApplicationId, + this._searchScope.AgentId, + this._searchScope.ThreadId, + this._searchScope.UserId); + return new AIContext(); + } + } + + /// + public override async ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(context); + + // Only store if invocation was successful + if (context.InvokeException != null) + { + return; + } + + try + { + // Ensure the collection is initialized + var collection = await this.EnsureCollectionExistsAsync(cancellationToken).ConfigureAwait(false); + + List> itemsToStore = context.RequestMessages + .Concat(context.ResponseMessages ?? []) + .Select(message => new Dictionary + { + ["Key"] = Guid.NewGuid(), + ["Role"] = message.Role.ToString(), + ["MessageId"] = message.MessageId, + ["AuthorName"] = message.AuthorName, + ["ApplicationId"] = this._storageScope?.ApplicationId, + ["AgentId"] = this._storageScope?.AgentId, + ["UserId"] = this._storageScope?.UserId, + ["ThreadId"] = this._storageScope?.ThreadId, + ["Content"] = message.Text, + ["CreatedAt"] = message.CreatedAt?.ToString("O") ?? DateTimeOffset.UtcNow.ToString("O"), + ["ContentEmbedding"] = message.Text, + }) + .ToList(); + + if (itemsToStore.Count > 0) + { + await collection.UpsertAsync(itemsToStore, cancellationToken).ConfigureAwait(false); + } + } + catch (Exception ex) + { + this._logger?.LogError( + ex, + "ChatHistoryMemoryProvider: Failed to add messages to chat history vector store due to error. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'.", + this._searchScope.ApplicationId, + this._searchScope.AgentId, + this._searchScope.ThreadId, + this._searchScope.UserId); + } + } + + /// + /// Function callable by the AI model (when enabled) to perform an ad-hoc chat history search. + /// + /// The query text. + /// Cancellation token. + /// Formatted search results (may be empty). + internal async Task SearchTextAsync(string userQuestion, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(userQuestion)) + { + return string.Empty; + } + + var results = await this.SearchChatHistoryAsync(userQuestion, this._maxResults, cancellationToken).ConfigureAwait(false); + if (!results.Any()) + { + return string.Empty; + } + + // Format the results as a single context message + var outputResultsText = string.Join("\n", results.Select(x => (string?)x["Content"]).Where(c => !string.IsNullOrWhiteSpace(c))); + if (string.IsNullOrWhiteSpace(outputResultsText)) + { + return string.Empty; + } + + var formatted = $"{this._contextPrompt}\n{outputResultsText}"; + + this._logger?.LogTrace( + "ChatHistoryMemoryProvider: Search Results\nInput:{Input}\nOutput:{MessageText}\n ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'.", + userQuestion, + formatted, + this._searchScope.ApplicationId, + this._searchScope.AgentId, + this._searchScope.ThreadId, + this._searchScope.UserId); + return formatted; + } + + /// + /// Searches for relevant chat history items based on the provided query text. + /// + /// The text to search for. + /// The maximum number of results to return. + /// The cancellation token. + /// A list of relevant chat history items. + private async Task>> SearchChatHistoryAsync( + string queryText, + int top, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(queryText)) + { + return []; + } + + var collection = await this.EnsureCollectionExistsAsync(cancellationToken).ConfigureAwait(false); + + string? applicationId = this._searchScope.ApplicationId; + string? agentId = this._searchScope.AgentId; + string? userId = this._searchScope.UserId; + string? threadId = this._searchScope.ThreadId; + + Expression, bool>>? filter = null; + if (applicationId != null) + { + filter = x => (string?)x["ApplicationId"] == applicationId; + } + + if (agentId != null) + { + Expression, bool>> agentIdFilter = x => (string?)x["AgentId"] == agentId; + filter = filter == null ? agentIdFilter : Expression.Lambda, bool>>( + Expression.AndAlso(filter.Body, agentIdFilter.Body), + filter.Parameters); + } + + if (userId != null) + { + Expression, bool>> userIdFilter = x => (string?)x["UserId"] == userId; + filter = filter == null ? userIdFilter : Expression.Lambda, bool>>( + Expression.AndAlso(filter.Body, userIdFilter.Body), + filter.Parameters); + } + + if (threadId != null) + { + Expression, bool>> threadIdFilter = x => (string?)x["ThreadId"] == threadId; + filter = filter == null ? threadIdFilter : Expression.Lambda, bool>>( + Expression.AndAlso(filter.Body, threadIdFilter.Body), + filter.Parameters); + } + + // Use search to find relevant messages + var searchResults = collection.SearchAsync( + queryText, + top, + options: new() + { + Filter = filter + }, + cancellationToken: cancellationToken); + + var results = new List>(); + await foreach (var result in searchResults.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + results.Add(result.Record); + } + + this._logger?.LogInformation( + "ChatHistoryMemoryProvider: Retrieved {Count} search results. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'.", + results.Count, + this._searchScope.ApplicationId, + this._searchScope.AgentId, + this._searchScope.ThreadId, + this._searchScope.UserId); + + return results; + } + + /// + /// Ensures the collection exists in the vector store, creating it if necessary. + /// + /// The cancellation token. + /// The vector store collection. + private async Task>> EnsureCollectionExistsAsync( + CancellationToken cancellationToken = default) + { + if (this._collectionInitialized) + { + return this._collection; + } + + await this._initializationLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (this._collectionInitialized) + { + return this._collection; + } + + await this._collection.EnsureCollectionExistsAsync(cancellationToken).ConfigureAwait(false); + this._collectionInitialized = true; + + return this._collection; + } + finally + { + this._initializationLock.Release(); + } + } + + /// + private void Dispose(bool disposing) + { + if (!this._disposedValue) + { + if (disposing) + { + this._initializationLock.Dispose(); + this._collection?.Dispose(); + } + + this._disposedValue = true; + } + } + + /// + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + this.Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + /// + /// Serializes the current provider state to a including storage and search scopes. + /// + /// Optional serializer options. + /// Serialized provider state. + public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null) + { + var state = new ChatHistoryMemoryProviderState + { + StorageScope = this._storageScope, + SearchScope = this._searchScope, + }; + + var jso = jsonSerializerOptions ?? AgentJsonUtilities.DefaultOptions; + return JsonSerializer.SerializeToElement(state, jso.GetTypeInfo(typeof(ChatHistoryMemoryProviderState))); + } + + private static ChatHistoryMemoryProviderState? DeserializeState(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions) + { + if (serializedState.ValueKind != JsonValueKind.Object) + { + return null; + } + + var jso = jsonSerializerOptions ?? AgentJsonUtilities.DefaultOptions; + return serializedState.Deserialize(jso.GetTypeInfo(typeof(ChatHistoryMemoryProviderState))) as ChatHistoryMemoryProviderState; + } + + internal sealed class ChatHistoryMemoryProviderState + { + public ChatHistoryMemoryProviderScope? StorageScope { get; set; } + public ChatHistoryMemoryProviderScope? SearchScope { get; set; } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProviderOptions.cs new file mode 100644 index 0000000000..55f06d7429 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProviderOptions.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI; + +/// +/// Options controlling the behavior of . +/// +public sealed class ChatHistoryMemoryProviderOptions +{ + /// + /// Gets or sets a value indicating when the search should be executed. + /// + /// by default. + public SearchBehavior SearchTime { get; set; } = SearchBehavior.BeforeAIInvoke; + + /// + /// Gets or sets the name of the exposed search tool when operating in on-demand mode. + /// + /// Defaults to "Search". + public string? FunctionToolName { get; set; } + + /// + /// Gets or sets the description of the exposed search tool when operating in on-demand mode. + /// + /// Defaults to "Allows searching through previous chat history to help answer the user question.". + public string? FunctionToolDescription { get; set; } + + /// + /// Gets or sets the context prompt prefixed to results. + /// + public string? ContextPrompt { get; set; } + + /// + /// Gets or sets the maximum number of results to retrieve from the chat history. + /// + /// + /// Defaults to 3 if not set. + /// + public int? MaxResults { get; set; } + + /// + /// Behavior choices for the provider. + /// + public enum SearchBehavior + { + /// + /// Execute search prior to each invocation and inject results as a message. + /// + BeforeAIInvoke, + + /// + /// Expose a function tool to perform search on-demand via function/tool calling. + /// + OnDemandFunctionCalling + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProviderScope.cs b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProviderScope.cs new file mode 100644 index 0000000000..2715ed2e20 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProviderScope.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Allows scoping of chat history for the . +/// +public sealed class ChatHistoryMemoryProviderScope +{ + /// + /// Initializes a new instance of the class. + /// + public ChatHistoryMemoryProviderScope() { } + + /// + /// Initializes a new instance of the class by cloning an existing scope. + /// + /// The scope to clone. + public ChatHistoryMemoryProviderScope(ChatHistoryMemoryProviderScope sourceScope) + { + Throw.IfNull(sourceScope); + + this.ApplicationId = sourceScope.ApplicationId; + this.AgentId = sourceScope.AgentId; + this.ThreadId = sourceScope.ThreadId; + this.UserId = sourceScope.UserId; + } + + /// + /// Gets or sets an optional ID for the application to scope chat history to. + /// + /// If not set, the scope of the chat history will span all applications. + public string? ApplicationId { get; set; } + + /// + /// Gets or sets an optional ID for the agent to scope chat history to. + /// + /// If not set, the scope of the chat history will span all agents. + public string? AgentId { get; set; } + + /// + /// Gets or sets an optional ID for the thread to scope chat history to. + /// + public string? ThreadId { get; set; } + + /// + /// Gets or sets an optional ID for the user to scope chat history to. + /// + /// If not set, the scope of the chat history will span all users. + public string? UserId { get; set; } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Memory/ChatHistoryMemoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Memory/ChatHistoryMemoryProviderTests.cs new file mode 100644 index 0000000000..49e5a5d29c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Memory/ChatHistoryMemoryProviderTests.cs @@ -0,0 +1,396 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.VectorData; +using Moq; + +namespace Microsoft.Agents.AI.Memory.UnitTests; + +/// +/// Contains unit tests for the class. +/// +public class ChatHistoryMemoryProviderTests +{ + private readonly Mock> _loggerMock; + private readonly Mock _loggerFactoryMock; + + private readonly Mock _vectorStoreMock; + private readonly Mock>> _vectorStoreCollectionMock; + private const string TestCollectionName = "testcollection"; + + public ChatHistoryMemoryProviderTests() + { + this._loggerMock = new(); + this._loggerFactoryMock = new(); + this._loggerFactoryMock + .Setup(f => f.CreateLogger(It.IsAny())) + .Returns(this._loggerMock.Object); + this._loggerFactoryMock + .Setup(f => f.CreateLogger(typeof(ChatHistoryMemoryProvider).FullName!)) + .Returns(this._loggerMock.Object); + + this._vectorStoreCollectionMock = new(MockBehavior.Strict); + this._vectorStoreMock = new(MockBehavior.Strict); + + this._vectorStoreCollectionMock + .Setup(c => c.EnsureCollectionExistsAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + this._vectorStoreMock + .Setup(vs => vs.GetDynamicCollection( + It.IsAny(), + It.IsAny())) + .Returns(this._vectorStoreCollectionMock.Object); + } + + [Fact] + public void Constructor_Throws_ForNullVectorStore() + { + // Act & Assert + Assert.Throws(() => new ChatHistoryMemoryProvider(null!, "testcollection", 1, new ChatHistoryMemoryProviderScope() { UserId = "UID" })); + } + + [Fact] + public void Constructor_Throws_ForNullCollectionName() + { + // Act & Assert + Assert.Throws(() => new ChatHistoryMemoryProvider(this._vectorStoreMock.Object, null!, 1, new ChatHistoryMemoryProviderScope() { UserId = "UID" })); + } + + [Fact] + public void Constructor_Throws_ForNullStorageScope() + { + // Act & Assert + Assert.Throws(() => new ChatHistoryMemoryProvider(this._vectorStoreMock.Object, "testcollection", 1, null!)); + } + + [Fact] + public void Constructor_Throws_ForInvalidVectorDimensions() + { + // Act & Assert + Assert.Throws(() => new ChatHistoryMemoryProvider(this._vectorStoreMock.Object, "testcollection", 0, new ChatHistoryMemoryProviderScope() { UserId = "UID" })); + Assert.Throws(() => new ChatHistoryMemoryProvider(this._vectorStoreMock.Object, "testcollection", -5, new ChatHistoryMemoryProviderScope() { UserId = "UID" })); + } + + #region InvokedAsync Tests + + [Fact] + public async Task InvokedAsync_UpsertsMessages_ToCollectionAsync() + { + // Arrange + var stored = new List>(); + + this._vectorStoreCollectionMock + .Setup(c => c.UpsertAsync(It.IsAny>>(), It.IsAny())) + .Callback>, CancellationToken>((items, ct) => + { + if (items != null) + { + stored.AddRange(items); + } + }) + .Returns(Task.CompletedTask); + + var storeScope = new ChatHistoryMemoryProviderScope + { + ApplicationId = "app1", + AgentId = "agent1", + ThreadId = "thread1", + UserId = "user1" + }; + + var provider = new ChatHistoryMemoryProvider(this._vectorStoreMock.Object, TestCollectionName, 1, storeScope); + + var requestMsgWithValues = new ChatMessage(ChatRole.User, "request text") { MessageId = "req-1", AuthorName = "user1", CreatedAt = new DateTimeOffset(new DateTime(2000, 1, 1), TimeSpan.Zero) }; + var requestMsgWithNulls = new ChatMessage(ChatRole.User, "request text nulls"); + var responseMsg = new ChatMessage(ChatRole.Assistant, "response text") { MessageId = "resp-1", AuthorName = "assistant" }; + + var invokedContext = new AIContextProvider.InvokedContext([requestMsgWithValues, requestMsgWithNulls], aiContextProviderMessages: null) + { + ResponseMessages = [responseMsg] + }; + + // Act + await provider.InvokedAsync(invokedContext, CancellationToken.None); + + // Assert + this._vectorStoreCollectionMock.Verify( + m => m.EnsureCollectionExistsAsync(It.IsAny()), + Times.Once); + + Assert.Equal(3, stored.Count); + + Assert.Equal("req-1", stored[0]["MessageId"]); + Assert.Equal("request text", stored[0]["Content"]); + Assert.Equal("user1", stored[0]["AuthorName"]); + Assert.Equal(ChatRole.User.ToString(), stored[0]["Role"]); + Assert.Equal("2000-01-01T00:00:00.0000000+00:00", stored[0]["CreatedAt"]); + Assert.Equal("app1", stored[0]["ApplicationId"]); + Assert.Equal("agent1", stored[0]["AgentId"]); + Assert.Equal("thread1", stored[0]["ThreadId"]); + Assert.Equal("user1", stored[0]["UserId"]); + + Assert.Null(stored[1]["MessageId"]); + Assert.Equal("request text nulls", stored[1]["Content"]); + Assert.Null(stored[1]["AuthorName"]); + Assert.Equal(ChatRole.User.ToString(), stored[1]["Role"]); + Assert.Equal("app1", stored[1]["ApplicationId"]); + Assert.Equal("agent1", stored[1]["AgentId"]); + Assert.Equal("thread1", stored[1]["ThreadId"]); + Assert.Equal("user1", stored[1]["UserId"]); + + Assert.Equal("resp-1", stored[2]["MessageId"]); + Assert.Equal("response text", stored[2]["Content"]); + Assert.Equal("assistant", stored[2]["AuthorName"]); + Assert.Equal(ChatRole.Assistant.ToString(), stored[2]["Role"]); + Assert.Equal("app1", stored[2]["ApplicationId"]); + Assert.Equal("agent1", stored[2]["AgentId"]); + Assert.Equal("thread1", stored[2]["ThreadId"]); + Assert.Equal("user1", stored[2]["UserId"]); + } + + [Fact] + public async Task InvokedAsync_DoesNotUpsertMessages_WhenInvokeFailedAsync() + { + // Arrange + this._vectorStoreCollectionMock + .Setup(c => c.UpsertAsync(It.IsAny>>(), It.IsAny())) + .Returns(Task.CompletedTask); + + var provider = new ChatHistoryMemoryProvider( + this._vectorStoreMock.Object, + TestCollectionName, + 1, + new ChatHistoryMemoryProviderScope() { UserId = "UID" }); + var requestMsg = new ChatMessage(ChatRole.User, "request text") { MessageId = "req-1" }; + var invokedContext = new AIContextProvider.InvokedContext([requestMsg], aiContextProviderMessages: null) + { + InvokeException = new InvalidOperationException("Invoke failed") + }; + + // Act + await provider.InvokedAsync(invokedContext, CancellationToken.None); + + // Assert + this._vectorStoreCollectionMock.Verify( + c => c.UpsertAsync(It.IsAny>>(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task InvokedAsync_DoesNotThrow_WhenUpsertThrowsAsync() + { + // Arrange + this._vectorStoreCollectionMock + .Setup(c => c.UpsertAsync(It.IsAny>>(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Upsert failed")); + + var provider = new ChatHistoryMemoryProvider( + this._vectorStoreMock.Object, + TestCollectionName, + 1, + new ChatHistoryMemoryProviderScope() { UserId = "UID" }, + loggerFactory: this._loggerFactoryMock.Object); + var requestMsg = new ChatMessage(ChatRole.User, "request text") { MessageId = "req-1" }; + var invokedContext = new AIContextProvider.InvokedContext([requestMsg], aiContextProviderMessages: null); + + // Act + await provider.InvokedAsync(invokedContext, CancellationToken.None); + + // Assert + this._loggerMock.Verify( + l => l.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("ChatHistoryMemoryProvider: Failed to add messages to chat history vector store due to error")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + #endregion + + #region InvokingAsync Tests + + [Fact] + public async Task InvokedAsync_SearchesVectorStoreAsync() + { + // Arrange + var providerOptions = new ChatHistoryMemoryProviderOptions + { + SearchTime = ChatHistoryMemoryProviderOptions.SearchBehavior.BeforeAIInvoke, + MaxResults = 2, + ContextPrompt = "Here is the relevant chat history:\n" + }; + + var storedItems = new List>> + { + new( + new Dictionary + { + ["MessageId"] = "msg-1", + ["Content"] = "First stored message", + ["Role"] = ChatRole.User.ToString(), + ["CreatedAt"] = "2023-01-01T00:00:00.0000000+00:00" + }, + 0.9f), + new( + new Dictionary + { + ["MessageId"] = "msg-2", + ["Content"] = "Second stored message", + ["Role"] = ChatRole.User.ToString(), + ["CreatedAt"] = "2023-01-02T00:00:00.0000000+00:00" + }, + 0.8f) + }; + + this._vectorStoreCollectionMock + .Setup(c => c.SearchAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny())) + .Returns(ToAsyncEnumerableAsync(storedItems)); + + var provider = new ChatHistoryMemoryProvider( + this._vectorStoreMock.Object, + TestCollectionName, + 1, + new ChatHistoryMemoryProviderScope() { UserId = "UID" }, + options: providerOptions); + + var requestMsg = new ChatMessage(ChatRole.User, "requesting relevant history"); + var invokingContext = new AIContextProvider.InvokingContext([requestMsg]); + + // Act + await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + this._vectorStoreCollectionMock.Verify( + c => c.SearchAsync( + It.Is(s => s == "requesting relevant history"), + 2, + It.IsAny>>(), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task InvokedAsync_CreatesFilter_WhenSearchScopeProvidedAsync() + { + // Arrange + var providerOptions = new ChatHistoryMemoryProviderOptions + { + SearchTime = ChatHistoryMemoryProviderOptions.SearchBehavior.BeforeAIInvoke, + MaxResults = 2, + ContextPrompt = "Here is the relevant chat history:\n" + }; + + var searchScope = new ChatHistoryMemoryProviderScope + { + ApplicationId = "app1", + AgentId = "agent1", + ThreadId = "thread1", + UserId = "user1" + }; + + this._vectorStoreCollectionMock + .Setup(c => c.SearchAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny())) + .Callback((string query, int maxResults, VectorSearchOptions> options, CancellationToken ct) => + { + // Verify that the filter was created correctly + const string ExpectedFilter = "x => ((((x.ApplicationId == value(Microsoft.Agents.AI.VectorDataMemory.ChatHistoryMemoryProvider+<>c__DisplayClass20_0).applicationId) AndAlso (x.AgentId == value(Microsoft.Agents.AI.VectorDataMemory.ChatHistoryMemoryProvider+<>c__DisplayClass20_0).agentId)) AndAlso (x.UserId == value(Microsoft.Agents.AI.VectorDataMemory.ChatHistoryMemoryProvider+<>c__DisplayClass20_0).userId)) AndAlso (x.ThreadId == value(Microsoft.Agents.AI.VectorDataMemory.ChatHistoryMemoryProvider+<>c__DisplayClass20_0).threadId))"; + Assert.Equal(ExpectedFilter, options.Filter!.ToString()); + }) + .Returns(ToAsyncEnumerableAsync(new List>>())); + + var provider = new ChatHistoryMemoryProvider(this._vectorStoreMock.Object, TestCollectionName, 1, options: providerOptions, storageScope: searchScope, searchScope: searchScope); + + var requestMsg = new ChatMessage(ChatRole.User, "requesting relevant history"); + var invokingContext = new AIContextProvider.InvokingContext([requestMsg]); + + // Act + await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + this._vectorStoreCollectionMock.Verify( + c => c.SearchAsync( + It.Is(s => s == "requesting relevant history"), + 2, + It.IsAny>>(), + It.IsAny()), + Times.Once); + } + + #endregion + + #region Serialization Tests + + [Fact] + public void Serialize_Deserialize_RoundtripsScopes() + { + // Arrange + var storageScope = new ChatHistoryMemoryProviderScope + { + ApplicationId = "app", + AgentId = "agent", + ThreadId = "thread", + UserId = "user" + }; + + var searchScope = new ChatHistoryMemoryProviderScope + { + ApplicationId = "app2", + AgentId = "agent2", + ThreadId = "thread2", + UserId = "user2" + }; + + var provider = new ChatHistoryMemoryProvider(this._vectorStoreMock.Object, TestCollectionName, 1, storageScope: storageScope, searchScope: searchScope); + + // Act + var stateElement = provider.Serialize(); + + using JsonDocument doc = JsonDocument.Parse(stateElement.GetRawText()); + var storage = doc.RootElement.GetProperty("storageScope"); + Assert.Equal("app", storage.GetProperty("applicationId").GetString()); + Assert.Equal("agent", storage.GetProperty("agentId").GetString()); + Assert.Equal("thread", storage.GetProperty("threadId").GetString()); + Assert.Equal("user", storage.GetProperty("userId").GetString()); + + var search = doc.RootElement.GetProperty("searchScope"); + Assert.Equal("app2", search.GetProperty("applicationId").GetString()); + Assert.Equal("agent2", search.GetProperty("agentId").GetString()); + Assert.Equal("thread2", search.GetProperty("threadId").GetString()); + Assert.Equal("user2", search.GetProperty("userId").GetString()); + + // Act - deserialize and serialize again + var provider2 = new ChatHistoryMemoryProvider(this._vectorStoreMock.Object, TestCollectionName, 1, serializedState: stateElement); + var stateElement2 = provider2.Serialize(); + + // Assert - roundtrip the state + Assert.Equal(stateElement.GetRawText(), stateElement2.GetRawText()); + } + + #endregion + + private static async IAsyncEnumerable ToAsyncEnumerableAsync(IEnumerable values) + { + await Task.Yield(); + foreach (var update in values) + { + yield return update; + } + } +}