Skip to content

fix(core): settle foreign typed tool failures instead of dropping them - #43576

Open
kitlangton wants to merge 1 commit into
v2from
tool-error-blanket
Open

fix(core): settle foreign typed tool failures instead of dropping them#43576
kitlangton wants to merge 1 commit into
v2from
tool-error-blanket

Conversation

@kitlangton

@kitlangton kitlangton commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

What

A plugin tool that fails with a foreign typed error (anything that isn't Tool.Error) leaves its tool call permanently unsettled: the part stays running with a live spinner forever, while the step continues anyway and the execution reports success. Found by a drive gauntlet probe whose tool transport died mid-execution.

Tool.Info.execute declares Effect<Result, Tool.Error>, but a plugin can fail with anything at runtime. Nothing enforced the declaration, and every downstream handler assumes it:

  • the runner fiber's catchTag("Tool.Error", failTool) misses it (llm.ts)
  • classifyToolExits drops Fail reasons wholesale, assuming any typed failure surviving the fiber's catchTag is a decline (llm.ts:91)
  • the clean-stream sweep only settles hosted calls (llm.ts:543)

So the failure threads every needle: no tool.failed event is ever published, needsContinuation still fires off call.called, and the part is only healed if some later drain's settleStaleToolCalls sweep happens to run.

Before

  1. Plugin tool starts; part shows running, spinner animates.
  2. The tool's transport dies; the plugin fails with its own tagged error.
  3. The failure is silently dropped. No session.tool.* terminal event is published.
  4. The step continues, the model replies, the turn ends — session.execution.succeeded.
  5. The spinner keeps animating indefinitely; the server projection still says running. If the user never sends another message, it never settles.

Event stream from the repro (note: no tool terminal event between tool.called and execution.succeeded):

seq=8  session.tool.called
seq=11 session.step.ended
seq=12 session.step.started      ← continuation
seq=15 session.step.ended
seq=16 session.execution.succeeded   ← tool part still "running"

After

  1. Same trigger: the plugin fails with a foreign typed error.
  2. The tool runtime maps it to Tool.Error at the untrusted boundary; the fiber's existing failTool path publishes the durable failure immediately.
  3. The part settles as error with the real message, the model reads the failure, the continuation proceeds, the UI is quiescent right after the turn.

How

  • packages/core/src/tool/runtime.ts — enforce the declared contract where untrusted tool implementations enter typed code: any typed failure that isn't Tool.Error becomes one, preserving its message. Decline tunneling (defects) and interrupts are untouched.
  • packages/core/src/session/runner/llm.ts — defense in depth in classifyToolExits: a non-decline Fail reason is surfaced as a defect (failing the unsettled calls) instead of being silently dropped.
  • packages/core/test/tool-execute.test.ts — regression test: a tool failing with a foreign tagged error settles as Tool.Error.

Scope

  • Does not change decline semantics, interrupt semantics, or the stale-call sweep.
  • The drive-side transport bug that exposed this (tool controller idle timeout) is fixed separately in opencode-drive.

Testing

  • bun run typecheck (packages/core) clean.
  • bun run test -- test/tool-execute.test.ts test/session-runner.test.ts test/session-runner-tool-events.test.ts test/session-runner-tool-registry.test.ts — 198 pass, plus the new regression test.
  • End-to-end via opencode-drive: a probe holds a plugin shell open, kills its transport mid-execution, and asserts the part reaches a terminal state and the UI settles with no further prompts. Fails on v2, passes on this branch.

Demo

Before — turn is over (T0X_CONTINUED, 9.0s footer), but the shell row is still spinning; the server projection reports the part as running indefinitely:

before: stuck spinner after the turn ended

After — identical scenario: the part settles immediately as an error with the real transport message, then the continuation completes and the UI is quiescent:

after: part settled as error, UI quiescent

Flow

sequenceDiagram
    participant P as Plugin tool
    participant R as Tool runtime (runtime.ts)
    participant F as Runner tool fiber (llm.ts)
    participant S as Settlement

    P->>R: fail with foreign tagged error
    rect rgb(60,30,30)
    note over R,S: before: error escapes catchTag("Tool.Error"),<br/>classifyToolExits drops the Fail reason,<br/>part never settles, step continues
    end
    rect rgb(30,60,30)
    note over R: after: mapError enforces the declared contract
    R->>F: Tool.Error
    F->>S: failTool → durable tool.failed, part settles
    end
Loading

A tool implementation declares `Effect<Result, Tool.Error>` but a plugin can
fail with any typed error at runtime. Such a failure slipped past every
`catchTag("Tool.Error")" downstream and past classifyToolExits (which drops
Fail reasons on the assumption they are all declines), so the call was never
settled: the part stayed "running" forever, the step continued anyway, and
the execution reported success.

Enforce the declared contract at the untrusted boundary (tool runtime maps
foreign typed failures to Tool.Error) and harden classifyToolExits to surface
any surviving non-decline typed failure as a defect instead of silently
dropping it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant