An empty id/name in a later tool-call delta overwrites a good one: every call becomes unknown tool "" #2979
Replies: 1 comment
|
Correction: I pointed at the wrong adapter above, and two new facts narrow it further. 1. The active code path is That adapter guards both fields the same way: if (call.id !== undefined) block.callId = call.id
if (call.function?.name !== undefined) block.name = call.function.name
if (call.id) block.callId = call.id
if (call.function?.name) block.name = call.function.name2. The asymmetry noted in the report still stands as a separate latent case: |
Uh oh!
There was an error while loading. Please reload this page.
On
0.1.0-rc.7, every tool call fromdeepseek-v4-flashstarted failing withError: unknown tool "". The arguments arrive intact; only the call'sidandnameare empty by the time the tool layer sees them. Switching the same session todeepseek-v4-profixes it immediately, so this is specific to how one model's stream is shaped — but the part worth fixing looks like it is on the harness side, because the same accumulator already guards one field and not the other.Two things make it costly beyond the failed call: the agent cannot recover, and it blames itself. Its own reasoning reads
and the next call is empty again, because the name it emitted was never the problem.
repeat-tool-reminderthen fires, which reads to a user as the agent being stuck in a loop.What the frames look like
From the session log, one
bashcall (turn 1, step 4). These are the harness's own chunk vocabulary — post-translation, not raw provider SSE; see the caveat at the end.{"type":"block-start","index":2,"blockType":"tool-call"} {"type":"tool-call-delta","index":2,"id":"call_fe70060caf4d47938d2ae88d","name":"bash","argumentsDelta":""} {"type":"tool-call-delta","index":2,"id":"","name":null,"argumentsDelta":"{"} {"type":"tool-call-delta","index":2,"id":"","name":null,"argumentsDelta":"\"command\": \"pwd && ls -la\""} {"type":"tool-call-delta","index":2,"id":"","name":null,"argumentsDelta":", \"description\": \"Show working"} {"type":"tool-call-delta","index":2,"id":"","name":null,"argumentsDelta":" directory and list files\"}"} {"type":"block-end","index":2,"block":{"type":"tool-call","id":"","name":"", "arguments":"{\"command\": \"pwd && ls -la\", \"description\": \"Show working directory and list files\"}"}}and the result:
{"type":"tool/call","data":{"turn":1,"step":4,"callId":"","name":"", "arguments":"{\"command\": \"pwd && ls -la\", ...}"}} {"type":"tool/result","data":{"error":{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}, "...":"Error: unknown tool \"\""}}The first delta carries
idandnamecorrectly. The arguments accumulate perfectly across every later delta. Only the identity is lost.Where it looks reachable
Per the OpenAI-compatible streaming contract,
idandfunction.nameappear on the first delta for an index and later deltas carry argument fragments only — so correlating byindexand caching the identity, which is whatpackages/llm/llm-pi-ai/src/stream.tsdoes, is right. What is not covered is a provider that sends an empty string rather than omitting the field.Two places treat that case differently, and the inconsistency is the tell:
packages/llm/llm-pi-ai/src/stream.ts— the name is guarded on length, the id is not:packages/llm/llm/src/assembler.ts:70-71— the same asymmetry, the other way round from what a reader expects:The assembler's
idline is masked in practice becauseblock-endreplaces the accumulated block wholesale (partial.block = chunk.block), but it is the same latent defect on a second path.A one-line rule would cover both: an empty value never replaces a known one.
What I have not proven
I did not capture the raw SSE from the provider, so I cannot say whether
deepseek-v4-flashsendsid: ""andfunction.name: ""or omits them. Theid: ""visible above is the harness's own re-emission (CallId(known?.id ?? '')always produces a string), which hides the provider's shape. If the provider omits them, the guards as written already handle it and the emptiness originates inside pi-ai'sevent.toolCallattoolcall_end— worth checking either way, sincetoolcall_endis where the final block'sid/namecome from.Either way the harness ends up trusting a value it received as empty, and the failure mode is one an agent cannot work around.
Timeline, in case it helps date the provider-side change
All on one deployment, same image, same session lineage:
bash)unknown tool ""Tools that had been working that morning:
bash,read,read_image,skill,web_search. Nothing inllm-pi-ai/src/stream.tschanged between rc.6 and rc.7, so the trigger is very likely provider-side; the harness's exposure to it is what this report is about.Environment
dsh
0.1.0-rc.7, providerdeepseek-official, modeldeepseek-v4-flash(broken) vsdeepseek-v4-pro(fine). Linux container, agent presetstandard.CONTRIBUTING says external pull requests are not being accepted at the moment, so this is a report rather than a patch. Happy to supply the full session log frames if useful.
All reactions