diff --git a/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs index c400a1cb6c..c9de4cbc38 100644 --- a/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs +++ b/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs @@ -69,6 +69,7 @@ internal static partial class AgentJsonUtilities [JsonSerializable(typeof(ChatClientAgentThread.ThreadState))] [JsonSerializable(typeof(TextSearchProvider.TextSearchProviderState))] [JsonSerializable(typeof(ChatHistoryMemoryProvider.ChatHistoryMemoryProviderState))] + [JsonSerializable(typeof(Functions.ContextualFunctionProvider.ContextualFunctionProviderState))] [ExcludeFromCodeCoverage] internal sealed partial class JsonContext : JsonSerializerContext; diff --git a/dotnet/src/Microsoft.Agents.AI/Functions/ContextualFunctionProvider.cs b/dotnet/src/Microsoft.Agents.AI/Functions/ContextualFunctionProvider.cs index 5a0bd981b9..a2b0dc02d3 100644 --- a/dotnet/src/Microsoft.Agents.AI/Functions/ContextualFunctionProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Functions/ContextualFunctionProvider.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; @@ -60,6 +61,30 @@ public sealed class ContextualFunctionProvider : AIContextProvider int maxNumberOfFunctions, ContextualFunctionProviderOptions? options = null, ILoggerFactory? loggerFactory = null) + : this(vectorStore, vectorDimensions, functions, maxNumberOfFunctions, default(JsonElement), options, null, loggerFactory) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// An instance of a vector store. + /// The number of dimensions to use for the memory embeddings. + /// The functions to vectorize and store for searching related functions. + /// The maximum number of relevant functions to retrieve from the vector store. + /// A representing the serialized provider state. + /// Further optional settings for configuring the provider. + /// Optional serializer options. If not provided, will be used. + /// The logger factory to use for logging. If not provided, no logging will be performed. + public ContextualFunctionProvider( + VectorStore vectorStore, + int vectorDimensions, + IEnumerable functions, + int maxNumberOfFunctions, + JsonElement serializedState, + ContextualFunctionProviderOptions? options = null, + JsonSerializerOptions? jsonSerializerOptions = null, + ILoggerFactory? loggerFactory = null) { Throw.IfNull(vectorStore); Throw.IfLessThan(vectorDimensions, 1, "Vector dimensions must be greater than 0"); @@ -81,6 +106,21 @@ public sealed class ContextualFunctionProvider : AIContextProvider EmbeddingValueProvider = this._options.EmbeddingValueProvider, } ); + + // Restore recent messages from serialized state if provided + if (serializedState.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined) + { + JsonSerializerOptions jso = jsonSerializerOptions ?? AgentJsonUtilities.DefaultOptions; + ContextualFunctionProviderState? state = serializedState.Deserialize(jso.GetTypeInfo(typeof(ContextualFunctionProviderState))) as ContextualFunctionProviderState; + if (state?.RecentMessages is { Count: > 0 }) + { + // Restore recent messages respecting the limit (may truncate if limit changed afterwards). + foreach (ChatMessage message in state.RecentMessages.Take(this._options.NumberOfRecentMessagesInContext)) + { + this._recentMessages.Enqueue(message); + } + } + } } /// @@ -141,6 +181,22 @@ public sealed class ContextualFunctionProvider : AIContextProvider return default; } + /// + /// Serializes the current provider state to a containing the recent messages. + /// + /// Optional serializer options. This parameter is not used; is always used for serialization. + /// A with the recent messages, or default if there are no recent messages. + public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null) + { + ContextualFunctionProviderState state = new(); + if (this._options.NumberOfRecentMessagesInContext > 0 && !this._recentMessages.IsEmpty) + { + state.RecentMessages = this._recentMessages.Take(this._options.NumberOfRecentMessagesInContext).ToList(); + } + + return JsonSerializer.SerializeToElement(state, AgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ContextualFunctionProviderState))); + } + /// /// Builds the context from chat messages. /// @@ -166,4 +222,9 @@ public sealed class ContextualFunctionProvider : AIContextProvider .Where(m => !string.IsNullOrWhiteSpace(m?.Text)) .Select(m => m.Text)); } + + internal sealed class ContextualFunctionProviderState + { + public List? RecentMessages { get; set; } + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Functions/ContextualFunctionProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Functions/ContextualFunctionProviderTests.cs index b5e2657803..88d8684ae5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Functions/ContextualFunctionProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Functions/ContextualFunctionProviderTests.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Functions; @@ -305,7 +306,7 @@ public sealed class ContextualFunctionProviderTests } [Fact] - public async Task InvokedAsync_ShouldNotAddMessages_WhenExceptionIsPresent_Async() + public void Serialize_WithNoRecentMessages_ShouldReturnEmptyState() { // Arrange var functions = new List { CreateFunction("f1") }; @@ -320,7 +321,6 @@ public sealed class ContextualFunctionProviderTests functions: functions, maxNumberOfFunctions: 5, options: options); - var message1 = new ChatMessage() { Contents = [new TextContent("msg1")] }; var message2 = new ChatMessage() { Contents = [new TextContent("msg2")] }; var message3 = new ChatMessage() { Contents = [new TextContent("msg3")] }; @@ -343,6 +343,188 @@ public sealed class ContextualFunctionProviderTests var expected = string.Join(Environment.NewLine, ["msg1", "msg2", "new message"]); this._collectionMock.Verify(c => c.SearchAsync(expected, It.IsAny(), null, It.IsAny()), Times.Once); } + + [Fact] + public async Task InvokedAsync_ShouldNotAddMessages_WhenExceptionIsPresent_Async() + { + // Arrange + var functions = new List { CreateFunction("f1") }; + var options = new ContextualFunctionProviderOptions + { + NumberOfRecentMessagesInContext = 3 + }; + + var provider = new ContextualFunctionProvider( + vectorStore: this._vectorStoreMock.Object, + vectorDimensions: 1536, + functions: functions, + maxNumberOfFunctions: 5, + options: options); + + // Act + JsonElement state = provider.Serialize(); + + // Assert + Assert.Equal(JsonValueKind.Object, state.ValueKind); + Assert.False(state.TryGetProperty("recentMessages", out _)); + } + + [Fact] + public async Task Serialize_WithRecentMessages_ShouldPersistMessagesUpToLimitAsync() + { + // Arrange + var functions = new List { CreateFunction("f1") }; + var options = new ContextualFunctionProviderOptions + { + NumberOfRecentMessagesInContext = 2 + }; + + var provider = new ContextualFunctionProvider( + vectorStore: this._vectorStoreMock.Object, + vectorDimensions: 1536, + functions: functions, + maxNumberOfFunctions: 5, + options: options); + + var messages = new[] + { + new ChatMessage() { Contents = [new TextContent("M1")] }, + new ChatMessage() { Contents = [new TextContent("M2")] }, + new ChatMessage() { Contents = [new TextContent("M3")] } + }; + + // Act + await provider.InvokedAsync(new AIContextProvider.InvokedContext(messages, aiContextProviderMessages: null)); + JsonElement state = provider.Serialize(); + + // Assert + Assert.True(state.TryGetProperty("recentMessages", out JsonElement recentProperty)); + Assert.Equal(JsonValueKind.Array, recentProperty.ValueKind); + int count = recentProperty.GetArrayLength(); + Assert.Equal(2, count); + } + + [Fact] + public async Task SerializeAndDeserialize_RoundtripRestoresMessagesAsync() + { + // Arrange + var functions = new List { CreateFunction("f1") }; + var options = new ContextualFunctionProviderOptions + { + NumberOfRecentMessagesInContext = 4 + }; + + var provider = new ContextualFunctionProvider( + vectorStore: this._vectorStoreMock.Object, + vectorDimensions: 1536, + functions: functions, + maxNumberOfFunctions: 5, + options: options); + + var messages = new[] + { + new ChatMessage() { Contents = [new TextContent("A")] }, + new ChatMessage() { Contents = [new TextContent("B")] }, + new ChatMessage() { Contents = [new TextContent("C")] }, + new ChatMessage() { Contents = [new TextContent("D")] } + }; + + await provider.InvokedAsync(new AIContextProvider.InvokedContext(messages, aiContextProviderMessages: null)); + + // Act + JsonElement state = provider.Serialize(); + var roundTrippedProvider = new ContextualFunctionProvider( + vectorStore: this._vectorStoreMock.Object, + vectorDimensions: 1536, + functions: functions, + maxNumberOfFunctions: 5, + serializedState: state, + options: new ContextualFunctionProviderOptions + { + NumberOfRecentMessagesInContext = 4 + }); + + // Trigger search to verify messages are used + var invokingContext = new AIContextProvider.InvokingContext(Array.Empty()); + await roundTrippedProvider.InvokingAsync(invokingContext); + + // Assert + string expected = string.Join(Environment.NewLine, ["A", "B", "C", "D"]); + this._collectionMock.Verify(c => c.SearchAsync(expected, It.IsAny(), null, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Deserialize_WithChangedLowerLimit_ShouldTruncateToNewLimitAsync() + { + // Arrange + var functions = new List { CreateFunction("f1") }; + var initialProvider = new ContextualFunctionProvider( + vectorStore: this._vectorStoreMock.Object, + vectorDimensions: 1536, + functions: functions, + maxNumberOfFunctions: 5, + options: new ContextualFunctionProviderOptions + { + NumberOfRecentMessagesInContext = 5 + }); + + var messages = new[] + { + new ChatMessage() { Contents = [new TextContent("L1")] }, + new ChatMessage() { Contents = [new TextContent("L2")] }, + new ChatMessage() { Contents = [new TextContent("L3")] }, + new ChatMessage() { Contents = [new TextContent("L4")] }, + new ChatMessage() { Contents = [new TextContent("L5")] } + }; + + await initialProvider.InvokedAsync(new AIContextProvider.InvokedContext(messages, aiContextProviderMessages: null)); + JsonElement state = initialProvider.Serialize(); + + // Act + var restoredProvider = new ContextualFunctionProvider( + vectorStore: this._vectorStoreMock.Object, + vectorDimensions: 1536, + functions: functions, + maxNumberOfFunctions: 5, + serializedState: state, + options: new ContextualFunctionProviderOptions + { + NumberOfRecentMessagesInContext = 3 // Lower limit + }); + + var invokingContext = new AIContextProvider.InvokingContext(Array.Empty()); + await restoredProvider.InvokingAsync(invokingContext); + + // Assert + string expected = string.Join(Environment.NewLine, ["L1", "L2", "L3"]); + this._collectionMock.Verify(c => c.SearchAsync(expected, It.IsAny(), null, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Deserialize_WithEmptyState_ShouldHaveNoMessagesAsync() + { + // Arrange + var functions = new List { CreateFunction("f1") }; + JsonElement emptyState = JsonSerializer.Deserialize("{}", TestJsonSerializerContext.Default.JsonElement); + + // Act + var provider = new ContextualFunctionProvider( + vectorStore: this._vectorStoreMock.Object, + vectorDimensions: 1536, + functions: functions, + maxNumberOfFunctions: 5, + serializedState: emptyState, + options: new ContextualFunctionProviderOptions + { + NumberOfRecentMessagesInContext = 3 + }); + + var invokingContext = new AIContextProvider.InvokingContext(Array.Empty()); + await provider.InvokingAsync(invokingContext); + + // Assert + this._collectionMock.Verify(c => c.SearchAsync(string.Empty, It.IsAny(), null, It.IsAny()), Times.Once); + } private static AIFunction CreateFunction(string name, string description = "") {