Skip to content

feat(opencode): end every tool call that runs past a time limit - #54503

Open
Nowaker wants to merge 2 commits into
anomalyco:v1from
Nowaker:tool-call-limit
Open

Nowaker wants to merge 2 commits into
anomalyco:v1from
Nowaker:tool-call-limit

Conversation

@Nowaker

@Nowaker Nowaker commented Oct 11, 2026 •

Copy link
Copy Markdown

Note: I'm aware v1 is now the maintenance branch and new features go to v2 only. This PR is here for others to use, and I'll submit the v2 version once I've migrated my whole plugin system to it.

Issue for this PR

Closes #53433. Related: #46461, #54340.

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

A tool call that never returns stalls its session until someone interrupts it. In one case a plugin tool's worker stopped replying, and the turn hung for 2.5 hours. Across 616k completed calls, every tool except shell, subagent and human-wait tools finished within 2.6 minutes at p99.9, and in that group every call that ran over 30 minutes was a hang.

With this change, every tool call runs under a hard limit (packages/opencode/src/tool/timeout.ts). When a call is still running at its limit, it is ended the same way an abort ends it: its abort signal fires, its fiber is interrupted, and the tool part becomes an error that names the limit and where it came from, for example tool call exceeded its 30m limit (config: tool_timeout.default). The turn continues, so the model sees that error.

The most specific setting wins, and each level only sets the default for the levels below it:

  1. tool_timeout.<tool id> in config, for any built-in, plugin or MCP tool. It overrides the levels below, whether higher or lower.
  2. The default the tool's provider declares: timeout on a plugin tool({...}) definition, or tool_timeout on an mcp server entry. question and task default to no limit at this level, so a level-1 entry still applies to them. read, write, edit, grep and glob default to 10 minutes here, or to tool_timeout.default when that is lower: these tools never legitimately run that long, and a call that does is hung on I/O (for example a read of a named pipe nothing writes to). Their error names the key that changes it: tool call exceeded its 10m limit (built-in default for read, set tool_timeout.read to change it).
  3. tool_timeout.default, else 30 minutes.

false means no limit at every level. When a tool takes its own per-call timeout (bash's timeout argument), that timeout plus one minute of grace raises the limit from levels 2 and 3. This way bash still reports its own timeout first. A tool_timeout.bash entry is never raised. Time spent waiting on a permission prompt does not count toward the limit.

grep and glob pass the call's abort signal to ripgrep, so the child process stops when the call is ended.

MCP's new tool_timeout is separate from its existing timeout. timeout bounds each request and resets on progress notifications. The MCP spec has no per-tool duration hint (tool annotations carry only behavioral hints), so the server entry serves as the provider-level default.

Documented in config.mdx ("Tool time limits"), tools.mdx, custom-tools.mdx ("Time limit"), plugins.mdx and the mcp-servers.mdx option tables, and in the schema descriptions. The v2 SDK types are regenerated.

How did you verify your code works?

  • test/tool/timeout.test.ts covers resolution at every level with higher and lower values, the question/task exemptions and their overrides, the file tools' 10-minute default and how tool_timeout.default and per-tool entries interact with it, the bash per-call raise and its limits, the error format, and the case where a hanging fiber is interrupted and its signal aborted.
  • test/session/tools.test.ts covers an MCP tool ended at its server's tool_timeout, a user entry beating the MCP default, a hanging tool ended at its declared limit, and permission-prompt time not counting.
  • test/session/prompt.test.ts, through the real loop and a fake LLM:
    • A .opencode/tool that never resolves ends with the error part, and the next LLM request carries that error.
    • bash's own timeout raises the limit above tool_timeout.default.
    • tool_timeout.bash still ends a call that asks for longer.
    • A read blocked opening a FIFO ends at a lower tool_timeout.default, and the turn continues.
  • bun typecheck in packages/core, packages/opencode, packages/plugin and packages/sdk/js.
  • A built binary with scratch XDG dirs, a plugin whose tools never finish, and a scripted OpenAI-compatible endpoint:
    • The declared 3s limit, a 2s tool_timeout.default, and a 1.5s per-tool override of a plugin's timeout: false each ended their call with the error above, within about 5ms of the limit.
    • A 3s sleep with bash timeout: 10000 completed normally under the 2s default, and the turn finished with the model's final text.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

This PR is AI generated, but under direct supervision and on request of @Nowaker.

A tool call that never returns stalls its session forever: a plugin
tool whose worker stopped replying once held a turn for 2.5 hours with
nothing to stop it. Across 616k completed calls, every tool except
shell, subagent and human-wait tools finishes at p99.9 within 2.6
minutes, and every call in that group over 30 minutes was a hang.

Every call now runs under a hard limit (packages/opencode/src/tool/
timeout.ts). A call still running when its limit elapses is ended the
way an abort ends it: the call's abort signal fires, its fiber is
interrupted, and the tool part becomes an error naming the limit and
where it came from, e.g. "tool call exceeded its 30m limit (config:
tool_timeout.default)". The turn continues, so the model sees it.

The most specific setting wins, each level a default for the next:

1. tool_timeout.<tool id> in config, for any built-in, plugin or MCP
   tool, higher or lower than the rest;
2. the default the tool's provider declares: `timeout` on a plugin
   tool({...}) definition, or `tool_timeout` on an mcp server entry.
   question, task and call_omo_agent default to no limit here, so a
   level-1 entry still applies to them;
3. tool_timeout.default, else 30 minutes.

`false` means no limit at every level. A per-call timeout the tool
takes itself (bash's `timeout` argument) raises levels 2 and 3 to that
timeout plus a minute of grace, so bash still reports its own timeout
first; a tool_timeout.bash entry is never raised. Time spent waiting on
a permission prompt does not count toward the limit.

MCP's tool_timeout is separate from its `timeout`, which bounds each
request and resets on progress notifications. The MCP spec has no
per-tool duration hint (annotations carry only behavioral hints), so
the server entry is the provider-level default.

Docs: config.mdx (Tool time limits), tools.mdx, custom-tools.mdx (Time
limit), plugins.mdx and the mcp-servers.mdx option tables, plus schema
descriptions; the v2 SDK types are regenerated.

AI-Tool: opencode 1.18.34-vt-207-907b3bc518
AI-Model: anthropic/claude-opus-5-5
AI-Variant: high
AI-Platform: darwin
AI-Harness: Vibeterm d01daadf
AI-Session-ID: ses_ed5bde150ffepnn3xqxHaucDLN
@github-actions

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

Nowaker added a commit to Nowaker/opencode that referenced this pull request Oct 11, 2026
Feature and session records for d107564: tool_timeout, plugin
tool({ timeout }) and MCP tool_timeout, upstream PR anomalyco#54503 against v1,
the vt-212 install on m4max and vt-213 on desktop, and the desktop serve
unit restarts.

AI-Tool: opencode 1.18.34-vt-207-907b3bc518
AI-Model: anthropic/claude-opus-5-5
AI-Variant: high
AI-Platform: darwin
AI-Harness: Vibeterm e2a734d1
AI-Session-ID: ses_ed5bde150ffepnn3xqxHaucDLN
read, write, edit, grep and glob never legitimately run for minutes: a
call that does is hung on I/O, such as a read of a named pipe nothing
writes to, and it held its turn until the general 30-minute limit. A
write that started 2026-10-10 never returned and its turn never ended.

These five tools now default to 10 minutes at the provider level
(packages/opencode/src/tool/timeout.ts, FILE_TOOL_MS), next to the
question/task exemptions. A lower tool_timeout.default still applies to
them; a higher one, or false, does not lift them. tool_timeout.<tool>
replaces it either way. A call past the limit ends as a tool error the
model sees:

  tool call exceeded its 10m limit (built-in default for read, set
  tool_timeout.read to change it)

grep and glob now pass the call's abort signal to ripgrep, so the child
process stops when the call is ended.

Docs: config.mdx (Tool time limits), tools.mdx and the tool_timeout
schema description.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant