Skip to main content
Async tool calls in agent.achat() now route through the same safety mechanisms as sync execution, including approval checks, timeouts, and Loop Guard’s doom-loop / no-progress protection (PR #4005). Each async tool invocation now also runs through CircuitBreaker.acall(...) — event-loop safe, using the same per-agent breaker name (tool_{id(self)}_{function_name}) and weakref.finalize cleanup as the sync path (PR #4469). Two more sync/async parity gaps closed in PR #4634:
  • MCP tool resolutionMCP instances passed via tools= are now resolved on achat(), and MCP transport / timeout failures ("Error: MCP tool call timed out after Ns", "Error: MCP initialization timed out after Ns") are normalized to the same {"error": …, "timeout": True} dict shape used on the sync path.
  • Tool-name self-repair — a case / separator hallucination (e.g. WebSearch for web_search) is quietly re-matched on achat() too. A genuine miss now returns the same corrective error dict with available_tools and a closest-match suggestion.
A third parity gap closed in PR #4858:
  • Doom-loop approval override — a critical doom-loop verdict on achat() / astart() now routes through the same approval pipeline as chat() / start(), so a PermissionManager rule (doom_loop: allow), YAML permission, or interactive backend continue can override the stop. The async path uses approve_async(...) natively, so event-loop-bound approval backends work.
The user runs tools via achat(); approvals, circuit breakers, and timeouts apply the same way as synchronous execution.
Sync vs async tool failures: on agent.chat() the sync tool executor raises ToolExecutionError directly (fix in PR #4252). The async path returns an error dict to the model instead — except for a Loop Guard HALT, which raises ToolExecutionError(is_retryable=False). Both surfaces are documented on the Error Handling page.

Quick Start

1

Async Tool with Approval

2

Task-Scoped Tools


How It Works

Architecture: The unified async dispatcher now calls execute_tool_async for tool invocations, so long-running tools no longer block the event loop.

Safety Mechanisms

Approval Checks

User approval prompts now appear during async tool execution.6
A /stop during an async tool parked on approval now unwinds the approval at the core registry boundary too, not only inside the tool loop. The stale approval resolution is dropped fail-closed and no session/always grant is persisted for the abandoned turn (PraisonAI #4950). See Approval Protocol → Live-Authority Binding.

Circuit Breaker Protection

Every async tool call runs through CircuitBreaker.acall(...) — the same per-agent / per-tool breaker as sync (tool_{id(self)}_{function_name}), with the same failure_threshold=5, recovery_timeout=60.0, and graceful_degradation=True defaults (PR #4469).
Two kinds of failures count toward the breaker on the async path:
  1. A raised exception inside the tool.
  2. A tool result dict carrying error — surfaced to the breaker through a _ToolFailure sentinel so the dict is preserved.
Approval / permission / policy / guardrail denials never count as failures — the async path excludes the same keys the sync _ToolFailure wrapper does: approval_denied, permission_denied, approval_error, policy_denied, guardrail_denied. When the breaker is open, CircuitBreaker.acall(...) raises CircuitBreakerException and the async path returns:
The circuit_open: True flag activates the async retry-skip, so retries stop immediately instead of hammering the open breaker. Loop Guard also records the rejection, so open-circuit rejections still count toward its BLOCK/HALT thresholds.
If the circuit_breaker module cannot be imported (trimmed builds), the async path falls back to direct invocation — tools still run, just without breaker protection.
A repeatedly-failing async tool opens the breaker after 5 failures; the 6th call short-circuits:

MCP + Name Self-Repair Parity

MCP tools and hallucinated tool names now resolve identically on achat() and chat() (PR #4634).

Loop Guard on the async path

Every async tool call in agent.achat() now runs through Loop Guard the same way sync calls always did (PR #4005). Repeated identical calls, repeated timeouts, and repeated exceptions all accumulate toward WARN → BLOCK → HALT on the current turn.
Async-specific details:
  • Async tool timeouts (asyncio.wait_for firing after tool_timeout seconds) are recorded as failures and count toward the BLOCK/HALT thresholds. Previously they early-returned and bypassed the streak.
  • Async tool exceptions are also recorded as failures — a tool that keeps raising escalates the same way a tool that keeps returning {"error": …} does.
  • A Loop Guard HALT propagates as ToolExecutionError(is_retryable=False). The async retry wrapper honours both loop_blocked (in the returned dict) and the exception, so no custom retry policy can re-run a terminally blocked call.
See Loop Guard for the full threshold table, tool classification, and configuration options.

Retry parity on the async path

Raised exceptions on agent.achat(...) / agent.execute_tool_async(...) are now retried under ToolConfig(retry_policy=RetryPolicy(...)), matching the sync path (PR #4141) — programming errors (ValueError, TypeError, AttributeError) and ToolExecutionError(is_retryable=False) (including Loop Guard HALT) stay terminal. See Tool Retry Policy for the full policy reference. The reverse direction — not retrying when the tool asked not to be retried — is also at parity as of PR #4299. @tool(restart_safe=False) / .idempotent = False runs exactly once on agent.achat(...) / agent.execute_tool_async(...), matching the sync path. See Tool Retry & Backoff → Retrying Non-Idempotent Tools for the full veto rules.

Timeout Controls

Async tool execution respects timeout settings:

Wrapper-Level Timeout (YAML / framework: praisonai)

Two ToolTimeoutError classes exist in PraisonAI. This section covers praisonai.agents_generator.ToolTimeoutError — the wrapper-level version raised on YAML/CLI tool_timeout (seconds). The SDK tool-call executor has its own praisonaiagents.tools.ToolTimeoutError (milliseconds, llm={"tool_timeout_ms": N}), surfaced as a ToolResult.error with error_kind="timeout". See Tool Call Executor Timeout.
When running through YAML or the CLI, the wrapper wraps every tool with a timeout-enforcing shim before handing the agent to the SDK. This provides defense-in-depth timeout enforcement even for pathological tools. On timeout the wrapper raises ToolTimeoutError (a TimeoutError subclass) instead of returning a JSON dict. This preserves each tool’s declared return-type contract — a typed return value is never silently downgraded to a string. Framework adapters catch it and translate it per framework. The wrapper handles sync and async tools differently:
  • Sync tools run in an instance-owned ThreadPoolExecutor (see _get_tool_timeout_executor in agents_generator.py); on timeout the future is best-effort cancelled. A call that already started cannot be interrupted, so background_work_may_continue is True.
  • Async tools are wrapped with asyncio.wait_for(...), which cancels the underlying task cleanly, so background_work_may_continue is False.
In Python, configure with tool_config=ToolConfig(timeout=…). Exception raised on wrapper-level timeout:
To catch the executor-level version instead, use from praisonaiagents.tools import ToolTimeoutError — see Tool Call Executor Timeout. ToolTimeoutError carries three attributes:
Once half the pool’s workers are permanently leaked to stuck sync tools, the pool is automatically recycled: leaked threads continue until their syscall returns, but new tool calls get a fresh pool instead of queueing behind them.
See Tool Configuration for full details and Concurrency for shape comparison.

Task-Scoped Tools

The tools_override parameter allows tasks to provide their own tool set for async execution:
For users, this manifests as task-specific tools being available during async chat:

Trace Events

Async tool execution now emits the same trace events as sync execution:

Migration Notes

No code changes required - async tool safety is automatically enabled: Before: Async calls bypassed safety mechanisms
After: Async calls use full safety pipeline

Best Practices

When using approval in async environments, ensure your event loop can handle user input:
Set appropriate timeouts for async tool execution:
Circuit breaker state affects all calls - monitor in async workflows:
Design task tools to be self-contained since they override agent tools:

Safe Defaults

On a fresh interactive session, the runtime now routes dangerous tools through the CLI approval backend automatically — no approval= kwarg needed. Off-TTY (pipes, CI) keeps deny-by-default. See Tool Approval → Default Behaviour for the precedence ladder and bypass flags, and Approval Backends → Which class runs the prompt? for how the Python (ConsoleBackend) and CLI (InteractiveCLIApprovalBackend) paths differ.

Approval

Tool approval configuration

Tool Circuit Breaker

Tool failure protection

Tool Approval

Safe-by-default behaviour, bypass flags, and risk levels

Loop Guard

Always-on per-turn tool-call guardrails, now on both sync and async paths

Cost Tracking

Async cost telemetry is now at parity with sync (#4887)

Spawn & Announce

Reliable async sub-agent spawn — no hangs, safe sync/async mixing

Footnotes

  1. The async column was aspirational before PR #4469 — async tool calls bypassed the breaker entirely. The async path now wraps each invocation in CircuitBreaker.acall(...), so five consecutive failures open the breaker just like sync. See Circuit Breaker Protection.
  2. Before PR #4858 the async path (achat() / astart()) hard-stopped a critical doom-loop verdict with a bare return, never consulting the unified approval pipeline. A PermissionManager rule like doom_loop: allow, a YAML permissions: { doom_loop: allow }, PRAISONAI_AUTO_APPROVE, or an interactive backend continue — all honoured on chat() / start() since PR #3776 — were silently ignored on the async path. The async path now routes the critical verdict through _doom_loop_approved_async(...), which calls the registry’s native approve_async(...) so an async-only / event-loop-bound approval backend runs on the caller’s loop. Fail-closed semantics are preserved: deny, timeout, no backend, or any error still blocks with loop_blocked: True.
  3. The async column was broken before PR #4141 — a raised exception was retried zero times regardless of RetryPolicy. See Tool Retry Policy → Sync / async parity.
  4. Before PR #4634 the async path did not consult MCP at all — MCP instances in tools= hard-failed with "Function ... not found in tools" on achat(), and MCP transport / timeout failures were handed to the model as bare "Error: …" strings that looked like successful results. Both are now at sync parity via the shared _resolve_mcp_tool_result / _normalize_mcp_result helpers. 2
  5. Before PR #4634 the async path hard-failed on a slightly wrong tool name and returned only "Function ... not found in tools". It now runs the same case / separator-insensitive re-match as the sync path and, on a genuine miss, returns the same corrective error dict ({"error": "...", "available_tools": [...]}) so the model can retry with a valid name. 2
  6. On the tool-approval async path (not the doom-loop gate above), PRAISONAI_AUTO_APPROVE — along with YAML approve: and [s]/[a] session grants — is now honoured on attached-backend agents as well since PR #4878. Before #4878 the backend branch of _resolve_approval_decision went straight to the prompt and ignored these standing grants (measured: PRAISONAI_AUTO_APPROVE=true prompted 3 of 3 identical calls; 0 of 3 after the fix). Fail-closed: no standing grant, a denied backend response, or a bookkeeping exception → the historical behaviour is preserved. See Standing grants and attached backends.