dsh-empty-block-guard
Verified@argszero/dsh-empty-block-guard Β· v0.1.1 Β· MIT
Fixes a harness session that is refused by a strict Anthropic Messages-compatible gateway with "message[N]: content[0]: thinking type must have non-empty thinking field" / "HTTP 400 INVALID_REQUEST" - a refusal that repeats at the same message index on ev
Install
dsh plugin add @argszero/dsh-empty-block-guard Confirm the layer applied with dsh --profile default --dump-config β see the install guide.
Source
Tags
Readme
@argszero/dsh-empty-block-guard
Fixes a session that is dead from one message index onward, refused before the model ever starts generating, with
invalid Claude request: message[69]: content[0]: thinking type must have non-empty thinking field
HTTP 400 INVALID_REQUEST
for a reasoning (or text) block in the assistant history whose text is empty.
One report, one mechanism, one seam: #9239 β "an empty reasoning block in the history serializes to an empty thinking block, so an existing session can never be routed to a strict Anthropic Messages gateway again".
The problem
The block is not the mystery. llm-deepseek writes a reasoning block to the
Messages wire with no emptiness guard:
// packages/llm/llm-deepseek/src/serialize.ts
case 'text': return { type: 'text', text: block.text }
case 'reasoning': return {
type: 'thinking',
thinking: block.text,
...replay?.[index]?.signature === undefined ? {} : { signature: replay[index].signature },
}
An empty text therefore becomes {"type":"thinking","thinking":""} β a body no
strict Anthropic-compatible gateway accepts, and the reporter measured exactly
which shapes fail and which do not:
| thinking block on the wire | gateway |
|---|---|
{"type":"thinking","thinking":""} (with signature) |
refused |
{"type":"thinking","thinking":""} (without) |
refused |
{"type":"thinking","thinking":null} |
refused |
| non-empty, with signature | accepted |
| non-empty, without signature | accepted |
| no thinking block at all | accepted |
And the block is durable. It lives in the session log, not in one request, so every later turn rebuilds the identical request and is refused at the identical index:
turn N appendMessage(assistant) β reasoning block with text:"" LANDED
turn N+1 serialize(log) β {"type":"thinking","thinking":""} β gateway β 400
turn N+2 the same log, the same index, the same 400 β forever
The session is not slow and not waiting on a retry budget. It is permanently unroutable to that gateway, and only a new session escapes.
Two things widen the trap. llm-pi-ai builds the same { type: 'thinking', thinking: block.text } (packages/llm/llm-pi-ai), so swapping adapters does not
help. And readReplay() returns per-block metadata only when the response model
matches the requested one (packages/llm/llm-deepseek/src/replay.ts), so a
cross-model replay sends the block without its signature β the same refusal
from a second direction.
What this plugin does
It removes the block on the response side of the llm/stream waterfall β the
one seam that reaches both what the loop assembles and what it records.
// packages/llm/llm/src/index.ts β the waterfall's return value is a value
ctx.on('llm/stream', (options, next) => { β¦ return wrapped }, { global: true, prepend: true })
The request side of the same waterfall cannot help, by design: a loop-built
request arrives deep frozen ("its content is a pure function of the session
log β¦ so listeners read it, never rewrite it"), and next() takes no arguments.
The returned stream is different β it is a value a listener may replace:
// packages/core/agent-loop/src/agent.ts
for await (const chunk of stream) { β¦ }
The loop feeds one AssistantStreamAttempt that both assembles the durable
assistant/message and records the raw chunks for replay, so a block removed
there is removed from the message and from the record it will be replayed
from, and the two cannot disagree afterwards. The listener registers with
prepend: true because a waterfall's position is part of its meaning: the
removal has to be the last transformation before the consumer.
Which chunks go, and why it is not just the block
BlockAssembler.assemble() returns a closed block verbatim, and otherwise
builds one from the text its deltas accumulated. What has to be removed therefore
depends on what was already emitted when the emptiness became known:
| evidence | the assembler would⦠| what this gate does |
|---|---|---|
| non-empty deltas, or a close with non-empty text | make a representable block | keep, content-present |
| start/deltas/close all still buffered, close says empty | write {"type":"thinking","thinking":""} |
remove the whole block, empty-close-while-held |
| the close is the only evidence of the block | write the same empty block | remove it, empty-close-while-held |
| non-empty deltas already emitted, close says empty | return the empty close verbatim and discard the text | drop only the close, empty-close-after-content |
| stream ended with the block still open, nothing non-empty streamed | assemble it from the empty deltas | remove the block, empty-open |
The fourth row is the one that shows why "drop the block" is not the whole rule: a close is authoritative, so keeping it would poison the session and throw away text the model did send. Dropping only the close leaves the deltas to assemble, which is strictly less destructive than either alternative.
Removing a block is not enough: the replay envelope has to be pruned in step
The terminal chunk may carry a ReplayEnvelope whose blocks array holds one
adapter-private entry β for llm-deepseek, the thinking signature β per emitted
block, in first-seen stream order. The assembler compares that count with the
emitted block count and discards the whole envelope when they disagree:
// packages/llm/llm/src/assembler.ts
if (envelope.blocks.length !== all.length) return { blocks, replay: undefined }
So dropping a block without dropping its envelope entry trades a loud 400 for the
silent loss of every signature in that message, including the ones on the
blocks that were kept β a worse failure, because it is the quiet one. This plugin
removes the entry at the dropped block's own rank, exactly as core's own kept
vector does for max-token truncation, so the surviving signatures survive. The
suite measures both halves with the shipped assembler: the block is gone, and
content.length === replay.blocks.length still holds, position for position.
Install
npm install @argszero/[email protected]
Then mount it. The package ships a bundle patch
(dsh.bundle.patch: ./cordis.patch.yml), so a deployment that follows the
harness's bundle convention gets it by adding one entry:
- insert:
- id: empty-block-guard
name: '@argszero/dsh-empty-block-guard'
Mounting it directly works the same way and takes the same config:
import guard from '@argszero/dsh-empty-block-guard'
ctx.plugin(guard, { mode: 'observe' }) // measure before you drop
Measuring before dropping
mode: 'observe' runs the same analysis, counts everything it would remove,
and emits the stream unchanged. It answers the question the counters otherwise
answer by assumption β "is this happening in my deployment" β and it is meant
to be run first.
const guard = ctx.get('emptyBlockGuard')
guard.snapshot()
{
"mode": "observe",
"settings": { "mode": "observe", "types": ["text", "reasoning"], "report": true, "historyLimit": 20 },
"counters": {
"streams": 41, "errors": 0,
"verdicts": { "keep": 38, "drop": 3 },
"reasons": {
"content-present": 35, "empty-close-while-held": 3,
"empty-close-after-content": 0, "empty-open": 0
},
"envelopes": { "untouched": 38, "pruned": 3, "unaligned": 0 },
"entriesRemoved": 3
},
"recent": [
{
"provider": "deepseek-official", "model": "deepseek-chat",
"sessionId": "β¦", "index": 0,
"type": "reasoning", "verdict": "drop", "reason": "empty-close-while-held"
}
],
"notCovered": [ "β¦" ]
}
recent is the whole diagnosis: which route produced the block, which index it
was, and the evidence the decision rested on. notCovered is not decoration β a
guard that reports only its decisions leaves an operator unable to tell "nothing
needed dropping" from "the guard never ran", and those two look identical in a
log. guard.reset() zeroes the counters and drops the retained history.
Configuration
| option | default | meaning |
|---|---|---|
mode |
'drop' |
drop removes the empty block before the consumer sees it; observe counts the same decisions and emits the stream unchanged; off installs no listener at all |
types |
['text', 'reasoning'] |
which of the two text-bearing block types are guarded. Anything unrecognised is dropped from the list rather than widened |
report |
true |
log one line per decision, with the verb the mode earns (would remove / removed) |
historyLimit |
20 |
how many recent decisions snapshot() keeps (0β¦512); 0 keeps none while still counting |
drop is the default because there is no alternative that keeps the block and
survives: the only way an empty block becomes representable is to invent text for
it, and a guard that fabricated model output would be a worse failure than the
one it prevents. Removing it is also what the report's own "Expected behavior"
asks for β an empty reasoning block carries no information.
types covers both text and reasoning, because the wire refuses an empty
member of either and the sibling hole is one serializer line away
(serialize.ts: case 'text'). A guard that covered only the reported half would
leave the same session-killing shape behind.
An unrecognised mode spelling resolves to observe, not to drop: the
failure mode of a typo has to be the one that changes nothing. off still
provides the service, because a mount that answered nothing would be
indistinguishable from a plugin that failed to load.
Where it attaches
llm/stream is a documented waterfall, and a waterfall's position is part of
its meaning here. The guard registers with { global: true, prepend: true } β
prepend puts it outside every default listener so the removal is the last
transformation the consumer sees, and global is because the stream is dispatched
with the registry instance as the listener's this, so a mount scoped to a
profile must still cover it.
llm/stream waterfall β the return value is the chunk stream
βββ empty-block-guard β this plugin (prepend): removes the empty block
β βββ (a listener mounted after it with `prepend` sits OUTSIDE it β see below)
βββ β¦other listenersβ¦
βββ the adapter's stream
β the loop consumes exactly this value
agent-loop: AssistantStreamAttempt
βββ assemble() β the durable assistant/message β the block never reaches it
βββ raw chunks β the durable replay record β nor this
A guarded block is held until its emptiness is settled, and everything that
arrives behind it waits in the same buffer, so a held block can never be
overtaken. No chunk is re-ordered and no index is renumbered β BlockAssembler
keys blocks by index and records their order at first sight, so the index is part
of the stream's meaning, not a label this plugin may rewrite.
inject = ['llm'] makes the LLM service a hard requirement: the seam is a chunk
stream, so a composition with no models has nothing for this guard to guard, and
a mount that silently listened to nothing would be indistinguishable from one
that failed to load.
Honest boundaries
- It is preventive, not curative. A session whose log already contains the
empty block cannot be repaired from here, and this plugin does not pretend
otherwise. The request side of
llm/streamarrives deep-frozen, so a poisoned history cannot be filtered on its way to the adapter; repairing that needs core (a serializer guard, or rejecting the block at the write boundary). If you are reading this because a session is already stuck, this package is the fix for the next attempt, not a rescue for that one. - The rule is exactly empty (
text.length === 0). A whitespace-only block is representable on the wire β it is a non-empty string β so it is left exactly as the provider produced it. Narrowing the rule totrim() === ''would delete content the gateway would have accepted. - Only
textandreasoningare judged β the two the Messages wire spells with a free-form text field. Tool-call, image, file and extension blocks are passed through untouched, whatever they carry. - It covers layers mounted below it. A listener registered with
prependafter this plugin mounts sits outside it and can emit a block this guard never saw. In a deployment with several stream rewrites, mount this one last. The suite pins both directions. observeapplies nothing. It decides, counts and reports exactly asdropwould, so the two runs differ in nothing but the emitted stream.- An abandoned stream settles nothing. If the consumer breaks out of the loop β an abort, an interrupt β a block still held is dropped with the stream. That is safe because the harness discards the content of an interrupted attempt, and a block that is still open at interruption has no text by definition.
- A drop costs metadata in exactly one case, and the counters name it: an
envelope that was never index-aligned with its own stream (
unaligned) is already discarded by the assembler, and this plugin leaves it alone rather than fabricating a rewrite. - It fails open. If the gate throws on a stream, everything it had taken is
released in order and unchanged, the failure is counted under
errors, and the consumer sees the stream it would have seen without the plugin. A guard whose bug broke working model calls would be a worse failure than the one it prevents.
Compatibility
The plugin wraps a documented event's return value and reads one public type
surface, so what it depends on is the shape of StreamChunk and the waterfall's
dispatch signature. Peer ranges name the lines that were verified by pinning
every harness package to one line and running the suite against it
(npm run test:probe-lines), never inferred:
| peer | range |
|---|---|
@deepseek-ai/dsh-llm |
>=0.1.7-rc.2 <0.2.0 || >=0.2.0-rc.2 <0.2.1 |
@deepseek-ai/cordis |
^4.0.2 |
Verified against the 0.1.7-rc.2 and 0.2.0-rc.2 lines. Each || segment's
floor is the build that was actually run, so the range and the probed set are the
same set β a line this plugin has not been run on is a line it does not claim.
The ceiling is the next line rather than the next major, because StreamChunk is
a union this gate switches on.
@deepseek-ai/dsh-llm is a peer for the type contract, not for a runtime: the
plugin carries import type { ContentBlockType, ReplayEnvelope, StreamChunk } from '@deepseek-ai/dsh-llm', which TypeScript erases.
No runtime dependency, at all. The plugin never imports a harness package for
value β it subscribes to an event the harness dispatches and returns a wrapper
around the stream it was handed. Declaring the runtime as a dependency would be a
dependency the artifact does not have, and in a profile deployment a plugin's
own dependency can materialize a second copy of a harness package, which is a
failure this repository has already shipped more than once. The packaging suite
asserts this on the emitted JavaScript, not on the sources: lib/ reaches
nothing but its own modules.
Tests
npm test # the full suite, against a real cordis context, a real LlmRuntime and the real assembler
npm run test:inject # mutate the source one rule at a time; every arm must be caught
npm run test:probe-lines # pin every harness package to one line and re-run the suite
The integration suite does not simulate the seam. It builds a real Cordis
context, mounts a real LlmRuntime, and dispatches the real llm/stream
waterfall with a real BlockAssembler reading what the gate emits. The control
arm runs the same chunks with the plugin absent and reproduces the report: the
assembled block is empty, and its wire projection is the body the gateway
refuses. The guarded arm's message is sendable, and the signature of the block
beside the removed one is still there β which is the half a naive fix loses.
plugin mounted with prepend : the empty block is gone from the message and the record
plugin mounted without it : the block is there, and its wire form is refused
A second pair swaps the mount order to prove the listener position is real rather than asserted, and a third drives the shipped assembler with an envelope whose entries no longer match to show what the pairing above is protecting.
License
MIT