fix: avoid mutating handoff message roles (#5808)

Co-authored-by: Jacob Alber <jaalber@microsoft.com>
This commit is contained in:
Yufeng He
2026-05-14 02:52:19 +08:00
committed by GitHub
Unverified
parent bd0d6070f1
commit 9b9604ce18
4 changed files with 101 additions and 40 deletions
@@ -32,38 +32,8 @@ internal static class AIAgentsAbstractionsExtensions
return message;
}
/// <summary>
/// Iterates through <paramref name="messages"/> looking for <see cref="ChatRole.Assistant"/> messages and swapping
/// any that have a different <see cref="ChatMessage.AuthorName"/> from <paramref name="targetAgentName"/> to
/// <see cref="ChatRole.User"/>.
/// </summary>
public static List<ChatMessage>? ChangeAssistantToUserForOtherParticipants(this IEnumerable<ChatMessage> messages, string targetAgentName)
{
List<ChatMessage>? roleChanged = null;
foreach (var m in messages)
{
m.ChatAssistantToUserIfNotFromNamed(targetAgentName, out bool changed);
if (changed)
{
(roleChanged ??= []).Add(m);
}
}
return roleChanged;
}
/// <summary>
/// Undoes changes made by <see cref="ChangeAssistantToUserForOtherParticipants"/> when passed the list of changes
/// made by that method.
/// </summary>
public static void ResetUserToAssistantForChangedRoles(this List<ChatMessage>? roleChanged)
{
if (roleChanged is not null)
{
foreach (var m in roleChanged)
{
m.Role = ChatRole.Assistant;
}
}
}
public static List<ChatMessage> CopyWithAssistantToUserForOtherParticipants(
this IEnumerable<ChatMessage> messages,
string targetAgentName)
=> messages.Select(m => m.ChatAssistantToUserIfNotFromNamed(targetAgentName, out _, false)).ToList();
}
@@ -235,11 +235,10 @@ internal sealed class HandoffAgentExecutor :
// This will not filter out tool responses and approval responses that are part of this agent's turn, which is
// the expected behavior since those are part of the agent's reasoning process.
HandoffMessagesFilter handoffMessagesFilter = new(this._options.ToolCallFilteringBehavior);
IEnumerable<ChatMessage> messagesForAgent = state.IncomingState.RequestedHandoffTargetAgentId is not null
List<ChatMessage> messagesForAgent = (state.IncomingState.RequestedHandoffTargetAgentId is not null
? handoffMessagesFilter.FilterMessages(incomingMessages)
: incomingMessages;
List<ChatMessage>? roleChanges = messagesForAgent.ChangeAssistantToUserForOtherParticipants(this._agent.Name ?? this._agent.Id);
: incomingMessages)
.CopyWithAssistantToUserForOtherParticipants(this._agent.Name ?? this._agent.Id);
bool emitUpdateEvents = state.IncomingState!.ShouldEmitStreamingEvents(this._options.EmitAgentResponseUpdateEvents);
AgentInvocationResult result = await this.InvokeAgentAsync(messagesForAgent, context, emitUpdateEvents, cancellationToken)
@@ -250,8 +249,6 @@ internal sealed class HandoffAgentExecutor :
throw new InvalidOperationException("Cannot request a handoff while holding pending requests.");
}
roleChanges.ResetUserToAssistantForChangedRoles();
int newConversationBookmark = state.ConversationBookmark;
await this._sharedStateRef.InvokeWithStateAsync(
(sharedState, ctx, ct) =>
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class AIAgentsAbstractionsExtensionsTests
{
[Fact]
public void CopyWithAssistantToUserForOtherParticipants_DoesNotMutateOriginalMessages()
{
ChatMessage original = new(ChatRole.Assistant, "from first agent")
{
AuthorName = "firstAgent"
};
List<ChatMessage> copied = new[] { original }
.CopyWithAssistantToUserForOtherParticipants("secondAgent");
Assert.Single(copied);
Assert.Equal(ChatRole.Assistant, original.Role);
Assert.Equal(ChatRole.User, copied[0].Role);
Assert.NotSame(original, copied[0]);
}
}
@@ -209,6 +209,36 @@ public class HandoffOrchestrationTests
Assert.DoesNotContain(capturedNextAgentMessages, m => m.Role == ChatRole.Tool && m.Contents.Any(c => c is FunctionResultContent frc && frc.Result?.ToString() == "Transferred."));
}
[Fact]
public async Task Handoffs_ReassignedMessagesDoNotMutateSharedConversationAsync()
{
var firstAgent = new ChatClientAgent(new MockChatClient((_, options) =>
{
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
Assert.NotNull(transferFuncName);
return new ChatResponse([
new ChatMessage(ChatRole.Assistant, "Context from first agent"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)]),
]);
}), name: "firstAgent");
CapturingAgent secondAgent = new("secondAgent", "The second agent", "Context from first agent");
var workflow =
AgentWorkflowBuilder.CreateHandoffBuilderWith(firstAgent)
.WithHandoff(firstAgent, secondAgent)
.Build();
(_, List<ChatMessage>? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "start")]);
Assert.NotNull(result);
Assert.Equal(ChatRole.User, secondAgent.RoleSeenDuringRun);
ChatMessage sharedMessage = Assert.Single(result, m => m.Text == "Context from first agent");
Assert.Equal(ChatRole.Assistant, sharedMessage.Role);
Assert.NotSame(sharedMessage, secondAgent.MessageSeenDuringRun);
}
[Fact]
public async Task Handoffs_TwoTransfers_HandoffTargetsDoNotReceiveHandoffFunctionMessagesAsync()
{
@@ -1198,6 +1228,44 @@ public class HandoffOrchestrationTests
Workflow workflow, List<ChatMessage> input, ExecutionEnvironment executionEnvironment = ExecutionEnvironment.InProcess_Lockstep)
=> RunWorkflowCheckpointedAsync(workflow, input, executionEnvironment.ToWorkflowExecutionEnvironment());
private sealed class CapturingAgent(string name, string description, string textToCapture) : AIAgent
{
public override string Name => name;
public override string Description => description;
public ChatMessage? MessageSeenDuringRun { get; private set; }
public ChatRole? RoleSeenDuringRun { get; private set; }
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
=> new(new TestAgentSession());
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> new(new TestAgentSession());
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> default;
protected override Task<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.Yield();
this.MessageSeenDuringRun = messages.Single(m => m.Text == textToCapture);
this.RoleSeenDuringRun = this.MessageSeenDuringRun.Role;
yield return new AgentResponseUpdate(ChatRole.Assistant, "Done")
{
AuthorName = this.Name,
MessageId = Guid.NewGuid().ToString("N"),
};
}
}
private sealed class TestAgentSession() : AgentSession();
private sealed class DoubleEchoAgent(string name) : AIAgent
{
public override string Name => name;