How to Add Timeouts to Every Tool in the Harness
A single hung tool can burn more budget than a thousand good calls. Learn to add a harness tool timeout to every call, with process-group killing done right.
import subprocess
def with_timeout(fn, args, seconds=30):
try:
return fn(**args, _deadline=seconds)
except TimeoutError:
return f"ERROR: tool timed out after {seconds}s"
# for subprocess-based tools, the timeout is built in:
def shell(argv, _deadline=30):
out = subprocess.run(argv, capture_output=True, text=True,
timeout=_deadline)
return (out.stdout + out.stderr)[:6000]A single hung tool call can burn more of your API budget than a thousand successful ones. That sounds backwards until you've watched it happen: one
subprocessQuick-Start (Copy This Right Now)
Wrap every tool in a timeout at the harness level, so no individual tool can decide how long it's allowed to run:
[object Object], subprocess
,[object Object], ,[object Object],(,[object Object],):
,[object Object],:
,[object Object], fn(**args, _deadline=seconds)
,[object Object], TimeoutError:
,[object Object], ,[object Object],
,[object Object],
,[object Object], ,[object Object],(,[object Object],):
out = subprocess.run(argv, capture_output=,[object Object],, text=,[object Object],,
timeout=_deadline)
,[object Object], (out.stdout + out.stderr)[:,[object Object],]What this does: it enforces a deadline on each tool from the harness, not from inside the tool, so a tool author can't forget to add one. A timeout becomes a readable error the model can react to, rather than a silent hang. This is the floor — everything below makes it correct and complete.
Understanding the Variables
Three separate timeouts matter, and conflating them is why "I added a timeout" often doesn't fix the freeze.
Per-tool timeout. How long one tool call may run. A quick
read_filerun_testsPer-step timeout. How long a full loop iteration may take, model call plus tool execution. This catches the case where the model itself hangs or a slow tool plus a slow model together blow past what you'd tolerate for one step.
Global run budget. The wall-clock ceiling on the entire agent run. Even if every individual step finishes, an agent can loop thirty times and consume an hour. The global budget is your last line of defense against slow-but-not-stuck runs.
You need all three. A per-tool timeout won't stop a thirty-step loop of fast tools; a global budget won't stop a single tool hanging for the whole budget's duration. They cover different failure shapes.
⚡ Pro tip: Set your per-tool timeouts by measuring, not guessing. Run each tool fifty times against realistic inputs, take the 95th-percentile latency, and set the timeout at roughly three times that. Guessed timeouts are either so tight they kill legitimate slow calls or so loose they don't fire until real damage is done.
Add a Harness Tool Timeout to Every Call
The quick-start covers
subprocesstimeout[object Object], concurrent.futures ,[object Object], cf
,[object Object], ,[object Object],(,[object Object],):
,[object Object], cf.ThreadPoolExecutor(max_workers=,[object Object],) ,[object Object], pool:
future = pool.submit(fn, **kwargs)
,[object Object],:
,[object Object], future.result(timeout=seconds)
,[object Object], cf.TimeoutError:
,[object Object], ,[object Object],What this does: it runs any function in a worker thread and gives up waiting after the deadline, returning a clean error. The wrapped call keeps running in the background until it finishes on its own — which is a limitation worth knowing, because a truly stuck thread lingers. For anything that can genuinely hang forever, you want process-level isolation instead, covered next.
Here's the gotcha almost nobody handles:
subprocess[object Object], subprocess, os, signal
,[object Object], ,[object Object],(,[object Object],):
proc = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=,[object Object],, start_new_session=,[object Object],) ,[object Object],
,[object Object],:
out, _ = proc.communicate(timeout=deadline)
,[object Object], out[:,[object Object],]
,[object Object], subprocess.TimeoutExpired:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL) ,[object Object],
,[object Object], ,[object Object],What this does:
start_new_session=Truekillpg⚡ Pro tip: After killing a process group, log the PIDs you signaled. On the rare occasion an orphan survives — a process that ignored SIGKILL because it was stuck in an uninterruptible system call — that log is how you'll find it. Silent orphans are the hardest resource leak to diagnose because nothing in your normal logs points at them.
Pro-Level Variations
Three upgrades for real deployments:
Adaptive timeouts. Track each tool's recent latencies and adjust its deadline dynamically. A
run_testsGrace periods before hard kill. Send SIGTERM first, wait a couple of seconds for the process to clean up, then SIGKILL if it's still alive. A clean shutdown lets a database connection close properly instead of leaving a lock behind.
Timeout as a signal, not just a stop. Feed the timeout back to the model with context: "the test suite timed out at 300s — it may be hanging on a specific test." The model can then narrow its next attempt instead of blindly retrying the whole suite.
A fourth pattern is worth adding once you run agents at scale: distinguish a soft deadline from a hard one. The soft deadline warns and lets the tool finish; the hard deadline kills. Many tools are worth waiting a little past their target for — a query that usually takes ten seconds and occasionally takes fifteen shouldn't die at twelve. But every tool needs a hard ceiling it can never cross. Two thresholds per tool — "warn here, kill there" — gives you tolerance for normal variance without giving up the guarantee that nothing runs forever.
⚡ Pro tip: Make your global run budget slightly shorter than any external timeout that wraps your agent — the platform's request timeout, the queue's job deadline. If your agent's own budget fires first, you get a clean, logged shutdown with a useful error. If the external timeout fires first, your agent gets killed mid-step with no trace and no cleanup. Owning the deadline means owning the failure message.
Three teams putting this to work:
- A CI platform engineer gives an agent a tool with a generous per-tool timeout but a strict global budget, so the agent can wait out a slow suite once but can't loop on it endlessly.
run_tests - A data engineer wraps warehouse queries with process-group timeouts, because a killed query that leaves a background process holding a table lock would block the whole team.
- A web-scraping team sets tight per-tool timeouts on fetches, since a hung request to an unresponsive site is their single most common failure, and a fast timeout keeps the agent moving to the next URL.
Troubleshooting Common Issues
⚠️ Common mistake: Adding a timeout to the tool but not killing what the tool spawned. A
subprocess.run(timeout=...)Other issues you'll hit:
- Timeouts fire on legitimate slow calls. Your deadline is too tight for the real worst case. Measure the 95th percentile and set the timeout above it, rather than guessing low and getting bitten.
- The thread-based timeout doesn't actually stop the work. Right — threads can't be forcibly killed in Python. For work that must truly stop, use process isolation, not threads.
- The global budget never fires. Check that you're measuring wall-clock from the run's start, not resetting it each step. A per-step timer masquerading as a global budget won't catch a long loop.
- Timeouts work locally but not in production. Signal handling differs across environments — some container runtimes and thread pools swallow the signals your timeout relies on. Test your timeout behavior in the environment you actually deploy to, not just on your laptop, because a timeout that fires in development and silently fails in production is worse than no timeout at all.
Your Turn
Start today by auditing which of your tools have timeouts and which don't — you'll almost certainly find gaps. Add a per-tool deadline to every one, then a global run budget, then process-group killing for anything that spawns children. Each layer closes a failure shape the others miss.
A harness tool timeout is a few lines per tool, and it's the difference between an agent that fails fast and one that hangs until you get a billing alert. The three layers together — per-tool deadlines, a per-step limit, and a global run budget — cover every shape of "stuck" an agent can produce, and process-group killing makes sure a timed-out tool leaves nothing running behind it. The per-tool deadline values you settle on, along with the tool descriptions that tell the model how long each operation should take, are worth keeping. Storing those tuned timeout configs and descriptions in a prompt library like PromptABCD — tagged by the harness they belong to — means your next agent inherits sensible deadlines instead of starting with none and learning about hung tools the expensive way., along with the tool descriptions that tell the model how long each operation should take, are worth keeping. Storing those tuned timeout configs and descriptions in a prompt library like PromptABCD — tagged by the harness they belong to — means your next agent inherits sensible deadlines instead of starting with none and learning about hung tools the expensive way.
Continue Reading
Save the prompts from this post
PromptABCD is a free prompt manager. Paste, organize, and reuse your best AI prompts — no more hunting through chat history.
