Skip to content
← Writing

Tool Timeout Budgets for AI Coding Agents Without Hung Runs

23 Jun 2026 · 5 min read

  • Agent Reliability
  • Tooling
  • Automation
  • DevOps
  • AI Coding
Tool Timeout Budgets for AI Coding Agents Without Hung Runs

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:

  1. Task budget for the entire run
  2. Step budget for the current unit of work
  3. Tool budget for the exact command, API call, or browser action
  4. 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 diff and playwright test like 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

  1. Start with 3 to 4 tool classes, not 20.
  2. Make fallback lanes explicit before adding more retries.
  3. 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.