Install
Inside DeepSeek Harness, with dsh-market
dsh plugin --profile web add dshmarket
Or from the command line
dsh plugin --profile web add github:Leeminjing/dsh-messages-sanitizer
Installing runs third-party code with your own permissions — it can read your files, use your credentials and reach the network. Review the source first, and pin a commit (github:owner/repo#sha) when you can.
README
🔧 Did your conversation break while creating / loading a plugin in DeepSeek Harness? This plugin fixes exactly that.
While developing or loading a local plugin, one tool-dispatch crash (
Cannot read properties of undefined (reading 'prepare')) leaves an orphanedtool_callsin the session, after which every subsequent turn is rejected with400 INVALID_REQUEST, retries do nothing, and the session is stuck. This plugin automatically repairs themessagesarray back to a valid state so the conversation continues instead of freezing.
💥 Before ✅ After (with this plugin installed)
plugin crashes plugin crashes
↓ ↓
orphan tool_calls messages auto-repaired
↓ ↓
400 INVALID_REQUEST forever conversation continues
↓
conversation dead
A DeepSeek Harness plugin that automatically corrects the messages array — preventing every chat crash caused by an invalid messages array.
Background: the crash you hit
The OpenAI-compatible protocol requires tool calls to come in pairs, and a tool
message must immediately follow the assistant tool_calls message (no
user / assistant message may be inserted in between):
assistant { content: ..., tool_calls: [{ id: "call_A", ... }] }
tool { tool_call_id: "call_A", ... } ← must immediately follow, covering every id
When a tool dispatch crashes after "the assistant tool_calls / tool/call was
recorded but before a tool result was produced" (e.g. ctx.tools[symbol].prepare
throws Cannot read properties of undefined), the session log is left with an
orphaned tool_calls that has no tool-message response. The next request
assembles the history as:
[..., assistant{tool_calls:[write]}, user{...}] ← invalid
The API answers 400 INVALID_REQUEST, and because the history is unchanged on
retry, it is rejected again and again — the session is stuck. If several failed
retries follow the crash, the log also ends up with multiple duplicate user
messages sitting between the orphaned assistant and the injection point, which
makes "inserting a tool message" unable to satisfy the adjacency constraint either.
How the plugin fixes it (three layers of defense)
Prevention (
agent/pre-step, primary path): tracks, per session, calls that were "declared but never answered by atool/result"; only when the assistant that declared them is the last node of the surface (the typical shape of a fresh crash), it prepends a synthetic errortool-resultmessage to the incoming messages before the model request is built. The synthetic message is persisted as auser/messageevent along withdecision.messages, soderiveMessages()is valid from the root — the loop-built request (deep-frozen and immutable) is valid too, eliminating the 400 at the source.Healing (
agent/request-error): if the API still returns 400 for atool_callspairing/adjacency violation (e.g. an already-poisoned session from an older version, or stale messages already sitting after the orphaned assistant), it repairs the log with surface replacements, then forces one retry (the retry rebuilds the request from the repaired log and succeeds in one shot):- rewrites the dangling assistant message into a version without
tool_calls(strips the unanswered calls); - neutralizes orphan tool messages (a
tool-resultwith no precedingtool_calls) into plain-textusermessages; - restores assistants that were wrongly stripped but whose results are still
adjacent (re-adds their
tool_calls, preserving the historical tool context); - folds the duplicate
usermessages left behind by crash retries. The repair is idempotent: on a second encounter of the same violation there is nothing left to do, so it falls back to the downstream policy — no infinite retry.
- rewrites the dangling assistant message into a version without
Last resort (
llm/stream): runs a pure array correction on every request (pairing + adjacency reordering + orphan/duplicate dropping + empty-assistant dropping). Loop-built requests are frozen, so it only warns without rewriting; non-frozen requests that build their own messages (compaction, session-title, …) are replaced in place.
Installation
dsh plugin --profile web add github:Leeminjing/dsh-messages-sanitizer
Restart the harness — the plugin loads automatically as a profile layer.
Configuration
| Key | Type | Default | Meaning |
|---|---|---|---|
enabled |
boolean | true |
Master switch |
To disable, remove the insert entry from cordis.patch.yml, or use:
- insert:
- id: messages-sanitizer
name: 'dsh-messages-sanitizer'
disabled: true
Verification
cd dsh-messages-sanitizer
node --test # 40 test cases: pure-function correction + session tracking + request-failure healing + real cordis/Session integration
Coverage (all validated against the real @deepseek-ai/dsh-session foldSurface /
Session):
- real crash sequence end-to-end:
assistant/message{tool_calls}→tool/call→ crash →step/end→turn/end error→ after the next-turn injection the wire is valid; - a real poisoned log (orphan + stale duplicate user messages) becomes pure user/assistant after repair, with no tool messages left, and the repair is idempotent;
- surface replacements are executed on a real Session (validated by the Session itself);
- request-failure healing only intervenes on a
tool_calls-pairing 400, forces one retry after repairing, and never retries infinitely.
Directory structure
dsh-messages-sanitizer/
├── package.json # declares dsh.bundle (the `dsh plugin add` entry point)
├── cordis.patch.yml # bundle patch layer (mounts messages-sanitizer)
├── LICENSE
├── README.md
├── README.en.md
├── lib/
│ ├── index.js # plugin entry (name / inject / Config / apply)
│ ├── sanitize.js # pure messages-array corrector (pairing/adjacency/orphan/duplicate/empty)
│ └── repair.js # orphan tracking + pre-step prevention + surface-replacement healing + request-failure retry
└── tests/
├── sanitize.test.mjs # pure-function correction cases
├── repair.test.mjs # tracker + pre-step repair + request-failure healing (fake ctx)
├── integration.test.mjs # end-to-end simulation of the real crash sequence
├── heal.test.mjs # healing: normal turns untouched / orphan neutralization / wrong-strip restoration / mixed
└── cordis-integration.test.mjs # real cordis + real Session integration
Notes
- The plugin is zero-build pure ESM, directly loadable by the cordis loader; its
runtime dependencies —
@deepseek-ai/dsh-llm(synthetic messages),@deepseek-ai/dsh-session(surface folding),@deepseek-ai/cordis,@deepseek-ai/schemastery(config schema) — come from the harness runtime itself. - An already-crashed session is auto-healed by the healing path when you keep chatting after a restart (the first request fails once, the dangling calls are stripped, and the retry succeeds).
- The
node_modulesin this directory is a junction pointing at the harness runtime's~/.dsh/profiles/node_modules, used only to resolve dependencies for localnode --test; the harness runtime does not depend on it. - No rebuild is needed after editing the plugin code; restart the harness (or let cordis HMR reload it) for changes to take effect.