-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Description
Description
When using the HandoffBuilder pattern, the _HandoffCoordinator._append_tool_acknowledgement method creates duplicate tool result
messages in the conversation history. This occurs because the method doesn't check if a tool result with the same call_id already
exists before appending a new one.
Environment
- Agent Framework Version: (based on code analysis of _handoff.py)
- Pattern: HandoffBuilder with coordinator and specialists
- Scenario: Single-tier handoff (coordinator → specialist)
Steps to Reproduce
- Create a handoff workflow with a coordinator and specialist agents
- User sends a query that triggers a handoff
- Coordinator calls a handoff tool (e.g., handoff_to_specialist)
- Examine the checkpoint data after the handoff
Current Behavior
The conversation contains two identical tool result messages with the same call_id but different author_name:
{
"conversation": [
{
"role": "assistant",
"contents": [{
"type": "function_call",
"call_id": "call_8e640f5349644b609c78b1",
"name": "handoff_to_asst_36ed5b4qnyq8szcl1rrpikwh0ue"
}]
},
// First tool result (from ChatAgent/AutoHandoffMiddleware)
{
"role": "tool",
"contents": [{
"type": "function_result",
"call_id": "call_8e640f5349644b609c78b1",
"result": {"handoff_to": "asst_36Ed5b4QNYQ8sZCL1rrPiKWH0Ue"}
}],
"author_name": "coordinator_agent_name"
},
{
"role": "assistant",
"contents": [{
"type": "text",
"text": "Transferring you to the specialist..."
}]
},
// Second tool result (from _append_tool_acknowledgement) ❌ DUPLICATE
{
"role": "tool",
"contents": [{
"type": "function_result",
"call_id": "call_8e640f5349644b609c78b1",
"result": {"handoff_to": "asst_36Ed5b4QNYQ8sZCL1rrPiKWH0Ue"}
}],
"author_name": "handoff_to_asst_36ed5b4qnyq8szcl1rrpikwh0ue"
}
]
}
Expected Behavior
The conversation should contain only one tool result message for each call_id:
{
"conversation": [
{
"role": "assistant",
"contents": [{
"type": "function_call",
"call_id": "call_8e640f5349644b609c78b1",
"name": "handoff_to_asst_36ed5b4qnyq8szcl1rrpikwh0ue"
}]
},
{
"role": "tool",
"contents": [{
"type": "function_result",
"call_id": "call_8e640f5349644b609c78b1",
"result": {"handoff_to": "asst_36Ed5b4QNYQ8sZCL1rrPiKWH0Ue"}
}],
"author_name": "coordinator_agent_name"
}
]
}
Root Cause Analysis
Code Location
_workflows/_handoff.py, lines 441-461
Issue
The _append_tool_acknowledgement method is called in two scenarios:
- Line 428: When handoff target is resolved from _handoff_tool_targets
- Line 435: When handoff target is resolved from case-insensitive alias matching
However, by the time this method is called, the conversation may already contain a tool result message created by:
- _AutoHandoffMiddleware (lines 151-173) which short-circuits handoff tool execution
- The ChatAgent which appends the tool result to the conversation
Flow Analysis
-
ChatAgent executes coordinator
└─ LLM returns function_call: handoff_to_xxx -
_AutoHandoffMiddleware intercepts
└─ Returns synthetic result: {"handoff_to": "xxx"} -
ChatAgent creates tool message This repo is missing a LICENSE file #1
└─ author_name: coordinator.name
└─ Added to AgentRunResponse.messages -
_HandoffCoordinator.handle_agent_response
└─ Extends self._conversation with messages from response
└─ Calls _resolve_specialist(response, conversation) -
_resolve_specialist detects handoff
└─ Calls _append_tool_acknowledgement
└─ Creates tool message Adding Microsoft SECURITY.MD #2 (DUPLICATE)
└─ author_name: function_call.name
Proposed Solution
Add deduplication logic in _append_tool_acknowledgement to check if a tool result with the same call_id already exists:
def _has_tool_result_for_call(conversation: list[ChatMessage], call_id: str) -> bool:
"""Check if a tool result message exists for the given call_id.
Args:
conversation: The conversation history to check
call_id: The function call ID to look for
Returns:
True if a tool result with matching call_id exists, False otherwise
"""
for msg in conversation:
if msg.role != Role.TOOL:
continue
for content in getattr(msg, 'contents', []):
if isinstance(content, FunctionResultContent):
if getattr(content, 'call_id', None) == call_id:
return True
return False
def _append_tool_acknowledgement(
self,
conversation: list[ChatMessage],
function_call: FunctionCallContent,
resolved_id: str,
) -> None:
"""Append a synthetic tool result acknowledging the resolved specialist id."""
call_id = getattr(function_call, "call_id", None)
if not call_id:
return
# Check if tool result already exists to avoid duplicates
if _has_tool_result_for_call(conversation, call_id):
logger.debug(
f"Tool result for call_id '{call_id}' already exists in conversation, "
f"skipping duplicate acknowledgement for handoff to '{resolved_id}'"
)
return
# Add tool acknowledgement only if it doesn't exist
result_payload: Any = {"handoff_to": resolved_id}
result_content = FunctionResultContent(call_id=call_id, result=result_payload)
tool_message = ChatMessage(
role=Role.TOOL,
contents=[result_content],
author_name=function_call.name,
)
conversation.extend((tool_message,))
self._append_messages((tool_message,))
Impact
- Conversation History: Polluted with duplicate messages
- Token Usage: Unnecessary tokens sent to LLM
- Checkpoint Size: Increased storage due to redundant data
- Debugging: Confusing when inspecting conversation state
Workaround
Users can implement ChatMiddleware to prevent the coordinator from making a second LLM call after receiving the tool result, which
reduces the window for duplication:
class HandoffShortCircuitMiddleware(ChatMiddleware):
async def process(self, context: ChatContext, next):
if self._is_handoff_completion_request(context.messages):
context.result = ChatResponse(messages=[], usage=None, finish_reason="stop")
context.terminate = True
return
await next(context)
However, this is a workaround that addresses symptoms rather than the root cause.
Additional Context
This issue appears to be related to the interaction between:
_AutoHandoffMiddleware(FunctionMiddleware layer)ChatAgentmessage management_HandoffCoordinatororchestration logic
The framework creates tool results at multiple layers without coordinating to avoid duplicates.
Metadata
Metadata
Assignees
Labels
Type
Projects
Status