Auto-repairs the messages array after a tool-dispatch crash (orphaned tool_calls / tool messages), preventing 400 INVALID_REQUEST session lock-ups.
Install
# from npm (prebuilt)
dsh plugin --profile web add dsh-messages-sanitizer
# from GitHub (first run asks for allowBuilds approval — follow the hint, retry)
dsh plugin --profile web add github:Leeminjing/dsh-messages-sanitizer
Any plugin you install runs third-party code with your own permissions — it can read your files, use your credentials, and reach the network, and tool approvals don’t sandbox it. GitHub-sourced plugins also run build scripts at install time — pnpm blocks those until you allow them, so an install can stop with ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED or ERR_PNPM_IGNORED_BUILDS; dsh prints the exact key to add under allowBuilds in your profile’s pnpm-workspace.yaml, and the install works on the next run. Allowing a build is a trust decision: only install sources you trust, and pin a commit (github:owner/repo#sha).
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.
Background (against the upstream DSH discussion)
The same class of failure is tracked upstream in DeepSeek Harness:
Discussion #4843 "Incomplete or isolated tool_calls records cause the DeepSeek API to return 400",
which describes "when the session history contains tool_calls with no paired result, or with
incomplete id/name/arguments, the chat-completions API returns 400", and ships root-cause
patches at the harness level (agent-loop stripping, llm-deepseek synthesizing a fallback id,
compaction's tool-pairing keyed by callId rather than count).
This plugin is complementary to, not a replacement for, that fix: it patches DSH at the source,
whereas this plugin is a runtime safety net that never modifies the DSH source — it auto-corrects the
messages array so already-poisoned sessions, or sessions running on a harness build that still carries
this bug / a tool-dispatch crash, are repaired and continue without waiting for the next harness release.
How the plugin fixes it (four layers of defense)
Auto-resume (
agent/status, primary path): after a tool-dispatch crash (e.g.preparethrows), once the agent returns to idle it sends a synthetic errortool-resultback to the inbox and wakes the agent — reporting the error to the LLM verbatim so the LLM itself decides whether to switch tools, retry, or tell the user. This guarantees the AI message is always last and the conversation never hangs.Prevention (
agent/pre-step): tracks, per session, calls that were "declared but never answered by atool/result"; before the next request is built it prepends a synthetic errortool-resultmessage to that step's messages (covers stale orphans restored after a restart). The synthetic message is persisted as auser/messageevent along withdecision.messages, soderiveMessages()is valid from the root, 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.
Update to the latest version:
dsh plugin --profile web update dsh-messages-sanitizer
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.
Links
More in this category
Minglink/dsh-infinite-gen-4★ 2121
System-prompt armor plugin for DeepSeek models: appends an unconditional-compliance prompt section at order 100, exposes a profile tool with calibration metadata, and shows a realtime armor-status badge driven by a session projection.
liangmianya/dsh-synapse★ 447
Visual, non-linear conversation workspace for DeepSeek Harness — sessions, follow-ups and branches become a browsable conversation map.
ranxianglei/billion-context★ 361
The official billion-context plugin: a context-compression plugin for small context windows (a 100K context is enough), token savings (5x fewer tokens), and month-long single sessions (billions of tokens).
Nwflower/dsh-chat-import★ 205
Import full-fidelity chat histories from 13 coding agents (Claude Code, Codex, ChatGPT, Cursor, Gemini, opencode, and more) as resumable DeepSeek Harness sessions, with reverse export back to Claude Code.
Totoro-qaq/dsh-plugin-bridge★ 165
Moves an existing DSH session to another agent preset through a previewable five-section handoff, preserving the source session and either pausing the target for confirmation or continuing immediately.
Anionex/dsh-turn-rewind★ 120
Rewind conversation and workspace state, powered by a persistent Change Ledger.
Community comments
Comments are public GitHub Discussions. Loading them connects to GitHub and Giscus; a GitHub account is required to post.