Skip to content

Commit ad57379

Browse files
author
Jacob Alber
committed
Fix handoff orchestration not passing user message to handoff target agent (#3161)
Filter out internal handoff function call and tool result messages before passing conversation history to the target agent's LLM. These messages confused the model into ignoring the original user question.
1 parent 8ad6663 commit ad57379

2 files changed

Lines changed: 116 additions & 2 deletions

File tree

‎dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.Collections.Generic;
55
using System.ComponentModel;
66
using System.Diagnostics;
7+
using System.Linq;
78
using System.Text.Json;
89
using System.Threading;
910
using System.Threading.Tasks;
@@ -67,9 +68,20 @@ protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
6768
List<AgentResponseUpdate> updates = [];
6869
List<ChatMessage> allMessages = handoffState.Messages;
6970

70-
List<ChatMessage>? roleChanges = allMessages.ChangeAssistantToUserForOtherParticipants(this._agent.Name ?? this._agent.Id);
71+
// If a handoff was invoked by a previous agent, filter out the handoff function
72+
// call and tool result messages before sending to the current agent's LLM. These
73+
// are internal workflow mechanics that confuse the target model into ignoring the
74+
// original user question. The full allMessages list is preserved for the workflow output.
75+
List<ChatMessage> messagesForAgent = handoffState.InvokedHandoff is not null
76+
? allMessages.Where(m =>
77+
!(m.Role == ChatRole.Assistant && m.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name.StartsWith(HandoffsWorkflowBuilder.FunctionPrefix, StringComparison.Ordinal))) &&
78+
!(m.Role == ChatRole.Tool && m.Contents.Any(c => c is FunctionResultContent)))
79+
.ToList()
80+
: allMessages;
7181

72-
await foreach (var update in this._agent.RunStreamingAsync(allMessages,
82+
List<ChatMessage>? roleChanges = messagesForAgent.ChangeAssistantToUserForOtherParticipants(this._agent.Name ?? this._agent.Id);
83+
84+
await foreach (var update in this._agent.RunStreamingAsync(messagesForAgent,
7385
options: this._agentOptions,
7486
cancellationToken: cancellationToken)
7587
.ConfigureAwait(false))

‎dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,108 @@ public async Task Handoffs_OneTransfer_ResponseServedBySecondAgentAsync()
273273
Assert.Contains("nextAgent", result[3].AuthorName);
274274
}
275275

276+
[Fact]
277+
public async Task Handoffs_OneTransfer_HandoffTargetDoesNotReceiveHandoffFunctionMessagesAsync()
278+
{
279+
// Regression test for https://github.com/microsoft/agent-framework/issues/3161
280+
// When a handoff occurs, the target agent should receive the original user message
281+
// but should NOT receive the handoff function call or tool result messages from the
282+
// source agent, as these confuse the target LLM into ignoring the user's question.
283+
284+
List<ChatMessage>? capturedNextAgentMessages = null;
285+
286+
var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
287+
{
288+
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
289+
Assert.NotNull(transferFuncName);
290+
291+
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)]));
292+
}), name: "initialAgent");
293+
294+
var nextAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
295+
{
296+
capturedNextAgentMessages = messages.ToList();
297+
return new(new ChatMessage(ChatRole.Assistant, "The derivative of x^2 is 2x."));
298+
}),
299+
name: "nextAgent",
300+
description: "The second agent");
301+
302+
var workflow =
303+
AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent)
304+
.WithHandoff(initialAgent, nextAgent)
305+
.Build();
306+
307+
(string updateText, List<ChatMessage>? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "What is the derivative of x^2?")]);
308+
309+
Assert.NotNull(capturedNextAgentMessages);
310+
311+
// The target agent should see the original user message
312+
Assert.Contains(capturedNextAgentMessages, m => m.Role == ChatRole.User && m.Text == "What is the derivative of x^2?");
313+
314+
// The target agent should NOT see the handoff function call or tool result from the source agent
315+
Assert.DoesNotContain(capturedNextAgentMessages, m => m.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name.StartsWith("handoff_to_", StringComparison.Ordinal)));
316+
Assert.DoesNotContain(capturedNextAgentMessages, m => m.Role == ChatRole.Tool && m.Contents.Any(c => c is FunctionResultContent frc && frc.Result?.ToString() == "Transferred."));
317+
}
318+
319+
[Fact]
320+
public async Task Handoffs_TwoTransfers_HandoffTargetsDoNotReceiveHandoffFunctionMessagesAsync()
321+
{
322+
// Regression test for https://github.com/microsoft/agent-framework/issues/3161
323+
// With two hops (initial -> second -> third), each target agent should receive the
324+
// original user message but NOT any handoff function call or tool result messages.
325+
326+
List<ChatMessage>? capturedSecondAgentMessages = null;
327+
List<ChatMessage>? capturedThirdAgentMessages = null;
328+
329+
var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
330+
{
331+
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
332+
Assert.NotNull(transferFuncName);
333+
334+
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)]));
335+
}), name: "initialAgent");
336+
337+
var secondAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
338+
{
339+
capturedSecondAgentMessages = messages.ToList();
340+
341+
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
342+
Assert.NotNull(transferFuncName);
343+
344+
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call2", transferFuncName)]));
345+
}), name: "secondAgent", description: "The second agent");
346+
347+
var thirdAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
348+
{
349+
capturedThirdAgentMessages = messages.ToList();
350+
return new(new ChatMessage(ChatRole.Assistant, "Hello from agent3"));
351+
}),
352+
name: "thirdAgent",
353+
description: "The third / final agent");
354+
355+
var workflow =
356+
AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent)
357+
.WithHandoff(initialAgent, secondAgent)
358+
.WithHandoff(secondAgent, thirdAgent)
359+
.Build();
360+
361+
(string updateText, List<ChatMessage>? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
362+
363+
Assert.Equal("Hello from agent3", updateText);
364+
365+
// Second agent should see the original user message only (no handoff messages from initialAgent)
366+
Assert.NotNull(capturedSecondAgentMessages);
367+
Assert.Contains(capturedSecondAgentMessages, m => m.Role == ChatRole.User && m.Text == "abc");
368+
Assert.DoesNotContain(capturedSecondAgentMessages, m => m.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name.StartsWith("handoff_to_", StringComparison.Ordinal)));
369+
Assert.DoesNotContain(capturedSecondAgentMessages, m => m.Role == ChatRole.Tool && m.Contents.Any(c => c is FunctionResultContent));
370+
371+
// Third agent should see the original user message only (no handoff messages from initialAgent or secondAgent)
372+
Assert.NotNull(capturedThirdAgentMessages);
373+
Assert.Contains(capturedThirdAgentMessages, m => m.Role == ChatRole.User && m.Text == "abc");
374+
Assert.DoesNotContain(capturedThirdAgentMessages, m => m.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name.StartsWith("handoff_to_", StringComparison.Ordinal)));
375+
Assert.DoesNotContain(capturedThirdAgentMessages, m => m.Role == ChatRole.Tool && m.Contents.Any(c => c is FunctionResultContent));
376+
}
377+
276378
[Fact]
277379
public async Task Handoffs_TwoTransfers_ResponseServedByThirdAgentAsync()
278380
{

0 commit comments

Comments
 (0)