.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:
Jacob Alber
2026-02-19 14:55:12 -05:00
committed by GitHub
Unverified
parent 40d3a0655c
commit 2cb4137501
4 changed files with 468 additions and 7 deletions
@@ -0,0 +1,28 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Specifies the behavior for filtering <see cref="FunctionCallContent"/> and <see cref="ChatRole.Tool"/> contents from
/// <see cref="ChatMessage"/>s flowing through a handoff workflow. This can be used to prevent agents from seeing external
/// tool calls.
/// </summary>
public enum HandoffToolCallFilteringBehavior
{
/// <summary>
/// Do not filter <see cref="FunctionCallContent"/> and <see cref="ChatRole.Tool"/> contents.
/// </summary>
None,
/// <summary>
/// Filter only handoff-related <see cref="FunctionCallContent"/> and <see cref="ChatRole.Tool"/> contents.
/// </summary>
HandoffOnly,
/// <summary>
/// Filter all <see cref="FunctionCallContent"/> and <see cref="ChatRole.Tool"/> contents.
/// </summary>
All
}
@@ -3,6 +3,7 @@
using System.Collections.Generic;
using System.Linq;
using Microsoft.Agents.AI.Workflows.Specialized;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
@@ -16,6 +17,7 @@ public sealed class HandoffsWorkflowBuilder
private readonly AIAgent _initialAgent;
private readonly Dictionary<AIAgent, HashSet<HandoffTarget>> _targets = [];
private readonly HashSet<AIAgent> _allAgents = new(AIAgentIDEqualityComparer.Instance);
private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly;
/// <summary>
/// Initializes a new instance of the <see cref="HandoffsWorkflowBuilder"/> class with no handoff relationships.
@@ -34,14 +36,38 @@ public sealed class HandoffsWorkflowBuilder
/// By default, simple instructions are included. This may be set to <see langword="null"/> to avoid including
/// any additional instructions, or may be customized to provide more specific guidance.
/// </remarks>
public string? HandoffInstructions { get; set; } =
$"""
public string? HandoffInstructions { get; private set; } = DefaultHandoffInstructions;
private const string DefaultHandoffInstructions =
$"""
You are one agent in a multi-agent system. You can hand off the conversation to another agent if appropriate. Handoffs are achieved
by calling a handoff function, named in the form `{FunctionPrefix}<agent_id>`; the description of the function provides details on the
target agent of that handoff. Handoffs between agents are handled seamlessly in the background; never mention or narrate these handoffs
in your conversation with the user.
""";
/// <summary>
/// Sets additional instructions to provide to an agent that has handoffs about how and when to
/// perform them.
/// </summary>
/// <param name="instructions">The instructions to provide, or <see langword="null"/> to restore the default instructions.</param>
public HandoffsWorkflowBuilder WithHandoffInstructions(string? instructions)
{
this.HandoffInstructions = instructions ?? DefaultHandoffInstructions;
return this;
}
/// <summary>
/// Sets the behavior for filtering <see cref="FunctionCallContent"/> and <see cref="ChatRole.Tool"/> contents from
/// <see cref="ChatMessage"/>s flowing through the handoff workflow. Defaults to <see cref="HandoffToolCallFilteringBehavior.HandoffOnly"/>.
/// </summary>
/// <param name="behavior">The filtering behavior to apply.</param>
public HandoffsWorkflowBuilder WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior behavior)
{
this._toolCallFilteringBehavior = behavior;
return this;
}
/// <summary>
/// Adds handoff relationships from a source agent to one or more target agents.
/// </summary>
@@ -149,8 +175,10 @@ public sealed class HandoffsWorkflowBuilder
HandoffsEndExecutor end = new();
WorkflowBuilder builder = new(start);
HandoffAgentExecutorOptions options = new(this.HandoffInstructions, this._toolCallFilteringBehavior);
// Create an AgentExecutor for each again.
Dictionary<string, HandoffAgentExecutor> executors = this._allAgents.ToDictionary(a => a.Id, a => new HandoffAgentExecutor(a, this.HandoffInstructions));
Dictionary<string, HandoffAgentExecutor> executors = this._allAgents.ToDictionary(a => a.Id, a => new HandoffAgentExecutor(a, options));
// Connect the start executor to the initial agent.
builder.AddEdge(start, executors[this._initialAgent.Id]);
@@ -12,10 +12,155 @@ using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
internal sealed class HandoffAgentExecutorOptions
{
public HandoffAgentExecutorOptions(string? handoffInstructions, HandoffToolCallFilteringBehavior toolCallFilteringBehavior)
{
this.HandoffInstructions = handoffInstructions;
this.ToolCallFilteringBehavior = toolCallFilteringBehavior;
}
public string? HandoffInstructions { get; set; }
public HandoffToolCallFilteringBehavior ToolCallFilteringBehavior { get; set; } = HandoffToolCallFilteringBehavior.HandoffOnly;
}
internal sealed class HandoffMessagesFilter
{
private readonly HandoffToolCallFilteringBehavior _filteringBehavior;
public HandoffMessagesFilter(HandoffToolCallFilteringBehavior filteringBehavior)
{
this._filteringBehavior = filteringBehavior;
}
internal static bool IsHandoffFunctionName(string name)
{
return name.StartsWith(HandoffsWorkflowBuilder.FunctionPrefix, StringComparison.Ordinal);
}
public IEnumerable<ChatMessage> FilterMessages(List<ChatMessage> messages)
{
if (this._filteringBehavior == HandoffToolCallFilteringBehavior.None)
{
return messages;
}
Dictionary<string, FilterCandidateState> filteringCandidates = new();
List<ChatMessage> filteredMessages = [];
HashSet<int> messagesToRemove = [];
bool filterHandoffOnly = this._filteringBehavior == HandoffToolCallFilteringBehavior.HandoffOnly;
foreach (ChatMessage unfilteredMessage in messages)
{
ChatMessage filteredMessage = unfilteredMessage.Clone();
// .Clone() is shallow, so we cannot modify the contents of the cloned message in place.
List<AIContent> contents = [];
contents.Capacity = unfilteredMessage.Contents?.Count ?? 0;
filteredMessage.Contents = contents;
// Because this runs after the role changes from assistant to user for the target agent, we cannot rely on tool calls
// originating only from messages with the Assistant role. Instead, we need to inspect the contents of all non-Tool (result)
// FunctionCallContent.
if (unfilteredMessage.Role != ChatRole.Tool)
{
for (int i = 0; i < unfilteredMessage.Contents!.Count; i++)
{
AIContent content = unfilteredMessage.Contents[i];
if (content is not FunctionCallContent fcc || (filterHandoffOnly && !IsHandoffFunctionName(fcc.Name)))
{
filteredMessage.Contents.Add(content);
// Track non-handoff function calls so their tool results are preserved in HandoffOnly mode
if (filterHandoffOnly && content is FunctionCallContent nonHandoffFcc)
{
filteringCandidates[nonHandoffFcc.CallId] = new FilterCandidateState(nonHandoffFcc.CallId)
{
IsHandoffFunction = false,
};
}
}
else if (filterHandoffOnly)
{
if (!filteringCandidates.TryGetValue(fcc.CallId, out FilterCandidateState? candidateState))
{
filteringCandidates[fcc.CallId] = new FilterCandidateState(fcc.CallId)
{
IsHandoffFunction = true,
};
}
else
{
candidateState.IsHandoffFunction = true;
(int messageIndex, int contentIndex) = candidateState.FunctionCallResultLocation!.Value;
ChatMessage messageToFilter = filteredMessages[messageIndex];
messageToFilter.Contents.RemoveAt(contentIndex);
if (messageToFilter.Contents.Count == 0)
{
messagesToRemove.Add(messageIndex);
}
}
}
else
{
// All mode: strip all FunctionCallContent
}
}
}
else
{
if (!filterHandoffOnly)
{
continue;
}
for (int i = 0; i < unfilteredMessage.Contents!.Count; i++)
{
AIContent content = unfilteredMessage.Contents[i];
if (content is not FunctionResultContent frc
|| (filteringCandidates.TryGetValue(frc.CallId, out FilterCandidateState? candidateState)
&& candidateState.IsHandoffFunction is false))
{
// Either this is not a function result content, so we should let it through, or it is a FRC that
// we know is not related to a handoff call. In either case, we should include it.
filteredMessage.Contents.Add(content);
}
else if (candidateState is null)
{
// We haven't seen the corresponding function call yet, so add it as a candidate to be filtered later
filteringCandidates[frc.CallId] = new FilterCandidateState(frc.CallId)
{
FunctionCallResultLocation = (filteredMessages.Count, filteredMessage.Contents.Count),
};
}
// else we have seen the corresponding function call and it is a handoff, so we should filter it out.
}
}
if (filteredMessage.Contents.Count > 0)
{
filteredMessages.Add(filteredMessage);
}
}
return filteredMessages.Where((_, index) => !messagesToRemove.Contains(index));
}
private class FilterCandidateState(string callId)
{
public (int MessageIndex, int ContentIndex)? FunctionCallResultLocation { get; set; }
public string CallId => callId;
public bool? IsHandoffFunction { get; set; }
}
}
/// <summary>Executor used to represent an agent in a handoffs workflow, responding to <see cref="HandoffState"/> events.</summary>
internal sealed class HandoffAgentExecutor(
AIAgent agent,
string? handoffInstructions) : Executor<HandoffState, HandoffState>(agent.GetDescriptiveId(), declareCrossRunShareable: true), IResettableExecutor
HandoffAgentExecutorOptions options) : Executor<HandoffState, HandoffState>(agent.GetDescriptiveId(), declareCrossRunShareable: true), IResettableExecutor
{
private static readonly JsonElement s_handoffSchema = AIFunctionFactory.Create(
([Description("The reason for the handoff")] string? reasonForHandoff) => { }).JsonSchema;
@@ -39,7 +184,7 @@ internal sealed class HandoffAgentExecutor(
ChatOptions = new()
{
AllowMultipleToolCalls = false,
Instructions = handoffInstructions,
Instructions = options.HandoffInstructions,
Tools = [],
},
};
@@ -69,10 +214,19 @@ internal sealed class HandoffAgentExecutor(
List<ChatMessage>? roleChanges = allMessages.ChangeAssistantToUserForOtherParticipants(this._agent.Name ?? this._agent.Id);
await foreach (var update in this._agent.RunStreamingAsync(allMessages,
// If a handoff was invoked by a previous agent, filter out the handoff function
// call and tool result messages before sending to the underlying agent. These
// are internal workflow mechanics that confuse the target model into ignoring the
// original user question.
HandoffMessagesFilter handoffMessagesFilter = new(options.ToolCallFilteringBehavior);
IEnumerable<ChatMessage> messagesForAgent = message.InvokedHandoff is not null
? handoffMessagesFilter.FilterMessages(allMessages)
: allMessages;
await foreach (var update in this._agent.RunStreamingAsync(messagesForAgent,
options: this._agentOptions,
cancellationToken: cancellationToken)
.ConfigureAwait(false))
.ConfigureAwait(false))
{
await AddUpdateAsync(update, cancellationToken).ConfigureAwait(false);
@@ -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()
{