From b67eb8466698b7e81149c67f31a06cc5b115b3a4 Mon Sep 17 00:00:00 2001 From: Chris <66376200+crickman@users.noreply.github.com> Date: Fri, 8 Nov 2024 13:42:03 -0800 Subject: [PATCH] .Net Agents - `AgentChat` Serialization (#7457) ### Motivation and Context Support ability to capture and restore `AgentChat` history: https://github.com/microsoft/semantic-kernel/blob/main/docs/decisions/0048-agent-chat-serialization.md ### Description Introduces `AgentChatSerializer` that supports serialization and deserialization of entire conversation state. ```json { "Participants": [ { "Id": "asst_YaLY1TRmxsIReVa0BiuhEDAj", "Name": "agent-1", "Type": "Microsoft.SemanticKernel.Agents.OpenAI.OpenAIAssistantAgent" }, { "Id": "1e884890-c344-4bc0-9367-068c482a499d", "Name": "agent-2", "Type": "Microsoft.SemanticKernel.Agents.ChatCompletionAgent" } ], "History": [{"Role":{"Label":"user"},"Items":[{"$type":"TextContent","Text":"..."}],"ModelId":"gpt-35-turbo-16k"},{"Role":{"Label":"assistant"},"Items":[{"$type":"TextContent","Text":"..."}],"ModelId":"gpt-35-turbo-16k"}], "Channels": [ { "ChannelKey": "4kjDzpCpeOLUNbsSUisHd9cphSzEAb6Hxdnmr\u002Bem1Jw=", "ChannelType": "Microsoft.SemanticKernel.Agents.OpenAI.OpenAIAssistantChannel", "ChannelState": "thread_ZSF1ovTVzyYy8cg9GEwmDWgy" }, { "ChannelKey": "Vdx37EnWT9BS\u002BkkCkEgFCg9uHvHNw1\u002BhXMA4sgNMKs4=", "ChannelType": "Microsoft.SemanticKernel.Agents.ChatHistoryChannel", "ChannelState": [{"Role":{"Label":"user"},"Items":[{"$type":"TextContent","Text":"..."}],"ModelId":"gpt-35-turbo-16k"},{"Role":{"Label":"assistant"},"Items":[{"$type":"TextContent","Text":"..."}],"ModelId":"gpt-35-turbo-16k"}] } ] } ``` Includes sample showing serialization and deserialization with `ChatCompletionAgent` and `OpenAIAssistantAgent` partipating on the same chat, as well as a chat that includes `ChatCompletionAgent` that calls a plug-in. ### Contribution Checklist - [X] The code builds clean without any errors or warnings - [X] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [X] All unit tests pass, and I have added new tests where possible - [X] I didn't break anyone :smile: --------- Co-authored-by: Ben Thomas --- .../Agents/ChatCompletion_Serialization.cs | 103 +++++++ .../Agents/MixedChat_Serialization.cs | 128 ++++++++ dotnet/src/Agents/Abstractions/Agent.cs | 12 + .../src/Agents/Abstractions/AgentChannel.cs | 5 + dotnet/src/Agents/Abstractions/AgentChat.cs | 61 ++++ .../Abstractions/AgentChatSerializer.cs | 61 ++++ .../Agents/Abstractions/AggregatorAgent.cs | 22 +- .../Agents/Abstractions/AggregatorChannel.cs | 4 + .../Logging/AggregatorAgentLogMessages.cs | 24 ++ .../Serialization/AgentChannelState.cs | 35 +++ .../Serialization/AgentChatState.cs | 31 ++ .../Serialization/AgentParticipant.cs | 43 +++ .../Serialization/ChatMessageReference.cs | 47 +++ .../JsonChannelStateConverter.cs | 61 ++++ dotnet/src/Agents/Core/AgentGroupChat.cs | 2 +- dotnet/src/Agents/Core/ChatCompletionAgent.cs | 10 + dotnet/src/Agents/Core/ChatHistoryChannel.cs | 12 +- .../OpenAIAssistantAgentLogMessages.cs | 26 ++ .../src/Agents/OpenAI/OpenAIAssistantAgent.cs | 14 + .../Agents/OpenAI/OpenAIAssistantChannel.cs | 3 + .../UnitTests/AgentChatSerializerTests.cs | 273 ++++++++++++++++++ dotnet/src/Agents/UnitTests/AgentChatTests.cs | 2 + .../UnitTests/Core/ChatHistoryChannelTests.cs | 2 +- dotnet/src/Agents/UnitTests/MockAgent.cs | 10 + dotnet/src/Agents/UnitTests/MockChannel.cs | 5 + 25 files changed, 990 insertions(+), 6 deletions(-) create mode 100644 dotnet/samples/Concepts/Agents/ChatCompletion_Serialization.cs create mode 100644 dotnet/samples/Concepts/Agents/MixedChat_Serialization.cs create mode 100644 dotnet/src/Agents/Abstractions/AgentChatSerializer.cs create mode 100644 dotnet/src/Agents/Abstractions/Serialization/AgentChannelState.cs create mode 100644 dotnet/src/Agents/Abstractions/Serialization/AgentChatState.cs create mode 100644 dotnet/src/Agents/Abstractions/Serialization/AgentParticipant.cs create mode 100644 dotnet/src/Agents/Abstractions/Serialization/ChatMessageReference.cs create mode 100644 dotnet/src/Agents/Abstractions/Serialization/JsonChannelStateConverter.cs create mode 100644 dotnet/src/Agents/UnitTests/AgentChatSerializerTests.cs diff --git a/dotnet/samples/Concepts/Agents/ChatCompletion_Serialization.cs b/dotnet/samples/Concepts/Agents/ChatCompletion_Serialization.cs new file mode 100644 index 000000000000..a0494c67bd70 --- /dev/null +++ b/dotnet/samples/Concepts/Agents/ChatCompletion_Serialization.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft. All rights reserved. +using System.ComponentModel; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.OpenAI; + +namespace Agents; +/// +/// Demonstrate that serialization of in with a participant. +/// +public class ChatCompletion_Serialization(ITestOutputHelper output) : BaseAgentsTest(output) +{ + private const string HostName = "Host"; + private const string HostInstructions = "Answer questions about the menu."; + + [Fact] + public async Task SerializeAndRestoreAgentGroupChatAsync() + { + // Define the agent + ChatCompletionAgent agent = + new() + { + Instructions = HostInstructions, + Name = HostName, + Kernel = this.CreateKernelWithChatCompletion(), + Arguments = new KernelArguments(new OpenAIPromptExecutionSettings() { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() }), + }; + + // Initialize plugin and add to the agent's Kernel (same as direct Kernel usage). + KernelPlugin plugin = KernelPluginFactory.CreateFromType(); + agent.Kernel.Plugins.Add(plugin); + + AgentGroupChat chat = CreateGroupChat(); + + // Invoke chat and display messages. + Console.WriteLine("============= Dynamic Agent Chat - Primary (prior to serialization) =============="); + await InvokeAgentAsync(chat, "Hello"); + await InvokeAgentAsync(chat, "What is the special soup?"); + + AgentGroupChat copy = CreateGroupChat(); + Console.WriteLine("\n=========== Serialize and restore the Agent Chat into a new instance ============"); + await CloneChatAsync(chat, copy); + + Console.WriteLine("\n============ Continue with the dynamic Agent Chat (after deserialization) ==============="); + await InvokeAgentAsync(copy, "What is the special drink?"); + await InvokeAgentAsync(copy, "Thank you"); + + Console.WriteLine("\n============ The entire Agent Chat (includes messages prior to serialization and those after deserialization) =============="); + await foreach (ChatMessageContent content in copy.GetChatMessagesAsync()) + { + this.WriteAgentChatMessage(content); + } + + // Local function to invoke agent and display the conversation messages. + async Task InvokeAgentAsync(AgentGroupChat chat, string input) + { + ChatMessageContent message = new(AuthorRole.User, input); + chat.AddChatMessage(message); + + this.WriteAgentChatMessage(message); + + await foreach (ChatMessageContent content in chat.InvokeAsync()) + { + this.WriteAgentChatMessage(content); + } + } + + async Task CloneChatAsync(AgentGroupChat source, AgentGroupChat clone) + { + await using MemoryStream stream = new(); + await AgentChatSerializer.SerializeAsync(source, stream); + + stream.Position = 0; + using StreamReader reader = new(stream); + Console.WriteLine(await reader.ReadToEndAsync()); + + stream.Position = 0; + AgentChatSerializer serializer = await AgentChatSerializer.DeserializeAsync(stream); + await serializer.DeserializeAsync(clone); + } + + AgentGroupChat CreateGroupChat() => new(agent); + } + + private sealed class MenuPlugin + { + [KernelFunction, Description("Provides a list of specials from the menu.")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1024:Use properties where appropriate", Justification = "Too smart")] + public string GetSpecials() => + """ + Special Soup: Clam Chowder + Special Salad: Cobb Salad + Special Drink: Chai Tea + """; + + [KernelFunction, Description("Provides the price of the requested menu item.")] + public string GetItemPrice( + [Description("The name of the menu item.")] + string menuItem) => + "$9.99"; + } +} diff --git a/dotnet/samples/Concepts/Agents/MixedChat_Serialization.cs b/dotnet/samples/Concepts/Agents/MixedChat_Serialization.cs new file mode 100644 index 000000000000..27212e292366 --- /dev/null +++ b/dotnet/samples/Concepts/Agents/MixedChat_Serialization.cs @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft. All rights reserved. +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.Agents.Chat; +using Microsoft.SemanticKernel.Agents.OpenAI; +using Microsoft.SemanticKernel.ChatCompletion; + +namespace Agents; +/// +/// Demonstrate the serialization of with a +/// and an . +/// +public class MixedChat_Serialization(ITestOutputHelper output) : BaseAgentsTest(output) +{ + private const string TranslatorName = "Translator"; + private const string TranslatorInstructions = + """ + Spell the last number in chat as a word in english and spanish on a single line without any line breaks. + """; + + private const string CounterName = "Counter"; + private const string CounterInstructions = + """ + Increment the last number from your most recent response. + Never repeat the same number. + + Only respond with a single number that is the result of your calculation without explanation. + """; + + [Fact] + public async Task SerializeAndRestoreAgentGroupChatAsync() + { + // Define the agents: one of each type + ChatCompletionAgent agentTranslator = + new() + { + Instructions = TranslatorInstructions, + Name = TranslatorName, + Kernel = this.CreateKernelWithChatCompletion(), + }; + + OpenAIAssistantAgent agentCounter = + await OpenAIAssistantAgent.CreateAsync( + kernel: new(), + clientProvider: this.GetClientProvider(), + definition: new(this.Model) + { + Instructions = CounterInstructions, + Name = CounterName, + }); + + AgentGroupChat chat = CreateGroupChat(); + + // Invoke chat and display messages. + ChatMessageContent input = new(AuthorRole.User, "1"); + chat.AddChatMessage(input); + this.WriteAgentChatMessage(input); + + Console.WriteLine("============= Dynamic Agent Chat - Primary (prior to serialization) =============="); + await InvokeAgents(chat); + + AgentGroupChat copy = CreateGroupChat(); + Console.WriteLine("\n=========== Serialize and restore the Agent Chat into a new instance ============"); + await CloneChatAsync(chat, copy); + + Console.WriteLine("\n============ Continue with the dynamic Agent Chat (after deserialization) ==============="); + await InvokeAgents(copy); + + Console.WriteLine("\n============ The entire Agent Chat (includes messages prior to serialization and those after deserialization) =============="); + await foreach (ChatMessageContent content in copy.GetChatMessagesAsync()) + { + this.WriteAgentChatMessage(content); + } + + async Task InvokeAgents(AgentGroupChat chat) + { + await foreach (ChatMessageContent content in chat.InvokeAsync()) + { + this.WriteAgentChatMessage(content); + } + } + + async Task CloneChatAsync(AgentGroupChat source, AgentGroupChat clone) + { + await using MemoryStream stream = new(); + await AgentChatSerializer.SerializeAsync(source, stream); + + stream.Position = 0; + using StreamReader reader = new(stream); + Console.WriteLine(await reader.ReadToEndAsync()); + + stream.Position = 0; + AgentChatSerializer serializer = await AgentChatSerializer.DeserializeAsync(stream); + await serializer.DeserializeAsync(clone); + } + + AgentGroupChat CreateGroupChat() => + new(agentTranslator, agentCounter) + { + ExecutionSettings = + new() + { + TerminationStrategy = + new CountingTerminationStrategy(5) + { + // Only the art-director may approve. + Agents = [agentTranslator], + // Limit total number of turns + MaximumIterations = 20, + } + } + }; + } + + private sealed class CountingTerminationStrategy(int maxTurns) : TerminationStrategy + { + private int _count = 0; + + protected override Task ShouldAgentTerminateAsync(Agent agent, IReadOnlyList history, CancellationToken cancellationToken) + { + ++this._count; + + bool shouldTerminate = this._count >= maxTurns; + + return Task.FromResult(shouldTerminate); + } + } +} diff --git a/dotnet/src/Agents/Abstractions/Agent.cs b/dotnet/src/Agents/Abstractions/Agent.cs index 8af2de3b0869..d8cd91b9100d 100644 --- a/dotnet/src/Agents/Abstractions/Agent.cs +++ b/dotnet/src/Agents/Abstractions/Agent.cs @@ -72,5 +72,17 @@ public abstract class Agent /// protected internal abstract Task CreateChannelAsync(CancellationToken cancellationToken); + /// + /// Produce the an appropriate for the agent type based on the provided state. + /// + /// The channel state, as serialized + /// The to monitor for cancellation requests. The default is . + /// An appropriate for the agent type. + /// + /// Every agent conversation, or , will establish one or more + /// objects according to the specific type. + /// + protected internal abstract Task RestoreChannelAsync(string channelState, CancellationToken cancellationToken); + private ILogger? _logger; } diff --git a/dotnet/src/Agents/Abstractions/AgentChannel.cs b/dotnet/src/Agents/Abstractions/AgentChannel.cs index 9eee5c336085..046348443a39 100644 --- a/dotnet/src/Agents/Abstractions/AgentChannel.cs +++ b/dotnet/src/Agents/Abstractions/AgentChannel.cs @@ -18,6 +18,11 @@ public abstract class AgentChannel /// public ILogger Logger { get; set; } = NullLogger.Instance; + /// + /// Responsible for providing the serialized representation of the channel. + /// + protected internal abstract string Serialize(); + /// /// Receive the conversation messages. Used when joining a conversation and also during each agent interaction.. /// diff --git a/dotnet/src/Agents/Abstractions/AgentChat.cs b/dotnet/src/Agents/Abstractions/AgentChat.cs index 7f2d09af6569..f458739e3bb4 100644 --- a/dotnet/src/Agents/Abstractions/AgentChat.cs +++ b/dotnet/src/Agents/Abstractions/AgentChat.cs @@ -2,12 +2,14 @@ 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; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.SemanticKernel.Agents.Extensions; using Microsoft.SemanticKernel.Agents.Internal; +using Microsoft.SemanticKernel.Agents.Serialization; using Microsoft.SemanticKernel.ChatCompletion; namespace Microsoft.SemanticKernel.Agents; @@ -28,6 +30,11 @@ public abstract class AgentChat private int _isActive; private ILogger? _logger; + /// + /// The agents participating in the chat. + /// + public abstract IReadOnlyList Agents { get; } + /// /// Indicates if a chat operation is active. Activity is defined as /// any the execution of any public method. @@ -324,6 +331,60 @@ public async Task ResetAsync(CancellationToken cancellationToken = default) } } + internal async Task DeserializeAsync(AgentChatState state) + { + if (this._agentChannels.Count > 0 || this.History.Count > 0) + { + throw new KernelException($"Unable to restore chat to instance of {this.GetType().Name}: Already in use."); + } + + try + { + Dictionary channelStateMap = state.Channels.ToDictionary(c => c.ChannelKey); + foreach (Agent agent in this.Agents) + { + string channelKey = this.GetAgentHash(agent); + + if (this._agentChannels.ContainsKey(channelKey)) + { + continue; + } + + AgentChannel channel = await agent.RestoreChannelAsync(channelStateMap[channelKey].ChannelState, CancellationToken.None).ConfigureAwait(false); + this._agentChannels.Add(channelKey, channel); + channel.Logger = this.LoggerFactory.CreateLogger(channel.GetType()); + } + + IEnumerable? history = JsonSerializer.Deserialize>(state.History); + if (history != null) + { + this.History.AddRange(history); + } + } + catch + { + this._agentChannels.Clear(); + this.History.Clear(); + throw; + } + } + + internal AgentChatState Serialize() => + new() + { + Participants = this.Agents.Select(a => new AgentParticipant(a)), + History = JsonSerializer.Serialize(ChatMessageReference.Prepare(this.History)), + Channels = + this._agentChannels.Select( + kvp => + new AgentChannelState + { + ChannelKey = kvp.Key, + ChannelType = kvp.Value.GetType().FullName!, + ChannelState = kvp.Value.Serialize() + }) + }; + /// /// Clear activity signal to indicate that activity has ceased. /// diff --git a/dotnet/src/Agents/Abstractions/AgentChatSerializer.cs b/dotnet/src/Agents/Abstractions/AgentChatSerializer.cs new file mode 100644 index 000000000000..146e00d7965f --- /dev/null +++ b/dotnet/src/Agents/Abstractions/AgentChatSerializer.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft. All rights reserved. +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Microsoft.SemanticKernel.Agents.Serialization; + +namespace Microsoft.SemanticKernel.Agents; + +/// +/// Able to serialize and deserialize an . +/// +public sealed class AgentChatSerializer +{ + private readonly AgentChatState _state; + + private static readonly JsonSerializerOptions s_defaultOptions = + new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + }; + + /// + /// Serialize the provided to the target stream. + /// + public static async Task SerializeAsync(TChat chat, Stream stream, JsonSerializerOptions? serializerOptions = null) where TChat : AgentChat + { + AgentChatState state = chat.Serialize(); + await JsonSerializer.SerializeAsync(stream, state, serializerOptions ?? s_defaultOptions).ConfigureAwait(false); + } + + /// + /// Provides a that is able to restore an . + /// + public static async Task DeserializeAsync(Stream stream, JsonSerializerOptions? serializerOptions = null) + { + AgentChatState state = + await JsonSerializer.DeserializeAsync(stream, serializerOptions ?? s_defaultOptions).ConfigureAwait(false) ?? + throw new KernelException("Unable to restore chat: invalid format."); + + return new AgentChatSerializer(state); + } + + /// + /// Enumerates the participants of the original so that + /// the caller may be include them in the restored . + /// + public IEnumerable Participants => this._state.Participants; + + /// + /// Restore the to the previously captured state. + /// + public Task DeserializeAsync(TChat chat) where TChat : AgentChat => chat.DeserializeAsync(this._state); + + private AgentChatSerializer(AgentChatState state) + { + this._state = state; + } +} diff --git a/dotnet/src/Agents/Abstractions/AggregatorAgent.cs b/dotnet/src/Agents/Abstractions/AggregatorAgent.cs index 1efed6270c28..eb1f7d0fac98 100644 --- a/dotnet/src/Agents/Abstractions/AggregatorAgent.cs +++ b/dotnet/src/Agents/Abstractions/AggregatorAgent.cs @@ -1,8 +1,10 @@ // 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.SemanticKernel.Agents.Serialization; namespace Microsoft.SemanticKernel.Agents; @@ -43,7 +45,7 @@ public sealed class AggregatorAgent(Func chatProvider) : Agent protected internal override IEnumerable GetChannelKeys() { yield return typeof(AggregatorChannel).FullName!; - yield return this.GetHashCode().ToString(); + yield return this.Name ?? this.Id; } /// @@ -58,4 +60,22 @@ protected internal override Task CreateChannelAsync(CancellationTo return Task.FromResult(channel); } + + /// + protected internal async override Task RestoreChannelAsync(string channelState, CancellationToken cancellationToken) + { + this.Logger.LogOpenAIAssistantAgentRestoringChannel(nameof(CreateChannelAsync), nameof(AggregatorChannel)); + + AgentChat chat = chatProvider.Invoke(); + AgentChatState agentChatState = + JsonSerializer.Deserialize(channelState) ?? + throw new KernelException("Unable to restore channel: invalid state."); + + await chat.DeserializeAsync(agentChatState).ConfigureAwait(false); ; + AggregatorChannel channel = new(chat); + + this.Logger.LogOpenAIAssistantAgentRestoredChannel(nameof(CreateChannelAsync), nameof(AggregatorChannel)); + + return channel; + } } diff --git a/dotnet/src/Agents/Abstractions/AggregatorChannel.cs b/dotnet/src/Agents/Abstractions/AggregatorChannel.cs index 05adb1e2af04..90dc8a29ea17 100644 --- a/dotnet/src/Agents/Abstractions/AggregatorChannel.cs +++ b/dotnet/src/Agents/Abstractions/AggregatorChannel.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Runtime.CompilerServices; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -92,4 +93,7 @@ protected internal override Task ReceiveAsync(IEnumerable hi /// protected internal override Task ResetAsync(CancellationToken cancellationToken = default) => this._chat.ResetAsync(cancellationToken); + + protected internal override string Serialize() => + JsonSerializer.Serialize(this._chat.Serialize()); } diff --git a/dotnet/src/Agents/Abstractions/Logging/AggregatorAgentLogMessages.cs b/dotnet/src/Agents/Abstractions/Logging/AggregatorAgentLogMessages.cs index df8a752a098c..441c9da117f5 100644 --- a/dotnet/src/Agents/Abstractions/Logging/AggregatorAgentLogMessages.cs +++ b/dotnet/src/Agents/Abstractions/Logging/AggregatorAgentLogMessages.cs @@ -42,4 +42,28 @@ public static partial void LogAggregatorAgentCreatedChannel( string channelType, AggregatorMode channelMode, Type agentChatType); + + /// + /// Logs restoring serialized channel (started). + /// + [LoggerMessage( + EventId = 0, + Level = LogLevel.Debug, + Message = "[{MethodName}] Restoring assistant channel for {ChannelType}.")] + public static partial void LogOpenAIAssistantAgentRestoringChannel( + this ILogger logger, + string methodName, + string channelType); + + /// + /// Logs restored serialized channel (complete). + /// + [LoggerMessage( + EventId = 0, + Level = LogLevel.Information, + Message = "[{MethodName}] Restored assistant channel for {ChannelType}.")] + public static partial void LogOpenAIAssistantAgentRestoredChannel( + this ILogger logger, + string methodName, + string channelType); } diff --git a/dotnet/src/Agents/Abstractions/Serialization/AgentChannelState.cs b/dotnet/src/Agents/Abstractions/Serialization/AgentChannelState.cs new file mode 100644 index 000000000000..497a40f469a7 --- /dev/null +++ b/dotnet/src/Agents/Abstractions/Serialization/AgentChannelState.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. +using System.Text.Json.Serialization; + +namespace Microsoft.SemanticKernel.Agents.Serialization; + +/// +/// Captures the serialized state of an along with relevant meta-data. +/// +internal sealed class AgentChannelState +{ + /// + /// The unique key for the channel. + /// + /// + /// This is a hash generates and manages based . + /// + public string ChannelKey { get; set; } = string.Empty; + + /// + /// The fully qualified type name of the channel. + /// + /// + /// Not utilized in deserialization, but useful for auditing the serialization payload. + /// + public string ChannelType { get; set; } = string.Empty; + + /// + /// The serialized channel state, as provided by . + /// + /// + /// Converter will serialize JSON string as JSON. + /// + [JsonConverter(typeof(JsonChannelStateConverter))] + public string ChannelState { get; set; } = string.Empty; +} diff --git a/dotnet/src/Agents/Abstractions/Serialization/AgentChatState.cs b/dotnet/src/Agents/Abstractions/Serialization/AgentChatState.cs new file mode 100644 index 000000000000..cd29cc6bde33 --- /dev/null +++ b/dotnet/src/Agents/Abstractions/Serialization/AgentChatState.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft. All rights reserved. +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.SemanticKernel.Agents.Serialization; + +/// +/// Captures the serialized state of an along with relevant meta-data. +/// +internal sealed class AgentChatState +{ + /// + /// Metadata to identify the instances participating in an . + /// + public IEnumerable Participants { get; init; } = Array.Empty(); + + /// + /// The serialized chat history. + /// + /// + /// Converter will serialize JSON string as JSON. + /// + [JsonConverter(typeof(JsonChannelStateConverter))] + public string History { get; init; } = string.Empty; + + /// + /// The state of each active in an . + /// + public IEnumerable Channels { get; init; } = []; +} diff --git a/dotnet/src/Agents/Abstractions/Serialization/AgentParticipant.cs b/dotnet/src/Agents/Abstractions/Serialization/AgentParticipant.cs new file mode 100644 index 000000000000..564f68b72ab6 --- /dev/null +++ b/dotnet/src/Agents/Abstractions/Serialization/AgentParticipant.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft. All rights reserved. +using System.Text.Json.Serialization; + +namespace Microsoft.SemanticKernel.Agents.Serialization; + +/// +/// References an instance participating in an . +/// +public sealed class AgentParticipant +{ + /// + /// The captured . + /// + public string Id { get; init; } = string.Empty; + + /// + /// The captured . + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Name { get; init; } + + /// + /// The fully qualified type name. + /// + public string Type { get; init; } = string.Empty; + + /// + /// Parameterless constructor for deserialization. + /// + [JsonConstructor] + public AgentParticipant() { } + + /// + /// Convenience constructor for serialization. + /// + /// The referenced + internal AgentParticipant(Agent agent) + { + this.Id = agent.Id; + this.Name = agent.Name; + this.Type = agent.GetType().FullName!; + } +} diff --git a/dotnet/src/Agents/Abstractions/Serialization/ChatMessageReference.cs b/dotnet/src/Agents/Abstractions/Serialization/ChatMessageReference.cs new file mode 100644 index 000000000000..f71f86c18b9d --- /dev/null +++ b/dotnet/src/Agents/Abstractions/Serialization/ChatMessageReference.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft. All rights reserved. +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Serialization; +using Microsoft.SemanticKernel.ChatCompletion; + +namespace Microsoft.SemanticKernel.Agents.Serialization; + +/// +/// Present a for serialization without metadata. +/// +/// The referenced message +public sealed class ChatMessageReference(ChatMessageContent message) +{ + /// + /// The referenced property. + /// + public string? AuthorName => message.AuthorName; + + /// + /// The referenced property. + /// + public AuthorRole Role => message.Role; + + /// + /// The referenced collection. + /// + public IEnumerable Items => message.Items; + + /// + /// The referenced property. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ModelId => message.ModelId; + + /// + /// The referenced property. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? MimeType => message.MimeType; + + /// + /// Convenience method to reference a set of messages. + /// + public static IEnumerable Prepare(IEnumerable messages) => + messages.Select(m => new ChatMessageReference(m)); +} diff --git a/dotnet/src/Agents/Abstractions/Serialization/JsonChannelStateConverter.cs b/dotnet/src/Agents/Abstractions/Serialization/JsonChannelStateConverter.cs new file mode 100644 index 000000000000..f67f3e6f3322 --- /dev/null +++ b/dotnet/src/Agents/Abstractions/Serialization/JsonChannelStateConverter.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft. All rights reserved. +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.SemanticKernel.Agents.Serialization; + +/// +/// Translates the serialized state to avoid escaping nested JSON as string. +/// +/// +/// Without converter: +/// +/// { +/// "state": "{\"key\":\"value\"}" +/// } +/// +/// +/// With converter: +/// +/// { +/// "state": {"key": "value"} +/// } +/// +/// +/// Always: +/// +/// { +/// "state": "text", +/// } +/// +/// +internal class JsonChannelStateConverter : JsonConverter +{ + /// + public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + string? token = reader.GetString(); + return token; + } + + using var doc = JsonDocument.ParseValue(ref reader); + return doc.RootElement.GetRawText(); + } + + /// + public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) + { + if ((value.StartsWith("[", StringComparison.Ordinal) && value.EndsWith("]", StringComparison.Ordinal)) || + (value.StartsWith("{", StringComparison.Ordinal) && value.EndsWith("}", StringComparison.Ordinal))) + { + writer.WriteRawValue(value); + } + else + { + writer.WriteStringValue(value); + } + } +} diff --git a/dotnet/src/Agents/Core/AgentGroupChat.cs b/dotnet/src/Agents/Core/AgentGroupChat.cs index 3b2a2c9ba788..5d80f969eb4e 100644 --- a/dotnet/src/Agents/Core/AgentGroupChat.cs +++ b/dotnet/src/Agents/Core/AgentGroupChat.cs @@ -33,7 +33,7 @@ public sealed class AgentGroupChat : AgentChat /// /// The agents participating in the chat. /// - public IReadOnlyList Agents => this._agents.AsReadOnly(); + public override IReadOnlyList Agents => this._agents.AsReadOnly(); /// /// Add a to the chat. diff --git a/dotnet/src/Agents/Core/ChatCompletionAgent.cs b/dotnet/src/Agents/Core/ChatCompletionAgent.cs index cb814969600f..770153bbfb1e 100644 --- a/dotnet/src/Agents/Core/ChatCompletionAgent.cs +++ b/dotnet/src/Agents/Core/ChatCompletionAgent.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.SemanticKernel.ChatCompletion; @@ -144,6 +145,15 @@ public override async IAsyncEnumerable InvokeStream } } + /// + protected override Task RestoreChannelAsync(string channelState, CancellationToken cancellationToken) + { + ChatHistory history = + JsonSerializer.Deserialize(channelState) ?? + throw new KernelException("Unable to restore channel: invalid state."); + return Task.FromResult(new ChatHistoryChannel(history)); + } + internal static (IChatCompletionService service, PromptExecutionSettings? executionSettings) GetChatCompletionService(Kernel kernel, KernelArguments? arguments) { // Need to provide a KernelFunction to the service selector as a container for the execution-settings. diff --git a/dotnet/src/Agents/Core/ChatHistoryChannel.cs b/dotnet/src/Agents/Core/ChatHistoryChannel.cs index 29eb89a447a7..78345f084b3f 100644 --- a/dotnet/src/Agents/Core/ChatHistoryChannel.cs +++ b/dotnet/src/Agents/Core/ChatHistoryChannel.cs @@ -2,9 +2,11 @@ using System.Collections.Generic; using System.Linq; using System.Runtime.CompilerServices; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.SemanticKernel.Agents.Extensions; +using Microsoft.SemanticKernel.Agents.Serialization; using Microsoft.SemanticKernel.ChatCompletion; namespace Microsoft.SemanticKernel.Agents; @@ -12,7 +14,7 @@ namespace Microsoft.SemanticKernel.Agents; /// /// A specialization for that acts upon a . /// -public sealed class ChatHistoryChannel : AgentChannel +internal sealed class ChatHistoryChannel : AgentChannel { private readonly ChatHistory _history; @@ -122,11 +124,15 @@ protected override Task ResetAsync(CancellationToken cancellationToken = default return Task.CompletedTask; } + /// + protected override string Serialize() + => JsonSerializer.Serialize(ChatMessageReference.Prepare(this._history)); + /// /// Initializes a new instance of the class. /// - public ChatHistoryChannel() + public ChatHistoryChannel(ChatHistory? history = null) { - this._history = []; + this._history = history ?? []; } } diff --git a/dotnet/src/Agents/OpenAI/Logging/OpenAIAssistantAgentLogMessages.cs b/dotnet/src/Agents/OpenAI/Logging/OpenAIAssistantAgentLogMessages.cs index 1f85264ed9c4..2daffcfb5932 100644 --- a/dotnet/src/Agents/OpenAI/Logging/OpenAIAssistantAgentLogMessages.cs +++ b/dotnet/src/Agents/OpenAI/Logging/OpenAIAssistantAgentLogMessages.cs @@ -40,4 +40,30 @@ public static partial void LogOpenAIAssistantAgentCreatedChannel( string methodName, string channelType, string threadId); + + /// + /// Logs restoring serialized channel (started). + /// + [LoggerMessage( + EventId = 0, + Level = LogLevel.Debug, + Message = "[{MethodName}] Restoring assistant channel for {ChannelType}: #{ThreadId}.")] + public static partial void LogOpenAIAssistantAgentRestoringChannel( + this ILogger logger, + string methodName, + string channelType, + string threadId); + + /// + /// Logs restored serialized channel (complete). + /// + [LoggerMessage( + EventId = 0, + Level = LogLevel.Information, + Message = "[{MethodName}] Restored assistant channel for {ChannelType}: #{ThreadId}.")] + public static partial void LogOpenAIAssistantAgentRestoredChannel( + this ILogger logger, + string methodName, + string channelType, + string threadId); } diff --git a/dotnet/src/Agents/OpenAI/OpenAIAssistantAgent.cs b/dotnet/src/Agents/OpenAI/OpenAIAssistantAgent.cs index 67b77d7d4374..ee3ae415f320 100644 --- a/dotnet/src/Agents/OpenAI/OpenAIAssistantAgent.cs +++ b/dotnet/src/Agents/OpenAI/OpenAIAssistantAgent.cs @@ -444,6 +444,20 @@ internal void ThrowIfDeleted() internal Task GetInstructionsAsync(Kernel kernel, KernelArguments? arguments, CancellationToken cancellationToken) => this.FormatInstructionsAsync(kernel, arguments, cancellationToken); + /// + protected override async Task RestoreChannelAsync(string channelState, CancellationToken cancellationToken) + { + string threadId = channelState; + + this.Logger.LogOpenAIAssistantAgentRestoringChannel(nameof(RestoreChannelAsync), nameof(OpenAIAssistantChannel), threadId); + + AssistantThread thread = await this._client.GetThreadAsync(threadId, cancellationToken).ConfigureAwait(false); + + this.Logger.LogOpenAIAssistantAgentRestoredChannel(nameof(RestoreChannelAsync), nameof(OpenAIAssistantChannel), threadId); + + return new OpenAIAssistantChannel(this._client, thread.Id); + } + /// /// Initializes a new instance of the class. /// diff --git a/dotnet/src/Agents/OpenAI/OpenAIAssistantChannel.cs b/dotnet/src/Agents/OpenAI/OpenAIAssistantChannel.cs index 9e69e997e095..506f0a837ebf 100644 --- a/dotnet/src/Agents/OpenAI/OpenAIAssistantChannel.cs +++ b/dotnet/src/Agents/OpenAI/OpenAIAssistantChannel.cs @@ -52,4 +52,7 @@ protected override IAsyncEnumerable GetHistoryAsync(Cancella /// protected override Task ResetAsync(CancellationToken cancellationToken = default) => this._client.DeleteThreadAsync(this._threadId, cancellationToken); + + /// + protected override string Serialize() => this._threadId; } diff --git a/dotnet/src/Agents/UnitTests/AgentChatSerializerTests.cs b/dotnet/src/Agents/UnitTests/AgentChatSerializerTests.cs new file mode 100644 index 000000000000..ba607d260689 --- /dev/null +++ b/dotnet/src/Agents/UnitTests/AgentChatSerializerTests.cs @@ -0,0 +1,273 @@ +// Copyright (c) Microsoft. All rights reserved. +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.Agents.Serialization; +using Microsoft.SemanticKernel.ChatCompletion; +using Xunit; + +namespace SemanticKernel.Agents.UnitTests; + +/// +/// Unit testing of . +/// +public class AgentChatSerializerTests +{ + /// + /// Verify serialization cycle for an empty . + /// + [Fact] + public async Task VerifySerializedChatEmptyAsync() + { + // Create chat + TestChat chat = new(); + + // Serialize and deserialize chat + AgentChatState chatState = chat.Serialize(); + string jsonState = await this.SerializeChatAsync(chat); + AgentChatState? restoredState = JsonSerializer.Deserialize(jsonState); + + // Validate state + Assert.Empty(chatState.Participants); + ChatHistory? chatHistory = JsonSerializer.Deserialize(chatState.History); + Assert.NotNull(chatHistory); + Assert.Empty(chatHistory); + Assert.Empty(chatState.Channels); + + Assert.NotNull(restoredState); + Assert.Empty(restoredState.Participants); + ChatHistory? restoredHistory = JsonSerializer.Deserialize(restoredState.History); + Assert.NotNull(restoredHistory); + Assert.Empty(restoredHistory); + Assert.Empty(restoredState.Channels); + } + + /// + /// Verify serialization cycle for a with only user message (no channels). + /// + [Fact] + public async Task VerifySerializedChatWithoutAgentsAsync() + { + // Create chat + TestChat chat = new(); + chat.AddChatMessage(new ChatMessageContent(AuthorRole.User, "test")); + + // Serialize and deserialize chat + AgentChatState chatState = chat.Serialize(); + string jsonState = await this.SerializeChatAsync(chat); + AgentChatState? restoredState = JsonSerializer.Deserialize(jsonState); + + // Validate state + Assert.Empty(chatState.Participants); + ChatHistory? chatHistory = JsonSerializer.Deserialize(chatState.History); + Assert.NotNull(chatHistory); + Assert.Single(chatHistory); + Assert.Empty(chatState.Channels); + + Assert.NotNull(restoredState); + Assert.Empty(restoredState.Participants); + ChatHistory? restoredHistory = JsonSerializer.Deserialize(restoredState.History); + Assert.NotNull(restoredHistory); + Assert.Single(restoredHistory); + Assert.Empty(restoredState.Channels); + } + + /// + /// Verify serialization cycle for a with history and channels. + /// + [Fact] + public async Task VerifySerializedChatWithAgentsAsync() + { + // Create chat + TestChat chat = new(CreateMockAgent(), CreateMockAgent()); + chat.AddChatMessage(new ChatMessageContent(AuthorRole.User, "test")); + ChatMessageContent[] messages = await chat.InvokeAsync().ToArrayAsync(); + + // Serialize and deserialize chat + AgentChatState chatState = chat.Serialize(); + string jsonState = await this.SerializeChatAsync(chat); + AgentChatState? restoredState = JsonSerializer.Deserialize(jsonState); + + // Validate state + Assert.Equal(2, chatState.Participants.Count()); + ChatHistory? chatHistory = JsonSerializer.Deserialize(chatState.History); + Assert.NotNull(chatHistory); + Assert.Equal(2, chatHistory.Count); + Assert.Single(chatState.Channels); + + Assert.NotNull(restoredState); + Assert.Equal(2, restoredState.Participants.Count()); + ChatHistory? restoredHistory = JsonSerializer.Deserialize(restoredState.History); + Assert.NotNull(restoredHistory); + Assert.Equal(2, restoredHistory.Count); + Assert.Single(restoredState.Channels); + } + + /// + /// Verify serialization cycle for a with a . + /// + [Fact] + public async Task VerifySerializedChatWithAggregatorAsync() + { + // Create chat + TestChat chat = new(new AggregatorAgent(() => new TestChat(CreateMockAgent()))); + chat.AddChatMessage(new ChatMessageContent(AuthorRole.User, "test")); + ChatMessageContent[] messages = await chat.InvokeAsync().ToArrayAsync(); + + // Serialize and deserialize chat + AgentChatState chatState = chat.Serialize(); + string jsonState = await this.SerializeChatAsync(chat); + AgentChatState? restoredState = JsonSerializer.Deserialize(jsonState); + + // Validate state + Assert.Single(chatState.Participants); + ChatHistory? chatHistory = JsonSerializer.Deserialize(chatState.History); + Assert.NotNull(chatHistory); + Assert.Equal(2, chatHistory.Count); + Assert.Single(chatState.Channels); + + Assert.NotNull(restoredState); + Assert.Single(restoredState.Participants); + ChatHistory? restoredHistory = JsonSerializer.Deserialize(restoredState.History); + Assert.NotNull(restoredHistory); + Assert.Equal(2, restoredHistory.Count); + Assert.Single(restoredState.Channels); + } + + /// + /// Verify Deserialization cycle for a with history and channels. + /// + [Fact] + public async Task VerifyDeserializedChatWithAgentsAsync() + { + // Create chat + TestChat chat = new(CreateMockAgent(), CreateMockAgent()); + chat.AddChatMessage(new ChatMessageContent(AuthorRole.User, "test")); + ChatMessageContent[] messages = await chat.InvokeAsync().ToArrayAsync(); + + // Serialize and deserialize chat + AgentChatSerializer serializer = await this.CreateSerializerAsync(chat); + Assert.Equal(2, serializer.Participants.Count()); + + TestChat copy = new(CreateMockAgent(), CreateMockAgent()); + + await serializer.DeserializeAsync(copy); + + // Validate chat state + ChatMessageContent[] history = await copy.GetChatMessagesAsync().ToArrayAsync(); + Assert.Equal(2, history.Length); + + await copy.InvokeAsync().ToArrayAsync(); + history = await copy.GetChatMessagesAsync().ToArrayAsync(); + Assert.Equal(3, history.Length); + } + + /// + /// Verify deserialization cycle for a with . + /// + [Fact] + public async Task VerifyDeserializedChatWithAggregatorAsync() + { + // Create chat + TestChat chat = new(new AggregatorAgent(() => new TestChat(CreateMockAgent())) { Name = "Group" }); + chat.AddChatMessage(new ChatMessageContent(AuthorRole.User, "test")); + ChatMessageContent[] messages = await chat.InvokeAsync().ToArrayAsync(); + + // Serialize and deserialize chat + AgentChatSerializer serializer = await this.CreateSerializerAsync(chat); + Assert.Single(serializer.Participants); + + TestChat copy = new(new AggregatorAgent(() => new TestChat(CreateMockAgent())) { Name = "Group" }); + + await serializer.DeserializeAsync(copy); + + // Validate chat state + ChatMessageContent[] history = await copy.GetChatMessagesAsync().ToArrayAsync(); + Assert.Equal(2, history.Length); + + await copy.InvokeAsync().ToArrayAsync(); + history = await copy.GetChatMessagesAsync().ToArrayAsync(); + Assert.Equal(3, history.Length); + } + + /// + /// Verify deserialization into a that already has history and channels. + /// + [Fact] + public async Task VerifyDeserializedChatWithActivityAsync() + { + // Create chat + TestChat chat = new(CreateMockAgent()); + + // Serialize and deserialize chat + AgentChatSerializer serializer = await this.CreateSerializerAsync(chat); + + TestChat copy = new(CreateMockAgent()); + ChatMessageContent[] messages = await copy.InvokeAsync().ToArrayAsync(); + + // Verify exception + await Assert.ThrowsAsync(() => serializer.DeserializeAsync(copy)); + } + + /// + /// Verify deserialization into a with only user message (no channels). + /// + [Fact] + public async Task VerifyDeserializedChatWithUserMessageAsync() + { + // Create chat + TestChat chat = new(CreateMockAgent()); + + // Serialize and deserialize chat + AgentChatSerializer serializer = await this.CreateSerializerAsync(chat); + + TestChat copy = new(CreateMockAgent()); + copy.AddChatMessage(new ChatMessageContent(AuthorRole.User, "test")); + + // Verify exception + await Assert.ThrowsAsync(() => serializer.DeserializeAsync(copy)); + } + + private async Task CreateSerializerAsync(TestChat chat) + { + string jsonState = await this.SerializeChatAsync(chat); + await using MemoryStream stream = new(); + await using StreamWriter writer = new(stream); + writer.Write(jsonState); + writer.Flush(); + stream.Position = 0; + + return await AgentChatSerializer.DeserializeAsync(stream); + } + + private async Task SerializeChatAsync(TestChat chat) + { + await using MemoryStream stream = new(); + await AgentChatSerializer.SerializeAsync(chat, stream); + + stream.Position = 0; + using StreamReader reader = new(stream); + return reader.ReadToEnd(); + } + + private static MockAgent CreateMockAgent() => new() { Response = [new(AuthorRole.Assistant, "sup")] }; + + private sealed class TestChat(params Agent[] agents) : AgentChat + { + public override IReadOnlyList Agents => agents; + + public override IAsyncEnumerable InvokeAsync( + CancellationToken cancellationToken = default) => + this.InvokeAgentAsync(this.Agents[0], cancellationToken); + + public override IAsyncEnumerable InvokeStreamingAsync(CancellationToken cancellationToken = default) + { + throw new System.NotImplementedException(); + } + } +} diff --git a/dotnet/src/Agents/UnitTests/AgentChatTests.cs b/dotnet/src/Agents/UnitTests/AgentChatTests.cs index be78e01b3211..44267a0f3298 100644 --- a/dotnet/src/Agents/UnitTests/AgentChatTests.cs +++ b/dotnet/src/Agents/UnitTests/AgentChatTests.cs @@ -153,6 +153,8 @@ private sealed class TestChat : AgentChat { public MockAgent Agent { get; } = new() { Response = [new(AuthorRole.Assistant, "sup")] }; + public override IReadOnlyList Agents => [this.Agent]; + public override IAsyncEnumerable InvokeAsync( CancellationToken cancellationToken = default) => this.InvokeAgentAsync(this.Agent, cancellationToken); diff --git a/dotnet/src/Agents/UnitTests/Core/ChatHistoryChannelTests.cs b/dotnet/src/Agents/UnitTests/Core/ChatHistoryChannelTests.cs index 92aca7fadb67..dc82bcef59b6 100644 --- a/dotnet/src/Agents/UnitTests/Core/ChatHistoryChannelTests.cs +++ b/dotnet/src/Agents/UnitTests/Core/ChatHistoryChannelTests.cs @@ -21,7 +21,7 @@ public class ChatHistoryChannelTests public async Task VerifyAgentWithoutIChatHistoryHandlerAsync() { // Arrange - Mock agent = new(); // Not a IChatHistoryHandler + Mock agent = new(); // Not a ChatHistoryKernelAgent ChatHistoryChannel channel = new(); // Requires IChatHistoryHandler // Act & Assert diff --git a/dotnet/src/Agents/UnitTests/MockAgent.cs b/dotnet/src/Agents/UnitTests/MockAgent.cs index 7439cefe1ea3..409a232b1044 100644 --- a/dotnet/src/Agents/UnitTests/MockAgent.cs +++ b/dotnet/src/Agents/UnitTests/MockAgent.cs @@ -1,7 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; using System.Linq; +using System.Text.Json; using System.Threading; +using System.Threading.Tasks; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Agents; using Microsoft.SemanticKernel.ChatCompletion; @@ -43,4 +45,12 @@ public override IAsyncEnumerable InvokeStreamingAsy { return base.MergeArguments(arguments); } + + protected internal override Task RestoreChannelAsync(string channelState, CancellationToken cancellationToken) + { + ChatHistory history = + JsonSerializer.Deserialize(channelState) ?? + throw new KernelException("Unable to restore channel: invalid state."); + return Task.FromResult(new ChatHistoryChannel(history)); + } } diff --git a/dotnet/src/Agents/UnitTests/MockChannel.cs b/dotnet/src/Agents/UnitTests/MockChannel.cs index 94103129992c..87cb8fa42d1f 100644 --- a/dotnet/src/Agents/UnitTests/MockChannel.cs +++ b/dotnet/src/Agents/UnitTests/MockChannel.cs @@ -65,4 +65,9 @@ protected internal override Task ResetAsync(CancellationToken cancellationToken { throw new NotImplementedException(); } + + protected internal override string Serialize() + { + throw new NotImplementedException(); + } }