Files
opencode-plugin-dsml-fix/README.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

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

  1. Streaming recovery (strategy B). Wraps the provider language model via ctx.aisdk.hook("language"). Leaked DSML blocks in text-delta parts become real AI-SDK tool-input-* + tool-call parts, and finishReason flips from stop to tool-calls — so the agent loop dispatches the intended call instead of stalling. Reasoning and native tool parts pass through untouched.
  2. 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).
  3. Correction directive. A system instruction stating the exact required envelope (complete outer block layer, one invoke per call, parameters inside invokes).
  4. 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 map
  • docs/STATUS.md — handoff / project status
  • test/fixtures.ts — fixture builders (the repo never stores a live DSML token)