Skip to content

Streamed handoff calls are emitted as both tool_called and handoff_requested #4144

Description

@hsusul

Describe the bug

In a streamed run, a handoff tool call is emitted twice as a RunItemStreamEvent: once as tool_called (wrapping a ToolCallItem) and once as handoff_requested (wrapping a HandoffCallItem). Both events wrap the exact same raw function_call object (same call_id, same Python object identity).

This contradicts:

  • docs/streaming.md, which documents a fixed mapping where handoff requests surface as handoff_requested and tool calls surface as tool_called.
  • agents.run_internal.streaming.stream_step_items_to_queue, which maps HandoffCallItem -> handoff_requested and ToolCallItem -> tool_called, and the streamed run loop, which deliberately filters HandoffCallItems out of the post-turn batch so the item is emitted exactly once.
  • The non-streaming path and RunResultStreaming.new_items, neither of which ever contains a ToolCallItem for a handoff call.

The extra event comes from the eager tool-call emitter added in #1300 (run_single_turn_streamed). It emits tool_called for any ResponseOutputItemDoneEvent whose item is a tool-call type, without excluding calls whose name resolves to a handoff.

Consumers that render progress from tool_called (a common pattern, and the one used in the docs/streaming.md example) show the handoff as if it were a regular tool call, and any per-tool_called state transition or counter runs once too often for handoffs.

Debug information

  • Agents SDK version: main @ 9f4292e (also present in v0.19.2)
  • Python version: 3.12.13
  • Operating system: macOS 15 (darwin 24.6.0)
  • Model and model provider: reproducible with a local fake model; no API key or live request needed
  • Does the issue reproduce with the latest Agents SDK release? Yes
  • Does the issue occur consistently or intermittently? Consistently

Repro steps

Using the repository's own test fakes (no API key, no network):

import asyncio

from agents import Agent, Runner

from tests.fake_model import FakeModel
from tests.test_responses import get_handoff_tool_call, get_text_message


async def main() -> None:
    english_agent = Agent(name="EnglishAgent", model=FakeModel())

    model = FakeModel()
    model.add_multiple_turn_outputs(
        [
            [get_handoff_tool_call(english_agent)],
            [get_text_message("Done")],
        ]
    )
    triage_agent = Agent(name="TriageAgent", handoffs=[english_agent], model=model)

    result = Runner.run_streamed(triage_agent, input="Start")
    async for event in result.stream_events():
        if event.type == "run_item_stream_event":
            print(event.name, event.item.type, id(event.item.raw_item))

    print("new_items:", [item.type for item in result.new_items])


asyncio.run(main())

Actual output:

tool_called             tool_call_item      4585825584
handoff_requested       handoff_call_item   4585825584
handoff_occured         handoff_output_item ...
message_output_created  message_output_item ...
new_items: ['handoff_call_item', 'handoff_output_item', 'message_output_item']

Expected behavior

A handoff call should produce exactly one run item stream event, handoff_requested, and no tool_called event. The set of streamed run items should match result.new_items, and match the item sequence produced by a non-streamed Runner.run.

This is a regression: before the eager emitter landed, tests/test_agent_runner_streamed.py::test_streaming_events expected "tool_call": 2 for a run containing two function tool calls plus one handoff. That expectation was raised to 3 in #1869 once FakeModel started emitting response.output_item.done events, which made the eager emitter observable in tests.

I have a fix and regression tests ready and would like to open a PR for this.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions