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 resolution —
MCPinstances passed viatools=are now resolved onachat(), 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.
WebSearchforweb_search) is quietly re-matched onachat()too. A genuine miss now returns the same corrective error dict withavailable_toolsand a closest-match suggestion.
- Doom-loop approval override — a critical doom-loop verdict on
achat()/astart()now routes through the same approval pipeline aschat()/start(), so aPermissionManagerrule (doom_loop: allow), YAML permission, or interactive backend continue can override the stop. The async path usesapprove_async(...)natively, so event-loop-bound approval backends work.
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.6A
/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 throughCircuitBreaker.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).
- A raised exception inside the tool.
- A tool result dict carrying
error— surfaced to the breaker through a_ToolFailuresentinel so the dict is preserved.
_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:
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.MCP + Name Self-Repair Parity
MCP tools and hallucinated tool names now resolve identically onachat() and chat() (PR #4634).
Loop Guard on the async path
Every async tool call inagent.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 tool timeouts (
asyncio.wait_forfiring aftertool_timeoutseconds) 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
HALTpropagates asToolExecutionError(is_retryable=False). The async retry wrapper honours bothloop_blocked(in the returned dict) and the exception, so no custom retry policy can re-run a terminally blocked call.
Retry parity on the async path
Raised exceptions onagent.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)
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 raisesToolTimeoutError (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_executorinagents_generator.py); on timeout the future is best-effort cancelled. A call that already started cannot be interrupted, sobackground_work_may_continueisTrue. - Async tools are wrapped with
asyncio.wait_for(...), which cancels the underlying task cleanly, sobackground_work_may_continueisFalse.
tool_config=ToolConfig(timeout=…).
Exception raised on wrapper-level timeout:
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.
Task-Scoped Tools
Thetools_override parameter allows tasks to provide their own tool set for async execution:
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 mechanismsBest Practices
Handle Approval in Async Context
Handle Approval in Async Context
When using approval in async environments, ensure your event loop can handle user input:
Configure Timeouts for Async Tools
Configure Timeouts for Async Tools
Set appropriate timeouts for async tool execution:
Monitor Circuit Breaker in Async Workflows
Monitor Circuit Breaker in Async Workflows
Circuit breaker state affects all calls - monitor in async workflows:
Task Tools Override Agent Tools
Task Tools Override Agent Tools
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 — noapproval= 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.
Related
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
-
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. ↩ -
Before PR #4858 the async path (
achat()/astart()) hard-stopped a critical doom-loop verdict with a barereturn, never consulting the unified approval pipeline. APermissionManagerrule likedoom_loop: allow, a YAMLpermissions: { doom_loop: allow },PRAISONAI_AUTO_APPROVE, or an interactive backend continue — all honoured onchat()/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 nativeapprove_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 withloop_blocked: True. ↩ -
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. ↩ -
Before PR #4634 the async path did not consult MCP at all —
MCPinstances intools=hard-failed with"Function ... not found in tools"onachat(), 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_resulthelpers. ↩ ↩2 -
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 -
On the tool-approval async path (not the doom-loop gate above),
PRAISONAI_AUTO_APPROVE— along with YAMLapprove: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_decisionwent straight to the prompt and ignored these standing grants (measured:PRAISONAI_AUTO_APPROVE=trueprompted 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. ↩

