Skip to content

fix(runtime): resolve a streamed tool call delta against the aliases that still claim it - #1995

Merged
Astro-Han merged 10 commits into
mainfrom
fix/1976-streamed-tool-call-identity
Aug 3, 2026
Merged

fix(runtime): resolve a streamed tool call delta against the aliases that still claim it#1995
Astro-Han merged 10 commits into
mainfrom
fix/1976-streamed-tool-call-identity

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Summary

A gateway that labels distinct streamed tool calls with the same tool_calls[].index had them merged: arguments concatenated into invalid JSON, the second call's id and name dropped, and nothing errored. Ollama labels every tool call in a turn with index: 0 (vercel/ai#14277); all 45 openai-compatible registry entries are status: 'ready', ollama and ollama-cloud among them.

This and the #1967 crash are one modelling defect in StreamingToolCallTracker: index is an association label a gateway may omit, repeat, or number freely, and it served as the storage slot, the identity, and the ordering at once. The class declares private toolCalls: TrackedToolCall[] while indexing it by that external number, so the type hid that the array was sparse.

The fix is not "identity lives in id". That was attempt one, and id is equally untrustworthy — gateways repeat it across calls and send '' for absent — so keying on it reproduced #1976 with the fields swapped. Four review rounds got to the current rule; see Root cause for what each one caught.

So the tracker keys on nothing. Records live in a dense array in creation order, each learning the aliases the wire uses for it (index, and id with '' and null read as absent) separately from the id we emit. Resolution scans newest-first and stops at the first call one of the delta's aliases claims: a candidate whose alias contradicts the delta is skipped, a candidate that has never seen an alias the delta carries is not contradicted by it, and at least one alias must actually match. Once a claimant is found, a function.name that disagrees rejects the delta — continuing the scan reaches a call whose claim the claimant already superseded.

Consequences worth naming:

  • A delta resolving to nothing starts a new call. Aliases that contradict every record are a new identity, not a guess; only a delta that cannot name a tool fails the turn.
  • One fallback remains, for a lone call: exactly one call exists and no alias the delta carries disagrees with it. That keeps an index or id arriving alone on a continuation from being worse than unpatched, which resolved it by array slot.
  • An unusable wire id — absent, empty, or already emitted — is replaced by a generated one, because tool-runtime.ts derives operationId from it. But the wire's id stays as an alias: gateways that repeat an id also echo it on continuations, and comparing those against the generated id killed turns in round two. (This leaves upstream's toolCall.id ?? this._generateId() provably dead; minting happens at creation.)
  • flush() emits in index order only when every call supplied an index and those indices are unique.

This replaces the #1967 sparse-slot patch rather than stacking on it. Not upstreamed by decision, so the README's deletion condition does not wait on a release.

Closes #1976. Refs #1967.

Verification

  • Guard — 41/41, on both the openai-compatible and openai paths. Unpatched, 34 of 41 fail.
  • 25 mutations of the tracker, zero survivors. Each is killed by the case that claims to pin it — name adjudication (and specifically reject-vs-skip), index disagreement, id disagreement, the matched requirement, reverse scan order, the fallback and each of its three conditions separately, ''-as-absent, null-as-absent, both minting clauses, both late-alias bindings, both sort guards, and each of the four tool-input-* enqueues including the name the start announces.
  • provider-conformance + provider-contract-matrix + deferred-tools-wire + deferred-tools-backend — 154/154.
  • Full @maka/runtime suite — 2812 tests, 4 failures, identical to the failure set on main (all builtin-tools.test.js, the macOS /private/var tmpdir symlink issue).
  • Patch mechanics: reverse-applies to the recorded pristine blob (851fce4); patch-package --error-on-fail re-applies to that pristine tree cleanly; npm ci applies it via postinstall.
  • npm run lint, npm run format:check, @maka/runtime run typecheck — clean.
  • Not run: desktop E2E and Storybook — this change does not reach the renderer.

Root cause

Unpatched upstream vs. this branch, through the real provider stack with a fake fetch:

Wire shape Unpatched This branch
Two calls, both index: 0 one call, '{"path":"a"}{"path":"b"}'; neither tool runs two calls, both run
Same, second call's args empty second call vanishes silently two calls
Three calls all index: 0, tool name repeating all merged three calls
Deltas omit index, call spans chunks throws Expected 'id' to be a string. accumulates
Out-of-order unique indices index order unchanged

Downstream, merged invalid JSON reached repairMakaToolCall, which rewrote it to INVALID_TOOL_NAME — so those shapes were not literally silent, but the error blamed the model's arguments for a transport defect and the natural retry reproduced the merge. The empty-args shape was genuinely silent.

What each review round caught in the previous round's fix, all verified against unpatched upstream on both paths:

Round Shape Then Now
1 id promoted to sole identity #1976 reproduced with fields swapped
2 Reused index + repeated/empty id + different names merged into invalid JSON two calls
2 Two calls sharing a wire id same toolCallId emitted twice distinct, one minted
3 Duplicated id echoed on continuations turn died; upstream had survived it two calls
3 id or index arriving only on a later delta turn died the same way continues
4 (self-caught) late alias not recorded id-only continuation died; late index reversed tool order both pinned
4 Three calls, one index, repeating name two calls, one unparsable, third lost three calls
4 An index or id arriving alone on a continuation worse than unpatched (upstream resolved by slot) continues

Round 4's P1 is the sharpest lesson: it is not undecidable — index 0 had demonstrably been reclaimed — and it is the exact shape the PR headlines. It survived three review rounds and 25 mutations because every case until then used two distinct tool names.

Three defects were hidden by the tests themselves. The dup-id cases asserted only {toolName, input}, projecting away the toolCallId that decides the outcome downstream. The guard asserted only the final tool-call, so all four tool-input-* enqueues could be deleted with every case green. And assertEventLifecycle accepted an unfinished call unconditionally — which is exactly the shape a silently dropped call takes — and never checked that the start announced the name the call runs.

Deletions

Each verified by removing it and running the suite:

  • The src/ half of the patch. Nothing compiles it and exports resolves to dist. Patch: 246 → 143 lines.
  • Both single-valued alias maps, which could not hold a repeated alias and forced a priority order the rule does not need. Replaced by a reverse scan; tool calls per turn are few.
  • findByAlias's leading early return — unreachable as a decision, since no alias means no match either way.
  • The hasFinished filter in the fallbackfinishToolCall is only reachable from flush(), so nothing is finished while deltas are processed.
  • Seven redundant cases across the rounds, including two keeps calls distinct when both reuse id "dup" at different indices that passed against unpatched upstream and were killed by no mutation, while claiming to pin a priority order that no longer exists.

No production line in the patched class is deletable. Each was prototype-deleted and produced a concrete failing shape or a red case.

Review focus

The remaining wrong shapes share one cause — an alias claimed by two calls at once, with the continuation carrying only that alias. The fragment goes to the most recent claimant: required for the sequential shape Ollama produces, wrong for an interleaved one, and nothing in the stream distinguishes them. patches/README.md tabulates them with what survives per shape, because it differs: three keep both calls and misplace only argument text, but same id + same name + no index loses a call. That row is why the guard's boundary suite states a per-shape expectation instead of one shared assertion — the earlier blanket claim "every call is emitted once" was false.

Two accepted non-fixes, both documented. A delta contradicting every record becomes a new call rather than an error, which can add a call the gateway did not send. And after a chunk the provider rejects, flush() still finalizes, so an already-failed turn can carry one tool-call with truncated arguments — upstream emitted nothing there only because the sparse-array crash got in first, finalizing in flush() is its documented design, and repairMakaToolCall rejects the unparsable input.

Prevalence is a gap, stated plainly. No deployed gateway is confirmed to emit duplicate or empty tool-call ids, to echo a duplicated id on continuations, or to supply an alias only on a continuation; the repo holds no captured gateway wire samples, so every shape is hand-authored, and even the index-reuse shape this PR headlines rests on an upstream report. The divergences from unpatched upstream stand regardless of prevalence.

Two subtleties worth knowing when editing this. The index-presence guard in flush() needs five calls with arrival order unlike index order to be observable — at three, V8's insertion sort leaves a NaN-comparing comparator looking correct. And name-related defects need three calls to surface, because at two the names are always distinct.

@Astro-Han Astro-Han changed the title fix(runtime): key streamed tool calls on id instead of index fix(runtime): require index and id to agree on tool call identity Aug 3, 2026
@Astro-Han
Astro-Han force-pushed the fix/1976-streamed-tool-call-identity branch from 5fb079b to dc7acbe Compare August 3, 2026 12:15
@Astro-Han Astro-Han changed the title fix(runtime): require index and id to agree on tool call identity fix(runtime): agree on every alias a streamed tool call delta carries Aug 3, 2026
@Astro-Han Astro-Han changed the title fix(runtime): agree on every alias a streamed tool call delta carries fix(runtime): resolve a streamed tool call delta against the aliases that still claim it Aug 3, 2026
A gateway that labels two distinct streamed tool calls with the same
`tool_calls[].index` had them merged into one: arguments concatenated into
invalid JSON, the second call's `id` and `name` dropped, and nothing errored.
Ollama labels every tool call in a turn with `index: 0` (vercel/ai#14277), and
all 45 `openai-compatible` registry entries are `status: 'ready'`, `ollama` and
`ollama-cloud` among them.

This and the #1967 crash are one modelling defect in `StreamingToolCallTracker`:
`index` is an association label a gateway may omit, repeat, or number freely,
and it was serving as the storage slot, the identity, and the ordering at once.
The class declares `private toolCalls: TrackedToolCall[]` while indexing it by
that external number, so the type hid that the array was sparse.

Separate the three jobs. Records live in a dense array in creation order;
`byId` and `byIndex` alias them; `activeToolCall` tracks the call the stream
last touched. `processDelta` resolves an explicit `id` first, falls back to
`index` for continuations that carry no `id`, and only then to `activeToolCall`
— not to "the last created call", since nothing finishes before the stream ends
and so `hasFinished` cannot discriminate mid-turn. `flush()` emits in index
order only when every call has a unique index, because a repeated or missing
index cannot order anything and sorting by it would let a malformed index
reorder the turn.

This replaces the #1967 sparse-slot patch rather than stacking on it: the four
existing guards stay green with that hunk gone.

Known boundary, locked in by test: `@ai-sdk/openai-compatible` keeps its own
index-keyed buffer ahead of the tracker, so a reused index whose new call has
not sent its `name` yet fails the turn instead of merging. Failing loudly is
the intent; recovering needs both layers folded together. No known gateway
sends that shape.

We are not upstreaming this, so `patches/README.md` now states a deletion
condition that does not wait on a release: drop the patch, reinstall, and the
13-case guard answers whether it is still needed.

Fixes #1976. Refs #1967.
Review found that the previous commit fixed the reported defect by promoting
`id` to sole authority, which reproduced the same defect with the two fields
swapped. Three regressions against unpatched upstream, all verified through the
real provider stack on both the `openai-compatible` and `openai` paths:

- A repeated `id` (including `''`) across two calls at distinct indices merged
  them into one call with concatenated, invalid JSON input — the #1976 symptom
  itself. With arguments split across chunks the fragments interleaved
  (`{"path"{"path":"a"}:"b"}`), which the previous test's `includes('}{')`
  assertion could not see.
- `id: ''` on a continuation delta read as a new identity with no name and
  killed the whole turn. Gateways send `''` instead of omitting the field.
- A delta carrying neither field was absorbed by `activeToolCall`, silently
  handing one call's arguments to another. Unpatched upstream throws here.

`id` is exactly as untrustworthy as `index`. Neither is accepted alone now:
when both are present the index selects the slot and the `id` decides whether
that slot still holds the same call, so a repeated `id` at another index cannot
absorb a delta and a repeated index under a new `id` cannot merge. `''`
normalizes to absent. A delta with neither field is attributable only when one
call is open; with several it is undecidable and falls through to fail on the
absent `id` rather than guessing.

This drops `activeToolCall` entirely — with guessing removed it has no job —
and records the creation index on each call instead, which collapses the
ordering pass to one stable sort. The redundant `byIndex` write in
`processNewToolCall` is gone; `processDelta` already covers every path to it.

Tests: 5 new cases (repeated `''` and `'dup'` ids, empty-id continuation, and
two on the previously uncovered `openai` path). The bare-delta case now asserts
the failure it used to assert an attachment for. Merge detection is a property,
`assertInputsSelfContained` — every emitted input must parse alone — because
the old exact-output check missed interleaving.

`patches/README.md`: the stated deletion condition was unsatisfiable, since two
cases assert an undecidable shape fails and a genuinely fixed upstream would
make them pass, turning the guard red for a better implementation. It now reads
by property. Also corrected: the patch survives a dependency bump only as a
blocked install, and only the `dist/` half is load-bearing.

Refs #1976, #1967.
Second review round found the previous rule still merged distinct calls, on
both provider paths, in the intersection of two shapes this patch already
treats as real: a gateway that reuses one index AND repeats or blanks the id.
Then `index` and `id` both agree and `function.name` is the only discriminator
left, so ignoring it produced concatenated invalid JSON — verbatim the #1976
symptom the patch exists to close.

The dup-id tests added last round certified that shape as fixed while hiding
it, because they asserted only `{toolName, input}` and projected `toolCallId`
away. That field decides the outcome: `tool-runtime.ts` derives `operationId`
from it, so two calls sharing an id collide and the second `commitToolPrepared`
is rejected after the first tool's side effects have run, and an empty id throws
out of `runtime-commit-sink.ts` before the call is recorded at all. A test named
`keeps calls distinct` passed while the calls were indistinguishable everywhere
that matters.

A delta now continues a call only when every identifying field it carries
agrees — `id` with `''` read as absent, and `function.name`. Candidate selection
still comes from the strongest alias present (index, then id, then the single
open call), so this is one predicate over three lookups rather than three
bespoke conditions. An id that cannot address a call — absent, empty, or taken —
is replaced with a generated one, which also makes upstream's `_generateId()`
fallback reachable for the first time; it was dead behind a throw.

Ordering also required uniqueness, not just presence: a duplicate index no
longer orders anything, and a missing one turns the comparator into NaN
comparisons that scramble instead of preserving.

Deletions, all verified by mutation rather than asserted:
- The `src/` half of the patch. Nothing compiles it and `exports` resolves to
  `dist`, so it was a second implementation to keep in sync, and it had already
  drifted. Patch drops from 246 to 120 lines.
- Three redundant cases: the `index: 7` arm (identical path to `index: 1`), the
  two-call reused-index arrival-order case (already pinned by the Ollama-shape
  deepEqual), and the `''` arm of the different-index dup-id loop (detected no
  mutation the file misses).

Tests: 23 cases, every one carrying detection power confirmed by mutating the
tracker. Both zero-coverage load-bearing branches are now pinned — the `byId`
lookup, and the index-presence guard in ordering. The latter needed five calls
with arrival order unlike index order; at three, V8's insertion sort leaves a
scrambling comparator looking correct, so the smaller case I first wrote proved
nothing. Merge and addressability are properties (`assertInputsSelfContained`,
`assertToolCallIdsUsable`) asserted by every multi-call case.

`patches/README.md`: corrected a self-contradiction (claimed neither field is
accepted alone, then described id-only and index-only lookups), restated the
deletion condition to include duplicate and empty ids, and noted that
`apply-dependency-patches.mjs` deliberately skips trees where `patch-package` is
unresolvable, so "blocked install" is not unconditional.

Refs #1976, #1967.
The previous round replaced an unusable wire `id` with a generated one and
stored only that, then compared the wire `id` a later delta carried against
it. Those can never be equal, so a gateway that repeats one `id` across calls
and echoes it on their continuations made every continuation unresolvable: it
became a new call with no `function.name` and threw, killing a turn upstream
had survived. Two more shapes died the same way, an `id` or an `index` that
arrives only on a later delta.

A record now keeps the aliases the wire used for it — `index`, and `id` with
`''` read as absent — separately from the `id` we emit, and a delta resolves
to the most recently created call every alias it carries agrees with. An alias
the candidate has never seen cannot disagree, and at least one must actually
match, so an index no call has claimed is not absorbed. That replaces the two
single-valued lookup maps, which could not hold a repeated alias at all, and
drops the priority order between them: contradicting aliases now resolve to
nothing rather than to whichever field was consulted first.

The guard asserted only the final `tool-call`, so every `tool-input-*` enqueue
could be deleted from the tracker with all of it still green. It now asserts
the event contract as a property — ordered, same id, deltas reconstructing the
input — and drops two cases that detected nothing. Shapes where one alias is
shared by two open calls cannot be demultiplexed at all; they get their own
suite asserting what does hold, because `patches/README.md` names input
self-containment as a deletion criterion and it was never true for them.

Refs #1967, #1976
The previous commit dropped both late-alias bindings on the grounds that no
wire shape needed them. The evidence for that was "delete them and the suite
stays green", which is the same reasoning this PR rejected one commit earlier
when it deleted two tests for detecting nothing: green means the guard is
silent, not that the code is idle. Both deletions were wrong.

A record that never learns an alias stops being addressable by it. When a
call's first delta omits `id`, a later delta supplies it, and a third carries
only that `id`, the lookup finds no record; with another call open the bare
delta fallback does not apply either, so the turn dies on the missing
`function.name`. And `flush()` orders by index only when every record has one,
so a call that supplies its index on a continuation left the whole turn in
arrival order — reversing the tool execution order the gateway asked for.

Both shapes are now cases, and each kills exactly the binding it needs.

Refs #1967, #1976
Round four found #1976 still open on its own headline shape. Three calls all
labelled `index: 0` with the tool name repeating — read, write, read — emitted
two calls: the first with `{"path":"p0"}{"path":"p2"}`, unparsable, and the
third gone with no error. Four alternating calls lost two. Both provider paths.

Resolution walks the records newest-first, and a `function.name` mismatch was
skipping a candidate and continuing the scan. So the delta ran past the current
claimant of index 0 and reached the `read_file` record whose claim that call had
already superseded, filing a whole new call as its continuation. A name that
disagrees with the claimant now rejects the delta instead, which starts the new
call the gateway asked for. This is not one of the undecidable shapes: index 0
was demonstrably reclaimed. Every case until now used two distinct names, which
is why three rounds of review and 25 mutations never saw it.

Two more shapes were worse than the code being replaced, both now fixed by one
narrower fallback: an `index` or an `id` arriving alone on a continuation, which
upstream resolved by array slot. The fallback fires when exactly one call exists
and no alias the delta carries disagrees with it, so an alias that contradicts
the lone call still fails rather than being absorbed.

Deleted: `findByAlias`'s leading early return, provably unreachable as a
decision — no alias means no match either way.

Guard additions, each killing a mutant that survived: the three-call repeated
name on both paths; an alias arriving alone; a differently named call with no
alias, which the fallback must not absorb; `index: null`, which
`@ai-sdk/openai-compatible` declares legal and which must not sort ahead of
real indices; and an id and an index that each disagree with the lone open
call. `assertEventLifecycle` now checks that the start announces the name the
call runs, and only tolerates an unfinished call when the turn actually failed —
a dropped call takes exactly that shape.

Refs #1967, #1976
…entity

`id` normalized `''` to absent; `function.name` did not. `name` is the field
that rejects a match rather than proposing one, so a gateway that blanks it on
a continuation delta — the same gateways that blank `id` — had its fragment
rejected by the call it was continuing and filed under a new call named `""`.
Two calls became four, half of them unnameable. Unpatched upstream handled that
shape correctly, so the patch was making it worse.

Normalize every optional field once, at the top of `processDelta`, and let the
new-call path validate and store the normalized name too, so `''`, `null`, and
absent behave identically in all three places instead of two.

Also drop the `=== void 0` conjuncts guarding late alias learning: a call
returned by resolution has, for each alias the delta carries, either no value or
an equal one, so the assignment is already either learning or a no-op. Re-adding
them survives all 50 cases, which is what an equivalent mutant looks like.

Three shapes the guard did not reach, found by mutation rather than by review:
a blank name on a continuation and on a first delta, and a duplicated wire id
separated from its original by an unrelated call — minting scans every emitted
id, and checking only the previous one survived the whole suite.

`patches/README.md` claimed a reused index with a shared id and name kept both
calls. It loses one, exactly like the no-index shape. Both are now table rows
with cases behind them, along with the shape this patch invents a call for,
which is the price of stopping resolution at an alias's newest claimant.

Refs #1967, #1976.
…right

The loud-failure boundary was described only as "a reused index whose new call
has not sent its name yet". The same bytes also read as one call whose id
drifted between its own fragments, and upstream continues that correctly — by
never looking at id, which is also why it cannot separate two calls sharing an
index. One reading has to lose; the one with observed gateways behind it wins.

Refs #1976.
The last round normalized `''` to absent for `id` and `function.name`. A
whitespace-only value is the same thing with different bytes, and it was still
being read as a claim: `name: " "` disagreed with the call it was continuing, so
the fragment became a new call nobody can name, and `id: " "` contradicted its
own call and killed the turn. Reverse-applied upstream emits the correct single
call in both shapes, on both provider paths, because it never consults either
field — so the patch was again worse than what it replaces.

Same defect found twice in two rounds, so state the rule as a property of the
value rather than a list of literals: blank means absent, where blank is `null`,
`''`, or nothing but whitespace. One named helper at the single normalization
point, which also shortens both derivations.

The guard was blind to the whole axis: the fix survives all 50 prior cases as a
mutant. Cases now cover empty and whitespace on each of the two fields, and the
new-call path on both spellings. The `id`-and-`name`-together case is deleted —
the mutants it killed are a strict subset of the two single-field cases'.

`patches/README.md` claimed every optional field is normalized. Only the two
aliases are; `arguments` and `type` are deliberately untouched, and
`arguments: ''` still emits an empty `tool-input-delta` where absent emits
nothing. Narrowed to what is true and says why the rest is left alone.

Refs #1967, #1976.
Five review rounds argued about the association rule; two of them found the
same defect in the value domain of the alias fields. So that axis got measured:
2448 runs over ten base shapes crossed with empty, whitespace, invisible,
padded, case-shifted, long and single-character values at every position a call
starts or continues, plus pathological index values, each against the same shape
on reverse-applied upstream.

No failure class beyond the two already documented. Every shape the patch loses
is a blank name starting a call (deliberate throw) or a differing alias on a
continuation (deliberate new identity), and upstream wins them only by ignoring
the fields it would need to read to fix #1976.

Labelled as a one-off record rather than a guarantee, since it is not committed
and does not run in CI — the guard is what keeps holding.

Also: #1967's crash was understated. A negative or fractional index makes
upstream drop the call silently, and 1e6 kills the turn.

Refs #1967, #1976.
@Astro-Han
Astro-Han force-pushed the fix/1976-streamed-tool-call-identity branch from e8dfc7d to 1af7a0e Compare August 3, 2026 15:32
@Astro-Han
Astro-Han marked this pull request as ready for review August 3, 2026 15:37
@Astro-Han
Astro-Han merged commit d9baad7 into main Aug 3, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(runtime): streamed tool calls silently merge when an OpenAI-compatible provider reuses one tool_calls index

1 participant