diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step06_PersistedConversations/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step06_PersistedConversations/Program.cs index 60a9b6a973..e2d3ae9459 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step06_PersistedConversations/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step06_PersistedConversations/Program.cs @@ -40,7 +40,7 @@ await File.WriteAllTextAsync(tempFilePath, JsonSerializer.Serialize(serializedTh JsonElement reloadedSerializedThread = JsonSerializer.Deserialize(await File.ReadAllTextAsync(tempFilePath)); // Deserialize the thread state after loading from storage. -AgentThread resumedThread = await agent.DeserializeThreadAsync(reloadedSerializedThread); +AgentThread resumedThread = agent.DeserializeThread(reloadedSerializedThread); // Run the agent again with the resumed thread. Console.WriteLine(await agent.RunAsync("Now tell the same joke in the voice of a pirate, and add some emojis to the joke.", resumedThread)); diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Program.cs index 8db527a2af..62f32cba67 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Program.cs @@ -38,12 +38,12 @@ AIAgent agent = new AzureOpenAIClient( { Name = JokerName, Instructions = JokerInstructions, - ChatMessageStoreFactory = () => + ChatMessageStoreFactory = (jsonElement, jso) => { // Create a new chat message store for this agent that stores the messages in a vector store. // Each thread must get its own copy of the VectorChatMessageStore, since the store // also contains the id that the thread is stored under. - return new VectorChatMessageStore(vectorStore); + return new VectorChatMessageStore(vectorStore, jsonElement, jso); } }); @@ -65,7 +65,7 @@ Console.WriteLine(JsonSerializer.Serialize(serializedThread, new JsonSerializerO // and loaded again later. // Deserialize the thread state after loading from storage. -AgentThread resumedThread = await agent.DeserializeThreadAsync(serializedThread); +AgentThread resumedThread = agent.DeserializeThread(serializedThread); // Run the agent with the thread that stores conversation history in the vector store a second time. Console.WriteLine(await agent.RunAsync("Now tell the same joke in the voice of a pirate, and add some emojis to the joke.", resumedThread)); @@ -75,10 +75,21 @@ namespace SampleApp /// /// A sample implementation of that stores chat messages in a vector store. /// - /// The vector store to store the messages in. - internal sealed class VectorChatMessageStore(VectorStore vectorStore) : IChatMessageStore + internal sealed class VectorChatMessageStore : IChatMessageStore { private string? _threadId; + private readonly VectorStore _vectorStore; + + public VectorChatMessageStore(VectorStore vectorStore, JsonElement serializedStoreState, JsonSerializerOptions? jsonSerializerOptions = null) + { + this._vectorStore = vectorStore ?? throw new ArgumentNullException(nameof(vectorStore)); + + if (serializedStoreState.ValueKind == JsonValueKind.String) + { + // Here we can deserialize the thread id so that we can access the same messages as before the suspension. + this._threadId = JsonSerializer.Deserialize(serializedStoreState); + } + } public string? ThreadId => this._threadId; @@ -86,7 +97,7 @@ namespace SampleApp { this._threadId ??= Guid.NewGuid().ToString(); - var collection = vectorStore.GetCollection("ChatHistory"); + var collection = this._vectorStore.GetCollection("ChatHistory"); await collection.EnsureCollectionExistsAsync(cancellationToken); await collection.UpsertAsync(messages.Select(x => new ChatHistoryItem() @@ -101,7 +112,7 @@ namespace SampleApp public async Task> GetMessagesAsync(CancellationToken cancellationToken) { - var collection = vectorStore.GetCollection("ChatHistory"); + var collection = this._vectorStore.GetCollection("ChatHistory"); await collection.EnsureCollectionExistsAsync(cancellationToken); var records = await collection @@ -124,13 +135,6 @@ namespace SampleApp return new ValueTask(JsonSerializer.SerializeToElement(this._threadId)); } - public ValueTask DeserializeStateAsync(JsonElement? serializedStoreState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) - { - // Here we can deserialize the thread id so that we can access the same messages as before the suspension. - this._threadId = JsonSerializer.Deserialize((JsonElement)serializedStoreState!); - return new ValueTask(); - } - /// /// The data structure used to store chat history items in the vector store. /// diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step13_Memory/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step13_Memory/Program.cs index c91c81bd4b..df6e9ecb7a 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step13_Memory/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step13_Memory/Program.cs @@ -38,7 +38,7 @@ ChatClient chatClient = new AzureOpenAIClient( AIAgent agent = chatClient.CreateAIAgent(new ChatClientAgentOptions() { Instructions = "You are a friendly assistant. Always address the user by their name.", - AIContextProviderFactory = () => new SampleApp.UserInfoMemory(chatClient.AsIChatClient()) + AIContextProviderFactory = (jse, jso) => new UserInfoMemory(chatClient.AsIChatClient(), jse, jso) }); // Create a new thread for the conversation. @@ -57,7 +57,7 @@ var threadElement = await thread.SerializeAsync(); Console.WriteLine("\n>> Use deserialized thread with previously created memories\n"); // Later we can deserialize the thread and continue the conversation with the previous memory component state. -var deserializedThread = await agent.DeserializeThreadAsync(threadElement); +var deserializedThread = agent.DeserializeThread(threadElement); Console.WriteLine(await agent.RunAsync("What is my name and age?", deserializedThread)); Console.WriteLine("\n>> Read memories from memory component\n"); @@ -89,16 +89,30 @@ namespace SampleApp /// /// Sample memory component that can remember a user's name and age. /// - internal sealed class UserInfoMemory(IChatClient chatClient, UserInfo? userInfo = null) : AIContextProvider + internal sealed class UserInfoMemory : AIContextProvider { - public UserInfo UserInfo { get; set; } = userInfo ?? new(); + private readonly IChatClient _chatClient; + + public UserInfoMemory(IChatClient chatClient, UserInfo? userInfo = null) + { + this._chatClient = chatClient; + this.UserInfo = userInfo ?? new UserInfo(); + } + + public UserInfoMemory(IChatClient chatClient, JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null) + { + this._chatClient = chatClient; + this.UserInfo = JsonSerializer.Deserialize(serializedState, jsonSerializerOptions) ?? new UserInfo(); + } + + public UserInfo UserInfo { get; set; } public override async ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default) { // Try and extract the user name and age from the message if we don't have it already and it's a user message. if ((this.UserInfo.UserName == null || this.UserInfo.UserAge == null) && context.RequestMessages.Any(x => x.Role == ChatRole.User)) { - var result = await chatClient.GetResponseAsync( + var result = await this._chatClient.GetResponseAsync( context.RequestMessages, new ChatOptions() { diff --git a/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs index 3302593ab0..ea93b001a3 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs @@ -77,8 +77,7 @@ internal class AIAgentHostExecutor : Executor JsonElement? threadValue = await context.ReadStateAsync(ThreadStateKey).ConfigureAwait(false); if (threadValue.HasValue) { - this._thread = await this._agent.DeserializeThreadAsync(threadValue.Value, cancellationToken: cancellation) - .ConfigureAwait(false); + this._thread = this._agent.DeserializeThread(threadValue.Value, cancellationToken: cancellation); } JsonElement? messagesValue = await context.ReadStateAsync(PendingMessagesStateKey).ConfigureAwait(false); diff --git a/dotnet/src/Microsoft.Agents.Workflows/WorkflowHostAgent.cs b/dotnet/src/Microsoft.Agents.Workflows/WorkflowHostAgent.cs index d0a48288d5..53d059522d 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/WorkflowHostAgent.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/WorkflowHostAgent.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Runtime.CompilerServices; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; @@ -47,6 +48,9 @@ internal class WorkflowHostAgent : AIAgent public override AgentThread GetNewThread() => new WorkflowThread(this.Id, this.Name, this.GenerateNewId()); + public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => new WorkflowThread(serializedThread, jsonSerializerOptions); + private async IAsyncEnumerable InvokeStageAsync( WorkflowThread conversation, diff --git a/dotnet/src/Microsoft.Agents.Workflows/WorkflowMessageStore.cs b/dotnet/src/Microsoft.Agents.Workflows/WorkflowMessageStore.cs index dd2aed2750..07e2ec965c 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/WorkflowMessageStore.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/WorkflowMessageStore.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; using System.Text.Json; using System.Threading; @@ -14,6 +15,30 @@ internal class WorkflowMessageStore : IChatMessageStore private int _bookmark = 0; private readonly List _chatMessages = new(); + public WorkflowMessageStore() + { + } + + public WorkflowMessageStore(JsonElement serializedStoreState, JsonSerializerOptions? jsonSerializerOptions = null) + { + if (serializedStoreState.ValueKind != JsonValueKind.Object) + { + throw new ArgumentException("The provided JsonElement must be a json object", nameof(serializedStoreState)); + } + + StoreState? state = + JsonSerializer.Deserialize( + serializedStoreState, + AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(StoreState))) as StoreState; + + if (state?.Messages is not null) + { + this._chatMessages.AddRange(state.Messages); + } + + this._bookmark = state?.Bookmark ?? 0; + } + internal class StoreState { public int Bookmark { get; set; } @@ -50,31 +75,6 @@ internal class WorkflowMessageStore : IChatMessageStore this._bookmark = this._chatMessages.Count; } - public ValueTask DeserializeStateAsync(JsonElement? serializedStoreState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) - { - if (serializedStoreState is null) - { - return default; - } - - object? maybeState = - JsonSerializer.Deserialize( - serializedStoreState.Value, - AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(StoreState))); - - if (maybeState is not StoreState state) - { - throw new JsonException("Invalid state format for WorkflowMessageStore."); - } - - this._chatMessages.Clear(); - this._chatMessages.AddRange(state.Messages); - - this._bookmark = state.Bookmark; - - return default; - } - public ValueTask SerializeStateAsync(JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) { StoreState state = new() diff --git a/dotnet/src/Microsoft.Agents.Workflows/WorkflowThread.cs b/dotnet/src/Microsoft.Agents.Workflows/WorkflowThread.cs index d8261c77e3..f61e0e63f5 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/WorkflowThread.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/WorkflowThread.cs @@ -25,6 +25,11 @@ internal class WorkflowThread : AgentThread this._workflowName = workflowName; } + public WorkflowThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) + { + throw new NotImplementedException("Pending Checkpointing work."); + } + public string RunId { get; } public int Halts { get; } = 0; @@ -35,11 +40,6 @@ internal class WorkflowThread : AgentThread throw new NotImplementedException("Pending Checkpointing work."); } - protected override Task DeserializeAsync(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) - { - throw new NotImplementedException("Pending Checkpointing work."); - } - public AgentRunResponseUpdate CreateUpdate(params AIContent[] parts) { Throw.IfNullOrEmpty(parts); diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AIAgent.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AIAgent.cs index b744a17892..9905d63412 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AIAgent.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AIAgent.cs @@ -90,12 +90,8 @@ public abstract class AIAgent /// Optional to use for deserializing the thread state. /// The to monitor for cancellation requests. The default is . /// The deserialized instance. - public async ValueTask DeserializeThreadAsync(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) - { - var thread = this.GetNewThread(); - await thread.DeserializeAsync(serializedThread, jsonSerializerOptions, cancellationToken).ConfigureAwait(false); - return thread; - } + public virtual AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => new(serializedThread, jsonSerializerOptions); /// /// Run the agent with no message assuming that all required instructions are already provided to the agent or on the thread. diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AgentThread.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AgentThread.cs index 9b1fbcdb5b..748c5b8f35 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AgentThread.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AgentThread.cs @@ -26,6 +26,46 @@ public class AgentThread { } + /// + /// Initializes a new instance of the class from serialized state. + /// + /// A representing the serialized state of the thread. + /// Optional settings for customizing the JSON deserialization process. + /// An optional factory function to create a custom . + /// An optional factory function to create a custom . + public AgentThread( + JsonElement serializedThreadState, + JsonSerializerOptions? jsonSerializerOptions = null, + Func? chatMessageStoreFactory = null, + Func? aiContextProviderFactory = null) + { + if (serializedThreadState.ValueKind != JsonValueKind.Object) + { + throw new ArgumentException("The serialized thread state must be a JSON object.", nameof(serializedThreadState)); + } + + var state = JsonSerializer.Deserialize( + serializedThreadState, + AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ThreadState))) as ThreadState; + + this.AIContextProvider = aiContextProviderFactory?.Invoke(state?.AIContextProviderState ?? default, jsonSerializerOptions); + + if (state?.ConversationId is string threadId) + { + this.ConversationId = threadId; + + // Since we have an ID, we should not have a chat message store and we can return here. + return; + } + + this._messageStore = chatMessageStoreFactory?.Invoke(state?.StoreState ?? default, jsonSerializerOptions); + if (this._messageStore is null) + { + // If we didn't get a custom store, create an in-memory one. + this._messageStore = new InMemoryChatMessageStore(state?.StoreState ?? default, jsonSerializerOptions); + } + } + /// /// Gets or sets the ID of the underlying service thread to support cases where the chat history is stored by the agent service. /// @@ -175,47 +215,6 @@ public class AgentThread } } - /// - /// Deserializes the state contained in the provided into the properties on this thread. - /// - /// A representing the state of the thread. - /// Optional settings for customizing the JSON deserialization process. - /// The to monitor for cancellation requests. The default is . - /// A that completes when the state has been deserialized. - protected internal virtual async Task DeserializeAsync(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) - { - var state = JsonSerializer.Deserialize( - serializedThread, - AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ThreadState))) as ThreadState; - - if (state?.ConversationId is string threadId) - { - this.ConversationId = threadId; - - // Since we have an ID, we should not have a chat message store and we can return here. - return; - } - - if (state?.AIContextProviderState.HasValue is true && this.AIContextProvider is not null) - { - await this.AIContextProvider.DeserializeAsync(state.AIContextProviderState.Value, jsonSerializerOptions, cancellationToken).ConfigureAwait(false); - } - - // If we don't have any IChatMessageStore state return here. - if (state?.StoreState is null || state?.StoreState.Value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) - { - return; - } - - if (this._messageStore is null) - { - // If we don't have a chat message store yet, create an in-memory one. - this._messageStore = new InMemoryChatMessageStore(); - } - - await this._messageStore.DeserializeStateAsync(state!.StoreState.Value, jsonSerializerOptions, cancellationToken).ConfigureAwait(false); - } - internal sealed class ThreadState { public string? ConversationId { get; set; } diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/DelegatingAIAgent.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/DelegatingAIAgent.cs index 81cc93628c..d79cf03914 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/DelegatingAIAgent.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/DelegatingAIAgent.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; @@ -52,6 +53,10 @@ public class DelegatingAIAgent : AIAgent /// public override AgentThread GetNewThread() => this.InnerAgent.GetNewThread(); + /// + public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => this.InnerAgent.DeserializeThread(serializedThread, jsonSerializerOptions, cancellationToken); + /// public override Task RunAsync( IEnumerable messages, diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/IChatMessageStore.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/IChatMessageStore.cs index e9c3bf374a..5990002fe1 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/IChatMessageStore.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/IChatMessageStore.cs @@ -44,28 +44,11 @@ public interface IChatMessageStore /// An async task. Task AddMessagesAsync(IEnumerable messages, CancellationToken cancellationToken = default); - /// - /// Deserializes the state contained in the provided into the properties on this store. - /// - /// A representing the state of the store. - /// Optional settings for customizing the JSON deserialization process. - /// The to monitor for cancellation requests. The default is . - /// A that completes when the state has been deserialized. - /// - /// This method, together with can be used to save and load messages from a persistent store - /// if this store only has messages in memory. - /// - ValueTask DeserializeStateAsync(JsonElement? serializedStoreState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default); - /// /// Serializes the current object's state to a using the specified serialization options. /// /// The JSON serialization options to use. /// The to monitor for cancellation requests. The default is . /// A representation of the object's state. - /// - /// This method, together with can be used to save and load messages from a persistent store - /// if this store only has messages in memory. - /// ValueTask SerializeStateAsync(JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default); } diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/InMemoryChatMessageStore.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/InMemoryChatMessageStore.cs index 6f59fd696c..f57d0a8acc 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/InMemoryChatMessageStore.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/InMemoryChatMessageStore.cs @@ -17,12 +17,23 @@ public sealed class InMemoryChatMessageStore : IList, IChatMessageS { private readonly IChatReducer? _chatReducer; private readonly ChatReducerTriggerEvent _reducerTriggerEvent; - private List _messages = new(); + private List _messages; /// /// Initializes a new instance of the class. /// public InMemoryChatMessageStore() + { + this._messages = new(); + } + + /// + /// Initializes a new instance of the class, with an existing state from a serialized JSON element. + /// + /// A representing the serialized state of the store. + /// Optional settings for customizing the JSON deserialization process. + public InMemoryChatMessageStore(JsonElement serializedStoreState, JsonSerializerOptions? jsonSerializerOptions = null) + : this(null, serializedStoreState, jsonSerializerOptions, ChatReducerTriggerEvent.BeforeMessagesRetrieval) { } @@ -32,9 +43,40 @@ public sealed class InMemoryChatMessageStore : IList, IChatMessageS /// An optional instance used to process or reduce chat messages. If null, no reduction logic will be applied. /// The event that should trigger the reducer invocation. public InMemoryChatMessageStore(IChatReducer chatReducer, ChatReducerTriggerEvent reducerTriggerEvent = ChatReducerTriggerEvent.BeforeMessagesRetrieval) + : this(chatReducer, default, null, reducerTriggerEvent) { - this._chatReducer = Throw.IfNull(chatReducer); + Throw.IfNull(chatReducer); + } + + /// + /// Initializes a new instance of the class, with an existing state from a serialized JSON element. + /// + /// An optional instance used to process or reduce chat messages. If null, no reduction logic will be applied. + /// A representing the serialized state of the store. + /// Optional settings for customizing the JSON deserialization process. + /// The event that should trigger the reducer invocation. + public InMemoryChatMessageStore(IChatReducer? chatReducer, JsonElement serializedStoreState, JsonSerializerOptions? jsonSerializerOptions = null, ChatReducerTriggerEvent reducerTriggerEvent = ChatReducerTriggerEvent.BeforeMessagesRetrieval) + { + this._chatReducer = chatReducer; this._reducerTriggerEvent = reducerTriggerEvent; + + if (serializedStoreState.ValueKind != JsonValueKind.Object) + { + this._messages = new(); + return; + } + + var state = JsonSerializer.Deserialize( + serializedStoreState, + AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(StoreState))) as StoreState; + + if (state?.Messages is { Count: > 0 } messages) + { + this._messages = messages; + return; + } + + this._messages = new(); } /// @@ -84,26 +126,6 @@ public sealed class InMemoryChatMessageStore : IList, IChatMessageS return this._messages; } - /// - public ValueTask DeserializeStateAsync(JsonElement? serializedStoreState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) - { - if (serializedStoreState is null) - { - return default; - } - - var state = JsonSerializer.Deserialize( - serializedStoreState.Value, - AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(StoreState))) as StoreState; - - if (state?.Messages is { Count: > 0 } messages) - { - this._messages.AddRange(messages); - } - - return default; - } - /// public ValueTask SerializeStateAsync(JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) { @@ -157,7 +179,7 @@ public sealed class InMemoryChatMessageStore : IList, IChatMessageS internal sealed class StoreState { - public IList Messages { get; set; } = new List(); + public List Messages { get; set; } = new List(); } /// diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentActor.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentActor.cs index 31d91c1c6d..328d8a5ad8 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentActor.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentActor.cs @@ -39,7 +39,7 @@ internal sealed class AgentActor( if (threadResult.Value is { } threadJson) { // Deserialize the thread state if it exists - this._thread = await agent.DeserializeThreadAsync(threadJson, cancellationToken: cancellationToken).ConfigureAwait(false); + this._thread = agent.DeserializeThread(threadJson, cancellationToken: cancellationToken); hasExistingThread = true; } } diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxy.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxy.cs index 5a0b41bb8a..97588d3779 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxy.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxy.cs @@ -36,6 +36,10 @@ public sealed class AgentProxy : AIAgent /// public override AgentThread GetNewThread() => new AgentProxyThread(); + /// + public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => new AgentProxyThread(serializedThread, jsonSerializerOptions); + /// /// Gets a thread by its . /// diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxyThread.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxyThread.cs index 83cea8eabb..d401016aba 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxyThread.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxyThread.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Text.Json; using System.Text.RegularExpressions; using Microsoft.Shared.Diagnostics; @@ -55,6 +56,16 @@ internal sealed partial class AgentProxyThread : AgentThread { } + /// + /// Initializes a new instance of the class from serialized state. + /// + /// A representing the serialized state of the thread. + /// Optional settings for customizing the JSON deserialization process. + public AgentProxyThread(JsonElement serializedThreadState, JsonSerializerOptions? jsonSerializerOptions = null) + : base(serializedThreadState, jsonSerializerOptions) + { + } + internal static string CreateId() => Guid.NewGuid().ToString("N"); /// diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/OpenAIChatClientAgent.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/OpenAIChatClientAgent.cs index e583d5d4e5..5e22814402 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/OpenAIChatClientAgent.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/OpenAIChatClientAgent.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Text.Json; using Microsoft.Extensions.AI; using Microsoft.Extensions.AI.Agents; using Microsoft.Extensions.Logging; @@ -83,6 +84,10 @@ public class OpenAIChatClientAgent : AIAgent public sealed override AgentThread GetNewThread() => this._chatClientAgent.GetNewThread(); + /// + public sealed override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => this._chatClientAgent.DeserializeThread(serializedThread, jsonSerializerOptions, cancellationToken); + /// public sealed override Task RunAsync( IEnumerable messages, diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgent.cs b/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgent.cs index 5878cc71c9..982cb2799a 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgent.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgent.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Runtime.CompilerServices; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -241,12 +242,16 @@ public sealed class ChatClientAgent : AIAgent { var thread = new AgentThread { - MessageStore = this._agentOptions?.ChatMessageStoreFactory?.Invoke(), - AIContextProvider = this._agentOptions?.AIContextProviderFactory?.Invoke() + MessageStore = this._agentOptions?.ChatMessageStoreFactory?.Invoke(default, null), + AIContextProvider = this._agentOptions?.AIContextProviderFactory?.Invoke(default, null) }; return thread; } + /// + public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => new(serializedThread, jsonSerializerOptions, this._agentOptions?.ChatMessageStoreFactory, this._agentOptions?.AIContextProviderFactory); + #region Private /// @@ -493,7 +498,7 @@ public sealed class ChatClientAgent : AIAgent // If the service doesn't use service side thread storage (i.e. we got no id back from invocation), and // the thread has no MessageStore yet, and we have a custom messages store, we should update the thread // with the custom MessageStore so that it has somewhere to store the chat history. - thread.MessageStore = this._agentOptions?.ChatMessageStoreFactory?.Invoke(); + thread.MessageStore = this._agentOptions?.ChatMessageStoreFactory?.Invoke(default, null); } } diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgentOptions.cs b/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgentOptions.cs index 461ed01aa3..2b3807864d 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgentOptions.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgentOptions.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Text.Json; namespace Microsoft.Extensions.AI.Agents; @@ -78,14 +79,14 @@ public class ChatClientAgentOptions /// Gets or sets a factory function to create an instance of /// which will be used to store chat messages for this agent. /// - public Func? ChatMessageStoreFactory { get; set; } + public Func? ChatMessageStoreFactory { get; set; } /// /// Gets or sets a factory function to create an instance of /// which will be used to create a context provider for each new thread, and can then /// provide additional context for each agent run. /// - public Func? AIContextProviderFactory { get; set; } + public Func? AIContextProviderFactory { get; set; } /// /// Gets or sets a value indicating whether to use the provided instance as is, diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/AgentThreadTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/AgentThreadTests.cs index 843ea3fb0f..b22b7fc51a 100644 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/AgentThreadTests.cs +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/AgentThreadTests.cs @@ -135,40 +135,39 @@ public class AgentThreadTests #region Deserialize Tests [Fact] - public async Task VerifyDeserializeWithMessagesAsync() + public async Task VerifyDeserializeConstructorWithMessagesAsync() { // Arrange - var chatMessageStore = new InMemoryChatMessageStore(); var json = JsonSerializer.Deserialize(""" { "storeState": { "messages": [{"authorName": "testAuthor"}] } } """, TestJsonSerializerContext.Default.JsonElement); - var thread = new AgentThread { MessageStore = chatMessageStore }; // Act. - await thread.DeserializeAsync(json); + var thread = new AgentThread(json); // Assert Assert.Null(thread.ConversationId); - Assert.Single(chatMessageStore); - Assert.Equal("testAuthor", chatMessageStore[0].AuthorName); + var messageStore = thread.MessageStore as InMemoryChatMessageStore; + Assert.NotNull(messageStore); + Assert.Single(messageStore); + Assert.Equal("testAuthor", messageStore[0].AuthorName); } [Fact] - public async Task VerifyDeserializeWithIdAsync() + public async Task VerifyDeserializeConstructorWithIdAsync() { // Arrange var json = JsonSerializer.Deserialize(""" { - "conversationId": "TestConvId" + "ConversationId": "TestConvId" } """, TestJsonSerializerContext.Default.JsonElement); - var thread = new AgentThread(); // Act - await thread.DeserializeAsync(json); + var thread = new AgentThread(json); // Assert Assert.Equal("TestConvId", thread.ConversationId); @@ -176,34 +175,33 @@ public class AgentThreadTests } [Fact] - public async Task VerifyDeserializeWithAIContextProviderAsync() + public async Task VerifyDeserializeConstructorWithAIContextProviderAsync() { // Arrange var json = JsonSerializer.Deserialize(""" { + "ConversationId": "TestConvId", "aiContextProviderState": ["CP1"] } """, TestJsonSerializerContext.Default.JsonElement); Mock mockProvider = new(); - var thread = new AgentThread() { AIContextProvider = mockProvider.Object }; // Act - await thread.DeserializeAsync(json); + var thread = new AgentThread(json, aiContextProviderFactory: (_, _) => mockProvider.Object); // Assert Assert.Null(thread.MessageStore); - mockProvider.Verify(m => m.DeserializeAsync(It.Is(e => e.ValueKind == JsonValueKind.Array && e.GetArrayLength() == 1), It.IsAny(), It.IsAny()), Times.Once); + Assert.Same(thread.AIContextProvider, mockProvider.Object); } [Fact] - public async Task DeserializeWithInvalidJsonThrowsAsync() + public async Task DeserializeContructorWithInvalidJsonThrowsAsync() { // Arrange var invalidJson = JsonSerializer.Deserialize("[42]", TestJsonSerializerContext.Default.JsonElement); - var thread = new AgentThread(); // Act & Assert - await Assert.ThrowsAsync(() => thread.DeserializeAsync(invalidJson)); + Assert.Throws(() => new AgentThread(invalidJson)); } #endregion Deserialize Tests diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/InMemoryChatMessageStoreTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/InMemoryChatMessageStoreTests.cs index 8b1503157f..98bd4caa46 100644 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/InMemoryChatMessageStoreTests.cs +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/InMemoryChatMessageStoreTests.cs @@ -89,19 +89,17 @@ public class InMemoryChatMessageStoreTests } [Fact] - public async Task DeserializeWithEmptyElementAsync() + public async Task DeserializeConstructorWithEmptyElementAsync() { - var newStore = new InMemoryChatMessageStore(); - var emptyObject = JsonSerializer.Deserialize("{}", TestJsonSerializerContext.Default.JsonElement); - await newStore.DeserializeStateAsync(emptyObject); + var newStore = new InMemoryChatMessageStore(emptyObject); Assert.Empty(newStore); } [Fact] - public async Task SerializeAndDeserializeRoundtripsAsync() + public async Task SerializeAndDeserializeConstructorRoundtripsAsync() { var store = new InMemoryChatMessageStore { @@ -110,9 +108,7 @@ public class InMemoryChatMessageStoreTests }; var jsonElement = await store.SerializeStateAsync(); - var newStore = new InMemoryChatMessageStore(); - - await newStore.DeserializeStateAsync(jsonElement); + var newStore = new InMemoryChatMessageStore(jsonElement.Value); Assert.Equal(2, newStore.Count); Assert.Equal("A", newStore[0].Text); @@ -141,57 +137,49 @@ public class InMemoryChatMessageStoreTests } [Fact] - public async Task DeserializeStateAsync_WithNullSerializedState_DoesNothingAsync() + public void DeserializeContructor_WithNullSerializedState_CreatesEmptyStore() { - // Arrange - var store = new InMemoryChatMessageStore(); - store.Add(new ChatMessage(ChatRole.User, "Existing message")); - // Act - await store.DeserializeStateAsync(null); + var store = new InMemoryChatMessageStore(new JsonElement()); - // Assert - Should still have the existing message - Assert.Single(store); - Assert.Equal("Existing message", store[0].Text); + // Assert + Assert.Empty(store); } [Fact] - public async Task DeserializeStateAsync_WithEmptyMessages_DoesNotAddMessagesAsync() + public async Task DeserializeContructor_WithEmptyMessages_DoesNotAddMessagesAsync() { // Arrange - var store = new InMemoryChatMessageStore(); var stateWithEmptyMessages = JsonSerializer.SerializeToElement( new Dictionary { ["Messages"] = new List() }, TestJsonSerializerContext.Default.IDictionaryStringObject); // Act - await store.DeserializeStateAsync(stateWithEmptyMessages); + var store = new InMemoryChatMessageStore(stateWithEmptyMessages); // Assert Assert.Empty(store); } [Fact] - public async Task DeserializeStateAsync_WithNullMessages_DoesNotAddMessagesAsync() + public async Task DeserializeConstructor_WithNullMessages_DoesNotAddMessagesAsync() { // Arrange - var store = new InMemoryChatMessageStore(); var stateWithNullMessages = JsonSerializer.SerializeToElement( new Dictionary { ["Messages"] = null! }, TestJsonSerializerContext.Default.DictionaryStringObject); // Act - await store.DeserializeStateAsync(stateWithNullMessages); + var store = new InMemoryChatMessageStore(stateWithNullMessages); // Assert Assert.Empty(store); } [Fact] - public async Task DeserializeStateAsync_WithValidMessages_AddsMessagesAsync() + public async Task DeserializeConstructor_WithValidMessages_AddsMessagesAsync() { // Arrange - var store = new InMemoryChatMessageStore(); var messages = new List { new(ChatRole.User, "User message"), @@ -203,7 +191,7 @@ public class InMemoryChatMessageStoreTests TestJsonSerializerContext.Default.DictionaryStringObject); // Act - await store.DeserializeStateAsync(serializedState); + var store = new InMemoryChatMessageStore(serializedState); // Assert Assert.Equal(2, store.Count); diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentOptionsTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentOptionsTests.cs index d8dd5d52fc..2e9192a56a 100644 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentOptionsTests.cs +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentOptionsTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; +using System.Text.Json; using Moq; namespace Microsoft.Extensions.AI.Agents.UnitTests.ChatCompletion; @@ -164,8 +165,8 @@ public class ChatClientAgentOptionsTests const string Name = "Test name"; const string Description = "Test description"; var tools = new List { AIFunctionFactory.Create(() => "test") }; - static IChatMessageStore ChatMessageStoreFactory() => new Mock().Object; - static AIContextProvider AIContextProviderFactory() => new Mock().Object; + static IChatMessageStore ChatMessageStoreFactory(JsonElement jse, JsonSerializerOptions? jso) => new Mock().Object; + static AIContextProvider AIContextProviderFactory(JsonElement jse, JsonSerializerOptions? jso) => new Mock().Object; var original = new ChatClientAgentOptions(Instructions, Name, Description, tools) { diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentTests.cs index 4ec63e04cd..420567acdc 100644 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentTests.cs +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentTests.cs @@ -461,7 +461,7 @@ public class ChatClientAgentTests .Setup(p => p.InvokedAsync(It.IsAny(), It.IsAny())) .Returns(new ValueTask()); - ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "base instructions", AIContextProviderFactory = () => mockProvider.Object, ChatOptions = new() { Tools = [AIFunctionFactory.Create(() => { }, "base function")] } }); + ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "base instructions", AIContextProviderFactory = (_, _) => mockProvider.Object, ChatOptions = new() { Tools = [AIFunctionFactory.Create(() => { }, "base function")] } }); // Act await agent.RunAsync(requestMessages); @@ -506,7 +506,7 @@ public class ChatClientAgentTests .Setup(p => p.InvokedAsync(It.IsAny(), It.IsAny())) .Returns(new ValueTask()); - ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "base instructions", AIContextProviderFactory = () => mockProvider.Object, ChatOptions = new() { Tools = [AIFunctionFactory.Create(() => { }, "base function")] } }); + ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "base instructions", AIContextProviderFactory = (_, _) => mockProvider.Object, ChatOptions = new() { Tools = [AIFunctionFactory.Create(() => { }, "base function")] } }); // Act await Assert.ThrowsAsync(() => agent.RunAsync(requestMessages)); @@ -548,7 +548,7 @@ public class ChatClientAgentTests .Setup(p => p.InvokingAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new AIContext()); - ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "base instructions", AIContextProviderFactory = () => mockProvider.Object, ChatOptions = new() { Tools = [AIFunctionFactory.Create(() => { }, "base function")] } }); + ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "base instructions", AIContextProviderFactory = (_, _) => mockProvider.Object, ChatOptions = new() { Tools = [AIFunctionFactory.Create(() => { }, "base function")] } }); // Act await agent.RunAsync([new(ChatRole.User, "user message")]); @@ -1713,7 +1713,7 @@ public class ChatClientAgentTests var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions { Instructions = "Test instructions", - ChatMessageStoreFactory = () => + ChatMessageStoreFactory = (_, _) => { factoryCalled = true; return mockStore.Object; @@ -1738,7 +1738,7 @@ public class ChatClientAgentTests var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions { Instructions = "Test instructions", - AIContextProviderFactory = () => + AIContextProviderFactory = (_, _) => { factoryCalled = true; return mockContextProvider.Object;