Skip to content

Commit

Permalink
.Net Agents - AgentChat Serialization (#7457)
Browse files Browse the repository at this point in the history
### Motivation and Context
<!-- Thank you for your contribution to the semantic-kernel repo!
Please help reviewers and future users, providing the following
information:
  1. Why is this change required?
  2. What problem does it solve?
  3. What scenario does it contribute to?
  4. If it fixes an open issue, please link to the issue here.
-->

Support ability to capture and restore `AgentChat` history:


https://github.com/microsoft/semantic-kernel/blob/main/docs/decisions/0048-agent-chat-serialization.md

### Description
<!-- Describe your changes, the overall approach, the underlying design.
These notes will help understanding how your code works. Thanks! -->

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
<!-- Before submitting this PR, please make sure: -->

- [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 😄

---------

Co-authored-by: Ben Thomas <[email protected]>
  • Loading branch information
crickman and alliscode authored Nov 8, 2024
1 parent 05f4589 commit b67eb84
Show file tree
Hide file tree
Showing 25 changed files with 990 additions and 6 deletions.
103 changes: 103 additions & 0 deletions dotnet/samples/Concepts/Agents/ChatCompletion_Serialization.cs
Original file line number Diff line number Diff line change
@@ -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;
/// <summary>
/// Demonstrate that serialization of <see cref="AgentGroupChat"/> in with a <see cref="ChatCompletionAgent"/> participant.
/// </summary>
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<MenuPlugin>();
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";
}
}
128 changes: 128 additions & 0 deletions dotnet/samples/Concepts/Agents/MixedChat_Serialization.cs
Original file line number Diff line number Diff line change
@@ -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;
/// <summary>
/// Demonstrate the serialization of <see cref="AgentGroupChat"/> with a <see cref="ChatCompletionAgent"/>
/// and an <see cref="OpenAIAssistantAgent"/>.
/// </summary>
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<bool> ShouldAgentTerminateAsync(Agent agent, IReadOnlyList<ChatMessageContent> history, CancellationToken cancellationToken)
{
++this._count;

bool shouldTerminate = this._count >= maxTurns;

return Task.FromResult(shouldTerminate);
}
}
}
12 changes: 12 additions & 0 deletions dotnet/src/Agents/Abstractions/Agent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,5 +72,17 @@ public abstract class Agent
/// </remarks>
protected internal abstract Task<AgentChannel> CreateChannelAsync(CancellationToken cancellationToken);

/// <summary>
/// Produce the an <see cref="AgentChannel"/> appropriate for the agent type based on the provided state.
/// </summary>
/// <param name="channelState">The channel state, as serialized</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>An <see cref="AgentChannel"/> appropriate for the agent type.</returns>
/// <remarks>
/// Every agent conversation, or <see cref="AgentChat"/>, will establish one or more <see cref="AgentChannel"/>
/// objects according to the specific <see cref="Agent"/> type.
/// </remarks>
protected internal abstract Task<AgentChannel> RestoreChannelAsync(string channelState, CancellationToken cancellationToken);

private ILogger? _logger;
}
5 changes: 5 additions & 0 deletions dotnet/src/Agents/Abstractions/AgentChannel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ public abstract class AgentChannel
/// </summary>
public ILogger Logger { get; set; } = NullLogger.Instance;

/// <summary>
/// Responsible for providing the serialized representation of the channel.
/// </summary>
protected internal abstract string Serialize();

/// <summary>
/// Receive the conversation messages. Used when joining a conversation and also during each agent interaction..
/// </summary>
Expand Down
61 changes: 61 additions & 0 deletions dotnet/src/Agents/Abstractions/AgentChat.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,6 +30,11 @@ public abstract class AgentChat
private int _isActive;
private ILogger? _logger;

/// <summary>
/// The agents participating in the chat.
/// </summary>
public abstract IReadOnlyList<Agent> Agents { get; }

/// <summary>
/// Indicates if a chat operation is active. Activity is defined as
/// any the execution of any public method.
Expand Down Expand Up @@ -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<string, AgentChannelState> 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<ChatMessageContent>? history = JsonSerializer.Deserialize<IEnumerable<ChatMessageContent>>(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()
})
};

/// <summary>
/// Clear activity signal to indicate that activity has ceased.
/// </summary>
Expand Down
Loading

0 comments on commit b67eb84

Please sign in to comment.