Tool Timeout Budgets for AI Coding Agents Without Hung Runs
23 Jun 2026 · 5 min read
- Agent Reliability
- Tooling
- Automation
- DevOps
- AI Coding
A lot of agent failures are not really reasoning failures. They are timeout failures with good marketing.
A shell command waits forever on a package mirror. A browser step hangs behind a modal. A model gateway streams one token and then stalls. The agent looks indecisive, but the real bug is that nobody taught the tool lane when to give up.
This post shows how I would design timeout budgets for coding agents so slow tools degrade predictably instead of consuming the entire run. The pattern is simple: layered deadlines, explicit cancellation, retry classes, and fallback lanes that match the value of the step.
Why this matters
If your agent has a 20 minute task budget and one verifier command can sit for 18 minutes before dying, you do not have a verifier. You have a single-point schedule risk.
Timeout policy becomes more important as agents get more autonomous:
- parallel runs amplify queue contention
- browser steps hide stalls better than CLI tools
- API retries can silently turn a 10 second step into a 4 minute one
- human reviewers only see "agent timed out" unless you keep per-tool receipts
The practical goal is not to make every tool fast. It is to make every delay legible.
Architecture or workflow overview
flowchart LR
A[Agent task budget] --> B[Step planner]
B --> C[Tool budget resolver]
C --> D[Soft deadline]
D --> E[Grace window]
E --> F[Hard kill]
F --> G[Retry or fallback lane]
G --> H[Verifier receipt + summary]
A good timeout ladder has four parts:
- Task budget for the entire run
- Step budget for the current unit of work
- Tool budget for the exact command, API call, or browser action
- Fallback policy when the tool exceeds budget
Implementation details
1. Put deadlines in the tool manifest, not in scattered shell strings
# tool-budgets.yaml
defaults:
soft_timeout_ms: 15000
grace_timeout_ms: 5000
hard_timeout_ms: 25000
max_retries: 1
classes:
quick_read:
soft_timeout_ms: 4000
grace_timeout_ms: 1000
hard_timeout_ms: 6000
max_retries: 0
verify_medium:
soft_timeout_ms: 30000
grace_timeout_ms: 10000
hard_timeout_ms: 45000
max_retries: 1
browser_action:
soft_timeout_ms: 12000
grace_timeout_ms: 5000
hard_timeout_ms: 20000
max_retries: 1
tools:
npm_test_changed:
class: verify_medium
playwright_checkout_flow:
class: browser_action
git_diff_name_only:
class: quick_read
This keeps two mistakes out of the system:
- hard-coded timeouts copied across scripts
- one global timeout that treats
git diffandplaywright testlike the same job
2. Resolve a per-step deadline from the remaining task budget
interface Budget {
softMs: number;
graceMs: number;
hardMs: number;
maxRetries: number;
}
function resolveBudget(tool: Budget, remainingTaskMs: number, remainingStepMs: number): Budget {
const ceiling = Math.max(1500, Math.min(remainingTaskMs, remainingStepMs));
const hardMs = Math.min(tool.hardMs, ceiling);
const graceMs = Math.min(tool.graceMs, Math.floor(hardMs * 0.35));
const softMs = Math.max(500, Math.min(tool.softMs, hardMs - graceMs));
return {
softMs,
graceMs,
hardMs,
maxRetries: hardMs < tool.hardMs ? 0 : tool.maxRetries,
};
}
This is the part teams skip. A 45 second verifier is fine early in a run and irresponsible when 18 seconds remain.
3. Use soft timeout, grace timeout, then hard kill
async function runWithBudget(cmd: string[], budget: Budget) {
const child = spawn(cmd[0], cmd.slice(1), { stdio: ['ignore', 'pipe', 'pipe'] });
let softExpired = false;
const softTimer = setTimeout(() => {
softExpired = true;
child.kill('SIGTERM');
}, budget.softMs);
const hardTimer = setTimeout(() => {
child.kill('SIGKILL');
}, budget.softMs + budget.graceMs);
const result = await collectChildResult(child);
clearTimeout(softTimer);
clearTimeout(hardTimer);
return {
...result,
softExpired,
timedOut: softExpired && result.exitCode === null,
};
}
Soft timeout gives cooperative tools a chance to flush buffers and write cleanup state. Hard kill protects the run when they do not.
4. Keep a receipt so the agent can explain what happened
{
"tool": "playwright_checkout_flow",
"budget": {
"softMs": 12000,
"graceMs": 5000,
"hardMs": 17000
},
"attempt": 1,
"stdoutHeartbeatMs": 840,
"result": "timed_out",
"fallback": "switch_to_api_health_check",
"capturedTail": "waiting for selector [data-testid=checkout-submit]"
}
That receipt is what turns a vague failure into a reviewable decision.
Example terminal output
$ agent verify --target checkout-flow
[budget] task=19.8m step=2.0m tool=17.0s (soft=12.0s grace=5.0s)
[tool] playwright_checkout_flow attempt=1
[warn] soft timeout reached, sending SIGTERM
[warn] grace expired, forcing SIGKILL
[fallback] switching to api_health_check + screenshot artifact review
[summary] verifier degraded safely, patch review may continue with medium confidence
What went wrong and the tradeoffs
The most common failure mode: retrying the wrong thing
A slow package registry or flaky model gateway can justify a retry. A deadlocked migration script usually cannot. If you retry both equally, you multiply bad waits.
| Situation | Good default | Why |
|---|---|---|
git diff, metadata reads |
no retry | cheap to rerun at a higher layer |
| HTTP 429 or 503 | bounded retry with jitter | provider may recover quickly |
| browser selector timeout | one retry after page reset | session may be poisoned |
| long verifier with zero stdout | no retry, downgrade lane | repeated silence is signal |
| schema migration lock wait | abort and escalate | risk of unsafe side effects |
Timeout budgets can hide real performance problems
If the fix for every slow test is "lower the timeout," you are only becoming better at failing earlier. Keep percentile data by tool class, or the budget file becomes superstition.
Cancellation is part of correctness
Some tools do not exit cleanly on SIGTERM. Browser workers, Docker builds, and subprocess trees are classic offenders. If your kill path leaks child processes, your timeout policy will slowly poison the host.
Pitfall: A timeout without cleanup can be worse than no timeout. You stop waiting, but the machine keeps doing the work anyway.
Security matters here too
Budget decisions affect security-sensitive flows:
- secrets brokers should fail closed on timeout
- approval checks should not auto-pass when the policy service is slow
- destructive tools should not resume from partial state without an explicit receipt
Practical checklist
Use this if you are adding timeout control to an existing agent stack:
- define timeout classes for reads, writes, browsers, and verifiers
- derive tool budgets from remaining task time, not static config alone
- separate soft deadline, grace period, and hard kill
- record retry reason and fallback lane in a machine-readable receipt
- track stdout heartbeat gaps so "slow" and "stuck" are not treated the same
- fail closed for approvals, secrets, and destructive operations
- measure tool percentiles weekly and tune classes with data
What I would do again
- Start with 3 to 4 tool classes, not 20.
- Make fallback lanes explicit before adding more retries.
- Instrument timeout receipts before arguing about model quality.
Conclusion
Hung runs are usually orchestration debt wearing an agent costume. Once each tool has a real budget, a kill path, and a fallback lane, the agent becomes much easier to trust and much easier to debug.