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.
Describe the bug
In a streamed run, a handoff tool call is emitted twice as a
RunItemStreamEvent: once astool_called(wrapping aToolCallItem) and once ashandoff_requested(wrapping aHandoffCallItem). Both events wrap the exact same rawfunction_callobject (samecall_id, same Python object identity).This contradicts:
docs/streaming.md, which documents a fixed mapping where handoff requests surface ashandoff_requestedand tool calls surface astool_called.agents.run_internal.streaming.stream_step_items_to_queue, which mapsHandoffCallItem->handoff_requestedandToolCallItem->tool_called, and the streamed run loop, which deliberately filtersHandoffCallItems out of the post-turn batch so the item is emitted exactly once.RunResultStreaming.new_items, neither of which ever contains aToolCallItemfor a handoff call.The extra event comes from the eager tool-call emitter added in #1300 (
run_single_turn_streamed). It emitstool_calledfor anyResponseOutputItemDoneEventwhose 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 thedocs/streaming.mdexample) show the handoff as if it were a regular tool call, and any per-tool_calledstate transition or counter runs once too often for handoffs.Debug information
main@ 9f4292e (also present in v0.19.2)Repro steps
Using the repository's own test fakes (no API key, no network):
Actual output:
Expected behavior
A handoff call should produce exactly one run item stream event,
handoff_requested, and notool_calledevent. The set of streamed run items should matchresult.new_items, and match the item sequence produced by a non-streamedRunner.run.This is a regression: before the eager emitter landed,
tests/test_agent_runner_streamed.py::test_streaming_eventsexpected"tool_call": 2for a run containing two function tool calls plus one handoff. That expectation was raised to3in #1869 onceFakeModelstarted emittingresponse.output_item.doneevents, 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.