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.
7.9 KiB
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 asU+FF5Cescapes. The parser tests are the place for real bytes.
0. Notation
BAR= one full-width barU+FF5C(|) or one ASCII pipe|.DSMLliteral word.- Canonical marker:
< BAR DSML BAR >— e.g.<‹DSML›tool_calls>. - Doubled marker (observed):
< BAR BAR DSML BAR BAR >. - In real bytes:
\uFF5Crepeated.
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 mangledcalls. - 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>` |
| 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:
- Streaming (new turns,
ctx.aisdk.language) — convert a DSML block into a realtool-callpart before OpenCode's lowering, and rewritefinishReasontotool-calls. The block must never reach the transcript. - 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, emittool-input-*+tool-callfor each call, droprangesfrom the emitted text, setfinishtotool-callsif any call. - Repair sink:
stripRangesWithNotefor 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.