Skip to main content
Concurrency controls let you limit parallel agent execution and set timeouts for tool calls to prevent resource exhaustion.
The user runs parallel work; concurrency controls cap simultaneous operations and tool timeouts.

Quick Start

1

Limit parallel runs of an agent

Control how many instances of the same agent can run concurrently:
2

Same, async

Use async context for better resource utilization:
3

Bound tool time with ToolConfig

Prevent slow tools from blocking agent execution:

How It Works

PraisonAI’s concurrency controls at a glance:
  • ConcurrencyRegistry caps how many agent runs happen in parallel.
  • Per-agent tool executor applies tool_timeout and recycles a hung worker.
  • AgentTeam / PraisonAIAgents instances are single-run — a second concurrent start() / astart() on the same object raises RuntimeError. Batched runs via start_for_each / astart_for_each are unaffected. See AgentTeam Batch Runs → Concurrent-run safety.
Global cap across loops and threads. Each agent’s limit is backed by a loop-neutral threading.Semaphore keyed by agent name, so the configured cap holds across every event loop and thread. Sync and async callers share the same per-agent permit pool. Multi-loop / multi-thread deployments no longer crash with “bound to a different event loop”. See PR #5050.

Sync vs Async Rule

acquire_sync() is safe from any context; in async code prefer await acquire() to avoid blocking the loop.
acquire_sync() is safe to call from any context (sync or async). Under the hood it acquires a loop-neutral threading.Semaphore, so it never raises “bound to a different event loop”. In an async context, however, it blocks the current thread until a permit is available — prefer await acquire() there so other tasks on the loop keep running. Sync and async callers share the same per-agent permit pool, so the configured limit is a true global cap across every event loop and thread.
Prefer await acquire() in async code:

Tool Timeout Behavior

When tool_config=ToolConfig(timeout=...) is set, tools run in a dedicated executor with these characteristics: In YAML the field name is still tool_timeout:; in Python use tool_config=ToolConfig(timeout=…).

Timeout Return Shape

On timeout, each layer surfaces the timeout differently:

Effective Timeout Precedence

When tool_timeout values are declared, the wrapper resolves each agent’s budget independently:
  1. CLI wins for every agent. An explicit --tool-timeout N on the command line (or cli_config={"tool_timeout": N} when embedding) is used verbatim for all agents.
  2. Uniform declared values take the shared-wrap fast path. When every agent under roles: and agents: declares tool_timeout and every declared value is identical, a single guard wraps the shared tool dict. If any agent omits the field, the shared wrap is skipped and the per-agent resolver runs instead.
  3. Heterogeneous per-agent values are honoured per agent. When agents declare different values, each agent’s tools carry its own budget (tool objects stay shared; only the guard closure differs). The tightest value no longer collapses onto every agent. Agents that omit tool_timeout get no wrap (they do not inherit another agent’s declared value).
  4. Otherwise, no wrapping. If nothing declares a timeout, tools run without wrapper-layer enforcement (the SDK executor-layer enforcement still applies if tool_config=ToolConfig(timeout=…) is set in Python).
The uniform path is AgentsGenerator._resolve_uniform_tool_timeout(config); heterogeneous budgets use make_agent_tool_wrap_resolver(config) / resolve_agent_tool_timeout(agent_key, config) — see praisonai/agents_generator.py.
As of PR #4477, heterogeneous per-agent tool_timeout values are honoured per agent instead of collapsing to the tightest value. A slow agent that previously inherited a fast agent’s tighter budget (masking a real timeout) may now run longer. Uniform declarations are unaffected. (Earlier, PR #3176 had reversed the collapse from max() to min().) PR #4468 follows up by tightening uniform detection: a single declared tool_timeout on one agent no longer counts as uniform — if any agent omits the field, undeclared agents get no wrap instead of silently inheriting the lone declared budget.
YAML boolean values are ignored, not coerced. Because bool subclasses int in Python, tool_timeout: yes or tool_timeout: true used to silently become a 1-second cap on every tool. As of PR #2609 the resolver explicitly rejects bool values — such entries are treated as “not declared” and fall through to the next precedence level. Use an integer or float (e.g. tool_timeout: 30).

Executor Details

  • One executor per Agent instance (lazy creation)
  • max_workers=2 threads per agent
  • Thread name prefix: tool-<agent_name> — useful for log filtering
  • Reused across calls — no resource leak
  • Recycled on timeout — a tool that hangs past tool_timeout is not reclaimable, so the executor is shutdown(wait=False) and the next call gets a fresh worker
Self-healing after a hang. The pool has 2 workers by default; before PR #3960, two consecutive hangs would deadlock the pool because future.cancel() cannot stop a thread that has already started. Now the executor is recycled on timeout — the next call starts on a fresh worker — so a hung tool cannot progressively degrade throughput toward a deadlock. Recycling is bounded so repeated timeouts cannot leak an unbounded number of stuck threads.
Which timeout to choose:

Common Patterns

Limit FastAPI Route Concurrency

Async Context Manager Helper

Timeout Selection by Tool Type


Best Practices

Prevents deadlocks when exceptions occur:
Mixing is no longer an error — acquire_sync() in an async context simply blocks its thread. Sync and async callers contend for the same per-agent permit pool (see the test_sync_and_async_share_one_pool test in PR #5050). Still prefer await acquire() in async code so the event loop keeps running:
Any tool that does network IO should have a timeout:
Filter logs by agent name using the thread prefix:

Retries

Tool failures can be automatically retried using the retry policy feature. This works alongside timeouts to handle transient errors:
ToolConfig.parallel is a deprecated alias for ExecutionConfig.parallel_tool_calls. Enable parallel tool calls with execution=ExecutionConfig(parallel_tool_calls=True) alongside ToolConfig for timeout and retry. Do not set both spellings to conflicting values \u2014 that raises TypeError.
For complete retry configuration and error handling strategies, see Tool Retry Policy.

Parallel tool calls inside one async turn

A single astart(...) turn can itself dispatch multiple independent tool calls concurrently when parallel_tool_calls=True.
Two situations users conflate compose cleanly: asyncio.gather(agent.astart(a), agent.astart(b)) runs two agents in parallel, while parallel_tool_calls=True runs several tools inside one agent turn in parallel.

Write-conflict guard for shell-like tools

When parallel_tool_calls=True batches two or more tool calls in one turn, PraisonAI runs them sequentially instead of concurrently if any pair could touch the same file. As of PraisonAI PR #4907, the guard also catches shell-like tools whose write target lives in a command string rather than a path/file_path argument — execute_command, acp_execute_command, and execute_code. Two calls to any of these in the same batch, or one of them alongside any other write, force sequential fallback. Path-only reads (read_file, list tools, etc.) still run concurrently.

Tool Retry Policy

Automatically retry failed tool calls with exponential backoff

Tool Configuration

Tool timeout settings and performance tuning

Async Bridge

Safe sync↔async boundary crossing utilities

Thread Safety

Chat history and state protection mechanisms