> ## Documentation Index
> Fetch the complete documentation index at: https://praison.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

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

<Note>
  **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](/docs/features/cooperative-tool-cancellation).
</Note>

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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)
```

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    T[🛠️ Tool call]:::agent --> X{timeout_ms /<br/>cancel_token}:::config
    X -->|completes| S[✅ success]:::result
    X -->|deadline hit| TO[⏱️ timeout]:::process
    X -->|token signalled| CA[🚫 cancelled]:::process
    X -->|raised| ER[❗ error]:::process

    classDef agent fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef process fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef config fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef result fill:#10B981,stroke:#7C90A0,color:#fff
```

<Note>
  **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.
</Note>

## Quick Start

<Steps>
  <Step title="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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    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,
    )
    ```
  </Step>

  <Step title="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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    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.
  </Step>

  <Step title="Handle structured errors">
    Pattern-match on `error_kind` and read `structured_error` for a discriminated payload:

    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    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)
    ```
  </Step>
</Steps>

***

## How It Works

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant A as Agent
    participant E as Executor
    participant T as Tool (long-running)

    A->>E: execute_batch(..., timeout_ms=3000)
    E->>T: run on dedicated worker
    Note over E,T: deadline exceeded
    E-->>A: ToolResult(error_kind="timeout")
    A->>A: 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.

Both executors forward `timeout_ms` and `cancel_token`:

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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).

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    A[Tool call] --> B{Completes<br/>within timeout?}
    B -->|Yes| C[Return result]
    B -->|No| D[Wall-clock timeout]
    D --> E{Tool marked<br/>idempotent?}
    E -->|Yes| F[Retry up to<br/>max_attempts]
    E -->|No / Unknown| G[Surface timeout<br/>once — no retry]

    classDef req fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef check fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef ok fill:#10B981,stroke:#7C90A0,color:#fff
    classDef warn fill:#8B0000,stroke:#7C90A0,color:#fff

    class A req
    class B,E check
    class C,F ok
    class D,G warn
```

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

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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.

<Note>
  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.
</Note>

***

## `error_kind` Reference

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

| `error_kind` | Exception            | `result` payload                                       | Example `structured_error`                                                                               |
| ------------ | -------------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- |
| `timeout`    | `ToolTimeoutError`   | `{"error": "timeout", "timeout_ms": ..., "tool": ...}` | `{"error": true, "kind": "timeout", "type": "ToolTimeoutError", "message": "...", "tool": "search"}`     |
| `cancelled`  | `ToolCancelledError` | `{"error": "cancelled", "tool": ...}`                  | `{"error": true, "kind": "cancelled", "type": "ToolCancelledError", "message": "...", "tool": "search"}` |
| `error`      | the raised exception | `"Error executing tool: ..."`                          | `{"error": true, "kind": "error", "type": "ValueError", "message": "...", "tool": "search"}`             |

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents.tools import ToolTimeoutError, ToolCancelledError
```

***

## Timeout, Cancel, or Both?

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Q{What kind of stop<br/>do you need?}:::agent
    Q -->|A hard deadline| A["timeout_ms=5000"]:::process
    Q -->|External interrupt| B["cancel_token=event"]:::config
    Q -->|Both| C["set timeout_ms<br/>and cancel_token"]:::result

    classDef agent fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef process fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef config fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef result fill:#10B981,stroke:#7C90A0,color:#fff
```

<AccordionGroup>
  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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](/docs/docs/developers/wrapper#cross-generator-tool-isolation) for the multi-generator behaviour.
  </Accordion>

  <Accordion title="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 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.
  </Accordion>
</AccordionGroup>

***

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

<Warning>
  **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).
</Warning>

<Tabs>
  <Tab title="Mixed — one declares, one omits">
    ```yaml theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    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.
  </Tab>

  <Tab title="Uniform — every agent declares the same value">
    ```yaml theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    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`.
  </Tab>

  <Tab title="CLI override">
    ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    praisonai run agents.yaml --tool-timeout 5
    ```

    Result: CLI wins for every agent regardless of what they declared.
  </Tab>
</Tabs>

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Q{Does every agent<br/>declare tool_timeout?}
    Q -->|No agents in config| N[No wrap applied]
    Q -->|No — some omit it| P[Per-agent resolver:<br/>declared agents get their value;<br/>undeclared agents get no wrap]
    Q -->|Yes — every agent declares| U{All declared<br/>values identical?}
    U -->|Yes| S[Uniform wrap:<br/>single guard on shared tools_dict]
    U -->|No| H[Per-agent resolver:<br/>each agent's tools get its own guard]

    classDef question fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef path fill:#10B981,stroke:#7C90A0,color:#fff

    class Q,U question
    class N,P,S,H path
```

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

```yaml theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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]
```

<Warning>
  **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.
</Warning>

<Note>
  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.
</Note>

***

## Related

<CardGroup cols={2}>
  <Card icon="circle-stop" href="/docs/features/cooperative-tool-cancellation">
    Abort a running tool body (not just the next one) on interrupt
  </Card>

  <Card title="YAML Validation" icon="shield-check" href="/docs/features/yaml-validation">
    Catch duplicate names and unknown task→agent references
  </Card>

  <Card title="Parallel Tool Calls" icon="bolt" href="/docs/features/allowed-tools">
    Run batched tool calls concurrently
  </Card>

  <Card title="Tool Progress" icon="wave-pulse" href="/docs/features/tool-progress-streaming">
    Stream incremental progress from slow tools
  </Card>

  <Card title="Deferred Tools" icon="hourglass-half" href="/docs/features/deferred-and-progress-tools">
    Hand back long-running work without blocking
  </Card>

  <Card title="Tools Overview" icon="screwdriver-wrench" href="/docs/tools">
    Build and register agent tools
  </Card>
</CardGroup>
