Tool Timeouts & Cancellation

Give every tool call a hard deadline, cancel with a token, and read discriminated error kinds

Give any single tool call a hard deadline, cancel a batch mid-flight with a token, and read a discriminated error_kind instead of an opaque error string — so your agent can decide to retry, switch tools, or abort.

python
from praisonaiagents.tools.call_executor import (
    ToolCall,
    SequentialToolCallExecutor,
)

def execute_tool_fn(name, arguments, tool_call_id):
    # Your real dispatch logic; this stub just echoes.
    return f"ran {name}"

executor = SequentialToolCallExecutor()
results = executor.execute_batch(
    [ToolCall("search", {"q": "recent papers on X"}, "call-1")],
    execute_tool_fn,
    timeout_ms=5000,   # abandon any single tool after 5s
)

for r in results:
    print(r.result, r.error_kind)
Diagram

completes

deadline hit

token signalled

raised

🛠️ Tool call

timeout_ms /
cancel_token

✅ success

⏱️ timeout

🚫 cancelled

❗ error

Quick Start

  1. Add a hard deadline

    Pass timeout_ms (milliseconds) to execute_batch. A tool that runs longer is abandoned on a dedicated worker and returns a typed timeout result — the turn never hangs.

    python
    from praisonaiagents.tools.call_executor import ToolCall, SequentialToolCallExecutor
    
    def execute_tool_fn(name, arguments, tool_call_id):
        return slow_lookup(**arguments)  # your tool
    
    executor = SequentialToolCallExecutor()
    results = executor.execute_batch(
        [ToolCall("slow_lookup", {"id": 42}, "call-1")],
        execute_tool_fn,
        timeout_ms=3000,
    )
  2. Add a cancel token

    Pass any threading.Event-like token. Signal it from another thread to short-circuit pending calls with a typed cancelled result.

    python
    import threading
    from praisonaiagents.tools.call_executor import ToolCall, SequentialToolCallExecutor
    
    cancel = threading.Event()
    
    executor = SequentialToolCallExecutor()
    # From elsewhere: cancel.set()
    results = executor.execute_batch(
        [ToolCall("search", {"q": "X"}, "call-1")],
        execute_tool_fn,
        cancel_token=cancel,
    )

    The token is duck-typed: threading.Event (.is_set()) and InterruptController-like tokens (.is_cancelled / .cancelled) both work — no concrete import required.

  3. Handle structured errors

    Pattern-match on error_kind and read structured_error for a discriminated payload:

    python
    for r in results:
        if r.error is None:
            print("ok:", r.result)
        elif r.error_kind == "timeout":
            print("retry later:", r.structured_error)
        elif r.error_kind == "cancelled":
            print("user cancelled")
        else:  # "error"
            print("tool failed:", r.structured_error)

How It Works

Diagram
Tool (long-running)ExecutorAgentTool (long-running)ExecutorAgentdeadline exceededexecute_batch(…, timeout_ms=3000)run on dedicated workerToolResult(error_kind=”timeout”)decide retry vs abort

Because Python threads can’t be force-killed, a timed-out tool’s worker is abandoned (not joined) and a typed result is returned immediately — the turn keeps moving. It is now a daemon thread (PR #5589), so a hung tool cannot block interpreter exit.

Both executors forward timeout_ms and cancel_token:

python
from praisonaiagents.tools.call_executor import (
    ParallelToolCallExecutor,
    SequentialToolCallExecutor,
)

# Same signature on both:
SequentialToolCallExecutor().execute_batch(calls, fn, timeout_ms=5000, cancel_token=tok)
ParallelToolCallExecutor(max_workers=5).execute_batch(calls, fn, timeout_ms=5000, cancel_token=tok)

Retry-on-timeout is opt-in for mutating tools

A wall-clock timeout is no longer auto-retried unless the tool is idempotent — because the timed-out body keeps running in an orphaned worker thread (Future.cancel() is a no-op once the task has started), so re-driving a mutating tool would duplicate its side effects (a second invoice email, a double charge).

Diagram

Yes

No

Yes

No / Unknown

Tool call

Completes
within timeout?

Return result

Wall-clock timeout

Tool marked
idempotent?

Retry up to
max_attempts

Surface timeout
once — no retry

Opt in for a specific tool by setting idempotent = True on the callable:

python
from praisonaiagents import Agent

def read_current_price(symbol: str) -> float:
    ...
read_current_price.idempotent = True   # safe to retry on timeout

agent = Agent(
    name="Trader",
    instructions="Look up prices.",
    tools=[read_current_price],
)

The same attribute works on a registered FunctionTool — set it on the object the agent holds:

python
from praisonaiagents.tools import tool

@tool
def read_current_price(symbol: str) -> float:
    ...
read_current_price.idempotent = True   # opt in to retry-on-timeout

Idempotency is decided in this order: an explicit idempotent attribute on the tool wins; then, with ToolConfig(allow_global_tools=True), the global registry’s flag; then the shared IDEMPOTENT_TOOLS / MUTATING_TOOLS name registry in praisonaiagents.escalation.loop_guard. Unknown tools default to non-idempotent — the safe choice, so a timeout surfaces once instead of duplicating side effects.


AgentsGenerator is a closable resource

The wrapper-level tool-timeout guard runs inside an AgentsGenerator, and closing that generator closes its worker pool permanently.

That generator owns a ThreadPoolExecutor for sync tool workers. A tool invoked after close() no longer silently resurrects the pool — it raises an actionable RuntimeError naming the fix.

Prefer the context-manager form so cleanup is not conditional:

python
from praisonai.agents_generator import AgentsGenerator

with AgentsGenerator(agent_file="agents.yaml", framework="praisonai", config_list=cfg) as gen:
    gen.generate_crew_and_kickoff()
# Pool is torn down here. A tool held past this line raises RuntimeError.

If a background hook, framework lifecycle callback, or shared registry keeps a wrapped tool alive past the with … as gen: block, invoking it now raises:

text
RuntimeError: AgentsGenerator is closed; refusing to construct a new tool-timeout executor. This tool was invoked after close(); hold a live generator (e.g. keep the `with ... as gen:` block open) for the lifetime of any tool it wraps.

Per-call tool-timeout wraps travel on the per-run prep dict, not on the generator instance, so two concurrent calls on one AgentsGenerator cannot swap each other’s budget. See Registry DI → Concurrent runs on one AgentsGenerator.

Concurrent recycle races are absorbed

A concurrent recycle can shut the captured pool down between capture and submit, or cancel a queued future before it runs. The wrapper now retries once against a freshly-minted pool. A second failure surfaces as ToolTimeoutError (with background_work_may_continue=False when the tool never executed), preserving the module contract so RuntimeError / CancelledError never leak to caller code.

Both attempts share a single time.monotonic() deadline, so a retry can never extend the declared budget — if the first attempt’s pool recycle burned 3 s of a 5 s budget, the retry waits at most the remaining 2 s before surfacing ToolTimeoutError.

Diagram

OK

RuntimeError
or CancelledError

OK

Fails again

🛠️ Sync tool call

Capture pool

submit?

▶️ Run tool

Retry against fresh pool
remaining budget only

submit?

⏱️ ToolTimeoutError

✅ result


error_kind Reference

ToolResult.error_kind is a discriminated tag; ToolResult.structured_error is None on success and a discriminated dict on failure.

error_kindExceptionresult payloadExample structured_error
timeoutToolTimeoutError{"error": "timeout", "timeout_ms": ..., "tool": ...}{"error": true, "kind": "timeout", "type": "ToolTimeoutError", "message": "...", "tool": "search"}
cancelledToolCancelledError{"error": "cancelled", "tool": ...}{"error": true, "kind": "cancelled", "type": "ToolCancelledError", "message": "...", "tool": "search"}
errorthe raised exception"Error executing tool: ..."{"error": true, "kind": "error", "type": "ValueError", "message": "...", "tool": "search"}
python
from praisonaiagents.tools import ToolTimeoutError, ToolCancelledError

Timeout, Cancel, or Both?

Diagram

A hard deadline

External interrupt

Both

What kind of stop
do you need?

timeout_ms=5000

cancel_token=event

set timeout_ms
and cancel_token

Config default via ToolExecutionConfig

ToolExecutionConfig.timeout_ms is now consumed by the executor, so a configured default flows through to execute_batch without per-call wiring.

Parallel batches

ParallelToolCallExecutor enforces the same per-tool timeout inside each worker, so one hung tool resolves to a typed timeout result instead of blocking collection of the others.

Streaming path now forwards the token too

Prior to PraisonAI PR #5081, the streaming tool loop (get_response_stream) accepted cancel_token on public entry points but never forwarded it to execute_batch, so a Stop during a streamed tool batch let shell / MCP / file tools run to completion. Since #5081, start(stream=True) and iter_stream() thread the same token end-to-end (from Agent.interrupt_controller when no explicit token is passed), the batch short-circuits with typed cancelled results, and the follow-up completion request is skipped. See Cooperative Tool Cancellation → Streaming honours the same Stop.

BaseTool compatibility

When PraisonAI wraps a framework tool with tool_timeout, the resulting proxy is an instance of a dynamic subclass of the original tool’s class — so isinstance(proxy, BaseTool) (and any framework-specific base check used by CrewAI / LangChain / praisonaiagents.tool_execution for dispatch) still returns True. A BaseTool subclass with tool_timeout set on its owning agent executes normally through the same isinstance-routed .run path as an unwrapped tool; the wrapper adds a deadline but does not change dispatch. See PraisonAI Package Integration → Cross-generator tool isolation for the multi-generator behaviour.

ACP/LSP tools now honour tool_timeout

Prior to PR #4477, ACP/LSP agent-centric tools injected by PraisonAIAdapter._maybe_inject_centric_tools bypassed the per-run tool_timeout guard, so a hung LSP server or blocked ACP file op could hang the agent (and, under praisonai serve, the request thread) indefinitely. The wrapper now threads the same guard through to injected tools. As of PR #5228, the generator stores the per-run closure privately on AgentsGenerator._run_ctx and merges it into the cli_config kwarg the adapter receives via _dispatch_cli_config(), so PraisonAIAdapter._maybe_inject_centric_tools reads cli_config["_tool_timeout_wrap"] exactly as before — without the user-facing self.cli_config dict ever being stamped with those private closures. If you have config.acp: true in your YAML with --tool-timeout set, LSP/ACP calls now raise ToolTimeoutError at the declared deadline instead of hanging.

Tool middleware async path now honours tool_timeout

Prior to PR #5080, an async tool invoked through the tool middleware chain (@wrap_tool_call / @before_tool / @after_tool, or any plugin-registered tool middleware such as audit/guardrail wrappers) scheduled its coroutine on the running loop and blocked a thread-pool worker on future.result() with no timeout. A hung async tool blocked the middleware chain indefinitely and ToolConfig.timeout was silently ignored on this path. The worker wait is now bounded by the same ToolConfig.timeout the direct async path uses (with a small grace so the inner asyncio.wait_for surfaces its typed timeout first when configured). On timeout the future is cancelled and the same structured error dict the direct async path returns is surfaced ({"error": "Tool timed out after Ns", "timeout": True, "_praison_retryable": False}). No new API — set tool_config=ToolConfig(timeout=...) on the agent as before.

Native tools now delegate tool_timeout to core (PR #5652)

On framework: praisonai, a plain-callable native tool’s per-tool timeout is now enforced by core directly (Agent(tool_config=ToolConfig(timeout=…))) instead of by a duplicated wrapper-side executor. #5650 removes the second abandon-on-expiry pool and the second ToolTimeoutError that the wrapper kept in lock-step with core.

What stays the same: tool_timeout: in YAML, --tool-timeout on the CLI, and tool_config=ToolConfig(timeout=…) in Python all work exactly as before — no migration needed.

What subtly changes: on the native path with a plain callable, a timeout now surfaces core’s structured dict {"error": "Tool timed out after Ns", "timeout": True, "_praison_retryable": False} rather than the wrapper’s praisonai.agents_generator.ToolTimeoutError. Framework tool objects (CrewAI/LangChain BaseTool) still raise the wrapper ToolTimeoutError, as do non-native frameworks (crewai, autogen, ag2). Subsecond budgets (tool_timeout: 0.4) are forwarded to core intact instead of being rounded to 0.

python
from praisonaiagents import Agent, ToolConfig

def slow_lookup(symbol: str) -> float:
    ...  # takes a while

agent = Agent(
    name="Trader",
    instructions="Look up prices.",
    tools=[slow_lookup],
    tool_config=ToolConfig(timeout=5),   # core enforces — single pool
)

# A timeout now returns core's structured dict rather than raising
# the wrapper ToolTimeoutError:
#   {"error": "Tool timed out after 5s", "timeout": True,
#    "_praison_retryable": False}
agent.start("Price of X")

See PR #5652 for the rationale. As of PR #5798, this is gated by the adapter’s SUPPORTS_CORE_TOOL_TIMEOUT capability flag rather than a framework == 'praisonai' string check — see the Framework Adapter Plugins page if you ship a renamed or forked native adapter.

Retry no longer doubles the budget (PR #5809)

Before PR #5809, a CancelledError retry (pool recycled under a queued future) restarted the full tool_timeout countdown, so a 30 s budget could spend up to 60 s in the wrapper. A single monotonic deadline is now computed on entry and only the remaining budget is passed to future.result(), so tool_timeout is the true wall-clock cap in multi-agent runs where another agent recycles the shared pool.

Healthy tools no longer time out in the queue (PR #5796)

tool_timeout is now charged from the moment the tool body starts running, not from when it is submitted to the agent’s tool pool. Waiting for a worker never times out a healthy tool, but a pool stuck behind hung workers still fails at tool_timeout instead of blocking forever. The per-agent pool also grew from 2 to 5 workers to match the default parallel executor width. Before PR #5796 (#5795), 5 concurrent 1 s tools with tool_timeout=1.6 saw 3 of 5 falsely time out; now all 5 succeed. No API change — keep using tool_config=ToolConfig(timeout=...). See Concurrency → Executor Details.


YAML tool_timeout precedence (multi-agent configs)

When you declare tool_timeout on agents or roles in YAML, PraisonAI now honours each agent’s declared budget independently.

Precedence (per agent):

  1. CLI tool_timeout wins for every agent — an explicit --tool-timeout value overrides YAML for all agents.
  2. Per-agent YAML value wins over the CLI absence. Each agent’s declared value is applied to its own tool calls.
  3. Uniform declared values (every agent declares tool_timeout and every value is identical) take a shared-wrap fast path. If any agent omits tool_timeout, the shared wrap is skipped entirely — even if the remaining declarations agree.
  4. Heterogeneous per-agent values (different agents declare different values) are honoured per-agent — the tightest value no longer collapses onto every agent.
  5. Agents without a declared tool_timeout fall through to the CLI value if set, otherwise their tools run with no imposed timeout — they do not inherit another agent’s declared value via the shared dict.
  6. Booleans are ignored — only int/float values count.

Mixed — one declares, one omits

yaml
roles:
  strict_router:
    tool_timeout: 5          # declared
    tools: [internet_search]
  scraper:
    # omitted — no imposed timeout
    tools: [scrape_page]

Result: strict_router tools wrap at 5 s; scraper tools run unwrapped. No shared-dict wrap is installed.

Uniform — every agent declares the same value

yaml
roles:
  strict_router:
    tool_timeout: 5
    tools: [internet_search]
  scraper:
    tool_timeout: 5          # declared, same value
    tools: [scrape_page]

Result: shared-wrap fast path — one 5 s guard on every tool in tools_dict.

CLI override

bash
praisonai run agents.yaml --tool-timeout 5

Result: CLI wins for every agent regardless of what they declared.

Diagram

No agents in config

No — some omit it

Yes — every agent declares

Yes

No

Does every agent
declare tool_timeout?

No wrap applied

Per-agent resolver:
declared agents get their value;
undeclared agents get no wrap

All declared
values identical?

Uniform wrap:
single guard on shared tools_dict

Per-agent resolver:
each agent’s tools get its own guard

A heterogeneous config where both budgets are honoured — fast_router aborts cheap tools at 5s, slow_analyst keeps its 120s window:

yaml
roles:
  fast_router:
    role: Router
    tool_timeout: 5      # quick-abort on cheap tools
    tools: [internet_search]
  slow_analyst:
    role: Analyst
    tool_timeout: 120    # long window for large-doc scraping
    tools: [scrape_page]

Related