.NET: Multiple fixes in Microsoft.Agents.Orchestration to make handoffs work end-to-end (#568)

* Multiple fixes in Microsoft.Agents.Orchestration:
- Enable multi-turn (via RunAsync) interactions for all orchestrating agents
- Fix tool calling in HandoffOrchestration (multiple issues)
- Fixes for chat history serialization involving tool calls

* GHCP PR feedback

* Fix issue with returning old responses

---------

Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
This commit is contained in:
Chris Gillum
2025-09-09 15:23:00 +00:00
committed by GitHub
co-authored by Chris
parent b2a6b64d72
commit 5c3c2fe634
7 changed files with 39 additions and 25 deletions
@@ -61,10 +61,13 @@ public partial class ConcurrentOrchestration : OrchestratingAgent
this.ResumeAsync(messages, new AgentRunResponse?[this.Agents.Count], context, cancellationToken);
/// <inheritdoc />
protected override Task<AgentRunResponse> ResumeCoreAsync(JsonElement checkpointState, OrchestratingAgentContext context, CancellationToken cancellationToken)
protected override Task<AgentRunResponse> ResumeCoreAsync(JsonElement checkpointState, IReadOnlyCollection<ChatMessage> newMessages, OrchestratingAgentContext context, CancellationToken cancellationToken)
{
var state = checkpointState.Deserialize(OrchestrationJsonContext.Default.ConcurrentState) ?? throw new InvalidOperationException("The checkpoint state is invalid.");
return this.ResumeAsync(state.Messages, state.Completed, context, cancellationToken);
// Append the new messages to the checkpoint state
List<ChatMessage> allMessages = [.. state.Messages, .. newMessages];
return this.ResumeAsync(allMessages, state.Completed, context, cancellationToken);
}
/// <inheritdoc />
@@ -51,10 +51,14 @@ public sealed partial class GroupChatOrchestration : OrchestratingAgent
}
/// <inheritdoc />
protected override Task<AgentRunResponse> ResumeCoreAsync(JsonElement checkpointState, OrchestratingAgentContext context, CancellationToken cancellationToken)
protected override Task<AgentRunResponse> ResumeCoreAsync(JsonElement checkpointState, IReadOnlyCollection<ChatMessage> newMessages, OrchestratingAgentContext context, CancellationToken cancellationToken)
{
var state = checkpointState.Deserialize(OrchestrationJsonContext.Default.GroupChatState) ?? throw new InvalidOperationException("The checkpoint state is invalid.");
return this.ResumeAsync(state.AllMessages, state.OriginalMessageCount, context, cancellationToken);
// Append the new messages to the checkpoint state
List<ChatMessage> allMessages = [.. state.AllMessages, .. newMessages];
return this.ResumeAsync(allMessages, allMessages.Count, context, cancellationToken);
}
private async Task<AgentRunResponse> ResumeAsync(
@@ -52,26 +52,28 @@ public sealed partial class HandoffOrchestration : OrchestratingAgent
}
/// <inheritdoc />
protected override Task<AgentRunResponse> ResumeCoreAsync(JsonElement checkpointState, OrchestratingAgentContext context, CancellationToken cancellationToken)
protected override Task<AgentRunResponse> ResumeCoreAsync(JsonElement checkpointState, IReadOnlyCollection<ChatMessage> newMessages, OrchestratingAgentContext context, CancellationToken cancellationToken)
{
var state = checkpointState.Deserialize(OrchestrationJsonContext.Default.HandoffState) ?? throw new InvalidOperationException("The checkpoint state is invalid.");
AIAgent? nextAgent = null;
foreach (var agent in this.Agents)
if (state.NextAgent is null)
{
if (agent.Id == state.NextAgent)
nextAgent = this._handoffs.InitialAgent;
}
else
{
nextAgent = this.Agents.FirstOrDefault(a => a.Id == state.NextAgent);
if (nextAgent is null)
{
nextAgent = agent;
break;
Throw.InvalidOperationException($"The next agent '{state.NextAgent}' is not defined in the orchestration.");
}
}
if (nextAgent is null)
{
Throw.InvalidOperationException($"The next agent '{state.NextAgent}' is not defined in the orchestration.");
}
// Append the new messages to the checkpoint state
List<ChatMessage> allMessages = [.. state.AllMessages, .. newMessages];
return this.ResumeAsync(nextAgent, state.AllMessages, state.OriginalMessageCount, context, cancellationToken);
return this.ResumeAsync(nextAgent, allMessages, allMessages.Count, context, cancellationToken);
}
/// <inheritdoc />
@@ -144,7 +146,7 @@ public sealed partial class HandoffOrchestration : OrchestratingAgent
private static void RemoveHandoffFunctionCalls(AgentRunResponse response, List<AITool> handoffTools)
{
HashSet<string>? removeToolNames = null;
HashSet<string>? callIds = null;
HashSet<string>? handoffCallIds = null;
foreach (var message in response.Messages)
{
@@ -153,23 +155,22 @@ public sealed partial class HandoffOrchestration : OrchestratingAgent
if (message.Contents[i] is FunctionCallContent fcc)
{
removeToolNames ??= [.. handoffTools.Select(t => t.Name)];
(callIds ??= new()).Add(fcc.CallId);
if (removeToolNames.Contains(fcc.Name))
{
(handoffCallIds ??= []).Add(fcc.CallId);
message.Contents.RemoveAt(i);
}
}
}
}
if (callIds is not null)
if (handoffCallIds is not null)
{
foreach (var message in response.Messages)
{
for (int i = message.Contents.Count - 1; i >= 0; i--)
{
if (message.Contents[i] is FunctionResultContent frc && callIds.Contains(frc.CallId))
if (message.Contents[i] is FunctionResultContent frc && handoffCallIds.Contains(frc.CallId))
{
message.Contents.RemoveAt(i);
}
@@ -215,7 +216,7 @@ public sealed partial class HandoffOrchestration : OrchestratingAgent
static void Terminate()
{
if (FunctionInvokingChatClient.CurrentContext is not { } ctx)
if (NewFunctionInvokingChatClient.CurrentContext is not { } ctx)
{
throw new NotSupportedException($"The agent is not configured with a {nameof(FunctionInvokingChatClient)}. Cease execution.");
}
@@ -135,7 +135,7 @@ public abstract partial class OrchestratingAgent : AIAgent
JsonElement? checkpoint = await this.ReadCheckpointAsync(context, cancellationToken).ConfigureAwait(false);
Task<AgentRunResponse> completion = checkpoint is null ?
this.RunCoreAsync(messages, context, cancellationToken) :
this.ResumeCoreAsync(checkpoint.Value, context, cancellationToken);
this.ResumeCoreAsync(checkpoint.Value, messages, context, cancellationToken);
if (logger.IsEnabled(LogLevel.Trace))
{
@@ -157,9 +157,10 @@ public abstract partial class OrchestratingAgent : AIAgent
/// Resumes processing of the orchestration.
/// </summary>
/// <param name="checkpointState">The last checkpoint state available from which to resume the operation.</param>
/// <param name="newMessages">The new messages to be processed in addition to the checkpoint state.</param>
/// <param name="context">The context for this operation.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
protected abstract Task<AgentRunResponse> ResumeCoreAsync(JsonElement checkpointState, OrchestratingAgentContext context, CancellationToken cancellationToken);
protected abstract Task<AgentRunResponse> ResumeCoreAsync(JsonElement checkpointState, IReadOnlyCollection<ChatMessage> newMessages, OrchestratingAgentContext context, CancellationToken cancellationToken);
/// <summary>
/// Runs the agent with input messages and respond with both streamed and regular messages.
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.Orchestration;
@@ -8,4 +9,5 @@ namespace Microsoft.Agents.Orchestration;
[JsonSerializable(typeof(ConcurrentOrchestration.ConcurrentState))]
[JsonSerializable(typeof(GroupChatOrchestration.GroupChatState))]
[JsonSerializable(typeof(HandoffOrchestration.HandoffState))]
[JsonSerializable(typeof(JsonElement))]
internal sealed partial class OrchestrationJsonContext : JsonSerializerContext;
@@ -32,10 +32,13 @@ public sealed partial class SequentialOrchestration : OrchestratingAgent
this.ResumeAsync(0, messages, context, cancellationToken);
/// <inheritdoc />
protected override Task<AgentRunResponse> ResumeCoreAsync(JsonElement checkpointState, OrchestratingAgentContext context, CancellationToken cancellationToken)
protected override Task<AgentRunResponse> ResumeCoreAsync(JsonElement checkpointState, IReadOnlyCollection<ChatMessage> newMessages, OrchestratingAgentContext context, CancellationToken cancellationToken)
{
var state = checkpointState.Deserialize(OrchestrationJsonContext.Default.SequentialState) ?? throw new InvalidOperationException("The checkpoint state is invalid.");
return this.ResumeAsync(state.Index, state.Messages, context, cancellationToken);
// Append the new messages to the checkpoint state
List<ChatMessage> allMessages = [.. state.Messages, .. newMessages];
return this.ResumeAsync(state.Index, allMessages, context, cancellationToken);
}
/// <inheritdoc />
@@ -83,7 +83,7 @@ public class OrchestrationResultTests
protected override Task<AgentRunResponse> RunCoreAsync(IReadOnlyCollection<ChatMessage> messages, OrchestratingAgentContext context, CancellationToken cancellationToken) =>
throw new NotSupportedException();
protected override Task<AgentRunResponse> ResumeCoreAsync(JsonElement checkpointState, OrchestratingAgentContext context, CancellationToken cancellationToken) =>
protected override Task<AgentRunResponse> ResumeCoreAsync(JsonElement checkpointState, IReadOnlyCollection<ChatMessage> newMessages, OrchestratingAgentContext context, CancellationToken cancellationToken) =>
throw new NotSupportedException();
}