Repository navigation
Unknown tool name written to DB before repair callback fires; stuck pending part permanently kills session on Bedrock (400 toolUse.name constraint) #28989
Description
Description
When the model emits a tool call with an unrecognized name, opencode's experimental_repairToolCall callback correctly redirects to the "invalid" sentinel. However, a race condition means processor.ts has already written the original malformed name to SQLite with status: "pending" before the repair fires. Because "invalid" is excluded from activeTools, the AI SDK throws NoSuchToolError and aborts the turn — leaving the pending part stuck in the database forever.
On the next turn, toModelMessagesEffect re-serializes the stuck part as a tool_use block containing the original malformed name. On Bedrock, this triggers a non-retryable ValidationException:
messages.N.member.content.M.member.toolUse.name failed to satisfy constraint:
Member must satisfy regular expression pattern: [a-zA-Z0-9_-]+
The session becomes permanently unrecoverable. All subsequent messages return empty responses or a hard 400. Other providers may be more tolerant of the name constraint but will still see a dangling pending part that never resolves.
Root Cause
Four files interact to produce this failure:
1. processor.ts:274 — DB write fires before repair
The tool-input-start stream event handler writes the raw, unrepaired tool name to the database immediately:
// packages/opencode/src/session/processor.ts:274–296
case "tool-input-start":
const part = yield* session.updatePart({
...
type: "tool",
tool: value.toolName, // ← raw name from stream, before any repair
callID: value.id,
state: { status: "pending", input: {}, raw: "" },
} satisfies MessageV2.ToolPart)2. llm.ts:331 — repair callback fires too late
experimental_repairToolCall runs after the full tool input is received — well after tool-input-start has already fired:
// packages/opencode/src/session/llm.ts:331–351
async experimental_repairToolCall(failed) {
// case-correction for known tools (lowercase match) ...
return {
...failed.toolCall,
input: JSON.stringify({ tool: failed.toolCall.toolName, error: failed.error.message }),
toolName: "invalid", // ← redirected to sentinel, but DB already written
}
}3. llm.ts:356 — "invalid" excluded from activeTools
// packages/opencode/src/session/llm.ts:356
activeTools: Object.keys(sortedTools).filter((x) => x !== "invalid"),After the repair returns "invalid", the AI SDK validates the repaired name against activeTools — and because "invalid" is excluded, the SDK throws NoSuchToolError. The turn aborts. The pending part is never resolved.
4. message-v2.ts:848 — stuck part re-serialized on every subsequent turn
// packages/opencode/src/session/message-v2.ts:848–857
if (part.state.status === "pending" || part.state.status === "running")
assistantMessage.parts.push({
type: ("tool-" + part.tool) as `tool-${string}`, // ← original malformed name
state: "output-error",
toolCallId: part.callID,
input: part.state.input,
errorText: "[Tool execution was interrupted]",
})part.tool is the original malformed name persisted in step 1 (e.g. some command "with spaces"). The AI SDK converts this into a toolUse block sent to the provider. Bedrock validates toolUse.name against [a-zA-Z0-9_-]+ and rejects the entire request with isRetryable: false. Session is dead.
Steps to Reproduce
The following injects a malformed tool part directly into the database, deterministically replicating exactly what processor.ts writes on tool-input-start:
# 1. Start a session
opencode run "Say hello."
# Note the session ID from the output
# 2. Get the assistant message ID
sqlite3 ~/.local/share/opencode/opencode.db \
"SELECT session_id, id FROM message ORDER BY time_created DESC LIMIT 5;"
# 3. Inject a malformed tool part
# (data column stores PartData = Omit<ToolPart, "id"|"sessionID"|"messageID">)
sqlite3 ~/.local/share/opencode/opencode.db \
"INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (
'prt_repro_test',
'<MSG_ID>',
'<SESSION_ID>',
unixepoch() * 1000,
unixepoch() * 1000,
'{\"type\":\"tool\",\"callID\":\"call_repro_001\",\"tool\":\"some command \\\"with spaces\\\"\",\"state\":{\"status\":\"pending\",\"input\":{},\"raw\":\"\"}}'
);"
# 4. Send another message in the same session
opencode run --session <SESSION_ID> "What is 1+1?"
# On Bedrock: 400 ValidationException — toolUse.name fails [a-zA-Z0-9_-]+ constraint
# Session is now permanently deadTo trigger organically (no DB injection): prompt the model with an instruction that references a non-existent tool by a name containing spaces or other characters outside [a-zA-Z0-9_-]. The model will attempt to call it; the repair callback redirects to "invalid"; the DB write races ahead with the original bad name.
Expected: The session recovers — either the repair completes cleanly so the part resolves to completed/error, or the stuck part is cleaned up before the next API call.
Actual: Every subsequent turn re-sends the malformed name, triggering a non-retryable 400 on Bedrock. The TUI shows empty assistant responses (sub-200ms). The session never recovers.
Plugins
None
OpenCode version
1.14.33
Operating System
macOS 15
Terminal
Ghostty
Activity
nabilfreeman commentedon May 23, 2026
Tracing this further — there are two viable upstream fixes and a working plugin workaround for anyone hitting this today.
Option A: Include
"invalid"inactiveTools(minimal change)The deadlock is caused by two interacting decisions: (1) the repair callback returns
"invalid", and (2)"invalid"is excluded fromactiveTools. If the sentinel is allowed inactiveTools, the repair succeeds end-to-end — the SDK calls the sentinel'sexecutefunction, the part resolves tocompletedwith an error message, and the session continues normally.The
"invalid"tool already has a schema ({ tool: string, error: string }) and an execute handler inpackages/opencode/src/tool/invalid.ts. The exclusion fromactiveToolsappears intended to prevent the model from calling"invalid"deliberately — but it also blocks the repair callback from completing successfully, creating the deadlock.Option B: Validate the tool name before persisting (prevents DB poisoning at the source)
In
processor.ts, check the tool name before writing to the database. If the name would fail the provider constraint, skip the write — the repair callback will reroute to the"invalid"sentinel anyway:This prevents the poisoning at the source. The trade-off is ensuring
ctx.toolcallstracking stays consistent when the write is skipped.Plugin Workaround
For anyone who can't wait for an upstream fix, here's a hardened plugin that prevents the session death using two layers of defense:
experimental.chat.messages.transformscrubs stuckpending/runningparts with invalid names before every inference call (fired atprompt.ts:1810andcompaction.ts:407). Operates per-message so a malformed entry in one message never blocks scrubbing of later messages.eventhook listens formessage.part.updatedand deletes poisoned parts from the DB viaDELETE /session/:sid/message/:mid/part/:pid(handlers/session.ts:366, no busy-guard, safe during an active session). Keeps the DB clean across restarts so the Belt only needs to act once.The plugin is completely silent — no console output, no changes to the conversation the model sees.
Save as
plugins/invalid-tool-guard/index.ts(or.js) in your project root. OpenCode auto-discovers{plugin,plugins}/*.{ts,js}from the project root. No build step needed — Bun imports.tsnatively.The Belt protects every inference call; the Suspenders keep the DB clean across restarts.
github-actions commentedon Jul 24, 2026
To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.