Skip to content

Commit da7e63d

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 5ee0685 commit da7e63d

2 files changed

Lines changed: 152 additions & 2 deletions

File tree

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

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,18 @@ public override async ValueTask<HandoffState> HandleAsync(HandoffState message,
6969

7070
List<ChatMessage>? roleChanges = allMessages.ChangeAssistantToUserForOtherParticipants(this._agent.Name ?? this._agent.Id);
7171

72-
await foreach (var update in this._agent.RunStreamingAsync(allMessages,
72+
// If a handoff was invoked by a previous agent, filter out the handoff function
73+
// call and tool result messages before sending to the underlying agent. These
74+
// are internal workflow mechanics that confuse the target model into ignoring the
75+
// original user question.
76+
List<ChatMessage> messagesForAgent = message.InvokedHandoff is not null
77+
? FilterHandoffMessages(allMessages)
78+
: allMessages;
79+
80+
await foreach (var update in this._agent.RunStreamingAsync(messagesForAgent,
7381
options: this._agentOptions,
7482
cancellationToken: cancellationToken)
75-
.ConfigureAwait(false))
83+
.ConfigureAwait(false))
7684
{
7785
await AddUpdateAsync(update, cancellationToken).ConfigureAwait(false);
7886

@@ -112,5 +120,39 @@ async Task AddUpdateAsync(AgentResponseUpdate update, CancellationToken cancella
112120
}
113121
}
114122

123+
/// <summary>
124+
/// Creates a filtered copy of the message list with handoff function call contents and
125+
/// tool result messages removed, preserving any non-handoff content in mixed messages.
126+
/// </summary>
127+
private static List<ChatMessage> FilterHandoffMessages(List<ChatMessage> messages)
128+
{
129+
List<ChatMessage> filtered = [];
130+
foreach (ChatMessage m in messages)
131+
{
132+
if (m.Role == ChatRole.Tool && m.Contents.Any(c => c is FunctionResultContent))
133+
{
134+
continue;
135+
}
136+
137+
if (m.Role == ChatRole.Assistant && m.Contents.Any(c => c is FunctionCallContent))
138+
{
139+
List<AIContent> filteredContents = m.Contents
140+
.Where(c => c is not FunctionCallContent)
141+
.ToList();
142+
143+
if (filteredContents.Count > 0)
144+
{
145+
filtered.Add(new ChatMessage(m.Role, filteredContents) { AuthorName = m.AuthorName });
146+
}
147+
148+
continue;
149+
}
150+
151+
filtered.Add(m);
152+
}
153+
154+
return filtered;
155+
}
156+
115157
public ValueTask ResetAsync() => default;
116158
}

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

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

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

0 commit comments

Comments
 (0)