dsh-llm-deepseek drops tool call id/name when provider streams explicit nulls (every tool fails: unknown tool "") #4671
Replies: 6 comments
|
Verified against rc.2 — your diagnosis is exact, and this is the newest member of a documented bug family the repo has been fighting for months. 1. Source confirmation
if (call.id !== undefined) block.callId = call.id
if (call.function?.name !== undefined) block.name = call.function.name
2. Family context: this is the wire identity-loss family's newest member (report ~12), and the first at the adapter boundary The same shape has been filed at least a dozen times across layers — serializer null/""-delta (#1713, #2090, #2169, #2343, #2540, #2855, #2997, #4265, #4365, #4370), assembler index-fusion (#4427), tool-call block re-keying (#4410), and now SSE translation (#4671). The through-line: every place two streamed deltas fuse into one block must treat identity as first-write-wins and null-skip. Your one-line loose-comparison fix ( 3. One layer deeper: first-frame facts The OpenAI-compatible streaming contract puts a tool call's // identity fields are first-frame facts; later frames (null or otherwise)
// must never overwrite them
if (block.callId === undefined && call.id != null) block.callId = call.id
if (block.name === undefined && call.function?.name != null) block.name = call.function.nameThis also defends against gateways that re-emit a differing (not just null) value mid-stream — the same class of proxy wildness behind #4618/#4620 (interleaved 4. Regression test
|
|
I built a bounded reference implementation against official Thank you @salaheddine-11 for the exact failing frames and source diagnosis, and @argszero for independently confirming the path and identifying the broader first-frame identity family. The patch models compatible-gateway Evidence:
Reference branch: https://github.com/Jstn-1g/deepseek-harness/tree/reference/discussion-4671-sse-null-identity Exact commit: Jstn-1g@138691e This is a reviewable reference implementation, not a claim of upstream adoption. |
|
Confirmed and reproduced through the real SSE path, with the frame sequence exactly as you describe it. Your root-cause line is precisely the bug: if (call.id !== void 0) block.callId = call.id;
FixBoth spellings — omitted and if (call.id !== undefined && call.id !== null) block.callId = call.id
const streamedName = call.function?.name
if (streamedName !== undefined && streamedName !== null) block.name = streamedNameWorth pairing that with the wire type, so the guard is honest rather than defensive: id?: string | null
function?: { name?: string | null; arguments?: string }That file already models The half that actually kills the turnWorth separating the two symptoms, because only one of them is fatal. #4611 covers an empty So this needs the adapter fix above regardless of what happens on #4611 — the identity has to survive where it is lost, not be reconstructed downstream. |
|
Thanks — the id/name split is an important correction to my framing, and it checks out at source. One detail the split exposed, on top of your wire-type point: the null-overwrite corrupts the accumulator state, not just the emitted frame. With
So the null-skip guard fixes both the wire violation and the closeBlock poisoning in one line. (My capture-once spelling — Third line of defense, worth noting for the family: the assembler boundary ( |
|
Taken, and pinned. You are right that the accumulator state is the part my test was not actually proving. I had been asserting only the assembled block, which passes for the wrong reason: the assembler merges names through a falsy The test now consumes the raw stream instead of the assembled result, and asserts per delta: for (const delta of deltas) {
expect(delta.id).toBe(CallId('call_abc'))
expect(delta.name).not.toBeNull()
}
expect(deltas.map(d => d.name)).toEqual(['get_weather', 'get_weather', 'get_weather'])with the Your three-layer framing is the right way to hold this family: adapter capture → accumulator state → assembler merge, with only the first missing here. Worth adding that the third layer is defence by coincidence rather than by contract — The fix itself is unchanged; this is coverage it always deserved and did not have. |
|
Adding the cross-adapter measurement to the excellent id/name-split analysis above: We fault-injected the real pi-ai openai-completions accumulator (client/SSE parser/assembly unmodified; only the wire bytes synthetic) with this exact delta family — empty-string id/name continuations, late identity, parallel calls with no ids at all (index-keyed interleave), and index-less id-keyed deltas: all assemble correctly. The semantics that make it immune: blocks are keyed by That measured rule (index-first keying + first-non-empty-wins) is a ready-made semantic for the |
Uh oh!
There was an error while loading. Please reload this page.
Summary
Tool calls streamed from OpenAI-compatible providers (proxies) that emit explicit
nullcontinuation frames lose theiridandnamein@deepseek-ai/dsh-llm-deepseek's SSE translation, so every tool call fails withError: unknown tool ""(ToolNotFoundError). The agent loops ~10 steps and exits 0 with an empty final answer.Environment
https://opencode.ai/zen/go/v1(modeldeepseek-v4-flash)Symptom
The
argumentsarrive intact; the identity fields (callId,name) are both empty.Root cause
The proxy (standard OpenAI streaming behavior, also used by many gateways) emits:
In
dsh-llm-deepseek/lib/index.js(translate function):null !== void 0istrue, so frame 2 overwrites the goodid/namecaptured in frame 1 withnull.closeBlockthen yieldsid: CallId(block.callId ?? "")andname: block.name ?? ""→ both empty. Arguments survive because they only accumulate (block.text += fragment).Verified with raw probes: non-streaming, streaming, and streaming-with-
thinkingresponses from the proxy all contain the name in the first frame; only dsh's fuse drops it.Suggested fix
Treat
nullas absent (one-line change, loose inequality covers bothundefinedandnull):Same guards likely needed in any other adapter that fuses OpenAI-style streamed tool calls (checked 0.1.1-rc.2: only
dsh-llm-deepseekhas this pattern).Workaround
Local patch of the installed
dsh-llm-deepseek/lib/index.js(2 lines above); note that a server restart is required for already-running web instances (module loaded in memory).All reactions