Skip to main content
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.
This page covers executor-level cancellation — stopping the next tool call via timeout_ms / cancel_token. To abort a tool body that is already running (e.g. kill a running shell subprocess on /stop), see Cooperative Tool Cancellation.
Backward compatible. All new parameters are optional. With no timeout_ms and no cancel_token, execution delegates straight to the tool body — behaviour is unchanged.

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

How It Works

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. Both executors forward timeout_ms and cancel_token:

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). Opt in for a specific tool by setting idempotent = True on the callable:
The same attribute works on a registered FunctionTool — set it on the object the agent holds:
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.
Read-only names such as read_file, web_search, and search_memory already ship in IDEMPOTENT_TOOLS; mutating names such as write_file, git_commit, and execute_code are in MUTATING_TOOLS. Check that module for the current lists before relying on a name.

error_kind Reference

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

Timeout, Cancel, or Both?

ToolExecutionConfig.timeout_ms is now consumed by the executor, so a configured default flows through to execute_batch without per-call wiring.
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.
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.
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 via cli_config["_tool_timeout_wrap"]. 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.

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.
Correction (PR #4468). A previous version of the wrapper treated a single declared tool_timeout (e.g. one agent sets it, others omit it) as “uniform” and wrapped every shared tool with that value — so the undeclared agents silently inherited it and their long-running calls failed with ToolTimeoutError. That behaviour is removed: if any agent omits tool_timeout, the shared wrap is skipped and undeclared agents run without an imposed timeout. If you want a common budget across a mixed config, declare it on every agent (or pass --tool-timeout at the CLI).
Result: strict_router tools wrap at 5 s; scraper tools run unwrapped. No shared-dict wrap is installed.
A heterogeneous config where both budgets are honoured — fast_router aborts cheap tools at 5s, slow_analyst keeps its 120s window:
What changed (PR #4477). Before this fix, heterogeneous per-agent tool_timeout values silently collapsed to the tightest value across the whole run — the slow analyst inherited the fast router’s 5s budget and never completed. That silent downgrade is now removed. If a CI config passed only because a slow agent quietly inherited a fast agent’s tighter budget (masking a real timeout), the slow agent may now run longer than before. Uniform configs (all-equal declared values) are unaffected.
This precedence applies to the YAML per-agent tool_timeout field. It is independent of the per-call timeout_ms argument to execute_batch documented above.

Abort a running tool body (not just the next one) on interrupt

YAML Validation

Catch duplicate names and unknown task→agent references

Parallel Tool Calls

Run batched tool calls concurrently

Tool Progress

Stream incremental progress from slow tools

Deferred Tools

Hand back long-running work without blocking

Tools Overview

Build and register agent tools