An opencode plugin (1.x) that stops OpenAI-compatible providers from killing your session with:
Expected 'function.name' to be a string.
(AI_InvalidResponseDataError, thrown from the bundled
@ai-sdk/openai-compatible stream handler.)
Any opencode 1.x user on a provider whose server parses ChatML-native models
(Qwen, DeepSeek, GLM, …) into OpenAI-style tool_calls deltas:
- DashScope / Aliyun token-plan subscription endpoints (frequent)
- vLLM with
qwen3_xmltool parser - llama.cpp with certain parser flags
- NVIDIA NIM and other "OpenAI-compatible" gateways
Reported upstream as anomalyco/opencode#26412,
#24137,
#31295 — all closed
(duplicates / "file against vercel/ai"). Vercel fixed the SDK
(vercel/ai#18440 → PR #18445; the
throw string is gone in @ai-sdk/[email protected]), but opencode 1.x
ships a stale bundled copy inside its compiled binary, so the crash is
still live. Everyone's tracker says "not ours". This plugin fixes it client-side
today.
| Layer | Failure mode | Behavior |
|---|---|---|
Stream guard (wraps each provider's fetch) |
Server opens a tool_calls delta with name:null / missing id, or splits id/name across fragments → SDK throws → turn aborts |
Buffers unknown-index fragments until id+name resolve, re-emits one valid opening fragment with merged args; trims backtick-wrapped names; coerces numeric ids; drops phantom calls that never resolve — the turn ends as plain text instead of dying |
Outbound scrubber (experimental.chat.messages.transform) |
opencode replays reasoning_content/text verbatim, so a leaked/truncated special-token pattern stays in history and re-arms the server's parser every turn |
Rewrites <|name|> in outbound history to < |name| > — parser-inert, readable, idempotent, self-healing |
Auto-continue (event on session.idle) |
Server truncates generation at the pattern and closes with a clean finish_reason:"stop" — reply ends mid-sentence, silently |
Detects clipped turns (stop + no tool call + empty or dangling-backtick text tail), sends one nudge ("your reply was truncated, resume from this tail, don't repeat"); once per message, max 3 per session, subagents skipped |
Binary-output guard (tool.execute.after) |
cat of a model file / image / compiled artifact — NUL-bearing bytes flood the context AND the TUI, and opencode's truncator spills the full stream to disk (one live incident: 27 GB from cat *.gguf) |
Rewrites NUL-bearing outputs (first-8 KB window) into a short notice: printable excerpt, the spill-file path, and safe inspection commands (file, xxd -l 64, strings) |
Well-formed streams pass through byte-identical. The observation lanes
are on by default (a launch-line-env soak collected only 6 sessions
in 2 days — coverage demanded defaults): they write marker files and,
when a local decision server is reachable on 127.0.0.1, send turn
text to it; model-backed lanes self-disable while the server is
unreachable. TOOLCALL_GUARD=off disables every optional lane; per-lane
off/0 opts out individually. The stream guard + scrubber core is not
affected. See each lane's privacy note.
V1 (opencode 1.x):
mkdir -p ~/.config/opencode/plugins
cp plugins/toolcall-guard.js ~/.config/opencode/plugins/(or drop it in a project's .opencode/plugins/). Restart opencode —
config and plugins load once at startup.
V2 (opencode 2.x): V2 loads plugin package directories, not bare
files — point the plugins config array at this repo's plugin-v2/
directory (absolute path, e.g. in the global opencode.json):
Same core file, two entry shapes — because the runtimes are asymmetric and both asymmetries were proven live (2026-10-22, opencode 2.1.2 vs 1.19.3):
- V1 loads only a FUNCTION default export (
plugins/toolcall-guard.js— the documented 1.18.29+ object entry does NOT load in 1.19.3: the guarded crash resurfaces with zero plugin logs). - V2 loads only an OBJECT default export with
{ id, setup }— the function is skipped silently.plugin-v2/index.jsis the V2 package directory; it builds the object from the core'ssetupproperty. No@opencode/plugindependency needed.
V2 layers ported (env flags shared with V1):
| V1 hook | V2 destination |
|---|---|
config (wrap each openai-compatible provider's options.fetch) |
ctx.session.hook("http.response") — replaces event.response with the piped body; gated on SSE content-type only (the provider catalog is incomplete at plugin-boot, so an allowlist there races — and handleLine passes non-OpenAI lines byte-identical anyway) |
experimental.chat.messages.transform |
ctx.session.hook("context"/"compaction"/"generate"/"title") + scrubStrings (shape-agnostic deep walk covers V2 text/reasoning/tool-arg parts) |
event on session.idle |
ctx.event.subscribe() on session.execution.succeeded — V2's turn-complete event. The shared V2 service runs plugin setup once PER LOCATION and may also deliver the V1-compat session.idle per turn, so one event can reach several instances: nudges are guarded by an exclusive-create claim file per message (toolcall-guard.nudge.<messageID>), making double delivery a functional no-op (only the idles log doubles). Message shape via ctx.session.context() ({data:[…]} envelope; finish presence replaces the step-finish-part abort check) |
Not yet ported to V2 (V1 keeps them): pre-flight, binary guard, subreport annotation, JEV confab, turn audit, janitor.
V2 marker/log files land in $XDG_DATA_HOME/opencode/ (the V1 default when
unset; TOOLCALL_GUARD_DATA_DIR overrides both). The V2 marker
(toolcall-guard.loaded) reads {entry:"v2", providersWrapped:1, openaiSeen:N, lanes:"on"|"off"} — providersWrapped:1 means the global
guard hook is registered (V2 wraps once per stream, not per provider);
lanes:"off" distinguishes a deliberate TOOLCALL_GUARD=off from a plugin
that never loaded. TOOLCALL_GUARD=off on V2 keeps the stream guard and
scrubber active and disables only the auto-continue lane — the same
contract as V1 (unit-tested in test/v2.test.mjs).
V2 quirks found while verifying (fixture + harness handle all of them):
- The V2 title request embeds the raw user prompt — any server-side
"is this the main turn?" heuristic must key on something else (the
fixture uses the presence of
tools). opencode run --standalone "<msg>"stores the message with literal quotes around it.ctx.provider.list()/ctx.session.context()answer with a{location, data}/{data}envelope; provider.list() may also reject while the registry boots.- The bash tool is named
shellin V2 — the fixture auto-detects which name the harness offers.
Applies automatically to every provider whose npm is
@ai-sdk/openai-compatible. Verify via marker files:
~/.local/share/opencode/toolcall-guard.loaded # plugin active, N providers wrapped
~/.local/share/opencode/toolcall-guard.scrubbed # outbound history rewrites
~/.local/share/opencode/toolcall-guard.clips # auto-continue firings
Feature-flagged via environment; nothing below activates unless you set its flag. They share one dependency: a Rizzo Flow decision server (local, Jev-compatible API, ~1.7 GB model) — if it is not reachable, every lane fails open silently. Setup, the two API endpoints' exact shapes and gotchas, measured question-writing rules, and calibration: docs/rizzo-notes.md.
Observation only, never injects. On session.idle (when the clip
heuristic found nothing): a deterministic sieve exits question/offer-ended
turns for free, flags dangling ends, and the residue is scored by one
whose move is next? choice question on the decision server. Results
append to toolcall-guard.audit — a label stream for later calibration.
TOOLCALL_GUARD_AUDIT=1 opencode # interactive thresholds
TOOLCALL_GUARD_AUDIT=overnight opencode # alert-grade thresholdsDeterministic, model-free: logs zero-ambiguity unbounded commands
(rm -rf /, find ~, du $HOME, … — quote-aware argv parsing, not
substring grep) and binary-peek (cat/od/strings of a
binary-extension file with no bounding pipe — one live cat *.gguf
spilled 27 GB to disk). =block additionally refuses them. Optional
clamp sets a timeout on scan-like commands that lack one (note:
opencode's own default is already 120s — set TOOLCALL_GUARD_CLAMP_MS
deliberately, below your effective bashDefaultTimeoutMs).
TOOLCALL_GUARD_PREFLIGHT=1 opencode # log only
TOOLCALL_GUARD_PREFLIGHT=block opencode # log + refuse
TOOLCALL_GUARD_CLAMP=1 opencode # scan-like + no timeout -> clampSome Qwen-family models intermittently emit reasoning blocks narrating a
conversation that doesn't exist ("the user's message is a system reminder…
no actual task", "no previous thinking to reproduce", a phantom audit
tool). opencode replays those blocks every turn, poisoning context. Tier 1
(14 battle-tested signature rules) replaces matching reasoning parts in
the outgoing request only — the database keeps originals, and the scrub is
retroactive: old contaminated sessions heal on resume. Text parts are
logged, never rewritten. Tier 2 (broad candidates + one decision-server
question) logs verdicts only.
TOOLCALL_GUARD_SCRUB=1 opencode # log candidates only
TOOLCALL_GUARD_SCRUB=strip opencode # + strip rule-matched reasoningDo not enable strip in sessions whose topic is the artifact itself —
prose about confabulation matches the signatures (measured, both tiers).
Provider-scoped to qwen|ds4 by default (TOOLCALL_GUARD_SCRUB_PROVIDERS).
| Var | Values | Default |
|---|---|---|
TOOLCALL_GUARD |
off disables all optional lanes |
on |
TOOLCALL_GUARD_AUDIT |
off | 1 | overnight |
on (1) |
TOOLCALL_GUARD_AUDIT_T |
number (probability cut) | 0.5 / 0.6 by mode |
TOOLCALL_GUARD_RIZZO |
decision-server URL | http://127.0.0.1:8017/v1/systemone |
TOOLCALL_GUARD_RIZZO_MODEL |
model id | rizzo-flow-1.7b-q8_0 |
TOOLCALL_GUARD_PREFLIGHT |
off | 1 | block |
on (1) |
TOOLCALL_GUARD_CLAMP / _CLAMP_MS |
1 / ms |
off / 120000 |
TOOLCALL_GUARD_SCRUB |
off | 1 | strip |
on (1) |
TOOLCALL_GUARD_BINARY |
0 disables the binary-output guard (log path: TOOLCALL_GUARD_BINARY_LOG) |
on |
TOOLCALL_GUARD_JANITOR |
0 disables the startup sweep of >1 GB, >24 h tool-output spills |
on |
TOOLCALL_GUARD_DANGLING |
nudge activates auto-continue on deterministic dangling-announcement stops (colon/em-dash ending + zero tool calls, main sessions, max 2/session); unset = log-only |
log |
TOOLCALL_GUARD_LIVENESS_LOG |
redirect the guard-liveness warning (default: toolcall-guard.errors) |
unset |
TOOLCALL_GUARD_SUBREPORT |
off | log-only (unset) | annotate (append fact + resume-with-task-id hint to empty/clipped subagent reports in the outbound copy) |
log |
TOOLCALL_GUARD_SCRUB_PROVIDERS |
regex | qwen|ds4 |
TOOLCALL_GUARD_CONFAB_LOG |
log path override | real log (tests point here) |
TOOLCALL_GUARD_DATA_DIR |
absolute dir override for all markers/logs (wins over XDG_DATA_HOME) — pin it when a process might inherit the other install's env |
unset |
Privacy: the audit and scrub lanes send turn text (request + reply tail /
passage) to the configured URL — localhost by default, never remote unless
you point it there. Preflight logs command snippets; the confab log may
contain quoted reasoning snippets. All logs live under
~/.local/share/opencode/toolcall-guard.* (or $XDG_DATA_HOME/opencode/
when set — the V2 install) (audit, audit.shadow,
preflight, confab, clips, scrubbed, idles, errors, loaded, binary, janitor,
plus nudge.<messageID> / nudge.dangling.<messageID> claim files (exclusive-create tokens
guarantee one nudge per message across all plugin instances — the V2 service runs setup once
per location and every instance sees every shared-stream event).
for t in test/*.test.mjs; do node $t; done # unit suites, incl. test/v2.test.mjs
# (entry shapes, analyzeMessagesV2,
# guardSseResponse, deep scrub)End-to-end against a synthetic malformed SSE server (no real API needed):
# V1
SCENARIO=late PORT=8787 node test/toolcall-guard-server.mjs &
OPENCODE_CONFIG=$PWD/test/toolcall-guard-test.opencode.json \
opencode run --auto -m guardtest/guardtest-model "say hi MARKER7788"--pure(plugin disabled) reproduces the fatal error — RED- without
--purethe call is repaired and the tool executes — GREEN SCENARIO=drop→ phantom call dropped, graceful text-only turnSCENARIO=poison→ token in history scrubbed from the replayed requestSCENARIO=clip→ clipped turn auto-nudged and resumed (useopencode serve- HTTP API so the nudge isn't raced by
runexiting)
- HTTP API so the nudge isn't raced by
V2 — one script per harness (isolated XDG triple + standalone server;
requires OPENCODE_V2_BIN if opencode 2 is not at the default path):
zsh test/e2e-v2-matrix.sh # late/drop/poison/clip via run --standalone
zsh test/e2e-v2-clip-serve.sh # clip nudge round-trip on a persistent serveThe V2 fixture config (test/toolcall-guard-test.v2.opencode.json) is the
plugins-array twin of the V1 test config. The fixture auto-detects the
harness generation (tools → V2 shell vs V1 bash) and excludes the V2
title request from scenario serving — see the V2 support section.
The server fixture builds token strings by concatenation — the repo itself stays parser-inert, because these files were authored through the failing endpoint.
- Silent truncation that ends cleanly mid-word (no dangling backtick) is not detected by the clip heuristic; the audit lane measures the residue but is observation-only until its calibration earns promotion.
- The audit lane cannot see "statement-shaped" stops whose unfinished work lives in plan state, not in the text — no text-only detector can.
- The decision model never outperforms its state: blast-radius judgment needs the filesystem, conclusion judgment needs the plan. Both are documented dead ends for command-text-only designs.
- User-aborted turns (esc) are never nudged or audited (race-free
step-finishdiscriminator). - Modes 2/3 are the provider's parser bug; the guard can only absorb their consequences, not prevent clipped replies.
- Scoped to opencode 1.x (AI-SDK runtime + plugin API); opencode v2 is not targeted.
MIT
{ "plugins": ["/path/to/toolcall-guard/plugin-v2"] }