mirror of
https://github.com/bendtherules/opencode-plugin-dsml-fix.git
synced 2026-10-17 16:56:50 +00:00
Recovers DeepSeek DSML tool calls that leak as assistant text: streaming middleware (AI-SDK path), SSE rewrite (native path), history sanitization, preventive directive, conditional system nudge, and a capped idle watchdog. 116 tests, synthetic golden fixtures.
125 lines
7.9 KiB
Markdown
125 lines
7.9 KiB
Markdown
# DSML Tool-Call Pattern Catalogue
|
||
|
||
Single source of truth for what the parser **accepts**, what it **repairs**, what it
|
||
**rejects**, and which test proves each row. Every regex in `src/grammar.ts` must trace
|
||
to a row here. Add a row and a test before adding a pattern.
|
||
|
||
> Writing rule for this file: never paste a live DSML token. The model may emit it and
|
||
> our own harness (or the provider's greedy extractor) will turn this document into a
|
||
> phantom tool call. Represent tokens with the placeholder `‹DSML›` and show the
|
||
> literal bytes as `U+FF5C` escapes. The parser tests are the place for real bytes.
|
||
|
||
## 0. Notation
|
||
|
||
- `BAR` = one full-width bar `U+FF5C` (`|`) **or** one ASCII pipe `|`.
|
||
- `DSML` literal word.
|
||
- Canonical marker: `< BAR DSML BAR >` — e.g. `<‹DSML›tool_calls>`.
|
||
- Doubled marker (observed): `< BAR BAR DSML BAR BAR >`.
|
||
- In real bytes: `\uFF5C` repeated.
|
||
|
||
## 1. Canonical grammar
|
||
|
||
```
|
||
< MARKER tool_calls>
|
||
< MARKER invoke name="TOOL">
|
||
< MARKER parameter name="KEY" string="true">VALUE</ MARKER parameter>
|
||
< MARKER parameter name="KEY" string="false">JSON</ MARKER parameter>
|
||
</ MARKER invoke>
|
||
</ MARKER tool_calls>
|
||
```
|
||
|
||
- Outer block names: `tool_calls` (V4), `function_calls` (V3.2), and the mangled `calls`.
|
||
- Single or doubled bars; optional whitespace around the marker.
|
||
- `string="true"` → raw string value. `string="false"` → JSON value. Attribute absent →
|
||
raw string (default). Quotes may be `"` or `'`.
|
||
- Invoke/param names may be quoted with `"`/`'` or be a bare token.
|
||
|
||
## 2. Leak / degradation variants (the real-world enemies)
|
||
|
||
These are the shapes observed in the wild, in the order a stream degrades. Source tags:
|
||
`[DB]` = captured in the user's OpenCode session DB, `[vLLM]` = vLLM PR #54686,
|
||
`[OpenClaw]` = openclaw doubled-bar issue, `[Pi]` = pi-dsml grammar, `[CS]` = Cherry Studio.
|
||
|
||
| # | Variant | Shape | Handle by | Test |
|
||
|---|---------|-------|-----------|------|
|
||
| V1 | Canonical single-bar | `< BAR DSML BAR tool_calls>` complete | parse normally | `v1-canonical` |
|
||
| V2 | Doubled full-width bars | `< BAR BAR DSML BAR BAR tool_calls>` | marker accepts 1–2 bars | `v2-doubled-bars` |
|
||
| V3 | ASCII pipes | `< | DSML | tool_calls>` | marker accepts `|` | `v3-ascii-pipes` |
|
||
| V4 | Space inside marker | `< BAR DSML BAR tool_calls>` / `calls` | trailing `\s*` + `calls` alt | `v4-marker-space` |
|
||
| V5 | Truncated block tag | `< BAR DSML BAR calls>` | `calls` alternative for `tool_calls` | `v5-truncated-tag` |
|
||
| V6 | Spaces around invoke name | `< BAR DSML BAR invoke name = "x">` | `\s+` around `name=` | `v6-name-spacing` |
|
||
| V7 | `tool_call` (singular) | `< BAR DSML BAR tool_call>` | block tag alt | `v7-tool-call-singular` |
|
||
| V8 | `function_calls` (V3.2) | `< BAR DSML BAR function_calls>` | block tag alt | `v8-function-calls` |
|
||
| V9 | **Missing outer opener** | invoke/parameter appear with no `tool_calls` block | implicit-block rescue | `v9-orphan-invoke` |
|
||
| V10 | **Missing invoke opener** | leaked `parameter` only, no `invoke name=` | implicit-invoke rescue | `v10-orphan-parameter` |
|
||
| V11 | Mis-spelled closer | `</ BAR DSML BAR >` (bar then `>`), swallowing next param | tolerant scan + drop runaway param | `v11-misspelled-closer` |
|
||
| V12 | Runaway invoke name | `invoke name="tool {` (JSON leaked into name) | reject call, strip text | `v12-runaway-name` |
|
||
| V13 | Undeclared tool | well-formed, name not in tool set | **keep call**; runtime answers "tool not found" | `v13-undeclared-tool` |
|
||
| V14 | Empty invoke name | `name=""` | reject (never trap buffer) | `v14-empty-name` |
|
||
| V15 | Unclosed block at end | opener, no closer, stream ends | flush as text, recover complete calls only | `v15-unclosed` |
|
||
| V16 | Truncated mid-invoke | invoke has no closing tag | drop partial invoke, keep earlier ones | `v16-truncated-invoke` |
|
||
| V17 | Chunk-split opener | `<` `BAR` `DSML` … across deltas | suffix-prefix buffering | `v17-chunk-split` |
|
||
| V18 | Text before/after block | prose + block + prose | preserve prose, excise block | `v18-surrounding` |
|
||
| V19 | Multiple invokes / blocks | 2+ calls in one or many blocks | parse all | `v19-multiple` |
|
||
| V20 | Inside fenced code | grammar shown in ``` fence | **ignore** (no parse, no strip) | `v20-code-fence` |
|
||
| V21 | Trailing `‹EOS›` token | `…</…tool_calls>‹EOS›` | strip EOS token | `v21-eos` |
|
||
| V22 | HTML entities in values | `"` `&` `<` | decode entities | `v22-entities` |
|
||
| V23 | Multi-line / heredoc value | value contains newlines, indentation | preserve verbatim except hugging newline | `v23-multiline-value` |
|
||
| V24 | JSON params | `string="false"` | `JSON.parse`, fall back to raw on failure | `v24-json-param` |
|
||
| V25 | Raw JSON body (no params) | invoke body is a bare `{...}` | fallback JSON parse | `v25-raw-json-body` |
|
||
| V26 | **Wrapped tool** (dominant real shape) | orphan `<parameter name="X">` (marker optional) containing ≥1 complete inner `<MARKER parameter>` | recover call X with inner args (V13 policy: runtime validates) | `v26-*` |
|
||
|
||
## 3. Reject arms (never emit a call)
|
||
|
||
The parser is a **strict acceptor**. When in doubt it does not execute; it either strips
|
||
noise or leaves text alone. This is deliberate: a false positive executes an arbitrary
|
||
tool the model never asked for.
|
||
|
||
| Reject arm | Rationale | Test |
|
||
|-----------|-----------|------|
|
||
| No DSML marker anywhere | fast path, zero cost | `reject-no-marker` |
|
||
| Text inside a code fence | docs/examples must not execute | `v20-code-fence` |
|
||
| Complete call but empty name | unusable, and must not trap buffer | `v14-empty-name` |
|
||
| Invoke with no parseable parameter and no JSON body | nothing to run | `reject-empty-args` |
|
||
| Native tool calls already present on the message | strip text only, do not double-run | `reject-native-present` |
|
||
| `looksLikeDsml` false (no `DSML`/`end-of-sentence`) | skip whole pipeline | `reject-not-dsml` |
|
||
|
||
## 4. Transition: removal in old transcripts vs. rejection in new streams
|
||
|
||
The user wants the same engine to serve **two consumers**:
|
||
|
||
1. **Streaming (new turns, `ctx.aisdk.language`)** — convert a DSML block into a real
|
||
`tool-call` part *before* OpenCode's lowering, and rewrite `finishReason` to
|
||
`tool-calls`. The block must never reach the transcript.
|
||
2. **Stored transcript repair (old sessions)** — walk existing messages, excise DSML
|
||
spans, and (for messages that never got a native call) synthesize the missing call.
|
||
|
||
Both consume the **same `parseDsml(text) -> { calls, ranges }`** function. The only
|
||
difference is the sink:
|
||
|
||
- Stream sink: buffer `text-delta`, emit `tool-input-*` + `tool-call` for each call,
|
||
drop `ranges` from the emitted text, set `finish` to `tool-calls` if any call.
|
||
- Repair sink: `stripRangesWithNote` for display (short inline note naming the
|
||
removed tool when known, e.g. `[removed malformed edit call]`), append calls if
|
||
none were native.
|
||
|
||
Because both share the parser, a variant is "supported" only when both sinks agree, and
|
||
the test table covers both.
|
||
|
||
## 5. Streaming buffer policy
|
||
|
||
- Hold text ONLY from the first byte that could open a call: a DSML opener, a
|
||
partial-opener suffix (`<`, `<BAR`, `<parameter name="sh`, …), or a wrapped-tool
|
||
candidate (`<parameter name="X">` with or without marker). Everything before it is
|
||
emitted immediately, every chunk — plain prose is never buffered.
|
||
- Eager stop: a held candidate that produces no complete parameter-open within
|
||
1024 chars is prose — flush as text and resume scanning fresh. Once a complete
|
||
parameter is open (inside a value), only the 64 KiB cap bounds the hold.
|
||
- Transitions: any non-text part (reasoning, native tool parts) flushes held text
|
||
FIRST, preserving transcript order.
|
||
- End-of-text guarantee: `text-end` / `finish` / stream flush always resolve the
|
||
buffer — recovered calls plus verbatim remainder — so the buffer always ends
|
||
empty and no text is ever lost.
|
||
- Trailing markup noise (`</invoke>` / `</calls>` arriving after an already
|
||
recovered call) is dropped, not leaked.
|