# 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.
