Compare commits

...
Author SHA1 Message Date
Jacob AlberandGitHub d5777bc546 fix: Duplicate CallIds cause Handoff Message Filtering to fail (#5359)
Some providers, e.g. Gemini, do not use the CallId mechanism to disambiguate simultaneous function calls. This can result in message lists containing multiple turn to fail to filter properly.

The fix is to take advantage of the expectation that Handoff Orchestration is a "single-speaker" flow, which only has a single active AIAgent per "turn" and an agent's turn is not finished until all outstanding function calls are finished.

This allows us to expect that any ambiguous-CallId FunctionCallContent are either in separate turns or will have had a response before the next issued call with the same Id.
2026-04-21 08:14:35 +00:00
b6b191ad9c Python: Add second approval-required tool (set_stop_loss) to concurrent_builder_tool_approval sample (#4875)
* Add set_stop_loss tool to concurrent_builder_tool_approval sample

Add a second approval-gated tool (set_stop_loss) to the concurrent workflow
tool approval sample to demonstrate handling approval requests for different
tools in the same concurrent workflow.

Changes:
- Add set_stop_loss(symbol, stop_price) with approval_mode='always_require'
- Include new tool in both agents' tool lists
- Update agent instructions and prompt to encourage stop-loss usage
- Update docstring to reflect two approval-gated tools
- Update sample output to show mixed approval requests

Fixes #4874

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Print tool name and arguments in concurrent sample's process_event_stream (#4874)

Align process_event_stream in concurrent_builder_tool_approval.py to print
the tool name and arguments when collecting approval requests, matching the
sample output comment and the sequential_builder_tool_approval.py pattern.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add None-guard for function_call access in tool approval sample (#4874)

Add explicit None-checks before accessing function_call.name and
function_call.arguments in concurrent_builder_tool_approval.py. The
function_call field is typed Content | None, so direct attribute access
without a guard could raise AttributeError and required type: ignore
comments. The None-guard is consistent with the pattern used in
_agent_run.py and removes the suppression comments.

Also add a regression test verifying that function_call defaults to None
and that the None-guard pattern is safe.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Apply same function_call None-guard to sibling tool-approval samples (#4874)

Apply the same fix to sequential_builder_tool_approval.py and
group_chat_builder_tool_approval.py, which had the identical pattern
of accessing function_call.name/arguments without a None-guard.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-21 07:08:50 +00:00
7 changed files with 238 additions and 117 deletions
@@ -426,7 +426,7 @@ internal sealed class HandoffAgentExecutor :
{
AgentId = this._agent.Id,
AuthorName = this._agent.Name ?? this._agent.Id,
Contents = [new FunctionResultContent(handoffRequest.CallId, "Transferred.")],
Contents = [CreateHandoffResult(handoffRequest.CallId)],
CreatedAt = DateTimeOffset.UtcNow,
MessageId = Guid.NewGuid().ToString("N"),
Role = ChatRole.Tool,
@@ -459,4 +459,6 @@ internal sealed class HandoffAgentExecutor :
? this._handoffFunctionToAgentId.TryGetValue(requestedHandoff, out string? targetId) ? targetId : null
: null;
}
internal static FunctionResultContent CreateHandoffResult(string requestCallId) => new(requestCallId, "Transferred.");
}
@@ -3,7 +3,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
@@ -31,113 +30,78 @@ internal sealed class HandoffMessagesFilter
return messages;
}
Dictionary<string, FilterCandidateState> filteringCandidates = new();
List<ChatMessage> filteredMessages = [];
HashSet<int> messagesToRemove = [];
HashSet<string> filteredCallsWithoutResponses = new();
List<ChatMessage> retainedMessages = [];
bool filterAllToolCalls = this._filteringBehavior == HandoffToolCallFilteringBehavior.All;
// The logic of filtering is fairly straightforward: We are only interested in FunctionCallContent and FunctionResponseContent.
// We are going to assume that Handoff operates as follows:
// * Each agent is only taking one turn at a time
// * Each agent is taking a turn alone
//
// In the case of certain providers, like Gemini (see microsoft/agent-framework #5244), we will see the function call name as the
// call id as well, so we may see multiple calls with the same call id, and assume that the call is terminated before another
// "CallId-less" FCC is issued. We also need to rely on the idea that FRC follows their corresponding FCC in the message stream.
// (This changes the previous behaviour where FRC could arrive earlier, and relies on strict ordering).
//
// The benefit of expecting all the AIContent to be strictly ordered is that we never need to reach back into a post-filtered
// content to retroactively remove it, or to try to inject it back into the middle of a Message that has already been processed.
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)
if (unfilteredMessage.Contents is null || unfilteredMessage.Contents.Count == 0)
{
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
}
}
retainedMessages.Add(unfilteredMessage);
continue;
}
else
// We may need to filter out a subset of the message's content, but we won't know until we iterate through it. Create a new list
// of AIContent which we will stuff into a clone of the message if we need to filter out any content.
List<AIContent> retainedContents = new(capacity: unfilteredMessage.Contents.Count);
foreach (AIContent content in unfilteredMessage.Contents)
{
if (!filterHandoffOnly)
if (content is FunctionCallContent fcc
&& (filterAllToolCalls || IsHandoffFunctionName(fcc.Name)))
{
// If we already have an unmatched candidate with the same CallId, that means we have two FCCs in a row without an FRC,
// which violates our assumption of strict ordering.
if (!filteredCallsWithoutResponses.Add(fcc.CallId))
{
throw new InvalidOperationException($"Duplicate FunctionCallContent with CallId '{fcc.CallId}' without corresponding FunctionResultContent.");
}
// If we are filtering all tool calls, or this is a handoff call (and we are not filtering None, already checked), then
// filter this FCC
continue;
}
for (int i = 0; i < unfilteredMessage.Contents!.Count; i++)
else if (content is FunctionResultContent frc)
{
AIContent content = unfilteredMessage.Contents[i];
if (content is not FunctionResultContent frc
|| (filteringCandidates.TryGetValue(frc.CallId, out FilterCandidateState? candidateState)
&& candidateState.IsHandoffFunction is false))
// We rely on the corresponding FCC to have already been processed, so check if it is in the candidate dictionary.
// If it is, we can filter out the FRC, but we need to remove the candidate from the dictionary, since a future FCC can
// come in with the same CallId, and should be considered a new call that may need to be filtered.
if (filteredCallsWithoutResponses.Remove(frc.CallId))
{
// 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);
continue;
}
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.
}
// FCC/FRC, but not filtered, or neither FCC nor FRC: this should not be filtered out
retainedContents.Add(content);
}
if (filteredMessage.Contents.Count > 0)
if (retainedContents.Count == 0)
{
filteredMessages.Add(filteredMessage);
// message was fully filtered, skip it
continue;
}
ChatMessage filteredMessage = unfilteredMessage.Clone();
filteredMessage.Contents = retainedContents;
retainedMessages.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; }
return retainedMessages;
}
}
@@ -0,0 +1,115 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.Specialized;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class HandoffMessageFilterTests
{
private List<ChatMessage> CreateTestMessages(bool firstAgentUsesCallId, bool secondAgentUsesCallId, HandoffToolCallFilteringBehavior filter = HandoffToolCallFilteringBehavior.None)
{
FunctionCallContent handoffRequest1 = CreateHandoffCall(1, firstAgentUsesCallId);
FunctionResultContent handoffResponse1 = CreateHandoffResponse(handoffRequest1);
FunctionCallContent toolCall = CreateToolCall(secondAgentUsesCallId);
FunctionResultContent toolResponse = CreateToolResponse(toolCall);
// Approvals come from the function call middleware over ChatClient, so we can expect there to be a RequestId (not that we
// care, because we do not filter approval content)
ToolApprovalRequestContent toolApproval = new(Guid.NewGuid().ToString("N"), toolCall);
ToolApprovalResponseContent toolApprovalResponse = new(toolApproval.RequestId, true, toolCall);
FunctionCallContent handoffRequest2 = CreateHandoffCall(1, secondAgentUsesCallId);
FunctionResultContent handoffResponse2 = CreateHandoffResponse(handoffRequest2);
List<ChatMessage> result = [new(ChatRole.User, "Hello")];
// Agent 1 turn
result.Add(new(ChatRole.Assistant, "Hello! What do you want help with today?"));
result.Add(new(ChatRole.User, "Please explain temperature"));
// Unless we are filtering none, we expect the handoff call to be filtered out, so we add it conditionally
if (filter == HandoffToolCallFilteringBehavior.None)
{
result.Add(new(ChatRole.Assistant, [handoffRequest1]));
result.Add(new(ChatRole.Tool, [handoffResponse1]));
}
// Agent 2 turn
// Tool approvals are never filtered, so we add them unconditionally
result.Add(new(ChatRole.Assistant, [toolApproval]));
result.Add(new(ChatRole.User, [toolApprovalResponse]));
// Unless we are filtering all, we expect the tool call to be retained, so we add it conditionally
if (filter != HandoffToolCallFilteringBehavior.All)
{
result.Add(new(ChatRole.Assistant, [toolCall]));
result.Add(new(ChatRole.Tool, [toolResponse]));
}
result.Add(new(ChatRole.Assistant, "Temperature is a measure of the average kinetic energy of the particles in a substance."));
if (filter == HandoffToolCallFilteringBehavior.None)
{
result.Add(new(ChatRole.Assistant, [handoffRequest2]));
result.Add(new(ChatRole.Tool, [handoffResponse2]));
}
return result;
}
private static FunctionCallContent CreateHandoffCall(int id, bool useCallId)
{
string callName = $"{HandoffWorkflowBuilder.FunctionPrefix}{id}";
string callId = useCallId ? Guid.NewGuid().ToString("N") : callName;
return new FunctionCallContent(callId, callName);
}
private static FunctionResultContent CreateHandoffResponse(FunctionCallContent call)
=> HandoffAgentExecutor.CreateHandoffResult(call.CallId);
private static FunctionCallContent CreateToolCall(bool useCallId)
{
const string CallName = "ToolFunction";
string callId = useCallId ? Guid.NewGuid().ToString("N") : CallName;
return new FunctionCallContent(callId, CallName);
}
private static FunctionResultContent CreateToolResponse(FunctionCallContent call)
=> new(call.CallId, new object());
[Theory]
[InlineData(true, true, HandoffToolCallFilteringBehavior.None)]
[InlineData(true, false, HandoffToolCallFilteringBehavior.None)]
[InlineData(false, true, HandoffToolCallFilteringBehavior.None)]
[InlineData(false, false, HandoffToolCallFilteringBehavior.None)]
[InlineData(true, true, HandoffToolCallFilteringBehavior.HandoffOnly)]
[InlineData(true, false, HandoffToolCallFilteringBehavior.HandoffOnly)]
[InlineData(false, true, HandoffToolCallFilteringBehavior.HandoffOnly)]
[InlineData(false, false, HandoffToolCallFilteringBehavior.HandoffOnly)]
[InlineData(true, true, HandoffToolCallFilteringBehavior.All)]
[InlineData(true, false, HandoffToolCallFilteringBehavior.All)]
[InlineData(false, true, HandoffToolCallFilteringBehavior.All)]
[InlineData(false, false, HandoffToolCallFilteringBehavior.All)]
public void Test_HandoffMessageFilter_FiltersOnlyExpectedMessages(bool firstAgentUsesCallId, bool secondAgentUsesCallId, HandoffToolCallFilteringBehavior behavior)
{
// Arrange
List<ChatMessage> messages = this.CreateTestMessages(firstAgentUsesCallId, secondAgentUsesCallId);
List<ChatMessage> expected = this.CreateTestMessages(firstAgentUsesCallId, secondAgentUsesCallId, behavior);
HandoffMessagesFilter filter = new(behavior);
// Act
IEnumerable<ChatMessage> filteredMessages = filter.FilterMessages(messages);
// Assert
filteredMessages.Should().BeEquivalentTo(expected);
}
}
@@ -664,6 +664,21 @@ def test_function_approval_serialization_roundtrip():
# The Content union will need to be handled differently when we fully migrate
def test_function_approval_request_function_call_none_guard():
"""Test that accessing function_call attributes is safe when function_call is None."""
# Construct a Content with type "function_approval_request" but no function_call.
# This verifies the None-guard pattern used in samples to prevent AttributeError.
content = Content("function_approval_request", id="req-none")
assert content.function_call is None
# A proper approval request always has function_call set
fc = Content.from_function_call(call_id="call-1", name="do_something", arguments={"a": 1})
req = Content.from_function_approval_request(id="req-1", function_call=fc)
assert req.function_call is not None
assert req.function_call.name == "do_something"
assert req.function_call.arguments == {"a": 1}
def test_function_approval_accepts_mcp_call():
"""Ensure FunctionApprovalRequestContent supports MCP server tool calls."""
mcp_call = Content.from_mcp_server_tool_call(
@@ -29,19 +29,19 @@ approval will pause the workflow until the human responds.
This sample works as follows:
1. A ConcurrentBuilder workflow is created with two agents running in parallel.
2. Both agents have the same tools, including one requiring approval (execute_trade).
2. Both agents have the same tools, including two requiring approval (execute_trade, set_stop_loss).
3. Both agents receive the same task and work concurrently on their respective stocks.
4. When either agent tries to execute a trade, it triggers an approval request.
4. When either agent tries to execute a trade or set a stop-loss, it triggers an approval request.
5. The sample simulates human approval and the workflow completes.
6. Results from both agents are aggregated and output.
Purpose:
Show how tool call approvals work in parallel execution scenarios where multiple
agents may independently trigger approval requests.
agents may independently trigger approval requests for different tools.
Demonstrate:
- Handling multiple approval requests from different agents in concurrent workflows.
- Handling during concurrent agent execution.
- Handling approval requests for different tools during concurrent agent execution.
- Understanding that approval pauses only the agent that triggered it, not all agents.
Prerequisites:
@@ -89,6 +89,15 @@ def execute_trade(
return f"Trade executed: {action.upper()} {quantity} shares of {symbol.upper()}"
@tool(approval_mode="always_require")
def set_stop_loss(
symbol: Annotated[str, "The stock ticker symbol"],
stop_price: Annotated[float, "The stop-loss price"],
) -> str:
"""Set a stop-loss order for a stock. Requires human approval due to financial impact."""
return f"Stop-loss set for {symbol.upper()} at ${stop_price:.2f}"
@tool(approval_mode="never_require")
def get_portfolio_balance() -> str:
"""Get current portfolio balance and available funds."""
@@ -118,14 +127,17 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str
if event.type == "request_info" and isinstance(event.data, Content):
# We are only expecting tool approval requests in this sample
requests[event.request_id] = event.data
if event.data.type == "function_approval_request" and event.data.function_call is not None:
print(f"\nApproval requested for tool: {event.data.function_call.name}")
print(f"Arguments: {event.data.function_call.arguments}")
elif event.type == "output":
_print_output(event)
responses: dict[str, Content] = {}
if requests:
for request_id, request in requests.items():
if request.type == "function_approval_request":
print(f"\nSimulating human approval for: {request.function_call.name}") # type: ignore
if request.type == "function_approval_request" and request.function_call is not None:
print(f"\nSimulating human approval for: {request.function_call.name}")
# Create approval response
responses[request_id] = request.to_function_approval_response(approved=True)
@@ -145,9 +157,10 @@ async def main() -> None:
name="MicrosoftAgent",
instructions=(
"You are a personal trading assistant focused on Microsoft (MSFT). "
"You manage my portfolio and take actions based on market data."
"You manage my portfolio and take actions based on market data. "
"Use stop-loss orders to manage risk."
),
tools=[get_stock_price, get_market_sentiment, get_portfolio_balance, execute_trade],
tools=[get_stock_price, get_market_sentiment, get_portfolio_balance, execute_trade, set_stop_loss],
)
google_agent = Agent(
@@ -155,9 +168,10 @@ async def main() -> None:
name="GoogleAgent",
instructions=(
"You are a personal trading assistant focused on Google (GOOGL). "
"You manage my trades and portfolio based on market conditions."
"You manage my trades and portfolio based on market conditions. "
"Use stop-loss orders to manage risk."
),
tools=[get_stock_price, get_market_sentiment, get_portfolio_balance, execute_trade],
tools=[get_stock_price, get_market_sentiment, get_portfolio_balance, execute_trade, set_stop_loss],
)
# 4. Build a concurrent workflow with both agents
@@ -172,7 +186,8 @@ async def main() -> None:
# Runs are not isolated; state is preserved across multiple calls to run.
stream = workflow.run(
"Manage my portfolio. Use a max of 5000 dollars to adjust my position using "
"your best judgment based on market sentiment. No need to confirm trades with me.",
"your best judgment based on market sentiment. Set stop-loss orders to manage risk. "
"No need to confirm trades with me.",
stream=True,
)
@@ -191,22 +206,32 @@ async def main() -> None:
Approval requested for tool: execute_trade
Arguments: {"symbol":"MSFT","action":"buy","quantity":13}
Approval requested for tool: set_stop_loss
Arguments: {"symbol":"MSFT","stop_price":340.0}
Approval requested for tool: execute_trade
Arguments: {"symbol":"GOOGL","action":"buy","quantity":35}
Simulating human approval for: execute_trade
Approval requested for tool: set_stop_loss
Arguments: {"symbol":"GOOGL","stop_price":126.0}
Simulating human approval for: execute_trade
Simulating human approval for: set_stop_loss
Simulating human approval for: execute_trade
Simulating human approval for: set_stop_loss
------------------------------------------------------------
Workflow completed. Aggregated results from both agents:
- user: Manage my portfolio. Use a max of 5000 dollars to adjust my position using your best judgment based on
market sentiment. No need to confirm trades with me.
- MicrosoftAgent: I have successfully executed the trade, purchasing 13 shares of Microsoft (MSFT). This action
was based on the positive market sentiment and available funds within the specified limit.
Your portfolio has been adjusted accordingly.
- GoogleAgent: I have successfully executed the trade, purchasing 35 shares of GOOGL. If you need further
assistance or any adjustments, feel free to ask!
market sentiment. Set stop-loss orders to manage risk. No need to confirm trades with me.
- MicrosoftAgent: I have successfully purchased 13 shares of Microsoft (MSFT) and set a stop-loss at $340.00.
This action was based on the positive market sentiment and available funds within the
specified limit. Your portfolio has been adjusted accordingly.
- GoogleAgent: I have successfully purchased 35 shares of GOOGL and set a stop-loss at $126.00. If you need
further assistance or any adjustments, feel free to ask!
"""
@@ -121,11 +121,11 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str
responses: dict[str, Content] = {}
if requests:
for request_id, request in requests.items():
if request.type == "function_approval_request":
if request.type == "function_approval_request" and request.function_call is not None:
print("\n[APPROVAL REQUIRED]")
print(f" Tool: {request.function_call.name}") # type: ignore
print(f" Arguments: {request.function_call.arguments}") # type: ignore
print(f"Simulating human approval for: {request.function_call.name}") # type: ignore
print(f" Tool: {request.function_call.name}")
print(f" Arguments: {request.function_call.arguments}")
print(f"Simulating human approval for: {request.function_call.name}")
# Create approval response
responses[request_id] = request.to_function_approval_response(approved=True)
@@ -94,11 +94,11 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str
responses: dict[str, Content] = {}
if requests:
for request_id, request in requests.items():
if request.type == "function_approval_request":
if request.type == "function_approval_request" and request.function_call is not None:
print("\n[APPROVAL REQUIRED]")
print(f" Tool: {request.function_call.name}") # type: ignore
print(f" Arguments: {request.function_call.arguments}") # type: ignore
print(f"Simulating human approval for: {request.function_call.name}") # type: ignore
print(f" Tool: {request.function_call.name}")
print(f" Arguments: {request.function_call.arguments}")
print(f"Simulating human approval for: {request.function_call.name}")
# Create approval response
responses[request_id] = request.to_function_approval_response(approved=True)