Skip to content

fix(gemini): disambiguate parallel function calls across stream events - #24676

Open
cdbartholomew wants to merge 1 commit into
NousResearch:mainfrom
cdbartholomew:fix/gemini-stream-parallel-functioncall-collision
Open

fix(gemini): disambiguate parallel function calls across stream events#24676
cdbartholomew wants to merge 1 commit into
NousResearch:mainfrom
cdbartholomew:fix/gemini-stream-parallel-functioncall-collision

Conversation

@cdbartholomew

Copy link
Copy Markdown
Contributor

Symptom

translate_stream_event in agent/gemini_native_adapter.py mishandles parallel function calls when each call arrives in its own SSE event with parts[0] only — a common shape for streamGenerateContent responses. Downstream consumers that JSON-validate the accumulated tool-call args read the malformed accumulator as truncated, and Hermes' top-level handler converts Gemini's finishReason=STOP into finish_reason="length". The user sees Response truncated due to output length limit in under a second despite a perfectly successful Gemini response.

Observed in production with gemini-3-flash-preview + tool use: prompts that elicit several parallel function calls with overlapping names (e.g. an agent investigating with skill_view called for each of several skills) consistently fail.

Root cause

The accumulator slot's key is the tuple (part_index, name, thought_signature). When each function call is in its own event with parts[0] only, part_index is always 0. Parallel calls that share the same name (different args) collide on a single slot. The delta-emitting logic below the lookup:

if last_arguments:
    if args_str == last_arguments:
        emitted_arguments = ""
    elif args_str.startswith(last_arguments):
        emitted_arguments = args_str[len(last_arguments):]
slot["last_arguments"] = args_str

…concatenates the args of distinct calls because args_str.startswith(last_arguments) is False for different payloads. After three events with args {"q":"a"}, {"q":"b"}, {"q":"c"}, the slot's emitted args are the concatenation {"q":"a"}{"q":"b"}{"q":"c"} — invalid JSON.

Fix

Replace the rigid call_key → slot lookup with a value-based match: find any existing slot for this (part_index, name, thought_signature) whose accumulated args either equals args_str or is a prefix of it (i.e. a delta continuation). When no such slot exists, allocate a fresh one.

This preserves both existing invariants:

  • Streamed delta accumulation for the same logical call across events — test_stream_event_translation_emits_tool_call_delta_with_stable_index still passes.
  • Distinct slots for identical calls in separate parts of one event (different part_index) — test_stream_event_translation_keeps_identical_calls_in_distinct_parts still passes.

…while fixing the parallel-different-args-across-events case.

Tests

Two regression tests added in tests/agent/test_gemini_native_adapter.py:

  • test_stream_event_translation_distinct_parallel_calls_across_events_same_name — reproduces the bug exactly. Three events, each parts[0] only, name="search" with args {"q":"a"}, {"q":"b"}, {"q":"c"}. Asserts each call gets a distinct slot index/id and that each slot's args are isolated (not concatenated).
  • test_stream_event_translation_streamed_arg_delta_still_merges — asserts consecutive events for the SAME logical call still collapse to one slot (Test 1's invariant, preserved by the new prefix-match path).

Verified the regression test FAILS without the fix (all three calls collapse to slot 0; emitted args become the concatenation) and PASSES with the fix. All 13 existing tests in tests/agent/test_gemini_native_adapter.py still pass.

$ pytest tests/agent/test_gemini_native_adapter.py
============================== 13 passed in 1.40s ==============================

Notes

  • Internal bookkeeping fields use a leading underscore (_match_part_index, etc.) to make it clear they're not part of any wire-level slot contract.
  • The slot lookup is now O(n) in the count of active slots in this stream, vs O(1) before — but the slot count is bounded by the request's tool budget (typically <50), so the wall-cost is negligible.
  • No change to the emitted _GeminiStreamChunk shape — downstream consumers are unaffected.

translate_stream_event keys its tool-call accumulator slots on the
tuple (part_index, name, thought_signature). Gemini's streaming SSE
emits each function call in its own event with `parts[0]` only — so
`part_index` is always 0. When the model emits multiple parallel
function calls that share the same `name` across separate events
(common with provider-side dispatch patterns and with models that
fan out per-target tool calls), all of them collide on a single
slot. The delta-emitting logic below the lookup then concatenates
the args of the colliding calls because
`args_str.startswith(last_arguments)` is False for distinct
payloads, producing invalid JSON like
`{"q":"a"}{"q":"b"}{"q":"c"}`.

Downstream consumers that JSON-validate the accumulated args (e.g.
the run_agent.py tool-call validator at the truncation handler)
read the malformed args as truncated and convert Gemini's
`finishReason=STOP` into `finish_reason="length"` — surfacing as
a spurious "Response truncated due to output length limit" failure
on a perfectly successful Gemini response.

The fix replaces the rigid `call_key` -> slot lookup with a
value-based match: find any existing slot for this
(part_index, name, thought_signature) whose accumulated args either
equals args_str or is a prefix of it (delta continuation). When
no such slot exists, allocate a fresh one. This preserves both
existing invariants:

  - Streamed delta accumulation for the SAME logical call across
    events (Test 1: `*_emits_tool_call_delta_with_stable_index`)
  - Distinct slots for identical calls in separate parts of one
    event (Test 2:
    `*_keeps_identical_calls_in_distinct_parts`)

while fixing the parallel-different-args-across-events case.

Two regression tests added:

  - test_stream_event_translation_distinct_parallel_calls_across_events_same_name
    Reproduces the bug exactly — three events, each `parts[0]` only,
    name="search" with args {"q":"a"}, {"q":"b"}, {"q":"c"}. Without
    the fix, all three collide on slot 0 and the third event's args
    become the concatenation `{"q":"a"}{"q":"b"}{"q":"c"}`. With the
    fix, each gets a distinct slot with its own index, id, and
    isolated args.
  - test_stream_event_translation_streamed_arg_delta_still_merges
    Asserts that consecutive events for the SAME logical call still
    collapse to one slot (Test 1's invariant, preserved by the new
    prefix-match path).

Observed in production with `gemini-3-flash-preview` + tool use:
prompts that elicit several parallel function calls with
overlapping names (e.g. an agent investigating with `skill_view`
called for each of several skills) failed with "Response truncated"
in under a second despite the model returning a valid response.
@donbowman

Copy link
Copy Markdown

I can confirm this fixes my problems with tool calling and the false Response truncated due to output length limit message with gemini 3 flash.

@aurelienp-alt

Copy link
Copy Markdown

I would love this pr to be merged

@konsisumer

Copy link
Copy Markdown
Contributor

Closing — deferring to #25346 by @LeonSGP43 which addresses the same. Reopen if that PR stalls.

@teknium1

Copy link
Copy Markdown
Contributor

Thanks for targeting the collision at the Gemini adapter boundary. Current main still constructs one slot key from (part_index, name, thought_signature) in agent/gemini_native_adapter.py:688-703, while the streaming generator reuses that dictionary across SSE events at agent/gemini_native_adapter.py:977-979; the reported differing-argument collision is therefore still present.

Problems

  • The new test_stream_event_translation_streamed_arg_delta_still_merges sends the same complete {"q": ""} dictionary twice. Per the PR diff, it exercises equality de-duplication, not the proposed strict-prefix continuation branch; its comment also says it cannot create partial JSON through this adapter.
  • A value matcher still merges distinct calls when name, thought signature, and arguments are all identical across separate events. Please make the intended contract for that ambiguous case explicit in coverage or scope.

Suggested changes

  • Rename/rework the second test to match what it proves, and add a true prefix-path test only if a valid adapter input reaches it.
  • Add a focused identical-payload-across-events regression documenting the intended behavior.

Automated hermes-sweeper review.

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform area/streaming Streaming responses: gateway delivery, provider wire labels Jul 13, 2026
@alt-glitch alt-glitch removed the sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades label Jul 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/streaming Streaming responses: gateway delivery, provider wire comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists provider/gemini Google Gemini (AI Studio, Cloud Code) sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants