Skip to content

fix(run): stop emitting handoff calls as streamed tool_called events - #4146

Merged
seratch merged 1 commit into
openai:mainfrom
hsusul:fix/streamed-handoff-tool-called-event
Aug 3, 2026
Merged

fix(run): stop emitting handoff calls as streamed tool_called events#4146
seratch merged 1 commit into
openai:mainfrom
hsusul:fix/streamed-handoff-tool-called-event

Conversation

@hsusul

@hsusul hsusul commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Affected component: src/agents/run_internal/run_loop.py (run_single_turn_streamed), streamed RunItemStreamEvent delivery.

Problem. In a streamed run, a handoff tool call is emitted twice as a run item stream event: once as tool_called (wrapping a ToolCallItem) and once as handoff_requested (wrapping a HandoffCallItem). Both events wrap the identical raw function_call object — same call_id, same Python object.

That contradicts three existing contracts:

  • docs/streaming.md documents a fixed name mapping in which handoff requests surface as handoff_requested and tool calls as tool_called.
  • stream_step_items_to_queue maps HandoffCallItemhandoff_requested, and run_single_turn_streamed deliberately drops HandoffCallItems from the post-turn batch (items_to_filter = [... if not isinstance(item, HandoffCallItem)]) precisely so the handoff item is emitted exactly once, from get_single_step_result_from_response.
  • Neither the non-streaming path nor RunResultStreaming.new_items ever contains a ToolCallItem for a handoff call, so the streamed event set does not match the recorded item set.

Minimal reproduction (no API key, no live request — uses the repo's own fakes):

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])

Current behavior

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']

Corrected behavior

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

Root cause. The eager tool-call emitter added in #1300 fires on every response.output_item.done whose item matches TOOL_CALL_TYPES. Handoffs are transported as ResponseFunctionToolCall, so a handoff call matched that branch, and nothing excluded it. The later HandoffCallItem filter only removes the batched duplicate, not the eager one.

This is a regression against the pre-#1300 contract: tests/test_agent_runner_streamed.py::test_streaming_events expected "tool_call": 2 for a run with two function tool calls plus one handoff. #1869 raised it to 3 (with the comment "because handoffs are implemented via tool calls too") once FakeModel began emitting response.output_item.done, which made the eager emitter observable in tests. This PR restores the original expectation.

Implementation. process_model_response already owns the handoff-vs-tool decision (get_tool_call_qualified_name(output) == output.name and output.name in handoff_map). That predicate is extracted verbatim as turn_resolution.is_handoff_tool_call(output, handoff_tool_names), used at its original call site, and reused by the streamed emitter to skip handoff calls. Namespaced calls still never resolve to a handoff, so behavior for namespaced tools is unchanged.

Why this approach is minimal. One shared predicate, one guard on the eager emitter, no new state and no second source of truth for handoff routing. The handoff_requested event, its timing, and every other streamed event are untouched; a handoff call's call_id is not added to emitted_tool_call_ids, so the existing dedupe filter continues to apply only to real tool calls.

Execution modes covered. Streamed runs only — this is the only path with an eager emitter. Runner.run / Runner.run_sync were already correct and their behavior is unchanged (process_model_response is a pure refactor there).

Cleanup and lifecycle. No task, stream, span, or session lifecycle is touched. The eager emitter is a synchronous queue.put_nowait; skipping one item cannot leave work pending. A handoff ResponseFunctionToolCall now falls through the elif chain without matching any later branch.

Compatibility. No public API, signature, or event-name change. Applications keying on handoff_requested / HandoffCallItem are unaffected. An application that relied on a handoff also arriving as tool_called in streamed runs will stop receiving that event — that is the defect being fixed, and such an application was already inconsistent with Runner.run and with result.new_items.

Non-goals. Not changing when handoff_requested is emitted, not changing the streamed-vs-batched item ordering, and not touching the eager emitter's behavior for real tool calls, reasoning items, or tool-search items.

Test plan

Regression tests added to tests/test_stream_events.py:

  • test_streamed_handoff_call_is_not_emitted_as_tool_called — the demonstrated failure: a handoff-only turn yields exactly one handoff_requested event and no tool_called event / ToolCallItem.
  • test_streamed_tool_call_alongside_handoff_still_emits_tool_called — normal behavior is preserved: a real function tool call in the same turn as a handoff still gets exactly one tool_called event, matched by call_id, while the handoff stays a single handoff_requested.
  • test_streamed_handoff_item_events_match_new_items — streamed run item events stay in sync with result.new_items when a message and a handoff arrive in the same turn.

tests/test_agent_runner_streamed.py::test_streaming_events expectation restored from "tool_call": 3 to "tool_call": 2.

All three new tests fail on upstream/main @ 9f4292e5 with the production files reverted, for exactly this reason:

FAILED tests/test_stream_events.py::test_streamed_handoff_call_is_not_emitted_as_tool_called
  AssertionError: assert ['tool_called'] == []
FAILED tests/test_stream_events.py::test_streamed_tool_call_alongside_handoff_still_emits_tool_called
  AssertionError: assert 2 == 1
FAILED tests/test_stream_events.py::test_streamed_handoff_item_events_match_new_items
  AssertionError: Left contains one more item: 'tool_call_item'
3 failed, 8 deselected

Commands run on the final branch (macOS 15, Python 3.12.13, uv 0.11.24):

  • uv run pytest tests/test_stream_events.py tests/test_agent_runner_streamed.py -q78 passed (repeated 5×, stable)
  • bash .agents/skills/code-change-verification/scripts/run.shall commands passed (make format, make lint, make typecheck, make tests)
  • make tests6224 passed, 3 skipped (parallel) and 45 passed, 4 skipped (serial)
  • make typecheck → mypy Success: no issues found in 835 source files; pyright 0 errors, 0 warnings, 0 informations
  • make lintAll checks passed!
  • git diff --check → clean

Limitations / not run. Integration-test profiles (make integration-tests*) were not run: they require live provider credentials, which this change does not touch. make coverage was not run separately; the full make tests suite passes. No inline snapshots changed. No API key or live OpenAI request was used anywhere in reproduction or validation.

Issue number

Closes #4144

Checks

  • I've added new tests, if relevant
  • I've run .agents/skills/code-change-verification/scripts/run.sh
  • I've confirmed all verification steps pass
  • If using Codex, I've run /review before submitting this PR

@seratch seratch added this to the 0.19.x milestone Aug 3, 2026

@seratch seratch left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the thorough fix. I reviewed the compatibility implications because v0.19.2 explicitly tests the current extra tool_called event for handoffs.

I am treating this as a patch-level bug fix rather than an intentional behavior change. At the SDK's semantic event layer, handoffs are represented by HandoffCallItem / handoff_requested, while regular tool calls use ToolCallItem / tool_called. Emitting both for the same raw call leaks the underlying function-call representation and is inconsistent with non-streaming results and new_items. Consumers should use handoff_requested for handoffs.

@seratch
seratch merged commit 9af785b into openai:main Aug 3, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Streamed handoff calls are emitted as both tool_called and handoff_requested

2 participants