mirror of
https://github.com/bendtherules/opencode-plugin-dsml-fix.git
synced 2026-10-17 16:56:50 +00:00
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:
@@ -1,125 +1,208 @@
|
||||
# opencode-plugin-dsml-fix
|
||||
|
||||
OpenCode V2 plugin that recovers DeepSeek DSML tool calls when the provider's
|
||||
parser fails and raw markup leaks into assistant text as prose.
|
||||
Recovers DeepSeek DSML tool calls that leak into assistant text. OpenCode V2 plugin.
|
||||
|
||||
## The problem
|
||||
|
||||
On DeepSeek served through OpenCode (notably `opencode-go/deepseek-*`), tool
|
||||
calls sometimes arrive as visible XML-ish markup in the assistant message
|
||||
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.
|
||||
DeepSeek sometimes sends a tool call as visible markup instead of executing
|
||||
it. The agent stalls mid-task, and you end up looking at raw tags in the chat.
|
||||
|
||||
This plugin repairs those leaks client-side, in OpenCode, without waiting on
|
||||
the provider. Normal responses pass through byte-identical.
|
||||
Three things make it worse than a single failed turn:
|
||||
|
||||
- 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
|
||||
|
||||
```jsonc
|
||||
// opencode.jsonc
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"plugins": ["opencode-plugin-dsml-fix"],
|
||||
}
|
||||
```sh
|
||||
opencode plugin add opencode-plugin-dsml-fix
|
||||
```
|
||||
|
||||
Requires OpenCode V2 (`2.0.x`). No other setup — defaults cover the
|
||||
`opencode-go` and `opencode` (Zen) providers.
|
||||
|
||||
To confirm it's loaded, run any prompt and look for the probe log
|
||||
(`debug: true`, see below).
|
||||
- Requires OpenCode V2 (`2.0.x`).
|
||||
- Defaults have everything enabled: `parse`, `history`, `responseFix`,
|
||||
`retry`. The only off-by-default flag is `debug`.
|
||||
|
||||
## What it does
|
||||
|
||||
- **Streaming recovery.** Leaked DSML blocks in response text become real tool
|
||||
calls, so the agent loop dispatches the intended call instead of stalling.
|
||||
Covers both the native SSE path (what `opencode-go` uses) and the AI-SDK
|
||||
path (other providers).
|
||||
- **History sanitization.** Strips leaked markup from replayed assistant
|
||||
messages so the model stops imitating its own degraded output. History is
|
||||
never turned into calls — no double execution.
|
||||
- **Prevention.** A system directive states the exact required envelope, plus
|
||||
a conditional nudge when a turn ends with unrecovered markup.
|
||||
- **Safety net.** An idle watchdog sends one short retry when a session stalls
|
||||
on unrecovered markup, capped per message.
|
||||
|
||||
Every layer is independently toggleable (see Configuration). If any layer
|
||||
misfires, switch that one flag off — the plugin degrades to a safer subset
|
||||
and never breaks normal inference.
|
||||
|
||||
## Verify it's working
|
||||
|
||||
Enable debug logging:
|
||||
|
||||
```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.
|
||||
1. **Parse leaked tool calls.** Tolerant parser for complete blocks plus
|
||||
every degraded shape I catalogued: orphan invokes, mis-closed parameters,
|
||||
bare-JSON bodies, wrapped tools, marker noise, chunk-split tags.
|
||||
[Settings](#parse)
|
||||
2. **Sanitize history.** Strips old leaks from replayed assistant text, with
|
||||
a short inline note where each one was. History never becomes calls, so
|
||||
nothing executes twice. [Settings](#history)
|
||||
3. **Fix live tool calls.** Leaked markup in a live response becomes a real
|
||||
call, so the agent loop dispatches it instead of stalling.
|
||||
[Settings](#responsefix)
|
||||
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.
|
||||
Instead of leaving the session stuck, the plugin tells the model its last
|
||||
reply had broken tool markup and asks it to re-issue the call properly.
|
||||
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.
|
||||
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
|
||||
reply always starts fresh, so retrying never stops a live chat.
|
||||
[Settings](#retry)
|
||||
|
||||
## Configuration
|
||||
|
||||
Object form in `opencode.jsonc`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"plugins": [
|
||||
{
|
||||
"package": "opencode-plugin-dsml-fix",
|
||||
"options": {
|
||||
"providers": ["opencode-go", "opencode"],
|
||||
"mode": "relaxed",
|
||||
"resume": { "enabled": true, "maxAttempts": 3 },
|
||||
"parse": { "wrappedTool": false },
|
||||
"retry": { "maxAttempts": 5 },
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
### `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 |
|
||||
|---|---|---|
|
||||
| `providers` | `["opencode-go","opencode"]` | Provider IDs in scope. Empty disables the plugin. |
|
||||
| `mode` | `"relaxed"` | `"relaxed"` = full variant catalogue; `"strict"` = complete blocks only. |
|
||||
| `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). |
|
||||
| `debug` | `false` | Per-turn logging (see Verify). Only off-by-default flag. |
|
||||
|
||||
Older names (`recovery.*`, `strategies.*`, `resume.*`, `prompt.*`) still work
|
||||
as aliases. Precedence: `response`/`request` > `recovery` > `strategies` >
|
||||
legacy names > defaults.
|
||||
Full shape catalogue: `docs/PATTERNS.md`.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -127,13 +210,14 @@ legacy names > defaults.
|
||||
bun install
|
||||
bun test # 115 tests: grammar, parser, stream, wrapper, config, sse, golden
|
||||
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/TEST-MATRIX.md` — behaviour ↔ test map
|
||||
- `test/golden/` — synthetic byte-faithful fixtures + generator
|
||||
(`build-fixtures.ts`); the repo never stores live markup tokens
|
||||
- `docs/PATTERNS.md`: failure-shape catalogue, source of truth for recovery
|
||||
- `docs/TEST-MATRIX.md`: behaviour to test map
|
||||
- `test/golden/`: synthetic fixtures and generator (`build-fixtures.ts`).
|
||||
No live markup bytes anywhere in the repo.
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](./LICENSE).
|
||||
MIT. See [LICENSE](./LICENSE).
|
||||
|
||||
+22
-21
@@ -121,10 +121,14 @@ when zero calls were recovered — unclosed/unparseable tails emit verbatim.
|
||||
- `v12` runaway invoke name (`name="tool {`) → no call, strip.
|
||||
- Code fences are never parsed or stripped.
|
||||
- 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
|
||||
synthetic correction nudge, capped via `ctx.storage`.
|
||||
- Strategy A live: on idle, if last assistant text ends in DSML, send a
|
||||
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
|
||||
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.
|
||||
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):
|
||||
|
||||
- **Preventive directive** (`strategies.directive`, default on): appends a fixed
|
||||
~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
|
||||
- **History sanitization** (`history.sanitize`, default on): strips leaked DSML
|
||||
spans from replayed assistant text. Fires only when a leak exists; deterministic
|
||||
given stored messages, so retries converge to identical bytes (cache-friendly).
|
||||
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
|
||||
**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`,
|
||||
flips `finish` to `tool-calls`. Zero calls → buffer emitted verbatim, finish
|
||||
untouched. 64 KiB cap → over-cap unclosed tail emitted as text. Zero request
|
||||
impact → zero cache impact.
|
||||
- **B2 generate** (`strategies.generate`): same recovery for non-streaming results.
|
||||
impact → zero cache impact. 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)
|
||||
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;
|
||||
`channel:"user"` restores the legacy full-text synthetic turn.
|
||||
- Attempts capped per message; counters in plugin storage.
|
||||
`channel:"user"` sends the full correction as a synthetic user turn.
|
||||
- One poke per message; a message that stays silent is left alone.
|
||||
|
||||
Fixed prompt texts live in `src/prompt.ts` (`preventiveDirective()`,
|
||||
`recoveryNudge()`); tests assert they contain no live marker bytes.
|
||||
The fixed nudge text lives in `src/prompt.ts` (`recoveryNudge()`); tests assert
|
||||
it contains no live marker bytes.
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
Rejected alternative: `session.instructions.entry.put` as the nudge channel. It
|
||||
|
||||
+7
-7
@@ -83,13 +83,13 @@ fragment. This row therefore expects `calls: []`, `ranges: [the parameter markup
|
||||
|
||||
| ID | Fixture | Expect |
|
||||
|----|---------|--------|
|
||||
| `config-defaults` | `resolveConfig()` | both Zen providers, relaxed, resume 3 |
|
||||
| `config-strict` | `mode:"strict"` | every rescue off |
|
||||
| `config-granular` | one rescue off | others stay on |
|
||||
| `strict-block` | complete block + strict flags | parses |
|
||||
| `strict-orphan` | orphan invoke + strict flags | no call |
|
||||
| `prompt-no-marker` | both directives | no live marker bytes, outer-layer rule stated |
|
||||
| `fallback-*` | decide/messageText/attempts | nudge policy + cap |
|
||||
| `config-defaults` | `resolveConfig()` | both Zen providers, all flags on, debug off |
|
||||
| `config-granular` | one parse flag off | others stay on |
|
||||
| `config-layers` | each layer off | that layer disabled |
|
||||
| `parse-flags-off` | complete block + all flags off | parses |
|
||||
| `parse-orphan-off` | orphan invoke + flags off | no call |
|
||||
| `prompt-no-marker` | recovery nudge | no live marker bytes, outer-layer rule stated |
|
||||
| `fallback-*` | decide/messageText/send-once | nudge policy + send-once |
|
||||
|
||||
## Wrapper: `wrapDsmlLanguageModel`
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode-plugin-dsml-fix",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"description": "OpenCode V2 plugin that recovers DeepSeek DSML tool calls leaked as assistant text",
|
||||
|
||||
+56
-121
@@ -1,8 +1,11 @@
|
||||
/**
|
||||
* 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
|
||||
* off and the plugin degrades to the safer subset. The invariant is that the plugin
|
||||
* simpler version without uninstalling: if one shape misfires, switch its flag off
|
||||
* 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.
|
||||
*
|
||||
* Namespaces match the four features in the README: `parse`, `history`,
|
||||
* `responseFix`, `retry`. Top level holds only general settings.
|
||||
*/
|
||||
|
||||
export interface DsmlPluginOptions {
|
||||
@@ -12,159 +15,91 @@ export interface DsmlPluginOptions {
|
||||
*/
|
||||
providers?: string[]
|
||||
/**
|
||||
* Parsing strictness.
|
||||
* - `"relaxed"` (default): full catalogue — complete blocks, doubled bars,
|
||||
* truncated tags, orphan-invoke rescue, mis-closed fallback, raw-JSON body.
|
||||
* - `"strict"`: complete `tool_calls`/`function_calls` blocks with proper closers
|
||||
* only. Use when the deeper rescue ever misfires.
|
||||
* Parser tolerance. Each flag defaults to true; switch one off in isolation
|
||||
* when that shape misfires. The same flags govern live recovery and what
|
||||
* counts as a stain in history (one engine, both directions).
|
||||
*/
|
||||
mode?: "strict" | "relaxed"
|
||||
/**
|
||||
* 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. */
|
||||
parse?: {
|
||||
/** Recover `invoke` blocks with no outer opener. Default true. */
|
||||
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
|
||||
/** Bare-`{…}` invoke body parsed as JSON (V25). Default: mode. */
|
||||
/** Bare-`{…}` invoke body parsed as JSON. Default true. */
|
||||
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
|
||||
}
|
||||
/** Streaming swallow cap in bytes (V15/buffer-cap). Default 65536. */
|
||||
/** History sanitization: strip leaked spans from replayed assistant text. */
|
||||
history?: {
|
||||
/** Default true. Leave on: disabling re-poisons the transcript. */
|
||||
sanitize?: boolean
|
||||
}
|
||||
/** Live response fix: turn leaked markup into real tool calls. */
|
||||
responseFix?: {
|
||||
/** Master switch for all transports. Default true. */
|
||||
enabled?: boolean
|
||||
/** Streaming hold cap in bytes. Safety bound, not a target. Default 65536. */
|
||||
bufferLimit?: number
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
/** Retry safety net for turns the fix could not recover. */
|
||||
retry?: {
|
||||
/**
|
||||
* 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
|
||||
/** 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
|
||||
}
|
||||
/**
|
||||
* Per-strategy switches (deprecated alias for `recovery` + `request`; kept for
|
||||
* backward compatibility). When both forms are set, `recovery`/`request` win.
|
||||
*/
|
||||
strategies?: {
|
||||
/** Alias: recovery.aisdkStream. */
|
||||
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). */
|
||||
resume?: {
|
||||
/**
|
||||
* Master switch (aliases: strategies.watchdog). Default true.
|
||||
* Precedence: strategies.watchdog > resume.enabled > default.
|
||||
* Master switch for the idle wake. Default true.
|
||||
* Send-once per message: a message that stays silent after one
|
||||
* wake is left alone, never re-poked.
|
||||
*/
|
||||
enabled?: boolean
|
||||
/** Max nudges per assistant message. Default 3. */
|
||||
maxAttempts?: number
|
||||
/**
|
||||
* Delivery channel for the watchdog wake.
|
||||
* - `"system"` (default): the wake is a minimal synthetic ping; the full
|
||||
* correction rides as a transient system instruction via the context hook.
|
||||
* - `"user"`: legacy single-message correction nudge as a synthetic user turn.
|
||||
* Conditional correction nudge when history ends in unrecovered markup.
|
||||
* Default true.
|
||||
*/
|
||||
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"
|
||||
}
|
||||
/** Preventive system instruction (alias: request.directive). Default true. */
|
||||
prompt?: {
|
||||
/**
|
||||
* Master switch. Default true.
|
||||
* Precedence: request.directive > strategies.directive > prompt.enabled > default.
|
||||
*/
|
||||
enabled?: boolean
|
||||
}
|
||||
/** Log recoveries to stderr. Default false. */
|
||||
/** Log recoveries to stderr. Default false. The only off-by-default flag. */
|
||||
debug?: boolean
|
||||
}
|
||||
|
||||
export interface ResolvedDsmlConfig {
|
||||
readonly providers: readonly string[]
|
||||
readonly relaxed: boolean
|
||||
readonly orphanInvoke: boolean
|
||||
readonly looseParameters: boolean
|
||||
readonly rawJsonBody: boolean
|
||||
readonly wrappedTool: boolean
|
||||
readonly bufferLimit: number
|
||||
readonly streamEnabled: boolean
|
||||
readonly generateEnabled: boolean
|
||||
readonly sseEnabled: boolean
|
||||
readonly responseFixEnabled: boolean
|
||||
readonly sanitizeEnabled: boolean
|
||||
readonly directiveEnabled: boolean
|
||||
readonly systemNudgeEnabled: boolean
|
||||
readonly watchdogEnabled: boolean
|
||||
readonly resumeMaxAttempts: number
|
||||
readonly resumeChannel: "system" | "user"
|
||||
readonly retryEnabled: boolean
|
||||
readonly retryNudgeEnabled: boolean
|
||||
readonly retryChannel: "system" | "user"
|
||||
readonly debug: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_PROVIDERS = ["opencode-go", "opencode"] as const
|
||||
|
||||
export function resolveConfig(input: DsmlPluginOptions = {}): ResolvedDsmlConfig {
|
||||
const relaxed = (input.mode ?? "relaxed") === "relaxed"
|
||||
const s = input.strategies ?? {}
|
||||
const response = input.response ?? {}
|
||||
const recovery = input.recovery ?? {}
|
||||
const request = input.request ?? {}
|
||||
const parse = input.parse ?? {}
|
||||
const history = input.history ?? {}
|
||||
const responseFix = input.responseFix ?? {}
|
||||
const retry = input.retry ?? {}
|
||||
return {
|
||||
providers: input.providers ?? [...DEFAULT_PROVIDERS],
|
||||
relaxed,
|
||||
orphanInvoke: input.rescue?.orphanInvoke ?? relaxed,
|
||||
looseParameters: input.rescue?.looseParameters ?? relaxed,
|
||||
rawJsonBody: input.rescue?.rawJsonBody ?? relaxed,
|
||||
wrappedTool: input.rescue?.wrappedTool ?? relaxed,
|
||||
bufferLimit: input.bufferLimit ?? 64 * 1024,
|
||||
streamEnabled: response.aisdkStream ?? recovery.aisdkStream ?? s.stream ?? true,
|
||||
generateEnabled: response.aisdkGenerate ?? recovery.aisdkGenerate ?? s.generate ?? true,
|
||||
sseEnabled: response.sse ?? recovery.sse ?? s.sse ?? true,
|
||||
sanitizeEnabled: request.sanitize ?? s.sanitize ?? true,
|
||||
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",
|
||||
orphanInvoke: parse.orphanInvoke ?? true,
|
||||
looseParameters: parse.looseParameters ?? true,
|
||||
rawJsonBody: parse.rawJsonBody ?? true,
|
||||
wrappedTool: parse.wrappedTool ?? true,
|
||||
bufferLimit: responseFix.bufferLimit ?? 64 * 1024,
|
||||
responseFixEnabled: responseFix.enabled ?? true,
|
||||
sanitizeEnabled: history.sanitize ?? true,
|
||||
retryEnabled: retry.enabled ?? true,
|
||||
retryNudgeEnabled: retry.nudge ?? true,
|
||||
retryChannel: retry.channel ?? "system",
|
||||
debug: input.debug ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
+20
-19
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* Strategy A: auto-resume safety net for turns the streaming middleware could not
|
||||
* recover. Pure decision logic lives here so it is unit-testable; OpenCode I/O
|
||||
* (events, session reads, synthetic prompts, storage) is injected by `src/index.ts`.
|
||||
* Retry safety net for turns the live fix could not recover. Pure decision logic
|
||||
* lives here so it is unit-testable; OpenCode I/O (events, session reads,
|
||||
* 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
|
||||
* `src/prompt.ts`). Attempts are capped per message so a pathological loop cannot
|
||||
* run away.
|
||||
* `src/prompt.ts`). Send-once per message: a message that stays silent after one
|
||||
* 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"
|
||||
@@ -18,7 +19,7 @@ export interface FallbackDecision {
|
||||
readonly send: boolean
|
||||
/** The nudge text, present when `send` is true. */
|
||||
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
|
||||
/** Why no nudge is sent. */
|
||||
readonly reason?: string
|
||||
@@ -46,13 +47,13 @@ export interface HookMessage {
|
||||
}
|
||||
|
||||
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
|
||||
/** The correction text for the system channel. */
|
||||
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 {
|
||||
if (message.id) return message.id
|
||||
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
|
||||
* 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(
|
||||
messages: ReadonlyArray<HookMessage>,
|
||||
@@ -100,16 +101,17 @@ export function needsSystemNudge(
|
||||
|
||||
/**
|
||||
* 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 attempts nudges already sent for this message
|
||||
* @param maxAttempts cap from config
|
||||
* @param text the latest assistant text
|
||||
* @param alreadySent whether a nudge/wake was already sent for this message
|
||||
*/
|
||||
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" }
|
||||
const { calls } = parseDsml(text)
|
||||
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() }
|
||||
}
|
||||
|
||||
@@ -124,8 +126,7 @@ export function messageText(message: {
|
||||
return parts.map((part) => part.text as string).join("\n")
|
||||
}
|
||||
|
||||
/** Parse an attempt counter value read from storage. */
|
||||
export function parseAttempts(value: unknown): number {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value >= 0) return Math.floor(value)
|
||||
return 0
|
||||
/** Read a send-once flag from storage. Any truthy value means already sent. */
|
||||
export function alreadySent(value: unknown): boolean {
|
||||
return value === true || value === 1 || value === "1"
|
||||
}
|
||||
|
||||
+32
-40
@@ -1,36 +1,34 @@
|
||||
/**
|
||||
* opencode-plugin-dsml-fix — recover DeepSeek DSML tool calls that leak as text.
|
||||
*
|
||||
* Strategy matrix (each independently configurable, see `src/config.ts`):
|
||||
* - B1 `strategies.stream`: streaming middleware recovery via ctx.aisdk language hook.
|
||||
* - B2 `strategies.generate`: non-streaming generate recovery (same hook).
|
||||
* - `strategies.sanitize`: strip leaked DSML spans from replayed history.
|
||||
* - `strategies.directive`: preventive correction directive in system.
|
||||
* - `strategies.systemNudge`: conditional correction nudge in system when history
|
||||
* ends in unrecovered DSML (transient, never persisted).
|
||||
* - `strategies.watchdog`: idle watchdog that wakes stalled sessions; the wake
|
||||
* travels on `resume.channel` ("system" = minimal ping + system nudge,
|
||||
* "user" = legacy full-text synthetic turn).
|
||||
* Four features (see README), each independently configurable:
|
||||
* - `parse`: tolerant parser — complete blocks plus orphan invokes, loose
|
||||
* parameters, raw-JSON bodies, wrapped tools. Same flags govern live
|
||||
* recovery and history stain detection.
|
||||
* - `history`: strip leaked spans from replayed assistant text (+ note).
|
||||
* Never synthesizes calls from history (no double execution).
|
||||
* - `responseFix`: turn leaked markup into real calls on the live response,
|
||||
* native SSE path and AI-SDK path alike.
|
||||
* - `retry`: conditional correction nudge plus one idle wake per message.
|
||||
*
|
||||
* 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 { appendFileSync } from "node:fs"
|
||||
import { appliesToProvider, resolveConfig, type DsmlPluginOptions } from "./config.ts"
|
||||
import {
|
||||
alreadySent,
|
||||
attemptKey,
|
||||
decideResume,
|
||||
messageText,
|
||||
needsSystemNudge,
|
||||
parseAttempts,
|
||||
turnId,
|
||||
wakeText,
|
||||
} from "./fallback.ts"
|
||||
import { createDsmlStreamMiddleware, wrapDsmlLanguageModel } from "./middleware.ts"
|
||||
import { parseDsml, redactionNote, stripRangesWithNote, type ParseOptions } from "./parse.ts"
|
||||
import { preventiveDirective } from "./prompt.ts"
|
||||
import { rewriteSseResponse } from "./sse.ts"
|
||||
|
||||
const PLUGIN_ID = "dsml"
|
||||
@@ -70,8 +68,8 @@ export default Plugin.define({
|
||||
// `session/message` identity. Attempts count physical requests within a turn.
|
||||
const turns = new Map<string, { turn: string; attempts: number }>()
|
||||
|
||||
// --- Strategies B1+B2: model recovery ------------------------------------
|
||||
if (config.streamEnabled || config.generateEnabled) {
|
||||
// --- Live response fix: model recovery ------------------------------------
|
||||
if (config.responseFixEnabled) {
|
||||
await ctx.aisdk.hook("language", (event) => {
|
||||
try {
|
||||
appendFileSync(
|
||||
@@ -97,8 +95,8 @@ export default Plugin.define({
|
||||
}
|
||||
event.language = wrapDsmlLanguageModel(base, {
|
||||
...parseOptions,
|
||||
stream: config.streamEnabled,
|
||||
generate: config.generateEnabled,
|
||||
stream: true,
|
||||
generate: true,
|
||||
bufferLimit: config.bufferLimit,
|
||||
onSettled: (stats) => {
|
||||
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
|
||||
// openai-compatible route, where `aisdk.language` never fires. The native
|
||||
// route offers every request to the http hooks, so rewrite the SSE bytes
|
||||
// there instead. Same parser, same policy, different wire format.
|
||||
if (config.sseEnabled) {
|
||||
if (config.responseFixEnabled) {
|
||||
await ctx.session.hook("http.response", (event) => {
|
||||
if (!appliesToProvider(config, event.model.providerID)) 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) => {
|
||||
if (!appliesToProvider(config, event.model.providerID)) return
|
||||
// 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}`
|
||||
let sanitizedMsgs = 0
|
||||
let sanitizedParts = 0
|
||||
let directive = false
|
||||
let nudge = false
|
||||
if (config.directiveEnabled) {
|
||||
event.system.push({ type: "text", text: preventiveDirective() })
|
||||
directive = true
|
||||
}
|
||||
if (config.sanitizeEnabled) {
|
||||
// Strip leaked DSML spans from replayed assistant text so the model does
|
||||
// not imitate its own degraded output. A short inline note marks each
|
||||
@@ -188,30 +181,29 @@ export default Plugin.define({
|
||||
if (touched) sanitizedMsgs++
|
||||
}
|
||||
}
|
||||
if (config.systemNudgeEnabled) {
|
||||
if (config.retryNudgeEnabled) {
|
||||
const need = needsSystemNudge(event.messages, parseOptions)
|
||||
if (need) {
|
||||
const key = attemptKey(event.sessionID, need.key)
|
||||
const attempts = parseAttempts(await ctx.storage.get(key))
|
||||
if (attempts >= config.resumeMaxAttempts) {
|
||||
log("system nudge cap reached for", need.key)
|
||||
if (alreadySent(await ctx.storage.get(key))) {
|
||||
log("nudge already sent for", need.key)
|
||||
} else {
|
||||
await ctx.storage.set(key, attempts + 1)
|
||||
await ctx.storage.set(key, true)
|
||||
event.system.push({ type: "text", text: need.text })
|
||||
nudge = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// One line per request turn: what each request layer actually did.
|
||||
if (sanitizedMsgs > 0 || directive || nudge) {
|
||||
if (sanitizedMsgs > 0 || nudge) {
|
||||
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 ----------------------------------------------
|
||||
if (!config.watchdogEnabled) return
|
||||
// --- Retry: one idle wake per stalled message -------------------------------
|
||||
if (!config.retryEnabled) return
|
||||
const controller = new AbortController()
|
||||
void (async () => {
|
||||
try {
|
||||
@@ -254,22 +246,22 @@ async function maybeWake(
|
||||
})
|
||||
if (!text) return
|
||||
const key = attemptKey(sessionID, last.id)
|
||||
const attempts = parseAttempts(await ctx.storage.get(key))
|
||||
const decision = decideResume(text, attempts, config.resumeMaxAttempts)
|
||||
const sent = alreadySent(await ctx.storage.get(key))
|
||||
const decision = decideResume(text, sent)
|
||||
if (!decision.send || !decision.text) {
|
||||
if (decision.reason && decision.reason !== "no-marker") log(decision.reason, "for", last.id)
|
||||
return
|
||||
}
|
||||
await ctx.storage.set(key, attempts + 1)
|
||||
if (config.resumeChannel === "user") {
|
||||
// Legacy: the full correction travels as a synthetic user turn.
|
||||
await ctx.storage.set(key, true)
|
||||
if (config.retryChannel === "user") {
|
||||
// The full correction travels as a synthetic user turn.
|
||||
await ctx.session.synthetic({ sessionID, text: decision.text })
|
||||
} else {
|
||||
// System channel: minimal wake ping; the context hook attaches the full
|
||||
// correction as a transient system instruction on the woken turn.
|
||||
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 }
|
||||
|
||||
+2
-1
@@ -246,7 +246,8 @@ function parseParameters(body: string, options: Required<ParseOptions>): Record<
|
||||
const strict = collectParameters(body, parameterRe())
|
||||
// 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)
|
||||
// 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 picked =
|
||||
options.looseParameters && (Object.keys(strict).length === 0 || ranAway)
|
||||
|
||||
+7
-30
@@ -1,38 +1,15 @@
|
||||
/**
|
||||
* Correction directives: the text the plugin shows the model so it emits tool calls
|
||||
* in the correct format with the outer XML layer.
|
||||
* Recovery-failure nudge: the text the plugin shows the model so it re-issues a
|
||||
* 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
|
||||
* phantom call by greedy provider extractors. The fixer tests assert the strings
|
||||
* contain no marker bytes.
|
||||
* phantom call by greedy provider extractors. The fixer tests assert the string
|
||||
* 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
|
||||
* 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
|
||||
* Recovery-failure nudge, sent when DSML markup appeared but no call
|
||||
* could be recovered, or when a session idles on leaked markup. Short, imperative,
|
||||
* and self-contained: it restates the required outer layer.
|
||||
*/
|
||||
@@ -47,7 +24,7 @@ export function recoveryNudge(toolHint?: string): string {
|
||||
].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 {
|
||||
return /[\uFF5C|]\s*DSML\s*[\uFF5C|]/i.test(text)
|
||||
}
|
||||
|
||||
+62
-91
@@ -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 { 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 { parseDsml, redactionNote, stripRangesWithNote } from "../src/parse.ts"
|
||||
import { containsLiveMarker, preventiveDirective, recoveryNudge } from "../src/prompt.ts"
|
||||
import { containsLiveMarker, recoveryNudge } from "../src/prompt.ts"
|
||||
|
||||
describe("config", () => {
|
||||
test("defaults target both Zen providers in relaxed mode", () => {
|
||||
test("defaults: everything protective on, only debug off", () => {
|
||||
const c = resolveConfig()
|
||||
expect([...c.providers]).toEqual(["opencode-go", "opencode"])
|
||||
expect(c.relaxed).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.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", () => {
|
||||
@@ -47,104 +60,66 @@ describe("config", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("strategy matrix", () => {
|
||||
test("each transport recovers independently", () => {
|
||||
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)
|
||||
})
|
||||
describe("parse flags", () => {
|
||||
const allOff = { orphanInvoke: false, looseParameters: false, rawJsonBody: false, wrappedTool: false }
|
||||
|
||||
test("recovery/request win over legacy strategies.*", () => {
|
||||
expect(resolveConfig({ strategies: { sse: false } }).sseEnabled).toBe(false)
|
||||
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)
|
||||
test("complete block still parses with every flag off", () => {
|
||||
const r = parseDsml(block([invoke("bash", [param("command", "pwd")])]), allOff)
|
||||
expect(r.calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("orphan invoke is not recovered", () => {
|
||||
const r = parseDsml(invoke("edit", [param("path", "a")]), strict)
|
||||
test("orphan invoke is not recovered with its flag off", () => {
|
||||
const r = parseDsml(invoke("edit", [param("path", "a")]), allOff)
|
||||
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 src = block([invoke("r", [a, param("beta", "second")])])
|
||||
const r = parseDsml(src, strict)
|
||||
// Strict keeps the strict scan as-is (runaway value), never the tolerant one.
|
||||
const r = parseDsml(src, allOff)
|
||||
// Without the tolerant scan the strict result stands (runaway value).
|
||||
expect(r.calls.map((c) => c.name)).toEqual(["r"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("prompt", () => {
|
||||
test("directives contain no live marker", () => {
|
||||
expect(containsLiveMarker(preventiveDirective())).toBe(false)
|
||||
test("nudge contains no live marker and states the outer-layer rule", () => {
|
||||
expect(containsLiveMarker(recoveryNudge("edit"))).toBe(false)
|
||||
})
|
||||
|
||||
test("directive states the outer-layer rule", () => {
|
||||
expect(preventiveDirective()).toContain("outer block")
|
||||
expect(recoveryNudge()).toContain("outer")
|
||||
})
|
||||
})
|
||||
|
||||
describe("fallback", () => {
|
||||
describe("retry", () => {
|
||||
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)", () => {
|
||||
const decision = decideResume(block([invoke("bash", [param("command", "pwd")])]), 0, 3)
|
||||
test("recoverable text → no nudge (live fix owns it)", () => {
|
||||
const decision = decideResume(block([invoke("bash", [param("command", "pwd")])]), false)
|
||||
expect(decision.send).toBe(false)
|
||||
expect(decision.reason).toBe("already-recoverable")
|
||||
})
|
||||
|
||||
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.text).toContain("outer")
|
||||
})
|
||||
|
||||
test("cap is enforced", () => {
|
||||
const decision = decideResume("broken \uFF5CDSML\uFF5C tool_calls garbage", 3, 3)
|
||||
expect(decision).toEqual({ send: false, reason: "cap-reached" })
|
||||
test("send-once: already poked → silence", () => {
|
||||
const decision = decideResume("broken \uFF5CDSML\uFF5C tool_calls garbage", true)
|
||||
expect(decision).toEqual({ send: false, reason: "already-sent" })
|
||||
})
|
||||
|
||||
test("attempt key is namespaced per message", () => {
|
||||
expect(attemptKey("s1", "m1")).not.toBe(attemptKey("s1", "m2"))
|
||||
})
|
||||
|
||||
test("parseAttempts tolerates garbage", () => {
|
||||
expect(parseAttempts(undefined)).toBe(0)
|
||||
expect(parseAttempts("x")).toBe(0)
|
||||
expect(parseAttempts(2)).toBe(2)
|
||||
test("alreadySent reads the flag", () => {
|
||||
expect(alreadySent(undefined)).toBe(false)
|
||||
expect(alreadySent(false)).toBe(false)
|
||||
expect(alreadySent(true)).toBe(true)
|
||||
expect(alreadySent(1)).toBe(true)
|
||||
})
|
||||
|
||||
test("messageText reads assistant text only", () => {
|
||||
@@ -170,7 +145,7 @@ describe("system nudge", () => {
|
||||
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([
|
||||
{ role: "assistant", content: [{ type: "text", text: block([invoke("bash", [param("command", "pwd")])]) }] },
|
||||
])
|
||||
@@ -205,13 +180,9 @@ describe("system nudge", () => {
|
||||
).toBe("no-user-turn")
|
||||
})
|
||||
|
||||
test("sseEnabled defaults true; response.sse wins", () => {
|
||||
expect(resolveConfig().sseEnabled).toBe(true)
|
||||
expect(resolveConfig({ response: { sse: false } }).sseEnabled).toBe(false)
|
||||
expect(resolveConfig({ recovery: { sse: false } }).sseEnabled).toBe(false)
|
||||
expect(
|
||||
resolveConfig({ recovery: { sse: false }, response: { sse: true } }).sseEnabled,
|
||||
).toBe(true)
|
||||
test("responseFix defaults on", () => {
|
||||
expect(resolveConfig().responseFixEnabled).toBe(true)
|
||||
expect(resolveConfig({ responseFix: { enabled: false } }).responseFixEnabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user