# dsh-messages-sanitizer [![δΈ­ζ–‡](https://img.shields.io/badge/%E4%B8%AD%E6%96%87-README.md-2ea44f)](./README.md) [![English](https://img.shields.io/badge/English-README.en.md-2ea44f)](./README.en.md) > **πŸ”§ 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 orphaned > `tool_calls` in the session, after which **every subsequent turn** is rejected with > `400 INVALID_REQUEST`, retries do nothing, and the session is stuck. **This plugin > automatically repairs the `messages` array 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"](https://github.com/deepseek-ai/deepseek-harness/discussions/4843), 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) 1. **Auto-resume (`agent/status`, primary path)**: after a tool-dispatch crash (e.g. `prepare` throws), once the agent returns to idle it sends a synthetic error `tool-result` back 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. 2. **Prevention (`agent/pre-step`)**: tracks, per session, calls that were "declared but never answered by a `tool/result`"; before the next request is built it prepends a synthetic error `tool-result` message to that step's messages (covers stale orphans restored after a restart). The synthetic message is persisted as a `user/message` event along with `decision.messages`, so `deriveMessages()` is valid from the root, **eliminating the 400 at the source**. 3. **Healing (`agent/request-error`)**: if the API still returns 400 for a `tool_calls` pairing/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-result` with no preceding `tool_calls`) into plain-text `user` messages; - **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 `user` messages** 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. 4. **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 ```bash 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: ```bash 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: ```yaml - insert: - id: messages-sanitizer name: 'dsh-messages-sanitizer' disabled: true ``` ## Verification ```bash 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_modules` in this directory is a junction pointing at the harness runtime's `~/.dsh/profiles/node_modules`, used only to resolve dependencies for local `node --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.