dsh-toolcall-id-repair
IdentifiedStops session lockups from OpenAI-compatible gateways that stream empty tool-call ids: intercepts llm/stream chunks in memory to synthesize consistent call_<sha256> ids across tool-call-delta and bloc
dsh-toolcall-id-repair
Repair and prevent a session-bricking bug in DeepSeek Harness (dsh) 0.2.x.
The bug in one paragraph
When an OpenAI-compatible provider streams a tool call, pi-ai stores the call's
id as toolCall.id || "". The harness's stream validator only checks
typeof chunk.id === 'string', and "" satisfies that — so an empty id passes
validation, reaches the persisted session log, and is then rejected by the
session-format invariants. The turn dies with:
format v4 tool/result at seq N requires toolCallId matching its tool source
Neither side is wrong on its own. Upstream deliberately preserves empty adapter
identities (see packages/llm/llm/tests/assistant-stream.spec.ts, the test named
"preserving JSON-safe empty adapter identities"), while the persistence layer
requires a non-empty id (packages/session/session-format-v3-to-v4/src/relationships.ts).
The two together brick the session, and the turn cannot recover: the assistant
message is already committed and the matching tool/result can never be appended.
Verified against deepseek-harness at 0.2.1-alpha.5. As of that revision the
inconsistency is still present.
Which providers trigger it
Any OpenAI-compatible backend that omits tool-call ids. Confirmed in the wild on an aggregator that routes to free-tier models: the failure rate rose with prompt size, from 0.4% under 8k tokens to ~36% past 64k. Paid, larger-context routes on the same gateway showed zero failures across ~2000 calls.
What's in here
| File | Purpose |
|---|---|
tools/guard-plugin.js | A cordis plugin that fills empty ids on the llm/stream waterfall. Install once, and the bug stops happening. |
tools/repair-session.py | Rewrites the three id sites in sessions that are already bricked. |
tools/verify-session.mjs | Validates session logs using the harness's own validator. Use it to confirm a repair. |
Why three id sites
An empty id is persisted in three places, and each one is independently fatal. All three carry the same id for a given call, so all three must be rewritten together:
assistant/message→message.content[].idRejected on read:tool call id requires a nonempty stringassistant/message→data.stream[].chunk(the embedded replay) Rejected on the next append:content disagrees with its embedded streamtool/call→data.callIdMakes the matchingtool/resultunappendable
Fixing only (1) and (3) is the dangerous case: the log reads back fine and looks repaired, then fails on the next append. A previous repair attempt in the wild made exactly this mistake across five sessions.
Suggested fix for the harness itself
The inconsistency is visible inside a single file. In
packages/llm/llm/src/assistant-stream.ts, two records carry the same data
under different rules:
| Record | Line | Rule for id |
|---|---|---|
tool-call-delta | 138 | if (chunk.id.length === 0 || chunk.name === '') — kept as a chunk, empty id allowed |
tool-call-chunks | 477 | if (typeof record.id !== 'string' || record.id.length === 0) throw — non-empty required |
So the compact form forbids what the delta form permits, and only the delta form
is reachable from a live provider stream. Tightening line 138 to reject an empty
id — or synthesizing one there — would close the bug at its source. Until that
lands, this plugin does the same job from the llm/stream waterfall, which is
the supported interception point.
Install the guard
Copy tools/guard-plugin.js somewhere permanent, then register it in your
profile's patch layer. For the Desktop profile that is
$DSH_HOME/profiles/desktop/cordis.patch.yml; add to the trailing insert list:
- id: toolcall-id-guard
name: '/absolute/path/to/guard-plugin.js'
An absolute path is resolved to a file:// URL and does not need to be installed
as a package. Note that $DSH_HOME/config.yaml is a dead file in 0.2.x —
nothing reads it. Registering there silently does nothing, which is an easy and
invisible mistake.
Restart the app to be sure the plugin is loaded. Confirm with the audit log:
cat "$DSH_HOME/toolcall-id-guard.log"
mounted pid=<n> means the plugin is live. Each id it fills appends a filled
line, so the file doubles as a record of how often your provider misbehaves.
Repair an already-bricked session
# Dry run first: prints the plan and writes nothing.
python3 tools/repair-session.py "$DSH_HOME/sessions/<escaped-cwd>/<session-id>"
# Then apply. A backup lands in $DSH_HOME/session-repair-backups/ first.
python3 tools/repair-session.py --apply "$DSH_HOME/sessions/<escaped-cwd>/<session-id>"
# Or sweep everything:
python3 tools/repair-session.py --apply --all
Requires Python 3 with zstandard.
message.content[].id is treated as authoritative: where it is missing an id is
synthesized, and where the embedded stream disagrees the stream is rewritten to
match. A tool/call is paired to its block by (turn, step, name, arguments) —
all four, so parallel calls sharing a name still pair by their distinct argument
strings. Synthesized ids are deterministic (call_ + 24 hex from session, turn,
step and block position), so re-running is a no-op.
The tool refuses to write unless it can reproduce every row byte-for-byte through its own serializer, and it verifies that exactly the rows carrying a bad id changed — nothing else.
Verify
node tools/verify-session.mjs "$DSH_HOME/sessions/<...>/<session-id>"
node tools/verify-session.mjs --all
This calls the harness's real sessionFormatCatalog.createRestore rather than
reimplementing the rules, and additionally checks append safety (no empty
tool-call ids anywhere).
Do not validate with zlib.zstdDecompressSync. dsh logs are concatenated
zstd frames, one per flush, and that function decodes only the first frame. A
scanner built on it reported a 646-event session as having 0 events and printed
ALL CLEAN. verify-session.mjs shells out to zstdcat for this reason.
Verifying the bug in your own setup
To confirm a provider is at fault before changing your config, ask it for a streamed tool call and inspect the raw id:
curl -sN http://127.0.0.1:3425/v1/chat/completions \
-H 'Content-Type: application/json' -H 'Authorization: Bearer magpie' \
-d '{"model":"<your-model>","stream":true,
"messages":[{"role":"user","content":"Call the bash tool with: echo hi"}],
"tools":[{"type":"function","function":{"name":"bash",
"parameters":{"type":"object","properties":{"command":{"type":"string"}},
"required":["command"]}}}],"tool_choice":"auto"}' \
| grep -o '"tool_calls":\[[^]]*\]'
An "id":"" in the output reproduces the bug. Note it is intermittent — one
clean response proves nothing. Prompt size matters: reproduce with a long
context, since that is where it concentrates.
Transcript shape that bricks a session
For reference, the failure always looks the same in the log: the turn ends on a
tool/call whose callId is "", with no matching tool/result, and every
tool-call block in that assistant message shares the empty id. One message, all
calls blank — not a per-call flake.
Plugin Installation (DSH Store / Cordis)
This repository is packaged as a standard DeepSeek Harness plugin (@dsh-local/dsh-toolcall-id-repair).
DSH Store
Install directly from DSH Store or by searching dsh-toolcall-id-repair.
Manual Cordis Installation
Add the bundle patch to ~/.dsh/cordis.patch.yml:
- insert:
- id: toolcall-id-repair
name: '@dsh-local/dsh-toolcall-id-repair'
Permissions & Safety Boundary
| Permission | Level | Details |
|---|---|---|
| Files | None | Pure in-memory stream normalizer. Zero filesystem reads or writes. |
| Network | None | No outbound requests, fetch, or sockets. |
| Commands | None | No subprocesses spawned. |
| Credentials | None | Does not read environment variables, API keys, or tokens. |
- Security Rating: Low risk, static stream transform.
- Profiles:
web,desktop,cli. - System Requirements: Node
^22.19 || >=24, DSH>=0.1.2.
Compatibility Evidence
| DSH Release | Status | Evidence |
|---|---|---|
0.2.1-rc.5 | compatible | In daily production use on local DSH host. |
0.2.2-alpha.2 | compatible | Verified against published npm closure (ctx.on('llm/stream') waterfall contract unchanged). |
0.2.2-alpha.3 | compatible | Verified against published npm closure. |
0.2.2-rc.1 | compatible | Verified against published npm closure. |
License
MIT — see LICENSE.
This project's own code is released under the MIT License. The harness itself is MIT-licensed as well; the file paths and invariants quoted above come from that project, and the guard hooks into its published extension points rather than patching it.
Comments
Loading…
Similar plugins
by alchemistwu
Neutralize tool calls with invalid JSON arguments on the wire so one malformed model generation cannot brick a session against strict OpenAI-compatible servers.
★ 0
↓ 68/wk
MIT
JavaScript
Aug 31, 2026
dsh plugin --profile web add dsh-tool-call-guardby snow-The
Scans and repairs DSH session logs: re-encodes single-frame corrupt zstd files back to the multi-frame layout and marks unknown event types (e.g. llm/failover) as ignorable so older harnesses can load
★ 0
MIT
TypeScript
Nov 1, 2026
dsh plugin --profile web add @snow-the/dsh-session-repairby Zn-Dk
Diagnose and safely repair corrupted DSH session history: raw zstd/JSONL validation, tool-call ID repair, single-slot pre-repair backup, restore, and an audit trail.
★ 4
↓ 322/wk
MIT
TypeScript
Sep 29, 2026
dsh plugin --profile web add dsh-session-repairby Equinox7379
Session log repair tool for DSH: scans zstd session logs for unknown event types and marks them ignorable with a compliant frame rewrite (backup included). Read-only scan by default.
★ 0
MIT
JavaScript
Aug 15, 2026
dsh plugin --profile web add dsh-session-repairby EIGHTfs
Session management for DSH: archive/restore, batch & conditional delete, AI auto-rename, interrupted-session auto-resume, workspace grouping, undo last message, repair damaged session logs (seq-gap/EI
★ 1
NOASSERTION
HTML
Oct 21, 2026
dsh plugin --profile web add dsh-session-conductorby Jessicaisleep
Strips DSML markers that leak into the assistant text stream and resumes a turn only when it genuinely produced nothing — an invoke-block leak with no tool call emitted, or a closing-tag leak whose pr
★ 2
MIT
JavaScript
Oct 28, 2026
dsh plugin --profile web add dsh-dsml-guard