Timeout Strategies for Long-Running Agent Steps
A timeout strategy sets hard clocks on model calls, tool calls, and whole runs so an agent cannot hang forever.
Long steps are normal in agent systems; unbounded steps are incidents waiting to happen.
Summary
Apply nested deadlines (run > turn > tool > HTTP), fail with structured timeout observations, cancel work when possible, and leave enough budget for fallbacks instead of spending the whole SLO on one hung call.
Recipe
- Define three clocks: run wall time, per-turn/step, per-tool/provider call.
- Prefer native client deadlines (HTTP timeout, SDK max duration) over hope.
- On timeout, return a typed observation (
error_type=timeout) and count it toward budgets. - Attempt cancellation of underlying tasks, subprocesses, or streaming reads.
- Reserve remaining run budget for degrade/fallback; do not start a long tool with 200ms left.
- Align timeouts with user-facing SLOs and with upstream SLAs.
- Log duration histograms per tool and model; retune outliers.
- Test hung dependencies so the loop always terminates.
Working Example
import time
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
from dataclasses import dataclass
from typing import Any, Callable, TypeVar
T = TypeVar("T")
@dataclass
class Deadline:
"""Absolute deadline in time.time() seconds."""
at: float
@classmethod
def in_seconds(cls, seconds: float) -> "Deadline":
return cls(at=time.time() + seconds)
def remaining(self) -> float:
return max(0.0, self.at - time.time())
def expired(self) -> bool:
return self.remaining() <= 0
@dataclass
class Observation:
ok: bool
data: Any = None
error_type: str | None = None
message: str | None = None
def call_with_timeout(fn: Callable[[], T], timeout_s: float) -> Observation:
if timeout_s <= 0:
return Observation(ok=False, error_type="timeout", message="no budget left")
with ThreadPoolExecutor(max_workers=1) as pool:
fut = pool.submit(fn)
try:
return Observation(ok=True, data=fut.result(timeout=timeout_s))
except FuturesTimeout:
return Observation(
ok=False,
error_type="timeout",
message=f"exceeded {timeout_s:.2f}s",
)
def run_turn(tools: list[tuple[str, Callable[[], Any]]], run_deadline: Deadline) -> list[Observation]:
results: list[Observation] = []
for name, fn in tools:
# Cap each tool, but never exceed remaining run budget
tool_cap = 2.0
budget = min(tool_cap, run_deadline.remaining())
obs = call_with_timeout(fn, budget)
results.append(obs)
if not obs.ok and obs.error_type == "timeout":
# stop scheduling more tools this turn
break
if run_deadline.expired():
break
return results
def slow() -> str:
time.sleep(3)
return "late"
def fast() -> str:
return "ok"
deadline = Deadline.in_seconds(2.5)
print(run_turn([("slow", slow), ("fast", fast)], deadline))Production code should use asyncio cancel scopes, HTTP timeout= parameters, or worker job deadlines instead of only thread result timeouts.
Deep Dive
Nested budgets beat a single global sleep
| Layer | Typical bound | Failure mode if missing |
|---|---|---|
| HTTP / SDK call | 5-60s | Hung socket forever |
| Tool wrapper | Slightly above HTTP | Soft retries exceed UX |
| Graph node / turn | Tens of seconds | One node stalls the graph |
| Whole run | Product SLO | Zombie sessions and spend |
Each inner timeout must be less than the remaining outer budget.
Model calls need timeouts too
Providers hang. Streams stall mid-token.
Set:
- Connect timeout
- Read / idle timeout between chunks
- Total generation cap when the API supports it
A tool timeout with an infinite model call still freezes the turn.
Cooperative vs preemptive cancellation
- Cooperative: check deadlines between steps (necessary).
- Preemptive: cancel tasks, close HTTP, SIGTERM child processes (necessary for long tools).
ThreadPool result(timeout=) alone may leave the worker thread running. Prefer APIs with real cancel.
Streaming and long research tools
For scrapers, browsers, or multi-page fetches:
- Chunk work into sub-deadlines.
- Checkpoint partial results into durable state.
- Return partial observation on timeout rather than nothing when partial is safe.
Partial search hits beat an empty hang.
Aligning with SLOs
If p95 time-to-final SLO is 45s:
- Run deadline might be 45-60s.
- Single tool might be 8-15s.
- Fallbacks need leftover budget (for example, never start primary tool with <2s left if fallback needs 3s).
See Setting SLOs for Agent Latency, Success Rate, and Cost.
Framework notes
LangGraph and similar systems expose per-node or run-level time controls in recent versions (verify at build).
Whatever the framework:
- Put deadlines in shared config.
- Enforce in one client layer used by all tools.
- Do not scatter magic numbers inside prompts.
Timeouts and retries
A timeout can be retryable once with backoff if the budget allows.
Never:
- Retry a 30s timeout three times under a 40s run deadline.
- Hide timeouts from the model when argument changes might help.
User-facing behavior
On run timeout:
- Persist terminal status
timed_out. - Show what completed.
- Offer resume or escalate rather than auto-restarting an identical long plan blindly.
Gotchas
- Only max turns, no wall clock. Slow tools make "3 turns" last an hour.
- Tool timeout > run timeout. Unreachable code paths and confusing races.
- No cancel on timeout. Cost and locks continue after UX failed.
- Identical timeout for every tool. Search and bulk export need different caps.
- Counting wall time while waiting on human approval. Freeze the run clock or use a separate approval SLA.
- Client timeout longer than load balancer idle timeout. LB cuts first; harder errors.
- Swallowing TimeoutError into empty success. Hallucination magnet.
Alternatives
| Approach | Strength | Weakness |
|---|---|---|
| Nested deadlines (this recipe) | Matches agent structure | Requires careful plumbing |
| Single run timeout only | Simple | One tool can burn the whole run |
| Async job + poll | Good for very long work | Needs product support for async UX |
| Serverless max duration only | Platform enforced | Coarse; cold starts; hard multi-step |
| Cooperative checkpoints only | Portable | Hung syscalls ignore you |
For multi-minute research, prefer async jobs with nested timeouts inside the worker.
FAQs
What is a good default tool timeout?
Start from p99 of successful calls times a small factor, with a product max. Many interactive tools land in 5-20s; bulk tools need explicit higher tiers.
Should timeout observations be retryable?
Often yes once, if remaining budget and error history allow. After repeated timeouts, open a breaker or fallback.
How do timeouts differ from kill switches?
Timeouts are planned bounds on duration. Kills are emergency stops for any reason, including policy and cost.
Do streaming UIs need different timeouts?
Yes. Add idle-stream timeouts so a stalled token stream fails even if the total cap has not been hit.
How should multi-agent graphs share deadlines?
Give the root a deadline and pass remaining budget into children. Children must not assume a fresh full SLO.
What about human-in-the-loop waits?
Pause the agent execution clock while waiting on approvals, or the run will false-timeout during business hours delays.
Can the model request a longer timeout?
Allow only via host policy for named tools/tiers. Never let free-form model text raise caps unbounded.
How do we test timeout behavior?
Inject sleeps and blocked sockets in CI; assert terminal status, metrics, and that fallbacks still run when budget remains.
Is `asyncio.wait_for` enough?
It helps for async code paths that honor cancellation. Confirm third-party SDKs actually cancel in-flight I/O.
Where do per-node timeouts show up in LangGraph 1.0+?
Check current LangGraph docs for per-node timeout support and streaming v2 behavior (verify at build); still wrap tools with your own client deadlines.
Related
- Reliability Engineering Basics - first timeout sketch
- Setting SLOs for Agent Latency, Success Rate, and Cost - what to bound toward
- Circuit Breakers for Agent Tool Calls - repeated timeouts as open signal
- Fallback Chains: Degrading Gracefully When a Model or Tool Fails - use remaining budget
- Why Agents Fail Differently Than Traditional Services - hang modes
- Kill Switches: Stopping a Runaway Agent Mid-Execution - emergency stop
- LangGraph 1.0's Per-Node Timeouts and v2 Streaming - framework hooks
Stack versions: Pins from the category manifest (verify at build): OpenRouter (~315+ models, July 2026 pricing/fees); LangGraph 1.0+; CrewAI 1.14+; Microsoft Agent Framework 1.0; Vercel AI SDK 6; Pydantic AI (latest); LlamaIndex (latest); OpenAI Agents SDK (latest + MCP); MCP (Linux Foundation governance); A2A (HTTP+SSE+JSON-RPC 2.0); Solana
@solana/web3.js+@solana/spl-token.