Skip to content

Unknown tool name written to DB before repair callback fires; stuck pending part permanently kills session on Bedrock (400 toolUse.name constraint) #28989

Closed as not planned

Description

@nabilfreeman

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 dead

To 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

  1. nabilfreeman commented on May 23, 2026

    @nabilfreeman
    ContributorAuthor

    Tracing this further — there are two viable upstream fixes and a working plugin workaround for anyone hitting this today.

    Option A: Include "invalid" in activeTools (minimal change)

    The deadlock is caused by two interacting decisions: (1) the repair callback returns "invalid", and (2) "invalid" is excluded from activeTools. If the sentinel is allowed in activeTools, the repair succeeds end-to-end — the SDK calls the sentinel's execute function, the part resolves to completed with an error message, and the session continues normally.

    // packages/opencode/src/session/llm.ts:356
    - activeTools: Object.keys(sortedTools).filter((x) => x !== "invalid"),
    + activeTools: Object.keys(sortedTools),

    The "invalid" tool already has a schema ({ tool: string, error: string }) and an execute handler in packages/opencode/src/tool/invalid.ts. The exclusion from activeTools appears 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:

    // packages/opencode/src/session/processor.ts, in the tool-input-start case
    const VALID_TOOL_NAME_RE = /^[a-zA-Z0-9_-]+$/
    
    case "tool-input-start":
      if (!VALID_TOOL_NAME_RE.test(value.toolName)) {
        // Don't persist — repair callback will reroute to "invalid" sentinel
        return
      }
      const part = yield* session.updatePart({ ... })

    This prevents the poisoning at the source. The trade-off is ensuring ctx.toolcalls tracking 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:

    • Belt — experimental.chat.messages.transform scrubs stuck pending/running parts with invalid names before every inference call (fired at prompt.ts:1810 and compaction.ts:407). Operates per-message so a malformed entry in one message never blocks scrubbing of later messages.
    • Suspenders — the event hook listens for message.part.updated and deletes poisoned parts from the DB via DELETE /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.

    // plugins/invalid-tool-guard/index.ts
    import type { Plugin } from "@opencode-ai/plugin"
    import type { Part } from "@opencode-ai/sdk"
    
    /** Valid tool names satisfy Bedrock's toolUse.name constraint */
    const VALID_TOOL_NAME_RE = /^[a-zA-Z0-9_-]+$/
    
    /** Validates IDs are safe to interpolate into URL path segments */
    const SAFE_ID_RE = /^[a-zA-Z0-9_-]+$/
    
    /**
     * Returns true if the part is a tool call stuck in pending/running with an
     * invalid name — either the "invalid" sentinel or a name that fails the
     * provider's tool name constraint.
     */
    function isPoisonedToolPart(part: Part): boolean {
      if (!part || part.type !== "tool") return false
      if (!part.state) return false
    
      const status = part.state.status
      if (status !== "pending" && status !== "running") return false
    
      // Case 1: The "invalid" sentinel injected by opencode's repair callback
      if (part.tool === "invalid") return true
    
      // Case 2: A malformed name (missing, empty, or chars outside provider constraint)
      if (typeof part.tool !== "string" || !VALID_TOOL_NAME_RE.test(part.tool)) return true
    
      return false
    }
    
    const server: Plugin = async (input) => {
      const { serverUrl } = input
    
      return {
        /**
         * Belt: scrub poisoned parts from the message array before every inference
         * call (prompt.ts:1810) and compaction (compaction.ts:407).
         *
         * Per-message try/catch: a malformed entry in one message is skipped without
         * aborting scrubbing of later messages.
         *
         * Mutation contract: msg.parts = msg.parts.filter(...) mutates in-place.
         * Replacing output.messages with a new array is a silent no-op — the consumer
         * holds the original reference.
         */
        "experimental.chat.messages.transform": async (_input, output) => {
          for (const msg of output.messages) {
            try {
              if (msg.info.role !== "assistant") continue
              msg.parts = msg.parts.filter((part) => !isPoisonedToolPart(part))
            } catch {
              // skip this message, continue processing the rest
            }
          }
        },
    
        /**
         * Suspenders: delete poisoned parts from the DB as soon as they appear.
         * IDs are validated before URL construction to reject crafted events with
         * path-traversal characters.
         */
        event: async ({ event }: { event: unknown }) => {
          try {
            const e = event as { type: string; properties: { part: Part } }
            if (e.type !== "message.part.updated") return
    
            const part = e.properties?.part
            if (!part) return
            if (!isPoisonedToolPart(part)) return
    
            if (
              !SAFE_ID_RE.test(part.sessionID) ||
              !SAFE_ID_RE.test(part.messageID) ||
              !SAFE_ID_RE.test(part.id)
            ) return
    
            const url = new URL(
              `/session/${encodeURIComponent(part.sessionID)}/message/${encodeURIComponent(part.messageID)}/part/${encodeURIComponent(part.id)}`,
              serverUrl,
            )
            await fetch(url.toString(), { method: "DELETE" })
          } catch {
            // Deletion failure is non-fatal — Belt still protects inference
          }
        },
      }
    }
    
    export default { id: "invalid-tool-guard", server }

    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 .ts natively.

    The Belt protects every inference call; the Suspenders keep the DB clean across restarts.

  2. github-actions commented on Jul 24, 2026

    @github-actions
    Contributor

    To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions