Tool Call Executor Timeout
Cap each tool call in milliseconds and short-circuit pending calls with a cancel token
The tool-call executor caps each tool call with a millisecond timeout and lets you cancel pending calls, returning a typed result the model can reason about instead of hanging the turn.
from praisonaiagents import Agent
def fetch_url(url: str) -> str:
... # a tool that may hang
Agent(
instructions="Look things up.",
tools=[fetch_url],
llm={"model": "gpt-4o", "tool_timeout_ms": 5000}, # 5-second per-tool cap
).start("Look up https://example.com")Quick Start
- Cap every tool call
Set
tool_timeout_msinside thellm=dict. A slow tool now returns a typed timeout result instead of freezing the turn.python from praisonaiagents import Agent def fetch_url(url: str) -> str: ... # a tool that may hang agent = Agent( instructions="Fetch pages and summarise them.", tools=[fetch_url], llm={"model": "gpt-4o", "tool_timeout_ms": 5000}, # 5s per tool ) agent.start("Summarise https://example.com") - Cancel from another thread
Signal a
threading.Event-shaped cancel token to abort pending tool calls — useful in a REPL or gateway where a user hits stop.python import threading from praisonaiagents.tools import ( SequentialToolCallExecutor, ToolCall, ToolCancelledError, ) cancel = threading.Event() def execute_tool(name, args, tool_call_id): return {"ok": True} executor = SequentialToolCallExecutor() cancel.set() # e.g. fired from a signal handler / stop button results = executor.execute_batch( [ToolCall("fetch_url", {"url": "https://example.com"}, "call_1")], execute_tool, cancel_token=cancel, ) print(results[0].error_kind) # "cancelled" print(results[0].structured_error) # {"error": True, "kind": "cancelled", ...}
How It Works
The executor wraps each tool call, branching to a typed result on timeout or cancellation.
| Condition | Behaviour |
|---|---|
timeout_ms is None or <= 0 | Tool runs directly with zero wrapping overhead. |
timeout_ms > 0 | Tool runs on a dedicated daemon worker thread (tool-timeout); on expiry the worker is abandoned (daemon, so it never blocks interpreter exit) and a typed timeout result is returned. Healthy concurrent timed calls are not capped; only workers that outlive their deadline count against a shared _TIMEOUT_POOL_MAX = 32 orphan cap. |
cancel_token signalled | Tool never runs; returns a typed cancelled result. |
A timed call lands in one of three terminal states: the worker finished (success), the deadline hit and a slot was charged lazily (timeout), or the orphan pool is saturated and the call never ran (capacity).
Configuration Options
| Option | Type | Default | Description |
|---|---|---|---|
tool_timeout_ms (in llm= dict / extra_settings) | int | None | Per-tool timeout in milliseconds. None or <= 0 disables it. |
cancel_token (kwarg to execute_batch) | threading.Event-like | None | Any object with is_set(), is_cancelled, or cancelled (attribute or callable). When signalled, pending tool calls short-circuit. |
The cancel token is duck-typed — it supports both threading.Event and InterruptController-shaped tokens without importing a concrete type.
Structured Error Envelope
ToolResult.structured_error returns a discriminated JSON envelope so the model can tell a timeout from a genuine bug, instead of an opaque "Error executing tool: ..." string.
{
"error": true,
"kind": "timeout",
"type": "ToolTimeoutError",
"message": "Tool 'fetch_url' timed out after 5000ms",
"tool": "fetch_url"
}| Field | Type | Values | |||
|---|---|---|---|---|---|
error | bool | Always true when present | |||
kind | str | "timeout" \ | "cancelled" \ | "capacity" \ | "error" |
type | str | Exception class name (ToolTimeoutError, ToolCancelledError, ToolCapacityError, or the tool’s own exception) | |||
message | str | str(exc) | |||
tool | str | Tool function name | |||
tool_pool_exhausted | bool | True when the tool worker pool is saturated by hung earlier tool calls; the healthy tool was refused rather than queued behind them. |
A capacity kind carries ToolResult.result == {"error": "capacity", "tool": "<name>"}, error_kind == "capacity", and ToolResult.error is a ToolCapacityError (“too many hung tool workers; refusing to abandon another”):
{
"error": true,
"kind": "capacity",
"type": "ToolCapacityError",
"message": "too many hung tool workers; refusing to abandon another",
"tool": "fetch_url"
}The kind discriminant lets the model retry a timeout, abort on a cancelled or capacity, and report a plain error differently.
Exceptions
| Exception | Module | When raised |
|---|---|---|
ToolTimeoutError | praisonaiagents.tools | Sync worker exceeded timeout_ms |
ToolCancelledError | praisonaiagents.tools | cancel_token was signalled before / between calls |
ToolCapacityError | praisonaiagents.tools (exposed via tools.call_executor) | Too many tool workers are already orphaned (timed out). Distinct from ToolTimeoutError: the new call never ran because the process is already saturated with abandoned workers (_TIMEOUT_POOL_MAX = 32). |
from praisonaiagents.tools import ToolTimeoutError, ToolCancelledError, ToolCapacityErrorThese are surfaced as ToolResult.error with the matching error_kind — they are not thrown to your calling code.
Common Patterns
Bound a whole batch with one timeout — most users’ entry point:
from praisonaiagents import Agent
def fetch_url(url: str) -> str:
...
Agent(
instructions="Summarise pages.",
tools=[fetch_url],
llm={"model": "gpt-4o", "tool_timeout_ms": 3000}, # 3s cap on every tool call
).start("Summarise https://example.com")Wire up graceful cancellation for a REPL or long-running gateway:
import threading
from praisonaiagents.tools import SequentialToolCallExecutor, ToolCall
cancel = threading.Event()
def stop():
cancel.set() # call from a signal handler or a UI stop button
def execute_tool(name, args, tool_call_id):
return {"ok": True}
executor = SequentialToolCallExecutor()
results = executor.execute_batch(
[ToolCall("fetch_url", {"url": "https://example.com"}, "call_1")],
execute_tool,
timeout_ms=5000,
cancel_token=cancel,
)Best Practices
Pick the right timeout layer
Use millisecond tool_timeout_ms for user-facing latency budgets (a chat turn should not hang). Use wrapper-level tool_timeout (seconds) for hard operational cutoffs in YAML/CLI crews.
Background work may continue
A timed-out sync tool’s daemon worker is abandoned, not killed. Assume the tool may still be running. Never use the timeout to enforce security-critical bounds.
Reserve cancel tokens for interactive loops
Use cancel_token for interactive or streaming loops (bot, CLI, gateway) where a user can hit stop. Background jobs should rely on tool_timeout_ms alone.
Let the model see structured_error
Pass ToolResult.structured_error to the LLM instead of flattening it to a string. The discriminated kind is what lets the agent choose retry vs. abort vs. report.