Deadline Propagation for Nested LLM Agent Tool Calls in October 2026: Enforce End-to-End Timeouts Without Killing Healthy Work
Nested LLM agents burn time the way nested HTTP calls do: a supervisor waits on a worker, the worker waits on three tools, and one of those tools waits on a slow upstream. Without an end-to-end deadline, the outer request sits open until a client times out, your gateway retries, and you pay for work that will never be delivered.
This guide shows how to propagate a single request deadline through agent steps and tool calls so every layer stops when the user-facing budget is gone—without aborting healthy work that still has time left.
Why flat timeouts fail for agents
A common first attempt is a fixed timeout on every tool: thirty seconds for search, ten for a database write, sixty for the whole agent. That looks tidy and still fails in production.
- Budget stacking: Five tools at thirty seconds each can run for two and a half minutes even when the HTTP client only waits ninety seconds.
- No remaining time: A tool that starts with twenty seconds left still gets a full thirty-second allowance and finishes after the client has already given up.
- Retry amplification: The gateway retries the whole agent after a client disconnect, while a slow child tool is still running against the first attempt.
- Partial side effects: A late write commits after the parent has already returned an error, so the next retry duplicates the write unless you also use idempotency keys.
Agents need a deadline (an absolute wall-clock cutoff) shared down the call tree, not a pile of independent timers.
Carry one absolute deadline
At the edge of your system—API gateway, queue worker, or chat websocket handler—compute a single absolute deadline from the client's timeout or your product SLO:
deadline = now() + client_timeout_ms
# or: deadline = request_start + max_agent_wall_ms
Pass that timestamp (or remaining milliseconds derived from it) into every child: supervisor steps, worker agents, tool executors, and HTTP clients those tools use. Each layer derives its own remaining budget:
remaining_ms = max(0, deadline - now())
if remaining_ms < min_useful_ms:
return DeadlineExceeded("not enough time to start this step")
Use a hard floor such as min_useful_ms (often 200–500 ms) so you do not start a tool that cannot finish before the deadline even in the happy path.
Propagate through tool calls and model calls
Treat both LLM completions and tools as deadline-aware operations.
Model calls
When you call the provider, set the HTTP client timeout to min(provider_cap, remaining_ms). If the provider supports a server-side request timeout or cancellation token, pass it too. On streaming responses, check the deadline between chunks: if time is up, abort the stream, discard partial tool-call JSON you have not validated, and surface deadline_exceeded to the agent loop.
Tool calls
Each tool handler should:
- Read the deadline from context (request-scoped, not a global).
- Refuse to start if
remaining_ms < min_useful_msfor that tool's class. - Pass a tightened timeout to any downstream HTTP, SQL, or subprocess call:
timeout = remaining_ms - safety_margin_ms. - On cancel, stop work that is safe to stop; for side-effecting tools, rely on idempotency so a partial attempt can be retried later under a new deadline.
async def run_tool(name, args, ctx):
remaining = ctx.deadline - time.monotonic()
if remaining < TOOL_MIN_MS.get(name, 0.5):
raise DeadlineExceeded(name)
return await TOOLS[name](
args,
timeout=max(0.1, remaining - 0.1),
cancel=ctx.cancel,
)
Budget children, do not give them the whole remainder
When a step fans out into parallel tools, do not give each child the full remaining time if that would make the parent wait for the slowest one past the deadline. For independent parallel calls, each child can use the same remaining budget and you cancel the rest when the parent deadline hits. For sequential plans, reserve a slice for later steps:
# Sequential plan with N steps left
slice_ms = remaining_ms / N # or weighted by historical p95
child_deadline = now() + min(slice_ms, remaining_ms)
Weighted slices work better than equal splits when you know that retrieval is usually fast and code execution is usually slow: give the historically heavy step a larger share, but never more than absolute remaining time.
Cancel cooperatively
A deadline only helps if cancellation reaches the work that is still running. Wire a single cancel signal from the parent context into:
- HTTP clients (
AbortController, context cancellation, or equivalent). - Subprocess runners (kill process group on cancel).
- Queue consumers (nack or abandon with a clear reason).
- The agent loop (stop scheduling new tool calls; do not send another model request).
When the client disconnects early, treat that as an immediate deadline of now unless you intentionally continue the job asynchronously. Continuing after disconnect is a product choice; if you do it, attach a new, explicit job deadline rather than inheriting a dead HTTP timeout.
Classify failures for the agent loop
Map timeout outcomes to distinct errors so the agent (or your supervisor) can choose the right next action:
deadline_exceeded: No time left for this request. Do not retry inside the same request. Return a partial answer if you have safe intermediate results, or a clear failure to the user.tool_timeout: This tool hit its slice but the parent deadline still has budget. Safe to try an alternate tool, a cheaper model step, or skip an optional enrichment.provider_timeout: The model API did not respond in time. May retry once with a smaller max-tokens or a fallback model if remaining time allows.
Never convert every timeout into a blind retry. Blind retries under a shared deadline usually make the miss worse.
Observe what actually ate the budget
Log and trace four numbers per agent run:
- Request deadline (absolute).
- Time spent in model calls (sum and count).
- Time spent in each tool (by name).
- Time spent idle in the agent loop (planning gaps).
When users report "the assistant hung," these four fields show whether you need a tighter tool slice, a smaller model for mid-plan steps, or fewer serial tool hops. Export them as OpenTelemetry spans with deadline_ms and remaining_ms attributes so you can alert when p95 remaining-at-first-tool-call drops near zero.
A minimal rollout checklist
- Set an absolute deadline at the edge from the client timeout or a product max wall clock.
- Thread deadline + cancel through the agent loop, every tool, and every provider HTTP client.
- Refuse to start steps below a per-tool minimum useful budget.
- For sequential plans, reserve slices for later steps; for parallel plans, cancel siblings when the parent deadline fires.
- Distinguish
deadline_exceeded,tool_timeout, andprovider_timeoutin errors and metrics. - On client disconnect, either cancel immediately or continue under a new explicit job deadline—never leave orphan work on a dead request timeout.
Deadline propagation will not make slow tools fast. It will stop your agents from doing expensive work after the user is gone, and it will give you a clean place to degrade: skip optional tools, switch to a smaller model, or return a partial result while there is still time to be useful.
Comments
Post a Comment