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();
+ }
}