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.

python
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")
Diagram

Tool Call Executor

within budget

timeout_ms exceeded

orphan pool full

cancel_token set

📞 Tool Call

⏱️ Timeout?

▶️ Run Tool

✅ ToolResult ok

⏰ ToolTimeoutError

🚧 ToolCapacityError

🛑 ToolCancelledError

📦 structured_error kind=timeout

📦 structured_error kind=capacity

📦 structured_error kind=cancelled

Quick Start

  1. Cap every tool call

    Set tool_timeout_ms inside the llm= 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")
  2. 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.

Diagram
ToolExecutorLLMAgentUserToolExecutorLLMAgentUseralt[within timeout_ms][timeout_ms exceeded][cancel_token set]RequestPrompt + toolsTool callRun (dedicated worker)ValueToolResult okToolResult error_kind=timeoutToolResult error_kind=cancelledNext stepResponse
ConditionBehaviour
timeout_ms is None or <= 0Tool runs directly with zero wrapping overhead.
timeout_ms > 0Tool 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 signalledTool 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).

Diagram

Yes — 32 abandoned workers

No

Yes

No

📞 Timed tool call
timeout_ms > 0

🚧 Orphan pool
saturated?

🚧 capacity
call refused, never ran

▶️ Run on daemon worker

⏱️ Finished before
deadline?

✅ success

⏰ timeout
worker abandoned, slot charged lazily


Configuration Options

OptionTypeDefaultDescription
tool_timeout_ms (in llm= dict / extra_settings)intNonePer-tool timeout in milliseconds. None or <= 0 disables it.
cancel_token (kwarg to execute_batch)threading.Event-likeNoneAny 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.

json
{
  "error": true,
  "kind": "timeout",
  "type": "ToolTimeoutError",
  "message": "Tool 'fetch_url' timed out after 5000ms",
  "tool": "fetch_url"
}
FieldTypeValues
errorboolAlways true when present
kindstr"timeout" \"cancelled" \"capacity" \"error"
typestrException class name (ToolTimeoutError, ToolCancelledError, ToolCapacityError, or the tool’s own exception)
messagestrstr(exc)
toolstrTool function name
tool_pool_exhaustedboolTrue 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”):

json
{
  "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

ExceptionModuleWhen raised
ToolTimeoutErrorpraisonaiagents.toolsSync worker exceeded timeout_ms
ToolCancelledErrorpraisonaiagents.toolscancel_token was signalled before / between calls
ToolCapacityErrorpraisonaiagents.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).
python
from praisonaiagents.tools import ToolTimeoutError, ToolCancelledError, ToolCapacityError

These 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:

python
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:

python
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.


Related