Back to home@udsy19

dsh-toolcall-stream-repair

DeepSeek Harness plugin: repairs malformed streaming tool-call deltas before they reach the block assembler

Stars
0
Language
TypeScript
Created
Aug 24, 2026
Updated
Aug 24, 2026
GitHub repo

Introduction

dsh-toolcall-stream-repair

A DeepSeek Harness plugin that repairs malformed streaming tool-call deltas from OpenAI-compatible gateways — before they assemble into a call the harness cannot dispatch.

If you have seen unknown tool "", a session that refuses to resume after a tool call, or two tool calls whose arguments got concatenated into one unparseable blob, this plugin targets that failure at its source.


What actually goes wrong

Two shipped adapters reach the same broken end state by different routes. Both are covered by fixtures.

llm-pi-ai

The harness assembles a streamed assistant message with BlockAssembler (packages/llm/llm/src/assembler.ts). Two lines make a malformed gateway response permanently corrupting:

// assembler.ts:71 — unconditional: a later empty id overwrites a good one
partial.toolCallId = chunk.id

// assembler.ts:112-117 — the intended fallback cannot fire for the EMPTY STRING
case 'tool-call': return {
  type: 'tool-call',
  id: partial.toolCallId ?? CallId(`call-${index}`),
  name: partial.toolCallName ?? '',
  arguments: partial.toolCallArguments,
}

llm-deepseek — the adapter behind every user report linked below

It reaches the same end state without touching those lines at all. translate.ts:99-105 closes every open block on [DONE], and assembler.ts:107-108 returns a pre-closed block verbatim — so assembler.ts:71, :72 and the :114 fallback never run. The empty identity is introduced upstream, at translate.ts:159-160:

if (call.id !== undefined) block.callId = call.id
if (call.function?.name !== undefined) block.name = call.function.name

undefined is guarded; '' is not. And because it re-assigns on every frame, a later "" erases an id that already arrived. The corruption therefore reaches you inside an authoritative, already-closed block. This plugin handles that because it treats an incoming block-end as evidence and emits a freshly constructed one, never forwarding the adapter's object.

Adapters verified. llm-deepseek and llm-pi-ai, both at deepseek-harness b150a551b, each with its own fixture family and negative controls. The llm-deepseek fixtures are driven through a byte-identical copy of the shipped translate.ts, so they reproduce the real adapter rather than a model of it. On llm-deepseek six of the eleven repair codes are reachable — the other five describe stream-grammar faults translate.ts cannot produce. Any other OpenAI-compatible adapter is expected to work but is not covered by a fixture.

?? catches null and undefined, not ''. And the shipped pi-ai adapter produces exactly '' when a gateway omits an id or a name (packages/llm/llm-pi-ai/src/stream.ts:160-161, partial?.type === 'toolCall' ? partial.id : ''). So an absent identity becomes an empty identity, survives every guard, and reaches the session log as a tool call with name: "".

This plugin sits on the llm/stream waterfall, which runs between the adapter and the assembler (packages/core/agent-loop/src/agent.ts:346-352). That position matters: the agent loop appends each post-waterfall chunk to the session log as assistant/chunk before assembly, so repairing here fixes the durable replay record too — not just the in-memory message.

adapter → [ llm/stream: this plugin ] → agent loop → assistant/chunk log → BlockAssembler → assistant/message

Install

dsh plugin --profile <name> add dsh-toolcall-stream-repair

Then restart the profile — bundle membership is fixed at profile start (apps/cli/reference/README.md:55).

Pin your harness packages. The latest dist-tag on several @deepseek-ai/dsh-* packages points at a stale 0.0.1-rc.1 placeholder while the real release sits on next (verified 2026-08-23: @deepseek-ai/dsh-llm latest → 0.0.1-rc.1, next → 0.1.1-rc.2). This package therefore declares harness packages as peer dependencies with explicit || ranges and never as dependencies — a duplicate Cordis would give the plugin a different, empty service registry.

Configure

Defaults are in cordis.patch.yml; override them in your profile's cordis.patch.yml.

KeyDefaultMeaning
onUnrecoverableToolCallfail-requestA tool call that never received a name cannot be dispatched. fail-request replaces the response with a terminal error finish, which the loop reports through agent/request-error — so dsh-llm-retry can retry it — instead of logging an undispatchable call. passthrough restores stock behaviour.
splitOnIdCollisiontrueGive a second call announced at an already-occupied block index its own block, instead of letting its arguments bleed into the first.
closeTruncatedStreamtrueEmit a terminal error finish when the upstream stream ends with no terminal chunk at all.
logRepairstrueLog each applied repair at debug level with its stable code.

What it handles

Every row has a fixture in tests/fixtures/ and a negative control — a test that asserts the corruption is present without the fix. Remove a repair and its paired assertion flips.

CodeMalformed shapeWithout this pluginWith it
unrecoverable-nameGateway sends no function.name on any frameAssembles name: ""unknown tool "", logged and unresumableTerminal error finish; nothing corrupt is logged; retryable
synthesized-idGateway sends no id on any frameAssembles id: ""; result cannot be paired with its callMints call-<index>, the value assembler.ts:114 already intended
empty-id-clobberA continuation delta carries id: "" after a good oneassembler.ts:71 overwrites the good id with ""Keeps the established id
id-collision-splitTwo distinct calls share one tool_calls[].indexOne block; arguments concatenated into invalid JSON; one call lostTwo blocks, correct ids/names, each arguments separately parseable
corrected-block-endTerminal frame carries a weaker id/name than the deltas didThe authoritative block-end overwrites good identity with ""Restores identity from the deltas
synthesized-block-startDeltas arrive with no opening blockFails the harness's own stream grammarSynthesizes the opening block
duplicate-block-startAn index is opened twiceFails the harness's own stream grammarDuplicate suppressed
straggler-droppedA delta arrives after its block closedFails the harness's own stream grammarDropped, matching what the assembler already does
synthesized-block-endTerminal finish arrives with a tool-call block still openFails the harness's own stream grammarCorrected block-end synthesized first
truncated-streamUpstream iterator ends with no terminal chunkLlmRuntime.adapterStream returns without one (index.ts:958-961); the assembler reports a clean stop for a half-written callTerminal error finish
recovered-nameA continuation delta omits name(Harmless — the assembler already guards this at :72)Reuses the established name

"Fails the harness's own stream grammar" means validateStream (packages/llm/llm/src/invariant.ts:36-84) reports a violation. That validator is installed on llm/stream with { global: true, prepend: true }, i.e. as the outermost waterfall listener — so it validates what this plugin emits. The test suite asserts every repaired fixture produces zero violations against a transcription of that exact grammar.

Two control fixtures assert that a well-formed stream passes through byte-identically with zero repairs reported.


What it does not handle

Stated plainly, because a repair tool that overstates its reach is worse than none.

  • It cannot invent a tool name. If no frame in the whole response names the tool, the call is unrecoverable. The plugin converts silent corruption into a loud, retryable error — it does not reconstruct the call. Nothing can.
  • Two nameless, id-less calls at one index are indistinguishable. The split relies on a non-empty id changing. With no ids at all, there is no signal that a second call started, and the arguments still concatenate.
  • max-tokens truncation is deliberately left alone. BlockAssembler already drops tool calls under max-tokens (assembler.ts:136-138); this plugin only closes the block so the stream stays well-formed.
  • Mid-stream adapter failures were already handled. LlmRuntime.adapterStream converts an adapter throw into a terminal error finish (packages/llm/llm/src/index.ts:940, 955). The truncated-stream repair covers the narrower case of an iterator that ends silently, which that code path returns from without synthesizing a finish. Neither shipped first-party adapter reaches it: llm-pi-ai and llm-deepseek (llm-deepseek/src/sse.ts:35-38) both throw STREAM_CLOSED on a truncated body rather than returning silently, and that throw propagates through this plugin. This repair is insurance for third-party adapters, not something either shipped adapter needs.
  • A consumer that ignores an error finish still sees the partial. On fail-request, deltas already forwarded remain in the stream. The agent loop never reads blocks() after an error finish (agent.ts:372-390) and the supported partial read, interruptedBlocks(), omits tool calls by contract (assembler.ts:161-178). A custom consumer that calls blocks() on an errored stream anyway will still see the malformed block.
  • It does not repair sessions that are already broken. This is prevention only. For repair of existing logs, see the projects below.
  • It does not touch text or reasoning content, tool results, or the session/* event vocabulary.
  • No Web UI. Host-side only; no dsh.client half.
  • It cannot help a gateway that is malformed at the HTTP level — this plugin sees the adapter's chunk stream, not raw SSE bytes.

Prior art — this is not the only tool in this space

Three existing projects address a weaker version of this problem. Check whether one of them fits you better:

  • Yukari316/dsh-toolcall-compat — the closest overlap: fixes tool-call JSON-schema misparsing for custom providers. In-band like this one, with one test file.
  • Whning0513/deepseek-protocol-doctor — a protocol checker with a test suite and CI, primarily a Python CLI. It diagnoses; it does not repair in band.
  • jhuanxx44/dsh-sseye — an SSE debug console for inspecting what a gateway actually sent. Complementary: use it to capture the shape, this to fix it.

Related, adjacent: lokic7123-star/dsh-route-resilience.

For sessions that are already corrupt, the repair niche is well served — see xiaoshenming/dsh-session-surgeon (a spec-driven repairer with 14 diagnostic codes) and mayf3/dsh-session-doctor, among others. This plugin is upstream of all of them: it tries to stop the log from breaking in the first place.

What this plugin offers that the above do not, as far as we could verify: a fixture corpus of malformed gateway streams with a negative control per repair, and assertions against the harness's real BlockAssembler and its real stream grammar rather than against a mock.


Development

npm ci
npm run typecheck
npm run build
npm test          # 37 tests, fixture-driven, with negative controls
npm run test:built # loads lib/ under plain node and drives the real waterfall

Fixtures live in two families:

  • tests/fixtures/sse/*.sse — OpenAI chat.completion.chunk frames, the format you capture from a gateway proxy. tests/support/sse.ts translates them the way an OpenAI-compatible adapter does. These are authored to reproduce the shapes described in the linked reports, not captured from a live gateway; the translator is a faithful model of the shipped adapter's observable behaviour (empty-string identity, 1:1 index mapping), not a copy of its internals.
  • tests/fixtures/chunks/*.json — harness StreamChunk sequences, for malformations that arise at the adapter protocol level rather than on the wire. Each file documents the shape it covers and what produces it.

A test asserts the fixture list on disk matches the list the suite exercises, so a fixture cannot be added and silently left untested.

Compatibility

Developer preview: the harness states "THERE WILL BE COMPATIBILITY-BREAKING CHANGES" (README.md:11) and there is no semver or deprecation policy at 0.1.1-rc.2. Peer ranges use || unions across -rc versions accordingly. Verified against deepseek-harness b150a551b (release(dsh): 0.1.1-rc.2).

Licence

MIT.