Files
opencode-plugin-dsml-fix/docs/PATTERNS.md
T
bendtherules 52b7776d5a feat: DSML tool-call recovery plugin for OpenCode V2
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.
2026-10-05 11:47:57 +05:30

125 lines
7.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 | `&quot;` `&amp;` `&lt;` | 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.