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.
4.8 KiB
opencode-plugin-dsml-fix
OpenCode V2 plugin that recovers DeepSeek DSML tool calls when the provider's parser fails and the raw markup leaks into assistant text.
What it does
- Streaming recovery (strategy B). Wraps the provider language model via
ctx.aisdk.hook("language"). Leaked DSML blocks intext-deltaparts become real AI-SDKtool-input-*+tool-callparts, andfinishReasonflips fromstoptotool-calls— so the agent loop dispatches the intended call instead of stalling. Reasoning and native tool parts pass through untouched. - History sanitization. Strips leaked DSML spans from replayed assistant text so the model stops imitating its own degraded output. Never synthesizes calls from history (no double execution).
- Correction directive. A system instruction stating the exact required envelope (complete outer block layer, one invoke per call, parameters inside invokes).
- Auto-resume safety net (strategy A). On session idle with unrecovered DSML markup, sends one short retry nudge, capped per message via plugin storage.
Install
Add to opencode.jsonc:
{
"plugins": ["/code/opencode-dsml-plugin"]
}
Or publish and reference the package name. Requires OpenCode V2 (2.0.x).
Configuration
{
"plugins": [
{
"package": "/code/opencode-dsml-plugin",
"options": {
"providers": ["opencode-go", "opencode"],
"mode": "relaxed",
"resume": { "enabled": true, "maxAttempts": 3 }
}
}
]
}
| Option | Default | Meaning |
|---|---|---|
providers |
["opencode-go","opencode"] |
Provider IDs in scope. Empty disables the plugin. |
mode |
"relaxed" |
"relaxed" = full catalogue; "strict" = complete blocks only. |
rescue.orphanInvoke |
mode | Recover invokes with no outer opener. |
rescue.looseParameters |
mode | Tolerant re-scan past broken closers. |
rescue.rawJsonBody |
mode | Bare-JSON invoke bodies. |
rescue.wrappedTool |
mode | Orphan <parameter name="X"> with complete inner params. |
bufferLimit |
65536 |
Streaming hold cap in bytes. |
response.sse |
true |
Native path: rewrite SSE bytes in http.response. |
response.aisdkStream |
true |
AI-SDK path: wrap language model, streaming parts. |
response.aisdkGenerate |
true |
AI-SDK path: wrap language model, generate results. |
request.sanitize |
true |
Strip leaked spans from replayed history (+ one note). |
request.directive |
true |
Preventive system directive (alias: prompt.enabled). |
request.systemNudge |
true |
Conditional correction in system on unrecovered tail. |
resume.enabled |
true |
Idle watchdog wake (alias: strategies.watchdog). |
resume.maxAttempts |
3 |
Cap per assistant message. |
resume.channel |
"system" |
"system" = minimal ping + system nudge; "user" = legacy full-text synthetic turn. |
debug |
false |
Log recoveries to stderr + probe file. |
recovery.* and strategies.* remain as deprecated aliases
(sse→response.sse, stream→response.aisdkStream,
generate→response.aisdkGenerate, sanitize/directive/systemNudge→request.*,
watchdog→resume.enabled). Precedence: response/request > recovery >
strategies > legacy resume/prompt > default.
If any layer misfires, switch that one flag off (or mode: "strict") — the plugin
degrades to the safer subset and never breaks normal inference.
Per-turn impact logging (debug: true)
Every request turn logs one line; every response stream logs one line on close.
Turns are identified as sessionID/user-messageID — OpenCode's own message
identity — with #n counting physical requests (retries) within the turn:
[dsml] request ses_abc/msg_xyz sanitized=2msgs/3parts directive=yes nudge=no
[dsml] response ses_abc/msg_xyz#1 recovered=1
[dsml] response ses_abc/msg_xyz#2 passthrough
[dsml] response ses_abc/msg_xyz#3 held-candidate-no-call
sanitized=Nmsgs/Mparts: replayed history messages/parts stripped (+ note).directive/nudge: whether system text was added this turn.recovered=N: tool calls recovered from leaked markup this turn.passthrough: no DSML candidate ever seen — bytes untouched.held-candidate-no-call: something looked like markup but parsed to nothing (emitted verbatim). If you see this often, send the log excerpt — it's a new variant to catalogue.
Development
bun install
bun test # 111 tests across grammar/parser/stream/wrapper/config/sse
bun run check # typecheck + tests (tsc --noEmit)
docs/PATTERNS.md— variant catalogue (source of truth for every regex)docs/TEST-MATRIX.md— behaviour ↔ test mapdocs/STATUS.md— handoff / project statustest/fixtures.ts— fixture builders (the repo never stores a live DSML token)