mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: Remove FunctionCalls and Tool Messages from Handoff passed messages (#3811)
* 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. * Add handoff tool call filtering behavior and enhance workflow builder - Introduced HandoffToolCallFilteringBehavior enum to specify filtering behavior for tool call contents in handoff workflows. - Updated HandoffsWorkflowBuilder to support customizable handoff instructions and tool call filtering behavior. - Enhanced HandoffAgentExecutor to utilize new filtering options for improved message handling during agent handoffs. * Enhance handoff message filtering logic and add unit tests for filtering behaviors * Refactor HandoffMessagesFilter to remove unused handoff function names and enhance filtering logic for non-handoff function calls * Refactor HandoffMessagesFilter to streamline FilterCandidateState initialization and improve clarity * Refactor HandoffMessagesFilter to improve filtering logic and add integration tests for handoff workflows * fix: HandoffAgentExecutor tests
This commit is contained in:
committed by
GitHub
Unverified
parent
40d3a0655c
commit
2cb4137501
@@ -274,6 +274,257 @@ public class AgentWorkflowBuilderTests
|
||||
Assert.Contains("nextAgent", result[3].AuthorName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handoffs_OneTransfer_HandoffTargetDoesNotReceiveHandoffFunctionMessagesAsync()
|
||||
{
|
||||
// Regression test for https://github.com/microsoft/agent-framework/issues/3161
|
||||
// When a handoff occurs, the target agent should receive the original user message
|
||||
// but should NOT receive the handoff function call or tool result messages from the
|
||||
// source agent, as these confuse the target LLM into ignoring the user's question.
|
||||
|
||||
List<ChatMessage>? capturedNextAgentMessages = null;
|
||||
|
||||
var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
{
|
||||
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
|
||||
Assert.NotNull(transferFuncName);
|
||||
|
||||
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)]));
|
||||
}), name: "initialAgent");
|
||||
|
||||
var nextAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
{
|
||||
capturedNextAgentMessages = messages.ToList();
|
||||
return new(new ChatMessage(ChatRole.Assistant, "The derivative of x^2 is 2x."));
|
||||
}),
|
||||
name: "nextAgent",
|
||||
description: "The second agent");
|
||||
|
||||
var workflow =
|
||||
AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent)
|
||||
.WithHandoff(initialAgent, nextAgent)
|
||||
.Build();
|
||||
|
||||
_ = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "What is the derivative of x^2?")]);
|
||||
|
||||
Assert.NotNull(capturedNextAgentMessages);
|
||||
|
||||
// The target agent should see the original user message
|
||||
Assert.Contains(capturedNextAgentMessages, m => m.Role == ChatRole.User && m.Text == "What is the derivative of x^2?");
|
||||
|
||||
// The target agent should NOT see the handoff function call or tool result from the source agent
|
||||
Assert.DoesNotContain(capturedNextAgentMessages, m => m.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name.StartsWith("handoff_to_", StringComparison.Ordinal)));
|
||||
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_TwoTransfers_HandoffTargetsDoNotReceiveHandoffFunctionMessagesAsync()
|
||||
{
|
||||
// Regression test for https://github.com/microsoft/agent-framework/issues/3161
|
||||
// With two hops (initial -> second -> third), each target agent should receive the
|
||||
// original user message and text responses from prior agents (as User role), but
|
||||
// NOT any handoff function call or tool result messages.
|
||||
|
||||
List<ChatMessage>? capturedSecondAgentMessages = null;
|
||||
List<ChatMessage>? capturedThirdAgentMessages = null;
|
||||
|
||||
var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
{
|
||||
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
|
||||
Assert.NotNull(transferFuncName);
|
||||
|
||||
// Return both a text message and a handoff function call
|
||||
return new(new ChatMessage(ChatRole.Assistant, [new TextContent("Routing to second agent"), new FunctionCallContent("call1", transferFuncName)]));
|
||||
}), name: "initialAgent");
|
||||
|
||||
var secondAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
{
|
||||
capturedSecondAgentMessages = messages.ToList();
|
||||
|
||||
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
|
||||
Assert.NotNull(transferFuncName);
|
||||
|
||||
// Return both a text message and a handoff function call
|
||||
return new(new ChatMessage(ChatRole.Assistant, [new TextContent("Routing to third agent"), new FunctionCallContent("call2", transferFuncName)]));
|
||||
}), name: "secondAgent", description: "The second agent");
|
||||
|
||||
var thirdAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
{
|
||||
capturedThirdAgentMessages = messages.ToList();
|
||||
return new(new ChatMessage(ChatRole.Assistant, "Hello from agent3"));
|
||||
}),
|
||||
name: "thirdAgent",
|
||||
description: "The third / final agent");
|
||||
|
||||
var workflow =
|
||||
AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent)
|
||||
.WithHandoff(initialAgent, secondAgent)
|
||||
.WithHandoff(secondAgent, thirdAgent)
|
||||
.Build();
|
||||
|
||||
(string updateText, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
|
||||
|
||||
Assert.Contains("Hello from agent3", updateText);
|
||||
|
||||
// Second agent should see the original user message and initialAgent's text as context
|
||||
Assert.NotNull(capturedSecondAgentMessages);
|
||||
Assert.Contains(capturedSecondAgentMessages, m => m.Text == "abc");
|
||||
Assert.Contains(capturedSecondAgentMessages, m => m.Text!.Contains("Routing to second agent"));
|
||||
Assert.DoesNotContain(capturedSecondAgentMessages, m => m.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name.StartsWith("handoff_to_", StringComparison.Ordinal)));
|
||||
Assert.DoesNotContain(capturedSecondAgentMessages, m => m.Role == ChatRole.Tool && m.Contents.Any(c => c is FunctionResultContent));
|
||||
|
||||
// Third agent should see the original user message and both prior agents' text as context
|
||||
Assert.NotNull(capturedThirdAgentMessages);
|
||||
Assert.Contains(capturedThirdAgentMessages, m => m.Text == "abc");
|
||||
Assert.Contains(capturedThirdAgentMessages, m => m.Text!.Contains("Routing to second agent"));
|
||||
Assert.Contains(capturedThirdAgentMessages, m => m.Text!.Contains("Routing to third agent"));
|
||||
Assert.DoesNotContain(capturedThirdAgentMessages, m => m.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name.StartsWith("handoff_to_", StringComparison.Ordinal)));
|
||||
Assert.DoesNotContain(capturedThirdAgentMessages, m => m.Role == ChatRole.Tool && m.Contents.Any(c => c is FunctionResultContent));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handoffs_FilteringNone_HandoffTargetReceivesAllMessagesIncludingToolCallsAsync()
|
||||
{
|
||||
// With filtering set to None, the target agent should see everything including
|
||||
// handoff function calls and tool results.
|
||||
|
||||
List<ChatMessage>? capturedNextAgentMessages = null;
|
||||
|
||||
var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
{
|
||||
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
|
||||
Assert.NotNull(transferFuncName);
|
||||
|
||||
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)]));
|
||||
}), name: "initialAgent");
|
||||
|
||||
var nextAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
{
|
||||
capturedNextAgentMessages = messages.ToList();
|
||||
return new(new ChatMessage(ChatRole.Assistant, "response"));
|
||||
}),
|
||||
name: "nextAgent",
|
||||
description: "The second agent");
|
||||
|
||||
var workflow =
|
||||
AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent)
|
||||
.WithHandoff(initialAgent, nextAgent)
|
||||
.WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior.None)
|
||||
.Build();
|
||||
|
||||
_ = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "hello")]);
|
||||
|
||||
Assert.NotNull(capturedNextAgentMessages);
|
||||
Assert.Contains(capturedNextAgentMessages, m => m.Text == "hello");
|
||||
|
||||
// With None filtering, handoff function calls and tool results should be visible
|
||||
Assert.Contains(capturedNextAgentMessages, m => m.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name.StartsWith("handoff_to_", StringComparison.Ordinal)));
|
||||
Assert.Contains(capturedNextAgentMessages, m => m.Contents.Any(c => c is FunctionResultContent));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handoffs_FilteringAll_HandoffTargetDoesNotReceiveAnyToolCallsAsync()
|
||||
{
|
||||
// With filtering set to All, the target agent should see no function calls or tool
|
||||
// results at all — not even non-handoff ones from prior conversation history.
|
||||
|
||||
List<ChatMessage>? capturedNextAgentMessages = null;
|
||||
|
||||
var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
{
|
||||
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
|
||||
Assert.NotNull(transferFuncName);
|
||||
|
||||
return new(new ChatMessage(ChatRole.Assistant, [new TextContent("Routing you now"), new FunctionCallContent("call1", transferFuncName)]));
|
||||
}), name: "initialAgent");
|
||||
|
||||
var nextAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
{
|
||||
capturedNextAgentMessages = messages.ToList();
|
||||
return new(new ChatMessage(ChatRole.Assistant, "response"));
|
||||
}),
|
||||
name: "nextAgent",
|
||||
description: "The second agent");
|
||||
|
||||
var workflow =
|
||||
AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent)
|
||||
.WithHandoff(initialAgent, nextAgent)
|
||||
.WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior.All)
|
||||
.Build();
|
||||
|
||||
// Input includes a pre-existing non-handoff tool call in the conversation history
|
||||
List<ChatMessage> input =
|
||||
[
|
||||
new(ChatRole.User, "What's the weather? Also help me with math."),
|
||||
new(ChatRole.Assistant, [new FunctionCallContent("toolcall1", "get_weather")]) { AuthorName = "initialAgent" },
|
||||
new(ChatRole.Tool, [new FunctionResultContent("toolcall1", "sunny")]),
|
||||
new(ChatRole.Assistant, "The weather is sunny. Now let me route your math question.") { AuthorName = "initialAgent" },
|
||||
];
|
||||
|
||||
_ = await RunWorkflowAsync(workflow, input);
|
||||
|
||||
Assert.NotNull(capturedNextAgentMessages);
|
||||
|
||||
// With All filtering, NO function calls or tool results should be visible
|
||||
Assert.DoesNotContain(capturedNextAgentMessages, m => m.Contents.Any(c => c is FunctionCallContent));
|
||||
Assert.DoesNotContain(capturedNextAgentMessages, m => m.Role == ChatRole.Tool);
|
||||
|
||||
// But text content should still be visible
|
||||
Assert.Contains(capturedNextAgentMessages, m => m.Text!.Contains("What's the weather"));
|
||||
Assert.Contains(capturedNextAgentMessages, m => m.Text!.Contains("Routing you now"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handoffs_FilteringHandoffOnly_PreservesNonHandoffToolCallsAsync()
|
||||
{
|
||||
// With HandoffOnly filtering (the default), non-handoff function calls and tool
|
||||
// results should be preserved while handoff ones are stripped.
|
||||
|
||||
List<ChatMessage>? capturedNextAgentMessages = null;
|
||||
|
||||
var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
{
|
||||
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
|
||||
Assert.NotNull(transferFuncName);
|
||||
|
||||
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)]));
|
||||
}), name: "initialAgent");
|
||||
|
||||
var nextAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
{
|
||||
capturedNextAgentMessages = messages.ToList();
|
||||
return new(new ChatMessage(ChatRole.Assistant, "response"));
|
||||
}),
|
||||
name: "nextAgent",
|
||||
description: "The second agent");
|
||||
|
||||
var workflow =
|
||||
AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent)
|
||||
.WithHandoff(initialAgent, nextAgent)
|
||||
.WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior.HandoffOnly)
|
||||
.Build();
|
||||
|
||||
// Input includes a pre-existing non-handoff tool call in the conversation history
|
||||
List<ChatMessage> input =
|
||||
[
|
||||
new(ChatRole.User, "What's the weather? Also help me with math."),
|
||||
new(ChatRole.Assistant, [new FunctionCallContent("toolcall1", "get_weather")]) { AuthorName = "initialAgent" },
|
||||
new(ChatRole.Tool, [new FunctionResultContent("toolcall1", "sunny")]),
|
||||
new(ChatRole.Assistant, "The weather is sunny. Now let me route your math question.") { AuthorName = "initialAgent" },
|
||||
];
|
||||
|
||||
_ = await RunWorkflowAsync(workflow, input);
|
||||
|
||||
Assert.NotNull(capturedNextAgentMessages);
|
||||
|
||||
// Handoff function calls and their tool results should be filtered
|
||||
Assert.DoesNotContain(capturedNextAgentMessages, m => m.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name.StartsWith("handoff_to_", StringComparison.Ordinal)));
|
||||
|
||||
// Non-handoff function calls and their tool results should be preserved
|
||||
Assert.Contains(capturedNextAgentMessages, m => m.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name == "get_weather"));
|
||||
Assert.Contains(capturedNextAgentMessages, m => m.Role == ChatRole.Tool && m.Contents.Any(c => c is FunctionResultContent frc && frc.CallId == "toolcall1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handoffs_TwoTransfers_ResponseServedByThirdAgentAsync()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user