feat: DSML tool-call recovery plugin for OpenCode V2

Recovers DeepSeek DSML tool calls that leak as assistant text: streaming
middleware (AI-SDK path), SSE rewrite (native path), history sanitization,
preventive directive, conditional system nudge, and a capped idle watchdog.
116 tests, synthetic golden fixtures.
This commit is contained in:
bendtherules
2026-10-05 11:47:57 +05:30
commit 52b7776d5a
26 changed files with 4600 additions and 0 deletions
+447
View File
@@ -0,0 +1,447 @@
/**
* Streaming DSML recovery as an AI SDK `LanguageModelV3Middleware`.
*
* This is the strategy-B seam: OpenCode installs it via `ctx.aisdk.hook("language")`
* and wraps the provider model with `wrapLanguageModel`. Everything downstream of
* this middleware is OpenCode's own AI-SDK lowering (`packages/core/src/aisdk.ts`),
* which already converts `tool-input-*` / `tool-call` / `finish` parts into real tool
* execution. We therefore only have to speak the AI SDK part vocabulary — see
* `docs/PATTERNS.md` §4 and `docs/TEST-MATRIX.md` for the cases.
*
* Design (ported from CherryStudio #14747, with pi-dsml + openclaw grammar):
* - Only `text-delta` is buffered; reasoning, tool, and metadata parts pass through.
* - A 64 KiB swallow cap prevents unbounded buffering if a block never closes.
* - Unclosed / unparseable blocks are re-emitted as text and `finish` stays `stop`.
* - Recovered calls rewrite `finishReason.unified` from `stop` to `tool-calls`.
*/
import type {
LanguageModelV3,
LanguageModelV3Content,
LanguageModelV3Middleware,
LanguageModelV3StreamPart,
} from "@ai-sdk/provider"
import { parseDsml, type DsmlToolCall, type ParseOptions } from "./parse.ts"
export interface DsmlMiddlewareOptions extends ParseOptions {
/** Streaming swallow cap in bytes. Default 65536. */
readonly bufferLimit?: number
/** Enable streaming recovery. Default true. */
readonly stream?: boolean
/** Enable non-streaming generate recovery. Default true. */
readonly generate?: boolean
/** Called once when the stream ends, with the per-turn outcome. */
readonly onSettled?: (stats: DsmlStats) => void
}
/** Per-turn outcome for logging: what the middleware actually did. */
export interface DsmlStats {
/** Tool calls recovered from leaked markup. */
readonly calls: number
/** Whether any DSML candidate was ever held (false = pure passthrough). */
readonly sawCandidate: boolean
}
const DEFAULT_BUFFER_LIMIT = 64 * 1024
/**
* Trailing suffix of `buffer` (after its last `<`) that could still grow into a
* tag opener. A tag split across deltas (`<`, `<BAR`, `<parameter name="sh`, …)
* must be held back, not emitted as text, until the next chunk arrives. Only the
* text after the LAST `<` is considered, it must contain no `>`, and it must
* match the opener-prefix grammar. (V17)
*/
function partialOpenerSuffix(buffer: string): number {
const lt = buffer.lastIndexOf("<")
if (lt === -1) return 0
const tail = buffer.slice(lt)
if (tail.length > 64 || tail.includes(">")) return 0
if (!TAG_PREFIX.test(tail)) return 0
return tail.length
}
/** A `<`-led prefix that may still become a DSML/wrapped open or close tag. */
const TAG_PREFIX =
/^(?:<)\/?[\uFF5C|]{0,2}\s*(?:D?S?M?L?[\uFF5C|]{0,2}\s*)?(?:p?a?r?a?m?e?t?e?r?|i?n?v?o?k?e?|t?o?o?l?_?c?a?l?l?s?|f?u?n?c?t?i?o?n?_?c?a?l?l?s?|c?a?l?l?s?)?(?:\s*n?a?m?e?\s*=?\s*["']?[^<>"']*)?\s*$/iu
let toolCallSequence = 0
/**
* Unique tool-call id across parallel streams and sessions. A per-stream index
* alone could collide when two streams recover a call in the same millisecond,
* so the id combines time, a process-wide sequence, and randomness.
*/
function toolCallId(index: number): string {
const seq = toolCallSequence++
const rand = Math.random().toString(36).slice(2, 8)
return `dsml_${Date.now().toString(36)}_${seq}_${index}_${rand}`
}
/** Best-effort id for parts that carry one (used only for fallback attribution). */
function textIdOf(chunk: LanguageModelV3StreamPart): string | undefined {
return typeof (chunk as { id?: unknown }).id === "string" ? (chunk as { id: string }).id : undefined
}
/**
* Buffering policy (streaming): hold text ONLY from the first byte that could
* open a tool call. Everything before it is emitted immediately, every chunk.
* Candidates, in order:
* 1. A DSML-marked opener (`<BAR DSML …`).
* 2. A wrapped-tool candidate: `<parameter name="X">` with or without a marker —
* the outer shell of the dominant real-world degradation (V26). Plain prose
* almost never contains this shape, and the eager-stop below bounds the hold.
*/
const WRAPPED_CANDIDATE = /<(?:[\uFF5C|]{1,2}\s*DSML\s*[\uFF5C|]{1,2}\s*)?parameter\s+name\s*=\s*["']?[^\s>"']+/iu
const DSML_OPENER = /<[\uFF5C|]{1,2}\s*DSML/iu
/** Earliest index in `buffer` that could open a call, or -1 when there is none. */
function holdStart(buffer: string): number {
let earliest = -1
for (const re of [DSML_OPENER, WRAPPED_CANDIDATE]) {
re.lastIndex = 0
const found = re.exec(buffer)
if (found && (earliest === -1 || found.index < earliest)) earliest = found.index
}
return earliest
}
/**
* Eager-stop window: when holding a candidate that has not yet produced a
* complete parameter-open (i.e. we are not inside a value), give up after this
* many held chars and flush as text. Prevents holding prose that merely looked
* like an opener. Once a complete parameter is open, only the 64 KiB cap bounds
* the hold, because values can be arbitrarily long.
*/
const EAGER_WINDOW = 1024
/** A complete parameter open tag exists in the held text: we may be inside a value. */
const PARAM_OPEN = /<[\uFF5C|]{1,2}\s*DSML\s*[\uFF5C|]{1,2}\s*parameter\s+name\s*=/iu
/**
* Recover tool calls from one complete text buffer. Returns the calls and the text
* with their ranges removed. Reuses the exact same parser as the repair path.
*/
export function recoverFromText(
text: string,
options?: ParseOptions,
): { calls: Array<{ name: string; arguments: Record<string, unknown> }>; text: string } {
const { calls, ranges } = parseDsml(text, options)
if (calls.length === 0 && ranges.length === 0) return { calls: [], text }
let cleaned = ""
let cursor = 0
for (const range of ranges) {
if (range.start > cursor) cleaned += text.slice(cursor, range.start)
cursor = Math.max(cursor, range.end)
}
cleaned += text.slice(cursor)
return { calls, text: cleaned }
}
export function createDsmlStreamMiddleware(input?: DsmlMiddlewareOptions): LanguageModelV3Middleware {
const bufferLimit = input?.bufferLimit ?? DEFAULT_BUFFER_LIMIT
const parseOptions: ParseOptions = {
orphanInvoke: input?.orphanInvoke,
looseParameters: input?.looseParameters,
rawJsonBody: input?.rawJsonBody,
wrappedTool: input?.wrappedTool,
}
return {
specificationVersion: "v3",
wrapStream: async ({ doStream }) => {
const { stream, ...rest } = await doStream()
let textBuffer = ""
let activeTextId: string | null = null
let recovered = false
let recoveredInBlock = false
let sawCandidate = false
let settled = false
let counter = 0
const settle = () => {
if (settled) return
settled = true
input?.onSettled?.({ calls: counter, sawCandidate })
}
const enqueueCall = (controller: TransformStreamDefaultController<LanguageModelV3StreamPart>, call: DsmlToolCall) => {
const id = toolCallId(counter++)
const input = JSON.stringify(call.arguments)
controller.enqueue({ type: "tool-input-start", id, toolName: call.name })
controller.enqueue({ type: "tool-input-delta", id, delta: input })
controller.enqueue({ type: "tool-input-end", id })
controller.enqueue({ type: "tool-call", toolCallId: id, toolName: call.name, input })
recovered = true
recoveredInBlock = true
}
/** True when `text` is only dangling markup/closers and whitespace. */
const isMarkupNoise = (text: string): boolean => {
const stripped = text
.replace(/<\/?[\uFF5C|]{1,2}\s*DSML\s*[\uFF5C|]{1,2}\s*(?:tool_calls|function_calls|tool_call|calls|invoke|parameter)(?:\s+[^>\n]*)?>?/giu, "")
// Leading fragment of a closer split across chunks (`| calls>`).
.replace(/^[\uFF5C|]{1,2}\s*(?:tool_calls|function_calls|tool_call|calls|invoke|parameter)\s*>?/iu, "")
.replace(/\s+/g, "")
return stripped.length === 0 && text.length > 0
}
/**
* Decide what to do with the current buffer.
*
* Buffering policy: hold text ONLY from the first byte that could open a
* tool call (a DSML opener, a partial opener suffix, or a wrapped-tool
* candidate). Everything before it is emitted immediately. A held
* candidate that never develops call structure hits the eager-stop window
* and is flushed as text. `final` resolves whatever remains — recovering
* complete calls, emitting the rest verbatim, so the buffer always ends
* empty and no text is ever lost.
*/
const drain = (controller: TransformStreamDefaultController<LanguageModelV3StreamPart>, textId: string, final: boolean) => {
if (!textBuffer) return
const start = holdStart(textBuffer)
if (start === -1) {
// No candidate: hold back only a suffix that could still grow into an
// opener. Everything before it is safe to emit.
const keep = final ? 0 : partialOpenerSuffix(textBuffer)
if (keep > 0) {
sawCandidate = true
const emit = textBuffer.slice(0, textBuffer.length - keep)
textBuffer = textBuffer.slice(textBuffer.length - keep)
if (emit.length > 0) controller.enqueue({ type: "text-delta", id: textId, delta: emit })
return
}
// Trailing markup noise after an already-recovered call (a mismatched
// `</invoke>` / `</calls>` that arrived in a later chunk) is dropped
// rather than leaked: it is pure closers, never prose.
if (recoveredInBlock && isMarkupNoise(textBuffer)) {
textBuffer = ""
return
}
controller.enqueue({ type: "text-delta", id: textId, delta: textBuffer })
textBuffer = ""
return
}
// Emit the safe head; hold from the candidate on.
if (start > 0) {
controller.enqueue({ type: "text-delta", id: textId, delta: textBuffer.slice(0, start) })
textBuffer = textBuffer.slice(start)
}
sawCandidate = true
// A complete, closed block is resolvable now. Wrapped-tool shapes end in
// a mismatched `</invoke>` / `</calls>` rather than a block closer —
// those count as completion ONLY when no block opener is pending
// (otherwise the trailing block closer would arrive later and leak).
const hasBlockOpen =
/<[\uFF5C|]{1,2}\s*DSML\s*[\uFF5C|]{1,2}\s*(?:tool_calls|function_calls|tool_call|calls)\s*>/iu.test(
textBuffer,
)
const closed =
/<\/[\uFF5C|]{1,2}\s*DSML\s*[\uFF5C|]{1,2}\s*(?:tool_calls|function_calls|tool_call|calls)\s*>/iu.test(
textBuffer,
) ||
(!hasBlockOpen &&
/<\/[\uFF5C|]{1,2}\s*DSML\s*[\uFF5C|]{1,2}\s*(?:invoke|calls)\s*>/iu.test(textBuffer))
const overCap = textBuffer.length > bufferLimit
if (!final && closed) {
emitResolved(controller, textId, false)
return
}
// Eager stop: a candidate that is not developing call structure (no
// complete parameter open yet, so we are not inside a long value) and has
// grown past the window is prose — flush it and resume scanning fresh.
PARAM_OPEN.lastIndex = 0
const insideValue = PARAM_OPEN.test(textBuffer)
if (!final && !overCap && !insideValue && textBuffer.length > EAGER_WINDOW) {
controller.enqueue({ type: "text-delta", id: textId, delta: textBuffer })
textBuffer = ""
return
}
// Nothing complete yet and we are still streaming: hold, until the cap.
if (!final && !overCap) return
// Final, over-cap, or unresolved at end: emit everything (recovering any
// complete calls that do exist, but never swallowing an unclosed tail).
if (overCap && !closed) {
controller.enqueue({ type: "text-delta", id: textId, delta: textBuffer })
textBuffer = ""
return
}
emitResolved(controller, textId, true)
}
const emitResolved = (controller: TransformStreamDefaultController<LanguageModelV3StreamPart>, textId: string, isFinal: boolean) => {
const original = textBuffer
const { calls, text } = recoverFromText(textBuffer, parseOptions)
textBuffer = ""
// Streaming rule: never strip unless we recovered something. An unclosed or
// unparseable tail is emitted verbatim so no content is lost mid-stream.
if (calls.length === 0) {
controller.enqueue({ type: "text-delta", id: textId, delta: original })
return
}
// The buffer can end mid-tag (a trailing closer already streaming in when
// resolution fired). Hold the partial back while the stream continues;
// at finality it ships verbatim (never swallow).
let cleaned = text
if (!isFinal) {
const holdBack = partialOpenerSuffix(cleaned)
if (holdBack > 0) {
textBuffer = cleaned.slice(cleaned.length - holdBack)
cleaned = cleaned.slice(0, cleaned.length - holdBack)
}
}
if (cleaned.length > 0) controller.enqueue({ type: "text-delta", id: textId, delta: cleaned })
for (const call of calls) enqueueCall(controller, call)
}
return {
stream: stream.pipeThrough(
new TransformStream<LanguageModelV3StreamPart, LanguageModelV3StreamPart>({
transform(chunk, controller) {
if (chunk.type === "text-start") {
activeTextId = chunk.id
recoveredInBlock = false
controller.enqueue(chunk)
return
}
if (chunk.type === "text-end") {
drain(controller, chunk.id, true)
controller.enqueue(chunk)
activeTextId = null
return
}
if (chunk.type === "finish") {
drain(controller, activeTextId ?? "dsml-fallback", true)
const rewritten =
recovered && chunk.finishReason.unified === "stop"
? { ...chunk, finishReason: { ...chunk.finishReason, unified: "tool-calls" as const } }
: chunk
controller.enqueue(rewritten)
return
}
if (chunk.type !== "text-delta") {
// Transition: the stream is moving to reasoning, a native tool
// part, or another block. Flush held text FIRST so transcript
// order is preserved — buffered text must never leak out after
// the parts that follow it.
if (textBuffer) drain(controller, activeTextId ?? textIdOf(chunk) ?? "dsml-fallback", true)
controller.enqueue(chunk)
return
}
const id = chunk.id
if (activeTextId === null) activeTextId = id
textBuffer += chunk.delta
drain(controller, id, false)
},
flush(controller) {
drain(controller, activeTextId ?? "dsml-fallback", true)
settle()
},
}),
),
...rest,
}
},
}
}
/**
* Recover DSML calls from a non-streaming generate result. Text parts are split:
* prose stays text, each recovered call becomes a `tool-call` content part. The
* finish reason flips to `tool-calls` when at least one call was recovered.
*/
export function recoverGenerateResult(
content: ReadonlyArray<LanguageModelV3Content>,
finishReason: { unified: "stop" | "length" | "content-filter" | "tool-calls" | "error" | "other"; raw: string | undefined },
options?: ParseOptions,
): { content: LanguageModelV3Content[]; finishReason: typeof finishReason; recovered: number } {
const next: LanguageModelV3Content[] = []
let recovered = 0
let counter = 0
for (const part of content) {
if (part.type !== "text") {
next.push(part)
continue
}
const { calls, ranges } = parseDsml(part.text, options)
if (calls.length === 0 && ranges.length === 0) {
next.push(part)
continue
}
let cursor = 0
let kept = ""
for (const range of ranges) {
if (range.start > cursor) kept += part.text.slice(cursor, range.start)
cursor = Math.max(cursor, range.end)
}
kept += part.text.slice(cursor)
// Streaming rule applies here too: no recovered call means the text goes back
// untouched rather than stripped.
if (calls.length === 0) {
next.push(part)
continue
}
if (kept.length > 0) next.push({ ...part, text: kept })
for (const call of calls) {
recovered++
next.push({
type: "tool-call",
toolCallId: toolCallId(counter++),
toolName: call.name,
input: JSON.stringify(call.arguments),
})
}
}
if (recovered === 0) return { content: [...content], finishReason, recovered }
return {
content: next,
finishReason: finishReason.unified === "stop" ? { ...finishReason, unified: "tool-calls" } : finishReason,
recovered,
}
}
/**
* Wrap a `LanguageModelV3` so every `doStream`/`doGenerate` passes through DSML
* recovery. This is a dependency-free alternative to `wrapLanguageModel` from the
* `ai` package: the returned object implements the same `LanguageModelV3` surface,
* so OpenCode's AI-SDK lowering consumes it unchanged.
*/
export function wrapDsmlLanguageModel(model: LanguageModelV3, input?: DsmlMiddlewareOptions): LanguageModelV3 {
const streamEnabled = input?.stream ?? true
const generateEnabled = input?.generate ?? true
if (!streamEnabled && !generateEnabled) return model
const middleware = createDsmlStreamMiddleware(input)
const parseOptions: ParseOptions = {
orphanInvoke: input?.orphanInvoke,
looseParameters: input?.looseParameters,
rawJsonBody: input?.rawJsonBody,
wrappedTool: input?.wrappedTool,
}
return {
specificationVersion: "v3",
provider: model.provider,
modelId: model.modelId,
supportedUrls: model.supportedUrls,
async doGenerate(options) {
const result = await model.doGenerate(options)
if (!generateEnabled) return result
const recovered = recoverGenerateResult(result.content, result.finishReason, parseOptions)
input?.onSettled?.({ calls: recovered.recovered, sawCandidate: recovered.recovered > 0 })
if (recovered.recovered === 0) return result
return { ...result, content: recovered.content, finishReason: recovered.finishReason }
},
async doStream(options) {
if (!streamEnabled) return model.doStream(options)
const wrapped = await middleware.wrapStream!({
doStream: () => model.doStream(options),
doGenerate: () => model.doGenerate(options),
params: options,
model,
})
return wrapped
},
}
}