feat: simplified config, send-once retry, README rewrite

Breaking config (pre-adoption, no aliases kept): parse/history/responseFix/
retry namespaces, top-level providers+debug only. Removed: mode presets,
preventive directive, per-transport flags, maxAttempts (send-once per
message instead). README rewritten usage-first with learnings, per-section
defaults, research links.
This commit is contained in:
bendtherules
2026-10-05 19:37:34 +05:30
parent 6c52cbbe28
commit eb5fb39b52
10 changed files with 386 additions and 424 deletions
+178 -94
View File
@@ -1,125 +1,208 @@
# opencode-plugin-dsml-fix # opencode-plugin-dsml-fix
OpenCode V2 plugin that recovers DeepSeek DSML tool calls when the provider's Recovers DeepSeek DSML tool calls that leak into assistant text. OpenCode V2 plugin.
parser fails and raw markup leaks into assistant text as prose.
## The problem ## The problem
On DeepSeek served through OpenCode (notably `opencode-go/deepseek-*`), tool DeepSeek sometimes sends a tool call as visible markup instead of executing
calls sometimes arrive as visible XML-ish markup in the assistant message it. The agent stalls mid-task, and you end up looking at raw tags in the chat.
instead of executing — the agent stalls or pastes the markup back at you. It
happens most in long sessions, where the provider's parser degrades: the outer
block opener goes missing, invoke openers get mangled, and only the inner
parameter tags survive.
This plugin repairs those leaks client-side, in OpenCode, without waiting on Three things make it worse than a single failed turn:
the provider. Normal responses pass through byte-identical.
- It hits late in long sessions, when the provider's parser degrades: the
outer block opener goes missing, invoke openers get mangled, only inner
parameter tags survive.
- The broken parser stays silent. It passes the wreckage through as ordinary
text instead of raising, so nothing errors. Looks like the model just
declined to call the function.
- The leak lands in the transcript, and from there the model copies its own
broken format. Every turn after fails the same way until you intervene.
This plugin repairs those leaks inside OpenCode. Good responses pass through
untouched.
## What I learned
Read this before touching any setting. Two sources: my own long sessions on
`opencode-go/deepseek-*`, and the upstream vLLM reports.
1. **History is the real battlefield.** Fresh leaks are rare — the vLLM team
served billions of production tokens, sampled 9,500 responses, and found
76 leaks ([0.80%](https://github.com/vllm-project/vllm/pull/54686)). But
one leak in the transcript poisons every turn after it, because the model
imitates its own degraded output. vLLM
[#40801](https://github.com/vllm-project/vllm/issues/40801) reports the
same contamination. So live recovery fires rarely by design, and **history
sanitization matters more than everything else combined**. If you change
one flag, make it `history.sanitize`. Leave it on.
2. **It only strikes at long context.** Every leak I saw came thousands of
messages into a session, never early. Short sessions need nothing. The
plugin sits idle and costs nothing.
3. **The backend is inconsistent.** My working theory: `opencode-go` fans out
across multiple backend providers and only some mangle DSML. Identical
prompts fail or succeed at random. I stopped waiting for a provider fix —
there may be no single provider to fix.
## Install ## Install
```jsonc ```sh
// opencode.jsonc opencode plugin add opencode-plugin-dsml-fix
{
"$schema": "https://opencode.ai/config.json",
"plugins": ["opencode-plugin-dsml-fix"],
}
``` ```
Requires OpenCode V2 (`2.0.x`). No other setup — defaults cover the - Requires OpenCode V2 (`2.0.x`).
`opencode-go` and `opencode` (Zen) providers. - Defaults have everything enabled: `parse`, `history`, `responseFix`,
`retry`. The only off-by-default flag is `debug`.
To confirm it's loaded, run any prompt and look for the probe log
(`debug: true`, see below).
## What it does ## What it does
- **Streaming recovery.** Leaked DSML blocks in response text become real tool 1. **Parse leaked tool calls.** Tolerant parser for complete blocks plus
calls, so the agent loop dispatches the intended call instead of stalling. every degraded shape I catalogued: orphan invokes, mis-closed parameters,
Covers both the native SSE path (what `opencode-go` uses) and the AI-SDK bare-JSON bodies, wrapped tools, marker noise, chunk-split tags.
path (other providers). [Settings](#parse)
- **History sanitization.** Strips leaked markup from replayed assistant 2. **Sanitize history.** Strips old leaks from replayed assistant text, with
messages so the model stops imitating its own degraded output. History is a short inline note where each one was. History never becomes calls, so
never turned into calls — no double execution. nothing executes twice. [Settings](#history)
- **Prevention.** A system directive states the exact required envelope, plus 3. **Fix live tool calls.** Leaked markup in a live response becomes a real
a conditional nudge when a turn ends with unrecovered markup. call, so the agent loop dispatches it instead of stalling.
- **Safety net.** An idle watchdog sends one short retry when a session stalls [Settings](#responsefix)
on unrecovered markup, capped per message. 4. **Retry stalled turns.** Sometimes a reply is too broken to repair —
the markup can't become a call no matter how tolerant the parser is.
Every layer is independently toggleable (see Configuration). If any layer Instead of leaving the session stuck, the plugin tells the model its last
misfires, switch that one flag off — the plugin degrades to a safer subset reply had broken tool markup and asks it to re-issue the call properly.
and never breaks normal inference. If the session then sits idle, it sends one minimal wake-up to get things
moving again. Each broken reply gets one correction and at most one wake.
## Verify it's working A reply that stays silent after its poke is left alone — if the first poke
got no reply, the cause is outside anything a second poke can fix. A new
Enable debug logging: reply always starts fresh, so retrying never stops a live chat.
[Settings](#retry)
```jsonc
{
"plugins": [
{
"package": "opencode-plugin-dsml-fix",
"options": { "debug": true },
},
],
}
```
Each turn logs one request line and one line per response attempt,
identified as `sessionID/user-messageID` (`#n` counts retries in 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
```
- `sanitized=Nmsgs/Mparts` — replayed history stripped (plus a short note).
- `recovered=N` — tool calls rescued from leaked markup. The fix fired.
- `passthrough` — no markup seen, bytes untouched.
- `held-candidate-no-call` — something looked like markup but parsed to
nothing (emitted verbatim). Frequent sightings mean a new variant worth
reporting — file an issue with the excerpt.
## Configuration ## Configuration
Object form in `opencode.jsonc`:
```jsonc ```jsonc
{ {
"plugins": [ "plugins": [
{ {
"package": "opencode-plugin-dsml-fix", "package": "opencode-plugin-dsml-fix",
"options": { "options": {
"providers": ["opencode-go", "opencode"], "parse": { "wrappedTool": false },
"mode": "relaxed", "retry": { "maxAttempts": 5 },
"resume": { "enabled": true, "maxAttempts": 3 },
}, },
}, },
], ],
} }
``` ```
### `parse`
Default: all on. Controls parser tolerance, for live recovery and for
deciding what counts as a stain in history (same engine, both directions).
| Option | Default | Meaning |
|---|---|---|
| `orphanInvoke` | `true` | Invokes with no outer block opener. |
| `looseParameters` | `true` | Re-scan past broken closers. |
| `rawJsonBody` | `true` | Bare-JSON invoke bodies. |
| `wrappedTool` | `true` | Orphan named parameter with complete inner params. |
When to disable one: that shape misfires (recovers something that was never
a call). Example: `"parse": { "wrappedTool": false }`. No master switch on
purpose. Flip individual flags.
### `history`
Default: on (`sanitize: true`). Highest impact layer (see above).
When to disable: don't. Switching it off re-poisons the transcript and the
imitation loop comes back. If the redaction notes bother you, file an issue
instead.
### `responseFix`
Default: on (`enabled: true`). One switch for the whole layer.
| Option | Default | Meaning |
|---|---|---|
| `enabled` | `true` | Turn leaked markup into real tool calls. |
| `bufferLimit` | `65536` | Cap on held text while deciding. Safety bound, not a target. |
When to disable: recovery ever corrupts a good turn
(`"responseFix": { "enabled": false }`).
On buffering, since people ask: normal text is never held. The fix holds
only a span that already opened as a real candidate (released if no call
structure develops), or a trailing fragment that already looks like a broken
tag (capped, released if it never becomes one). Everything else streams
through untouched.
### `retry`
Default: on. Wakes stalled turns, carries the conditional correction.
| Option | Default | Meaning |
|---|---|---|
| `enabled` | `true` | Idle wake for turns ending in unrecovered markup. One per message. |
| `channel` | `"system"` | `"system"` = minimal ping, correction rides in system. `"user"` = full correction as a synthetic user turn. |
| `nudge` | `true` | Correction when history ends unrecovered. One per message. |
Send-once per message: a reply that stays silent after its poke is left
alone. New replies always start fresh, so the cap never stops a live chat —
it only stops re-poking a message that never changes, where each poke would
burn a model call for nothing.
When to disable: wakes interrupt you (`"retry": { "enabled": false }`), or
keep the wake but drop the correction text (`"retry": { "nudge": false }`).
### General
| Option | Default | Meaning | | Option | Default | Meaning |
|---|---|---| |---|---|---|
| `providers` | `["opencode-go","opencode"]` | Provider IDs in scope. Empty disables the plugin. | | `providers` | `["opencode-go","opencode"]` | Provider IDs in scope. Empty disables the plugin. |
| `mode` | `"relaxed"` | `"relaxed"` = full variant catalogue; `"strict"` = complete blocks only. | | `debug` | `false` | Per-turn logging (see Verify). Only off-by-default flag. |
| `rescue.orphanInvoke` | mode | 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 (`http.response`). |
| `response.aisdkStream` | `true` | AI-SDK path: streaming parts. |
| `response.aisdkGenerate` | `true` | AI-SDK path: non-streaming results. |
| `request.sanitize` | `true` | Strip leaked spans from replayed history. |
| `request.directive` | `true` | Preventive system directive. |
| `request.systemNudge` | `true` | Conditional correction when a turn ends unrecovered. |
| `resume.enabled` | `true` | Idle watchdog wake. |
| `resume.maxAttempts` | `3` | Cap per assistant message. |
| `resume.channel` | `"system"` | `"system"` = minimal ping + nudge; `"user"` = legacy full-text turn. |
| `debug` | `false` | Per-turn impact logging (see above). |
Older names (`recovery.*`, `strategies.*`, `resume.*`, `prompt.*`) still work Full shape catalogue: `docs/PATTERNS.md`.
as aliases. Precedence: `response`/`request` > `recovery` > `strategies` >
legacy names > defaults. ## Verify
Set `"debug": true`. Each turn logs one request line plus one line per
response attempt (`sessionID/user-messageID`, `#n` counts retries):
```
[dsml] request ses_abc/msg_xyz sanitized=2msgs/3parts nudge=no
[dsml] response ses_abc/msg_xyz#1 recovered=1
[dsml] response ses_abc/msg_xyz#2 passthrough
```
- `sanitized=Nmsgs/Mparts`: replayed history stripped, notes left behind.
- `recovered=N`: the fix fired.
- `passthrough`: no markup seen, bytes untouched.
- `held-candidate-no-call`: markup-shaped but unparseable, emitted verbatim.
Seeing this often means a new variant. File an issue with the excerpt.
## Research
Same bug class, convergent fixes. This is the OpenCode-native one:
- [vLLM #54686](https://github.com/vllm-project/vllm/pull/54686): leak
taxonomy with production shares (mis-closed param 49%, runaway invoke
name 32%, unrecognized opener 21%). My catalogue mirrors it.
- [vLLM #48931](https://github.com/vllm-project/vllm/issues/48931): START
token omitted at long context (~95k+ tokens); same orphan-invoke fallback
I ship. Fixed upstream by
[#55954](https://github.com/vllm-project/vllm/pull/55954).
- [vLLM #40801](https://github.com/vllm-project/vllm/issues/40801):
streaming leaks plus the history-contamination effect behind my
sanitize-first stance.
- [CherryStudio #14747](https://github.com/CherryHQ/cherry-studio/pull/14747):
streaming DSML state machine and generate wrapper. I ported the algorithm
into the middleware.
- [openclaw](https://github.com/openclaw/openclaw) DSML transport and
grammar: doubled-bar markers, buffer cap, chunk boundaries. Same
conclusions.
- [pi-mono `pi-dsml`](https://github.com/badlogic/pi-mono) grammar: mangled
closers, ranges model, code-fence guard, `string=false` JSON rule. Shapes
ported into my parser.
## Development ## Development
@@ -127,13 +210,14 @@ legacy names > defaults.
bun install bun install
bun test # 115 tests: grammar, parser, stream, wrapper, config, sse, golden bun test # 115 tests: grammar, parser, stream, wrapper, config, sse, golden
bun run check # typecheck + tests bun run check # typecheck + tests
bun run build # compiled dist/ for npm (prepublish runs check + build)
``` ```
- `docs/PATTERNS.md` — failure-shape catalogue (source of truth for recovery) - `docs/PATTERNS.md`: failure-shape catalogue, source of truth for recovery
- `docs/TEST-MATRIX.md` — behaviour ↔ test map - `docs/TEST-MATRIX.md`: behaviour to test map
- `test/golden/` — synthetic byte-faithful fixtures + generator - `test/golden/`: synthetic fixtures and generator (`build-fixtures.ts`).
(`build-fixtures.ts`); the repo never stores live markup tokens No live markup bytes anywhere in the repo.
## License ## License
MIT — see [LICENSE](./LICENSE). MIT. See [LICENSE](./LICENSE).
+22 -21
View File
@@ -121,10 +121,14 @@ when zero calls were recovered — unclosed/unparseable tails emit verbatim.
- `v12` runaway invoke name (`name="tool {`) → no call, strip. - `v12` runaway invoke name (`name="tool {`) → no call, strip.
- Code fences are never parsed or stripped. - Code fences are never parsed or stripped.
- Message that already has native tool calls → strip leaked text only, do not re-run. - Message that already has native tool calls → strip leaked text only, do not re-run.
- Strategy A test-only for now: on idle, if last assistant text ends in DSML, send a - Strategy A live: on idle, if last assistant text ends in DSML, send a
synthetic correction nudge, capped via `ctx.storage`. synthetic correction nudge, send-once per message via `ctx.storage`.
## 8. The correction prompt (user's latest requirement) ## 8. The correction prompt (decided: nudge only, no preventive directive)
> Outcome: the preventive directive was removed — only the recovery-failure
> nudge shipped (`recoveryNudge()` in `src/prompt.ts`, wired as `retry.nudge`
> + idle wake). Original requirement below for the record.
When the parser **cannot** recover a call, and as preventive guidance, inject a When the parser **cannot** recover a call, and as preventive guidance, inject a
directive telling the model to always emit tool calls in the correct format **with the directive telling the model to always emit tool calls in the correct format **with the
@@ -151,46 +155,43 @@ providers is **not yet decided** — ask the user.
confirm `[dsml]` debug lines + recovery behaviour. confirm `[dsml]` debug lines + recovery behaviour.
5. Update `docs/PATTERNS.md`/`TEST-MATRIX.md` as rows move from FAIL to PASS. 5. Update `docs/PATTERNS.md`/`TEST-MATRIX.md` as rows move from FAIL to PASS.
## 11. Strategy report (final) ## 11. Feature report (final)
Per-request (in `context` hook, before the model call): Per-request (in `context` hook, before the model call):
- **Preventive directive** (`strategies.directive`, default on): appends a fixed - **History sanitization** (`history.sanitize`, default on): strips leaked DSML
~130-token instruction to `event.system` stating the exact required envelope
(complete outer block layer, single bars, one invoke per call, parameters inside
invokes). Static bytes every request → one-time prefix shift, then cache-stable.
- **History sanitization** (`strategies.sanitize`, default on): strips leaked DSML
spans from replayed assistant text. Fires only when a leak exists; deterministic spans from replayed assistant text. Fires only when a leak exists; deterministic
given stored messages, so retries converge to identical bytes (cache-friendly). given stored messages, so retries converge to identical bytes (cache-friendly).
Never synthesizes calls (no double-execution). Never synthesizes calls (no double-execution).
- **Conditional system nudge** (`strategies.systemNudge`, default on): when the - **Conditional nudge** (`retry.nudge`, default on): when the
assembled history ends in *unrecoverable* DSML, pushes the correction as a assembled history ends in *unrecoverable* DSML, pushes the correction as a
**transient system instruction** — never persisted, auto-clears when the **transient system instruction** — never persisted, auto-clears when the
condition clears. Capped per message (id, else content hash) via storage. condition clears. Send-once per message (id, else content hash) via storage.
Per-response (in `aisdk.language` wrapper, after the provider stream): Per-response (live fix, `responseFix.enabled`, default on):
- **B1 streaming** (`strategies.stream`): buffers `text-delta` only, holds partial - **AI-SDK path**: buffers `text-delta` only, holds partial
openers, resolves closed blocks immediately, emits `tool-input-*` + `tool-call`, openers, resolves closed blocks immediately, emits `tool-input-*` + `tool-call`,
flips `finish` to `tool-calls`. Zero calls → buffer emitted verbatim, finish flips `finish` to `tool-calls`. Zero calls → buffer emitted verbatim, finish
untouched. 64 KiB cap → over-cap unclosed tail emitted as text. Zero request untouched. 64 KiB cap → over-cap unclosed tail emitted as text. Zero request
impact → zero cache impact. impact → zero cache impact. Same recovery for non-streaming results.
- **B2 generate** (`strategies.generate`): same recovery for non-streaming results. - **Native SSE path** (`http.response` rewrite, the one `opencode-go` uses):
same parser, same policy on raw SSE bytes.
Idle safety net (`strategies.watchdog` + `resume.channel`/`maxAttempts`): Retry safety net (`retry.enabled` + `retry.channel`, send-once per message):
- On session idle with unrecovered DSML last message: `channel:"system"` (default) - On session idle with unrecovered DSML last message: `channel:"system"` (default)
sends a minimal synthetic wake ping (`"Resume the interrupted tool call."`) and sends a minimal synthetic wake ping (`"Resume the interrupted tool call."`) and
the context hook attaches the full correction in system on the woken turn; the context hook attaches the full correction in system on the woken turn;
`channel:"user"` restores the legacy full-text synthetic turn. `channel:"user"` sends the full correction as a synthetic user turn.
- Attempts capped per message; counters in plugin storage. - One poke per message; a message that stays silent is left alone.
Fixed prompt texts live in `src/prompt.ts` (`preventiveDirective()`, The fixed nudge text lives in `src/prompt.ts` (`recoveryNudge()`); tests assert
`recoveryNudge()`); tests assert they contain no live marker bytes. it contains no live marker bytes.
Caching verdict: only stable, deterministic bytes are ever added; nothing in the Caching verdict: only stable, deterministic bytes are ever added; nothing in the
plugin churns per-request content. The one real invalidation is the one-time shift plugin churns per-request content. The one real invalidation is the one-time shift
when the directive/nudge first appears — expected and unavoidable for any fix, and when the nudge first appears — expected and unavoidable for any fix, and
documented as acceptable. documented as acceptable.
Rejected alternative: `session.instructions.entry.put` as the nudge channel. It Rejected alternative: `session.instructions.entry.put` as the nudge channel. It
+7 -7
View File
@@ -83,13 +83,13 @@ fragment. This row therefore expects `calls: []`, `ranges: [the parameter markup
| ID | Fixture | Expect | | ID | Fixture | Expect |
|----|---------|--------| |----|---------|--------|
| `config-defaults` | `resolveConfig()` | both Zen providers, relaxed, resume 3 | | `config-defaults` | `resolveConfig()` | both Zen providers, all flags on, debug off |
| `config-strict` | `mode:"strict"` | every rescue off | | `config-granular` | one parse flag off | others stay on |
| `config-granular` | one rescue off | others stay on | | `config-layers` | each layer off | that layer disabled |
| `strict-block` | complete block + strict flags | parses | | `parse-flags-off` | complete block + all flags off | parses |
| `strict-orphan` | orphan invoke + strict flags | no call | | `parse-orphan-off` | orphan invoke + flags off | no call |
| `prompt-no-marker` | both directives | no live marker bytes, outer-layer rule stated | | `prompt-no-marker` | recovery nudge | no live marker bytes, outer-layer rule stated |
| `fallback-*` | decide/messageText/attempts | nudge policy + cap | | `fallback-*` | decide/messageText/send-once | nudge policy + send-once |
## Wrapper: `wrapDsmlLanguageModel` ## Wrapper: `wrapDsmlLanguageModel`
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "opencode-plugin-dsml-fix", "name": "opencode-plugin-dsml-fix",
"version": "0.1.0", "version": "0.2.0",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"description": "OpenCode V2 plugin that recovers DeepSeek DSML tool calls leaked as assistant text", "description": "OpenCode V2 plugin that recovers DeepSeek DSML tool calls leaked as assistant text",
+55 -120
View File
@@ -1,8 +1,11 @@
/** /**
* Plugin configuration. Every behaviour the plugin adds can be narrowed back to a * Plugin configuration. Every behaviour the plugin adds can be narrowed back to a
* simpler version without uninstalling: if the deeper fix misbehaves, turn its flag * simpler version without uninstalling: if one shape misfires, switch its flag off
* off and the plugin degrades to the safer subset. The invariant is that the plugin * and the plugin degrades to a safer subset. The invariant is that the plugin
* must never break normal inference — when in doubt it passes traffic through. * must never break normal inference — when in doubt it passes traffic through.
*
* Namespaces match the four features in the README: `parse`, `history`,
* `responseFix`, `retry`. Top level holds only general settings.
*/ */
export interface DsmlPluginOptions { export interface DsmlPluginOptions {
@@ -12,159 +15,91 @@ export interface DsmlPluginOptions {
*/ */
providers?: string[] providers?: string[]
/** /**
* Parsing strictness. * Parser tolerance. Each flag defaults to true; switch one off in isolation
* - `"relaxed"` (default): full catalogue — complete blocks, doubled bars, * when that shape misfires. The same flags govern live recovery and what
* truncated tags, orphan-invoke rescue, mis-closed fallback, raw-JSON body. * counts as a stain in history (one engine, both directions).
* - `"strict"`: complete `tool_calls`/`function_calls` blocks with proper closers
* only. Use when the deeper rescue ever misfires.
*/ */
mode?: "strict" | "relaxed" parse?: {
/** /** Recover `invoke` blocks with no outer opener. Default true. */
* Granular rescue toggles. Each defaults to the `mode` value; setting one
* explicitly overrides the mode for that area so a single failing rescue can be
* switched off in isolation.
*/
rescue?: {
/** Recover `invoke` blocks with no outer opener (V9). Default: mode. */
orphanInvoke?: boolean orphanInvoke?: boolean
/** Tolerant re-scan when a value ran past a broken closer (V11). Default: mode. */ /** Tolerant re-scan when a value ran past a broken closer. Default true. */
looseParameters?: boolean looseParameters?: boolean
/** Bare-`{…}` invoke body parsed as JSON (V25). Default: mode. */ /** Bare-`{…}` invoke body parsed as JSON. Default true. */
rawJsonBody?: boolean rawJsonBody?: boolean
/** Wrapped tool: orphan `<parameter name="X">` with complete inner params (V26). Default: mode. */ /** Orphan `<parameter name="X">` with complete inner params. Default true. */
wrappedTool?: boolean wrappedTool?: boolean
} }
/** Streaming swallow cap in bytes (V15/buffer-cap). Default 65536. */ /** History sanitization: strip leaked spans from replayed assistant text. */
bufferLimit?: number history?: {
/** /** Default true. Leave on: disabling re-poisons the transcript. */
* Response-side recovery: turn leaked markup into real tool calls.
* The two transports are independent paths to the model and are named as such.
* (`recovery` is accepted as a deprecated alias; `response` wins when set.)
*/
response?: {
/** Native path: rewrite SSE bytes in `http.response` (opencode-go). Default true. */
sse?: boolean
/** AI-SDK path: wrap the language model, streaming parts. Default true. */
aisdkStream?: boolean
/** AI-SDK path: wrap the language model, non-streaming results. Default true. */
aisdkGenerate?: boolean
}
/**
* Deprecated alias for `response`. When both forms are set, `response` wins.
*/
recovery?: {
/** Alias: response.sse. */
sse?: boolean
/** Alias: response.aisdkStream. */
aisdkStream?: boolean
/** Alias: response.aisdkGenerate. */
aisdkGenerate?: boolean
}
/**
* Request-side shaping: control what the model sees in history and system.
*/
request?: {
/** Strip leaked DSML spans from replayed assistant history. Default true. */
sanitize?: boolean sanitize?: boolean
/** Preventive system directive on every request. Default true. */
directive?: boolean
/** Conditional correction nudge in system when history ends in unrecovered DSML. Default true. */
systemNudge?: boolean
} }
/** /** Live response fix: turn leaked markup into real tool calls. */
* Per-strategy switches (deprecated alias for `recovery` + `request`; kept for responseFix?: {
* backward compatibility). When both forms are set, `recovery`/`request` win. /** Master switch for all transports. Default true. */
*/ enabled?: boolean
strategies?: { /** Streaming hold cap in bytes. Safety bound, not a target. Default 65536. */
/** Alias: recovery.aisdkStream. */ bufferLimit?: number
stream?: boolean
/** Alias: recovery.aisdkGenerate. */
generate?: boolean
/** Alias: recovery.sse. */
sse?: boolean
/** Alias: request.sanitize. */
sanitize?: boolean
/** Alias: request.directive. */
directive?: boolean
/** Alias: request.systemNudge. */
systemNudge?: boolean
/** Alias: watchdog.enabled. */
watchdog?: boolean
} }
/** Strategy-A auto-resume safety net (idle watchdog). */ /** Retry safety net for turns the fix could not recover. */
resume?: { retry?: {
/** /**
* Master switch (aliases: strategies.watchdog). Default true. * Master switch for the idle wake. Default true.
* Precedence: strategies.watchdog > resume.enabled > default. * Send-once per message: a message that stays silent after one
* wake is left alone, never re-poked.
*/ */
enabled?: boolean enabled?: boolean
/** Max nudges per assistant message. Default 3. */
maxAttempts?: number
/** /**
* Delivery channel for the watchdog wake. * Conditional correction nudge when history ends in unrecovered markup.
* - `"system"` (default): the wake is a minimal synthetic ping; the full * Default true.
* correction rides as a transient system instruction via the context hook. */
* - `"user"`: legacy single-message correction nudge as a synthetic user turn. nudge?: boolean
/**
* Delivery channel for the wake.
* - `"system"` (default): minimal wake ping; the correction rides as a
* transient system instruction via the context hook.
* - `"user"`: full correction as a synthetic user turn.
*/ */
channel?: "system" | "user" channel?: "system" | "user"
} }
/** Preventive system instruction (alias: request.directive). Default true. */ /** Log recoveries to stderr. Default false. The only off-by-default flag. */
prompt?: {
/**
* Master switch. Default true.
* Precedence: request.directive > strategies.directive > prompt.enabled > default.
*/
enabled?: boolean
}
/** Log recoveries to stderr. Default false. */
debug?: boolean debug?: boolean
} }
export interface ResolvedDsmlConfig { export interface ResolvedDsmlConfig {
readonly providers: readonly string[] readonly providers: readonly string[]
readonly relaxed: boolean
readonly orphanInvoke: boolean readonly orphanInvoke: boolean
readonly looseParameters: boolean readonly looseParameters: boolean
readonly rawJsonBody: boolean readonly rawJsonBody: boolean
readonly wrappedTool: boolean readonly wrappedTool: boolean
readonly bufferLimit: number readonly bufferLimit: number
readonly streamEnabled: boolean readonly responseFixEnabled: boolean
readonly generateEnabled: boolean
readonly sseEnabled: boolean
readonly sanitizeEnabled: boolean readonly sanitizeEnabled: boolean
readonly directiveEnabled: boolean readonly retryEnabled: boolean
readonly systemNudgeEnabled: boolean readonly retryNudgeEnabled: boolean
readonly watchdogEnabled: boolean readonly retryChannel: "system" | "user"
readonly resumeMaxAttempts: number
readonly resumeChannel: "system" | "user"
readonly debug: boolean readonly debug: boolean
} }
const DEFAULT_PROVIDERS = ["opencode-go", "opencode"] as const const DEFAULT_PROVIDERS = ["opencode-go", "opencode"] as const
export function resolveConfig(input: DsmlPluginOptions = {}): ResolvedDsmlConfig { export function resolveConfig(input: DsmlPluginOptions = {}): ResolvedDsmlConfig {
const relaxed = (input.mode ?? "relaxed") === "relaxed" const parse = input.parse ?? {}
const s = input.strategies ?? {} const history = input.history ?? {}
const response = input.response ?? {} const responseFix = input.responseFix ?? {}
const recovery = input.recovery ?? {} const retry = input.retry ?? {}
const request = input.request ?? {}
return { return {
providers: input.providers ?? [...DEFAULT_PROVIDERS], providers: input.providers ?? [...DEFAULT_PROVIDERS],
relaxed, orphanInvoke: parse.orphanInvoke ?? true,
orphanInvoke: input.rescue?.orphanInvoke ?? relaxed, looseParameters: parse.looseParameters ?? true,
looseParameters: input.rescue?.looseParameters ?? relaxed, rawJsonBody: parse.rawJsonBody ?? true,
rawJsonBody: input.rescue?.rawJsonBody ?? relaxed, wrappedTool: parse.wrappedTool ?? true,
wrappedTool: input.rescue?.wrappedTool ?? relaxed, bufferLimit: responseFix.bufferLimit ?? 64 * 1024,
bufferLimit: input.bufferLimit ?? 64 * 1024, responseFixEnabled: responseFix.enabled ?? true,
streamEnabled: response.aisdkStream ?? recovery.aisdkStream ?? s.stream ?? true, sanitizeEnabled: history.sanitize ?? true,
generateEnabled: response.aisdkGenerate ?? recovery.aisdkGenerate ?? s.generate ?? true, retryEnabled: retry.enabled ?? true,
sseEnabled: response.sse ?? recovery.sse ?? s.sse ?? true, retryNudgeEnabled: retry.nudge ?? true,
sanitizeEnabled: request.sanitize ?? s.sanitize ?? true, retryChannel: retry.channel ?? "system",
directiveEnabled: request.directive ?? s.directive ?? input.prompt?.enabled ?? true,
systemNudgeEnabled: request.systemNudge ?? s.systemNudge ?? true,
watchdogEnabled: s.watchdog ?? input.resume?.enabled ?? true,
resumeMaxAttempts: input.resume?.maxAttempts ?? 3,
resumeChannel: input.resume?.channel ?? "system",
debug: input.debug ?? false, debug: input.debug ?? false,
} }
} }
+20 -19
View File
@@ -1,12 +1,13 @@
/** /**
* Strategy A: auto-resume safety net for turns the streaming middleware could not * Retry safety net for turns the live fix could not recover. Pure decision logic
* recover. Pure decision logic lives here so it is unit-testable; OpenCode I/O * lives here so it is unit-testable; OpenCode I/O (events, session reads,
* (events, session reads, synthetic prompts, storage) is injected by `src/index.ts`. * synthetic prompts, storage) is injected by `src/index.ts`.
* *
* Policy: when a session goes idle and its latest assistant text ends in DSML markup * Policy: when a session goes idle and its latest assistant text holds DSML markup
* that never became a tool call, send one short correction nudge (see * that never became a tool call, send one short correction nudge (see
* `src/prompt.ts`). Attempts are capped per message so a pathological loop cannot * `src/prompt.ts`). Send-once per message: a message that stays silent after one
* run away. * wake is left alone, never re-poked — if the first poke got no reply the cause
* is outside anything a second poke can fix.
*/ */
import { looksLikeDsml } from "./grammar.ts" import { looksLikeDsml } from "./grammar.ts"
@@ -18,7 +19,7 @@ export interface FallbackDecision {
readonly send: boolean readonly send: boolean
/** The nudge text, present when `send` is true. */ /** The nudge text, present when `send` is true. */
readonly text?: string readonly text?: string
/** Storage key the attempt was (or would be) counted under. */ /** Storage key the send was (or would be) recorded under. */
readonly key?: string readonly key?: string
/** Why no nudge is sent. */ /** Why no nudge is sent. */
readonly reason?: string readonly reason?: string
@@ -46,13 +47,13 @@ export interface HookMessage {
} }
export interface SystemNudge { export interface SystemNudge {
/** Cap key: message id when present, otherwise a hash of the text. */ /** Dedupe key: message id when present, otherwise a hash of the text. */
readonly key: string readonly key: string
/** The correction text for the system channel. */ /** The correction text for the system channel. */
readonly text: string readonly text: string
} }
/** Stable cap key for a hook message: id when present, content hash otherwise. */ /** Stable dedupe key for a hook message: id when present, content hash otherwise. */
export function nudgeCapKey(message: HookMessage): string { export function nudgeCapKey(message: HookMessage): string {
if (message.id) return message.id if (message.id) return message.id
return contentHash(message) return contentHash(message)
@@ -83,7 +84,7 @@ export function turnId(messages: ReadonlyArray<HookMessage>): string {
/** /**
* Decide whether the assembled request history ends in unrecovered DSML and needs * Decide whether the assembled request history ends in unrecovered DSML and needs
* the correction as a transient system instruction. Pure: the caller enforces the * the correction as a transient system instruction. Pure: the caller enforces the
* per-key cap via storage. Returns undefined when no nudge is warranted. * send-once rule via storage. Returns undefined when no nudge is warranted.
*/ */
export function needsSystemNudge( export function needsSystemNudge(
messages: ReadonlyArray<HookMessage>, messages: ReadonlyArray<HookMessage>,
@@ -100,16 +101,17 @@ export function needsSystemNudge(
/** /**
* Decide whether the latest assistant text warrants a correction nudge. * Decide whether the latest assistant text warrants a correction nudge.
* Send-once per message: `alreadySent` means this message was already poked
* once and stayed silent — leave it alone.
* *
* @param text the latest assistant text (already stripped of nothing) * @param text the latest assistant text
* @param attempts nudges already sent for this message * @param alreadySent whether a nudge/wake was already sent for this message
* @param maxAttempts cap from config
*/ */
export function decideResume(text: string, attempts: number, maxAttempts: number): FallbackDecision { export function decideResume(text: string, alreadySent: boolean): FallbackDecision {
if (!looksLikeDsml(text)) return { send: false, reason: "no-marker" } if (!looksLikeDsml(text)) return { send: false, reason: "no-marker" }
const { calls } = parseDsml(text) const { calls } = parseDsml(text)
if (calls.length > 0) return { send: false, reason: "already-recoverable" } if (calls.length > 0) return { send: false, reason: "already-recoverable" }
if (attempts >= maxAttempts) return { send: false, reason: "cap-reached" } if (alreadySent) return { send: false, reason: "already-sent" }
return { send: true, text: recoveryNudge() } return { send: true, text: recoveryNudge() }
} }
@@ -124,8 +126,7 @@ export function messageText(message: {
return parts.map((part) => part.text as string).join("\n") return parts.map((part) => part.text as string).join("\n")
} }
/** Parse an attempt counter value read from storage. */ /** Read a send-once flag from storage. Any truthy value means already sent. */
export function parseAttempts(value: unknown): number { export function alreadySent(value: unknown): boolean {
if (typeof value === "number" && Number.isFinite(value) && value >= 0) return Math.floor(value) return value === true || value === 1 || value === "1"
return 0
} }
+32 -40
View File
@@ -1,36 +1,34 @@
/** /**
* opencode-plugin-dsml-fix — recover DeepSeek DSML tool calls that leak as text. * opencode-plugin-dsml-fix — recover DeepSeek DSML tool calls that leak as text.
* *
* Strategy matrix (each independently configurable, see `src/config.ts`): * Four features (see README), each independently configurable:
* - B1 `strategies.stream`: streaming middleware recovery via ctx.aisdk language hook. * - `parse`: tolerant parser — complete blocks plus orphan invokes, loose
* - B2 `strategies.generate`: non-streaming generate recovery (same hook). * parameters, raw-JSON bodies, wrapped tools. Same flags govern live
* - `strategies.sanitize`: strip leaked DSML spans from replayed history. * recovery and history stain detection.
* - `strategies.directive`: preventive correction directive in system. * - `history`: strip leaked spans from replayed assistant text (+ note).
* - `strategies.systemNudge`: conditional correction nudge in system when history * Never synthesizes calls from history (no double execution).
* ends in unrecovered DSML (transient, never persisted). * - `responseFix`: turn leaked markup into real calls on the live response,
* - `strategies.watchdog`: idle watchdog that wakes stalled sessions; the wake * native SSE path and AI-SDK path alike.
* travels on `resume.channel` ("system" = minimal ping + system nudge, * - `retry`: conditional correction nudge plus one idle wake per message.
* "user" = legacy full-text synthetic turn).
* *
* Invariant: the plugin must never break normal inference. Unknown shapes pass * Invariant: the plugin must never break normal inference. Unknown shapes pass
* through untouched, and every strategy degrades to a safer subset via config. * through untouched, and every layer degrades to a safer subset via config.
*/ */
import { Plugin } from "@opencode/plugin" import { Plugin } from "@opencode/plugin"
import { appendFileSync } from "node:fs" import { appendFileSync } from "node:fs"
import { appliesToProvider, resolveConfig, type DsmlPluginOptions } from "./config.ts" import { appliesToProvider, resolveConfig, type DsmlPluginOptions } from "./config.ts"
import { import {
alreadySent,
attemptKey, attemptKey,
decideResume, decideResume,
messageText, messageText,
needsSystemNudge, needsSystemNudge,
parseAttempts,
turnId, turnId,
wakeText, wakeText,
} from "./fallback.ts" } from "./fallback.ts"
import { createDsmlStreamMiddleware, wrapDsmlLanguageModel } from "./middleware.ts" import { createDsmlStreamMiddleware, wrapDsmlLanguageModel } from "./middleware.ts"
import { parseDsml, redactionNote, stripRangesWithNote, type ParseOptions } from "./parse.ts" import { parseDsml, redactionNote, stripRangesWithNote, type ParseOptions } from "./parse.ts"
import { preventiveDirective } from "./prompt.ts"
import { rewriteSseResponse } from "./sse.ts" import { rewriteSseResponse } from "./sse.ts"
const PLUGIN_ID = "dsml" const PLUGIN_ID = "dsml"
@@ -70,8 +68,8 @@ export default Plugin.define({
// `session/message` identity. Attempts count physical requests within a turn. // `session/message` identity. Attempts count physical requests within a turn.
const turns = new Map<string, { turn: string; attempts: number }>() const turns = new Map<string, { turn: string; attempts: number }>()
// --- Strategies B1+B2: model recovery ------------------------------------ // --- Live response fix: model recovery ------------------------------------
if (config.streamEnabled || config.generateEnabled) { if (config.responseFixEnabled) {
await ctx.aisdk.hook("language", (event) => { await ctx.aisdk.hook("language", (event) => {
try { try {
appendFileSync( appendFileSync(
@@ -97,8 +95,8 @@ export default Plugin.define({
} }
event.language = wrapDsmlLanguageModel(base, { event.language = wrapDsmlLanguageModel(base, {
...parseOptions, ...parseOptions,
stream: config.streamEnabled, stream: true,
generate: config.generateEnabled, generate: true,
bufferLimit: config.bufferLimit, bufferLimit: config.bufferLimit,
onSettled: (stats) => { onSettled: (stats) => {
const what = const what =
@@ -110,12 +108,12 @@ export default Plugin.define({
}) })
} }
// --- Strategy B3: SSE rewrite on the native protocol path ------------------ // --- Live response fix: SSE rewrite on the native protocol path ------------
// Catalog providers such as opencode-go resolve to the NATIVE // Catalog providers such as opencode-go resolve to the NATIVE
// openai-compatible route, where `aisdk.language` never fires. The native // openai-compatible route, where `aisdk.language` never fires. The native
// route offers every request to the http hooks, so rewrite the SSE bytes // route offers every request to the http hooks, so rewrite the SSE bytes
// there instead. Same parser, same policy, different wire format. // there instead. Same parser, same policy, different wire format.
if (config.sseEnabled) { if (config.responseFixEnabled) {
await ctx.session.hook("http.response", (event) => { await ctx.session.hook("http.response", (event) => {
if (!appliesToProvider(config, event.model.providerID)) return if (!appliesToProvider(config, event.model.providerID)) return
if (event.kind !== "primary") return if (event.kind !== "primary") return
@@ -144,7 +142,7 @@ export default Plugin.define({
}) })
} }
// --- Request strategies: directive + sanitize + system nudge ------------ // --- Request shaping: sanitize + conditional nudge -------------------------
await ctx.session.hook("context", async (event) => { await ctx.session.hook("context", async (event) => {
if (!appliesToProvider(config, event.model.providerID)) return if (!appliesToProvider(config, event.model.providerID)) return
// A new user message starts a new turn: reset the attempt counter so the // A new user message starts a new turn: reset the attempt counter so the
@@ -155,12 +153,7 @@ export default Plugin.define({
const label = `${event.sessionID}/${turn}` const label = `${event.sessionID}/${turn}`
let sanitizedMsgs = 0 let sanitizedMsgs = 0
let sanitizedParts = 0 let sanitizedParts = 0
let directive = false
let nudge = false let nudge = false
if (config.directiveEnabled) {
event.system.push({ type: "text", text: preventiveDirective() })
directive = true
}
if (config.sanitizeEnabled) { if (config.sanitizeEnabled) {
// Strip leaked DSML spans from replayed assistant text so the model does // Strip leaked DSML spans from replayed assistant text so the model does
// not imitate its own degraded output. A short inline note marks each // not imitate its own degraded output. A short inline note marks each
@@ -188,30 +181,29 @@ export default Plugin.define({
if (touched) sanitizedMsgs++ if (touched) sanitizedMsgs++
} }
} }
if (config.systemNudgeEnabled) { if (config.retryNudgeEnabled) {
const need = needsSystemNudge(event.messages, parseOptions) const need = needsSystemNudge(event.messages, parseOptions)
if (need) { if (need) {
const key = attemptKey(event.sessionID, need.key) const key = attemptKey(event.sessionID, need.key)
const attempts = parseAttempts(await ctx.storage.get(key)) if (alreadySent(await ctx.storage.get(key))) {
if (attempts >= config.resumeMaxAttempts) { log("nudge already sent for", need.key)
log("system nudge cap reached for", need.key)
} else { } else {
await ctx.storage.set(key, attempts + 1) await ctx.storage.set(key, true)
event.system.push({ type: "text", text: need.text }) event.system.push({ type: "text", text: need.text })
nudge = true nudge = true
} }
} }
} }
// One line per request turn: what each request layer actually did. // One line per request turn: what each request layer actually did.
if (sanitizedMsgs > 0 || directive || nudge) { if (sanitizedMsgs > 0 || nudge) {
log( log(
`request ${label} sanitized=${sanitizedMsgs}msgs/${sanitizedParts}parts directive=${directive ? "yes" : "no"} nudge=${nudge ? "yes" : "no"}`, `request ${label} sanitized=${sanitizedMsgs}msgs/${sanitizedParts}parts nudge=${nudge ? "yes" : "no"}`,
) )
} }
}) })
// --- Strategy: idle watchdog ---------------------------------------------- // --- Retry: one idle wake per stalled message -------------------------------
if (!config.watchdogEnabled) return if (!config.retryEnabled) return
const controller = new AbortController() const controller = new AbortController()
void (async () => { void (async () => {
try { try {
@@ -254,22 +246,22 @@ async function maybeWake(
}) })
if (!text) return if (!text) return
const key = attemptKey(sessionID, last.id) const key = attemptKey(sessionID, last.id)
const attempts = parseAttempts(await ctx.storage.get(key)) const sent = alreadySent(await ctx.storage.get(key))
const decision = decideResume(text, attempts, config.resumeMaxAttempts) const decision = decideResume(text, sent)
if (!decision.send || !decision.text) { if (!decision.send || !decision.text) {
if (decision.reason && decision.reason !== "no-marker") log(decision.reason, "for", last.id) if (decision.reason && decision.reason !== "no-marker") log(decision.reason, "for", last.id)
return return
} }
await ctx.storage.set(key, attempts + 1) await ctx.storage.set(key, true)
if (config.resumeChannel === "user") { if (config.retryChannel === "user") {
// Legacy: the full correction travels as a synthetic user turn. // The full correction travels as a synthetic user turn.
await ctx.session.synthetic({ sessionID, text: decision.text }) await ctx.session.synthetic({ sessionID, text: decision.text })
} else { } else {
// System channel: minimal wake ping; the context hook attaches the full // System channel: minimal wake ping; the context hook attaches the full
// correction as a transient system instruction on the woken turn. // correction as a transient system instruction on the woken turn.
await ctx.session.synthetic({ sessionID, text: wakeText(), description: "dsml-retry wake" }) await ctx.session.synthetic({ sessionID, text: wakeText(), description: "dsml-retry wake" })
} }
log("sent wake for", last.id, `attempt ${attempts + 1} channel=${config.resumeChannel}`) log("sent wake for", last.id, `channel=${config.retryChannel}`)
} }
export { createDsmlStreamMiddleware } export { createDsmlStreamMiddleware }
+2 -1
View File
@@ -246,7 +246,8 @@ function parseParameters(body: string, options: Required<ParseOptions>): Record<
const strict = collectParameters(body, parameterRe()) const strict = collectParameters(body, parameterRe())
// If the strict scan produced nothing, or one value ran away into the next // If the strict scan produced nothing, or one value ran away into the next
// parameter (the vLLM mis-closed-closer leak), retry with the tolerant scan. (V11) // parameter (the vLLM mis-closed-closer leak), retry with the tolerant scan. (V11)
// The tolerant scan is a relaxed-mode rescue; strict mode keeps the strict result. // The tolerant scan applies when `looseParameters` is on; otherwise the
// strict result stands as-is.
const ranAway = Object.values(strict).some((value) => typeof value === "string" && value.includes("DSML")) const ranAway = Object.values(strict).some((value) => typeof value === "string" && value.includes("DSML"))
const picked = const picked =
options.looseParameters && (Object.keys(strict).length === 0 || ranAway) options.looseParameters && (Object.keys(strict).length === 0 || ranAway)
+7 -30
View File
@@ -1,38 +1,15 @@
/** /**
* Correction directives: the text the plugin shows the model so it emits tool calls * Recovery-failure nudge: the text the plugin shows the model so it re-issues a
* in the correct format with the outer XML layer. * tool call that arrived malformed.
* *
* Writing rule: these strings are *sent to the model*, so they describe the grammar * Writing rule: this string is *sent to the model*, so it describes the grammar
* without containing a live DSML token. A literal token here would be parsed as a * without containing a live DSML token. A literal token here would be parsed as a
* phantom call by greedy provider extractors. The fixer tests assert the strings * phantom call by greedy provider extractors. The fixer tests assert the string
* contain no marker bytes. * contains no marker bytes.
*/ */
export const MARKER_DESCRIPTION =
"a DeepSeek DSML tag uses single full-width bars around the word DSML " +
"(open angle bracket, U+FF5C, the letters DSML, U+FF5C, then the tag name)"
/** /**
* Preventive instruction, installed via the `context` hook as a system part. Tells * Recovery-failure nudge, sent when DSML markup appeared but no call
* the model the exact envelope so degraded forms are less likely in the first place.
*/
export function preventiveDirective(): string {
return [
"Tool-call format (must follow exactly):",
"1. Wrap every tool call in a complete outer block layer: an opening block tag",
" for tool_calls, then one invoke element per call, then the closing block tag.",
`2. Each tag is ${MARKER_DESCRIPTION}. Never use doubled bars, never use plain ASCII pipes, never leave a space where the tag name belongs.`,
"3. Each invoke element carries the tool name in its name attribute and contains",
' one parameter element per argument, with string="true" for plain text values',
' and string="false" for JSON values.',
"4. Never emit a parameter element without its enclosing invoke element, and never",
" emit an invoke element without the outer block layer. Incomplete markup will",
" not be executed.",
].join("\n")
}
/**
* Recovery-failure nudge, sent (via Strategy A) when DSML markup appeared but no call
* could be recovered, or when a session idles on leaked markup. Short, imperative, * could be recovered, or when a session idles on leaked markup. Short, imperative,
* and self-contained: it restates the required outer layer. * and self-contained: it restates the required outer layer.
*/ */
@@ -47,7 +24,7 @@ export function recoveryNudge(toolHint?: string): string {
].join(" ") ].join(" ")
} }
/** Guard used by tests: neither directive may contain a live marker. */ /** Guard used by tests: model-facing text must contain no live marker. */
export function containsLiveMarker(text: string): boolean { export function containsLiveMarker(text: string): boolean {
return /[\uFF5C|]\s*DSML\s*[\uFF5C|]/i.test(text) return /[\uFF5C|]\s*DSML\s*[\uFF5C|]/i.test(text)
} }
+62 -91
View File
@@ -1,36 +1,49 @@
/** Config, mode gating, prompt guards, and fallback decisions. */ /** Config defaults, per-shape parse flags, prompt guards, and retry decisions. */
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { appliesToProvider, resolveConfig } from "../src/config.ts" import { appliesToProvider, resolveConfig } from "../src/config.ts"
import { attemptKey, decideResume, messageText, needsSystemNudge, nudgeCapKey, parseAttempts, wakeText } from "../src/fallback.ts" import { alreadySent, attemptKey, decideResume, messageText, needsSystemNudge, nudgeCapKey, turnId, wakeText } from "../src/fallback.ts"
import { block, invoke, param } from "./fixtures.ts" import { block, invoke, param } from "./fixtures.ts"
import { parseDsml, redactionNote, stripRangesWithNote } from "../src/parse.ts" import { parseDsml, redactionNote, stripRangesWithNote } from "../src/parse.ts"
import { containsLiveMarker, preventiveDirective, recoveryNudge } from "../src/prompt.ts" import { containsLiveMarker, recoveryNudge } from "../src/prompt.ts"
describe("config", () => { describe("config", () => {
test("defaults target both Zen providers in relaxed mode", () => { test("defaults: everything protective on, only debug off", () => {
const c = resolveConfig() const c = resolveConfig()
expect([...c.providers]).toEqual(["opencode-go", "opencode"]) expect([...c.providers]).toEqual(["opencode-go", "opencode"])
expect(c.relaxed).toBe(true)
expect(c.orphanInvoke).toBe(true) expect(c.orphanInvoke).toBe(true)
expect(c.watchdogEnabled).toBe(true)
expect(c.resumeMaxAttempts).toBe(3)
expect(c.directiveEnabled).toBe(true)
expect(c.resumeChannel).toBe("system")
})
test("strict mode disables every rescue", () => {
const c = resolveConfig({ mode: "strict" })
expect(c.relaxed).toBe(false)
expect(c.orphanInvoke).toBe(false)
expect(c.looseParameters).toBe(false)
expect(c.rawJsonBody).toBe(false)
})
test("one rescue can be switched off in isolation", () => {
const c = resolveConfig({ rescue: { orphanInvoke: false } })
expect(c.orphanInvoke).toBe(false)
expect(c.looseParameters).toBe(true) expect(c.looseParameters).toBe(true)
expect(c.rawJsonBody).toBe(true)
expect(c.wrappedTool).toBe(true)
expect(c.responseFixEnabled).toBe(true)
expect(c.bufferLimit).toBe(64 * 1024)
expect(c.sanitizeEnabled).toBe(true)
expect(c.retryEnabled).toBe(true)
expect(c.retryNudgeEnabled).toBe(true)
expect(c.retryChannel).toBe("system")
expect(c.debug).toBe(false)
})
test("one parse shape can be switched off in isolation", () => {
const c = resolveConfig({ parse: { wrappedTool: false } })
expect(c.wrappedTool).toBe(false)
expect(c.orphanInvoke).toBe(true)
})
test("whole layers switch off", () => {
const c = resolveConfig({
responseFix: { enabled: false },
history: { sanitize: false },
retry: { enabled: false, nudge: false },
})
expect(c.responseFixEnabled).toBe(false)
expect(c.sanitizeEnabled).toBe(false)
expect(c.retryEnabled).toBe(false)
expect(c.retryNudgeEnabled).toBe(false)
})
test("retry channel selects delivery", () => {
expect(resolveConfig({ retry: { channel: "user" } }).retryChannel).toBe("user")
}) })
test("empty providers disables scoping", () => { test("empty providers disables scoping", () => {
@@ -47,104 +60,66 @@ describe("config", () => {
}) })
}) })
describe("strategy matrix", () => { describe("parse flags", () => {
test("each transport recovers independently", () => { const allOff = { orphanInvoke: false, looseParameters: false, rawJsonBody: false, wrappedTool: false }
const off = resolveConfig({ recovery: { sse: false } })
expect(off.sseEnabled).toBe(false)
expect(off.generateEnabled).toBe(true)
const all = resolveConfig({
recovery: { sse: false, aisdkStream: false, aisdkGenerate: false },
request: { sanitize: false, directive: false, systemNudge: false },
})
expect(all.streamEnabled).toBe(false)
expect(all.generateEnabled).toBe(false)
expect(all.sanitizeEnabled).toBe(false)
expect(all.directiveEnabled).toBe(false)
expect(all.systemNudgeEnabled).toBe(false)
})
test("recovery/request win over legacy strategies.*", () => { test("complete block still parses with every flag off", () => {
expect(resolveConfig({ strategies: { sse: false } }).sseEnabled).toBe(false) const r = parseDsml(block([invoke("bash", [param("command", "pwd")])]), allOff)
expect(
resolveConfig({ strategies: { sse: false }, response: { sse: true } }).sseEnabled,
).toBe(true)
expect(
resolveConfig({ strategies: { sanitize: false }, request: { sanitize: true } }).sanitizeEnabled,
).toBe(true)
})
test("legacy aliases still work; new names win when set", () => {
expect(resolveConfig({ resume: { enabled: false } }).watchdogEnabled).toBe(false)
expect(resolveConfig({ prompt: { enabled: false } }).directiveEnabled).toBe(false)
expect(resolveConfig({ resume: { enabled: false }, strategies: { watchdog: true } }).watchdogEnabled).toBe(true)
expect(resolveConfig({ resume: { channel: "user" } }).resumeChannel).toBe("user")
})
})
describe("strict mode parsing", () => {
const strict = { orphanInvoke: false, looseParameters: false, rawJsonBody: false }
test("complete block still parses", () => {
const r = parseDsml(block([invoke("bash", [param("command", "pwd")])]), strict)
expect(r.calls).toHaveLength(1) expect(r.calls).toHaveLength(1)
}) })
test("orphan invoke is not recovered", () => { test("orphan invoke is not recovered with its flag off", () => {
const r = parseDsml(invoke("edit", [param("path", "a")]), strict) const r = parseDsml(invoke("edit", [param("path", "a")]), allOff)
expect(r.calls).toHaveLength(0) expect(r.calls).toHaveLength(0)
}) })
test("mis-closed parameter is not rescued", () => { test("mis-closed parameter is not rescued with its flag off", () => {
const a = `<\uFF5CDSML\uFF5Cparameter name="alpha" string="true">first</\uFF5CDSML\uFF5C>` const a = `<\uFF5CDSML\uFF5Cparameter name="alpha" string="true">first</\uFF5CDSML\uFF5C>`
const src = block([invoke("r", [a, param("beta", "second")])]) const src = block([invoke("r", [a, param("beta", "second")])])
const r = parseDsml(src, strict) const r = parseDsml(src, allOff)
// Strict keeps the strict scan as-is (runaway value), never the tolerant one. // Without the tolerant scan the strict result stands (runaway value).
expect(r.calls.map((c) => c.name)).toEqual(["r"]) expect(r.calls.map((c) => c.name)).toEqual(["r"])
}) })
}) })
describe("prompt", () => { describe("prompt", () => {
test("directives contain no live marker", () => { test("nudge contains no live marker and states the outer-layer rule", () => {
expect(containsLiveMarker(preventiveDirective())).toBe(false)
expect(containsLiveMarker(recoveryNudge("edit"))).toBe(false) expect(containsLiveMarker(recoveryNudge("edit"))).toBe(false)
})
test("directive states the outer-layer rule", () => {
expect(preventiveDirective()).toContain("outer block")
expect(recoveryNudge()).toContain("outer") expect(recoveryNudge()).toContain("outer")
}) })
}) })
describe("fallback", () => { describe("retry", () => {
test("no marker → no nudge", () => { test("no marker → no nudge", () => {
expect(decideResume("hello world", 0, 3)).toEqual({ send: false, reason: "no-marker" }) expect(decideResume("hello world", false)).toEqual({ send: false, reason: "no-marker" })
}) })
test("recoverable text → no nudge (middleware owns it)", () => { test("recoverable text → no nudge (live fix owns it)", () => {
const decision = decideResume(block([invoke("bash", [param("command", "pwd")])]), 0, 3) const decision = decideResume(block([invoke("bash", [param("command", "pwd")])]), false)
expect(decision.send).toBe(false) expect(decision.send).toBe(false)
expect(decision.reason).toBe("already-recoverable") expect(decision.reason).toBe("already-recoverable")
}) })
test("unrecoverable markup → nudge with correction text", () => { test("unrecoverable markup → nudge with correction text", () => {
const decision = decideResume("broken \uFF5CDSML\uFF5C tool_calls garbage", 0, 3) const decision = decideResume("broken \uFF5CDSML\uFF5C tool_calls garbage", false)
expect(decision.send).toBe(true) expect(decision.send).toBe(true)
expect(decision.text).toContain("outer") expect(decision.text).toContain("outer")
}) })
test("cap is enforced", () => { test("send-once: already poked → silence", () => {
const decision = decideResume("broken \uFF5CDSML\uFF5C tool_calls garbage", 3, 3) const decision = decideResume("broken \uFF5CDSML\uFF5C tool_calls garbage", true)
expect(decision).toEqual({ send: false, reason: "cap-reached" }) expect(decision).toEqual({ send: false, reason: "already-sent" })
}) })
test("attempt key is namespaced per message", () => { test("attempt key is namespaced per message", () => {
expect(attemptKey("s1", "m1")).not.toBe(attemptKey("s1", "m2")) expect(attemptKey("s1", "m1")).not.toBe(attemptKey("s1", "m2"))
}) })
test("parseAttempts tolerates garbage", () => { test("alreadySent reads the flag", () => {
expect(parseAttempts(undefined)).toBe(0) expect(alreadySent(undefined)).toBe(false)
expect(parseAttempts("x")).toBe(0) expect(alreadySent(false)).toBe(false)
expect(parseAttempts(2)).toBe(2) expect(alreadySent(true)).toBe(true)
expect(alreadySent(1)).toBe(true)
}) })
test("messageText reads assistant text only", () => { test("messageText reads assistant text only", () => {
@@ -170,7 +145,7 @@ describe("system nudge", () => {
expect(needsSystemNudge([{ role: "assistant", content: [{ type: "text", text: "done" }] }])).toBeUndefined() expect(needsSystemNudge([{ role: "assistant", content: [{ type: "text", text: "done" }] }])).toBeUndefined()
}) })
test("silent when the middleware could recover it", () => { test("silent when the live fix could recover it", () => {
const nudge = needsSystemNudge([ const nudge = needsSystemNudge([
{ role: "assistant", content: [{ type: "text", text: block([invoke("bash", [param("command", "pwd")])]) }] }, { role: "assistant", content: [{ type: "text", text: block([invoke("bash", [param("command", "pwd")])]) }] },
]) ])
@@ -205,13 +180,9 @@ describe("system nudge", () => {
).toBe("no-user-turn") ).toBe("no-user-turn")
}) })
test("sseEnabled defaults true; response.sse wins", () => { test("responseFix defaults on", () => {
expect(resolveConfig().sseEnabled).toBe(true) expect(resolveConfig().responseFixEnabled).toBe(true)
expect(resolveConfig({ response: { sse: false } }).sseEnabled).toBe(false) expect(resolveConfig({ responseFix: { enabled: false } }).responseFixEnabled).toBe(false)
expect(resolveConfig({ recovery: { sse: false } }).sseEnabled).toBe(false)
expect(
resolveConfig({ recovery: { sse: false }, response: { sse: true } }).sseEnabled,
).toBe(true)
}) })
}) })