Skip to content

[BUG] Streaming tool-call argument deltas are silently dropped when arguments arrive before the tool-call id #695

Closed

Description

@awschmeder

Description

When a provider streams a tool call whose first delta(s) carry argument bytes
before the tool-call id is known, those leading argument bytes are silently
discarded by NativeToolCallParser.processRawChunk. The accumulator then begins
mid-JSON, so the finalized arguments are incomplete or unparseable. Downstream
this surfaces as a misleading "missing required parameter" error even though the
model did supply the parameter.

This is the streaming-path root cause behind intermittent tool-call failures. Here the data is lost in transit during
streaming reassembly, so by the time finalization runs the parameter genuinely
is absent from the accumulated JSON.

Related issues

Root Cause

In src/core/assistant-message/NativeToolCallParser.ts, processRawChunk
(approx. lines 99-164) keys its rawChunkTracker by the stream index but only
initializes a tracker entry when a chunk carries an id:

let tracked = this.rawChunkTracker.get(index)

// Initialize new tool call tracking when we receive an id
if (id && !tracked) {
    tracked = { id, name: name || "", hasStarted: false, deltaBuffer: [] }
    this.rawChunkTracker.set(index, tracked)
}

if (!tracked) {
    return events // <-- any `arguments` on this chunk are dropped permanently
}

If the first chunk for a given index contains arguments but no id, the
early return discards those bytes. The existing deltaBuffer only buffers
deltas that arrive after a tracker exists (between tracker creation and the
tool_call_start emission); it cannot help pre-id deltas because no tracker
exists yet to hold them.

Failure chain

  1. Leading arguments fragment arrives without id -> dropped at the early return.
  2. id arrives in a later chunk -> tracker created, accumulation starts mid-JSON.
  3. finalizeStreamingToolCall cannot recover the missing leading keys -> returns
    null or an object missing the first parameter(s).
  4. Task.ts (approx. line 2817) marks the tool complete with missing params;
    the tool guard reports "missing required parameter" -- a misleading symptom.

Provider Scope

The fix is centralized: every provider funnels tool deltas through the single
processRawChunk function, so all of them benefit. The most exposed providers
are the raw-passthrough ones that forward id/name/arguments exactly as
received from upstream:

  • lite-llm.ts (proxies arbitrary upstream models -- highest exposure)
  • the OpenAI-compatible family (openai.ts, openrouter.ts, requesty.ts,
    unbound.ts, deepseek.ts, vercel-ai-gateway.ts, zoo-gateway.ts,
    base-openai-compatible-provider.ts, etc.)

openai-native.ts and openai-codex.ts already self-guard by withholding
tool_call_partial until a name is known; they are not currently susceptible,
and the parser fix makes that defensive logic redundant (cleanup is optional and
out of scope here).

This is a known, documented limitation

The weakness is already called out in code comments at the two providers that
work around it -- confirming the parser, not the providers, is the correct place
to fix it:

  • src/api/providers/openai-native.ts (~line 1235):

    // Avoid emitting incomplete tool_call_partial chunks; the downstream
    // NativeToolCallParser needs a name to start a call.
  • src/api/providers/openai-codex.ts (~line 954):

    // Codex/Responses may stream tool-call arguments, but these delta events are not guaranteed
    // to include a stable id/name. Avoid emitting incomplete tool_call_partial chunks because
    // NativeToolCallParser requires a name to start a call.

These guards prevent the drop for those two providers but were never added to the
raw-passthrough providers (LiteLLM, OpenAI-compatible family), which is precisely
why those remain exposed. Fixing processRawChunk resolves the limitation
centrally and renders both guards redundant.

Affected Files

  • src/core/assistant-message/NativeToolCallParser.ts (processRawChunk, ~99-164)

Proposed Fix

Initialize the tracker on first sight of an index, independent of id:

  1. Create the rawChunkTracker entry when an index is first seen, with id
    left optional until it arrives.
  2. Buffer all arguments into deltaBuffer while hasStarted is false (covers
    both pre-id and pre-name cases).
  3. Record id and name when they arrive; emit tool_call_start and flush the
    buffered deltas only once both id and name are known.
  4. Keep the downstream event sequence identical so no provider or Task.ts
    changes are required.

Test Procedure

  1. Add a parser-layer unit test in
    src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts that feeds
    processRawChunk a leading chunk with arguments and no id, followed by a
    chunk carrying id and name, and asserts the full argument string is
    preserved through finalizeStreamingToolCall.
  2. Verify existing provider specs that drive processRawChunk directly still
    pass (lmstudio-native-tools.spec.ts, qwen-code-native-tools.spec.ts,
    openrouter.spec.ts).
  3. Manual: reproduce a tool call via LiteLLM in F5 development mode and confirm
    no spurious "missing required parameter" error.

Activity

  1. leesilverman commented on Sep 8, 2026

    @leesilverman

    Caveat up front: I am a power-user, not a developer. I am trying to help by providing with real-world data from my own fleet (several dev containers plus my workstation running Zoo Code). Happy to run experiments or tests on my own setup and report back. I am writing this comment using zoocode + Spark 1.3. (This model can be quite terse, so please ask if you need more detail.)

    Corroboration for the pre-id delta-drop path on raw-passthrough routing:

    Fleet shape: same 24 of 24 live faults with "input": {} byte-identical across write_to_file, execute_command, and attempt_completion, non-deterministic, at any token level. Below-limit by construction — faults strike ordinary mid-chat turns (loop-chat cumulative tokensOut 4K-13K vs 32K per-request ceiling), so the mechanism must be ordering-dependent, never usage-dependent. Pre-id argument-delta loss predicts exactly that.

    Routing exposure: every fleet call routes exclusively through LiteLLM v1.99.1 with no provider-side incomplete-chunk guard. Your body already names this gap: openai-native.ts and openai-codex.ts withhold incomplete tool_call_partial chunks until a name is known, but those guards were never added to the raw-passthrough providers (LiteLLM plus the OpenAI-compatible family) — precisely the path every fleet call takes. We are the exposed class with no guard in front of the parser.

    Proxy-excluded control: LiteLLM proxy ruled out as an arg-stripper on all three surfaces — tool-policy cannot strip (allow-through, zero blocks), compression/cache 0-hit under load and never touches request args, passthrough preserves 200 OK with spend rows intact. That isolates the loss to client-side streaming reassembly, which is the layer this issue attributes it to.

    Version carry, no released fix: identical fault reproduces across every running version from 3.81.100441 through 3.83.100458 (workstation active 3.83.100457, one container host already on 3.83.100458).

    Offer to test: happy to test any pre-id buffering build (tracker init on first index sighting, buffer pre-id/pre-name deltas, flush once id plus name known) and to report fault rate plus any double-present or dropped-call regression on my setup. I considered but decided against patching the minified bundle locally. Waiting for a merged, released build. But I will run whatever test build or spec you point me at.

    Not filing a new issue — adding fleet signal to this thread only. No fix claimed.

    Environment signature: written using zoocode 3.81.100441 + Spark 1.3.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions