fix(run): stop emitting handoff calls as streamed tool_called events - #4146
Merged
seratch merged 1 commit intoAug 3, 2026
Merged
Conversation
seratch
approved these changes
Aug 3, 2026
seratch
left a comment
Member
There was a problem hiding this comment.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Affected component:
src/agents/run_internal/run_loop.py(run_single_turn_streamed), streamedRunItemStreamEventdelivery.Problem. In a streamed run, a handoff tool call is emitted twice as a run item stream event: once as
tool_called(wrapping aToolCallItem) and once ashandoff_requested(wrapping aHandoffCallItem). Both events wrap the identical rawfunction_callobject — samecall_id, same Python object.That contradicts three existing contracts:
docs/streaming.mddocuments a fixed name mapping in which handoff requests surface ashandoff_requestedand tool calls astool_called.stream_step_items_to_queuemapsHandoffCallItem→handoff_requested, andrun_single_turn_streameddeliberately dropsHandoffCallItems from the post-turn batch (items_to_filter = [... if not isinstance(item, HandoffCallItem)]) precisely so the handoff item is emitted exactly once, fromget_single_step_result_from_response.RunResultStreaming.new_itemsever contains aToolCallItemfor 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):
Current behavior
Corrected behavior
Root cause. The eager tool-call emitter added in #1300 fires on every
response.output_item.donewhose item matchesTOOL_CALL_TYPES. Handoffs are transported asResponseFunctionToolCall, so a handoff call matched that branch, and nothing excluded it. The laterHandoffCallItemfilter 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_eventsexpected"tool_call": 2for a run with two function tool calls plus one handoff. #1869 raised it to3(with the comment "because handoffs are implemented via tool calls too") onceFakeModelbegan emittingresponse.output_item.done, which made the eager emitter observable in tests. This PR restores the original expectation.Implementation.
process_model_responsealready 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 asturn_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_requestedevent, its timing, and every other streamed event are untouched; a handoff call'scall_idis not added toemitted_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_syncwere already correct and their behavior is unchanged (process_model_responseis 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 handoffResponseFunctionToolCallnow falls through theelifchain without matching any later branch.Compatibility. No public API, signature, or event-name change. Applications keying on
handoff_requested/HandoffCallItemare unaffected. An application that relied on a handoff also arriving astool_calledin streamed runs will stop receiving that event — that is the defect being fixed, and such an application was already inconsistent withRunner.runand withresult.new_items.Non-goals. Not changing when
handoff_requestedis 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 onehandoff_requestedevent and notool_calledevent /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 onetool_calledevent, matched bycall_id, while the handoff stays a singlehandoff_requested.test_streamed_handoff_item_events_match_new_items— streamed run item events stay in sync withresult.new_itemswhen a message and a handoff arrive in the same turn.tests/test_agent_runner_streamed.py::test_streaming_eventsexpectation restored from"tool_call": 3to"tool_call": 2.All three new tests fail on
upstream/main@9f4292e5with the production files reverted, for exactly this reason: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 -q→78 passed(repeated 5×, stable)bash .agents/skills/code-change-verification/scripts/run.sh→all commands passed(make format,make lint,make typecheck,make tests)make tests→6224 passed, 3 skipped(parallel) and45 passed, 4 skipped(serial)make typecheck→ mypySuccess: no issues found in 835 source files; pyright0 errors, 0 warnings, 0 informationsmake lint→All checks passed!git diff --check→ cleanLimitations / not run. Integration-test profiles (
make integration-tests*) were not run: they require live provider credentials, which this change does not touch.make coveragewas not run separately; the fullmake testssuite passes. No inline snapshots changed. No API key or live OpenAI request was used anywhere in reproduction or validation.Issue number
Closes #4144
Checks
.agents/skills/code-change-verification/scripts/run.sh/reviewbefore submitting this PR