Skip to content

Python: Duplicate Tool Result Messages in Handoff Workflow #2711

@lbbniu

Description

@lbbniu

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

  1. Create a handoff workflow with a coordinator and specialist agents
  2. User sends a query that triggers a handoff
  3. Coordinator calls a handoff tool (e.g., handoff_to_specialist)
  4. 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:

  1. Line 428: When handoff target is resolved from _handoff_tool_targets
  2. 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

  1. ChatAgent executes coordinator
    └─ LLM returns function_call: handoff_to_xxx

  2. _AutoHandoffMiddleware intercepts
    └─ Returns synthetic result: {"handoff_to": "xxx"}

  3. ChatAgent creates tool message This repo is missing a LICENSE file #1
    └─ author_name: coordinator.name
    └─ Added to AgentRunResponse.messages

  4. _HandoffCoordinator.handle_agent_response
    └─ Extends self._conversation with messages from response
    └─ Calls _resolve_specialist(response, conversation)

  5. _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)
  • ChatAgent message management
  • _HandoffCoordinator orchestration logic

The framework creates tool results at multiple layers without coordinating to avoid duplicates.

Metadata

Metadata

Assignees

Labels

agent orchestrationIssues related to agent orchestrationpythonv1.0Features being tracked for the version 1.0 GAworkflowsRelated to Workflows in agent-framework

Type

Projects

Status

In Progress

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions