Skip to content

feat(core): stop a hung tool from hanging the whole turn - #270

Merged
oratis merged 1 commit into
mainfrom
feat/tool-deadline
Aug 14, 2026
Merged

feat(core): stop a hung tool from hanging the whole turn#270
oratis merged 1 commit into
mainfrom
feat/tool-deadline

Conversation

@oratis

@oratis oratis commented Aug 14, 2026

Copy link
Copy Markdown
Owner

Third implementation PR from docs/DSH_ADOPTION_PLAN.md §1.3.

The gap

Only Bash bounded itself. Every other tool could hang the agent turn forever — a Grep against a stalled network mount, a fetch wedged before its own timer arms — and what the user sees is a blinking cursor with no way to distinguish a slow command from a dead one.

Backstop, not primary timeout

This is the part worth reviewing, because it is the opposite of how a timeout usually gets added. The opponent case in the plan was that most tools already bound themselves and a second layer creates ambiguous "which timeout fired" semantics.

That argument is correct, so the design concedes to it: the default is 10 minutes, deliberately far above every inner limit, and the inner limit is supposed to win. [killed by timeout after 120000ms] is a better error than a generic backstop message. This layer only covers the case where the inner limit does not fire at all.

A caller-requested timeout in the tool's own arguments always wins when it is longer. Bash({ timeout: 900000 }) is an explicit request for a 15-minute command; a 10-minute backstop killing it would make the parameter a lie. The resolver adds 30s of grace on top so the tool's own limit still fires first.

It does not claim the work stopped

For Bash, Edit, Write, NotebookEdit the message says the effect is unknown — "may still be running… check the current state before retrying" — rather than implying nothing happened. Abandoning the wait does not abort the work. The process may well still be going, and reporting otherwise would be a guess presented as a fact. Read-only tools get the more useful "narrow the request and try again" instead.

One bug the tests caught

The first implementation aborted the signal and then settled the race. A well-behaved tool resolves synchronously from its abort listener, so its own "stopped" result won the race — and the deadline message vanished, including its warning that a side effect might be half-applied. The order is now settle-then-abort, with a comment saying why, because the correct order looks arbitrary until it bites.

Verification

pnpm typecheck, lint, format:check clean. Full suite green — core 1076 passed / 28 skipped, plus cli 242, desktop 104, server 49, protocol 34, lsp 13, vscode 12, scripts 42.

13 new tests: 10 unit over the resolver and message (per-tool overrides, requested-timeout precedence, the 30s grace, unusable timeout arguments, side-effect wording) and 3 through the real loop — a tool that never returns on its own, proof its signal is actually aborted, and proof a tool returning in time is untouched.

Off by toolDeadlines: { disabled: true }; default and per-tool values are configurable.

Only Bash bounded itself. A Grep against a stalled network mount, or a fetch
wedged before its own timer arms, hangs the agent turn indefinitely — and what
the user sees is a blinking cursor with no way to tell a slow command from a
dead one.

This adds a backstop deadline around every tool call. Backstop is the operative
word: the default is deliberately generous, because a deadline that raced each
tool's own limit would replace a precise error ("killed by timeout after
120000ms") with a vague one. It fires only when the inner limit does not.

A caller-requested `timeout` in the tool's own arguments always wins when it is
longer. Bash({ timeout: 900000 }) is an explicit request for a fifteen-minute
command; a ten-minute backstop killing it would make that parameter a lie.

When it fires on a side-effecting tool, the message says the effect is unknown
rather than implying nothing happened. Abandoning the wait does not abort the
work — the process may still be running, and "it did not happen" would be a
guess presented as a fact.

The tool's signal is aborted so a well-behaved tool can stop, but the race is
settled first: a tool that resolves synchronously from its abort listener would
otherwise win the race and return its own cancellation result, losing both the
deadline message and its warning about a half-applied side effect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@oratis
oratis force-pushed the feat/tool-deadline branch from 7591e63 to 39fec76 Compare August 14, 2026 12:56
@oratis
oratis merged commit b040fce into main Aug 14, 2026
5 checks passed
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