Shared credential pool. The
FailoverManager is also thread-safe. Unlike per-agent chat history (which is guarded inside each agent), the failover pool is designed to be shared across many agents at once — it keeps the profile list and each provider’s status consistent under concurrency. See Failover → Thread safety for details.How It Works
Quick Start
1
Multi-threaded Chat
2
Async Concurrent Tasks
Thread-Safe Components
Chat History
Thechat_history property is now fully thread-safe with automatic locking. The SDK protects chat history mutations through internal helper methods and a locked setter:
What changed in PR #1488
Prior to PR #1488, chat_history mutations bypassed thread-safety locks at 31+ call sites. The SDK now uses internal helper methods that properly acquire locks:
_append_to_chat_history(message)- Thread-safe message appending_rollback_chat_history_to(length)— thread-safe, per-turn ownership-aware rollback (positional fallback for untracked callers)_replace_chat_history(new_history)- Thread-safe full replacementchat_historysetter now acquires theAsyncSafeStatelock for assignments
What changed in PR #3769 — per-turn rollback ownership
Fix reference: PraisonAI PR #3769.
chat() / achat() turns on the same Agent used to interfere on rollback: if turn A failed after turn B had already appended new messages, A’s positional rollback (chat_history[:snapshot_len]) would erase B’s messages too.
_rollback_chat_history_to() now identifies messages by object identity and removes only the messages the failing turn actually appended. A concurrent turn’s messages are never touched.
The mechanism uses contextvars.ContextVar — each chat() / achat() turn runs in its own thread or task and gets an isolated ownership list. The append helpers (_add_to_chat_history, _add_to_chat_history_if_not_duplicate) record every message into the owning turn’s list; on failure, only those messages are removed.
Practical effect: you can safely run multiple concurrent chat() / achat() calls on the same Agent, and a failure on one turn (LLM error, guardrail rejection, response validation) no longer corrupts a concurrent turn’s chat history.
achat() TOCTOU fix (Gap 2b in PR #3769): the async chat path’s custom-LLM branch now uses the same atomic _add_to_chat_history_if_not_duplicate() helper as chat(). Concurrent achat() calls with the same user prompt no longer duplicate the user message in chat_history, and the user message is now persisted to _persist_message before the LLM call in the async path too (matching sync).What changed in PR #1514
PR #1514 enhanced thread-safety in three key areas:1. Locked Memory Initialization:
Task.initialize_memory() now uses threading.Lock with double-checked locking pattern. A new async variant initialize_memory_async() uses asyncio.Lock and offloads construction with asyncio.to_thread() to prevent event loop blocking.2. Async-Locked Workflow State: New _set_workflow_finished(value) method uses async locks to safely update workflow completion status across concurrent tasks.3. Non-Mutating Task Context: Task execution no longer mutates task.description during runs. Per-execution context is stored in _execution_context field, keeping the user-facing task.description stable across multiple executions.What changed in PR #3769
Concurrent-turn rollback safety (PraisonAI #3769): a failing turn now rolls back only the messages it itself appended (tracked via
contextvars), never a concurrent turn’s — even when multiple chat()/achat() turns share the same Agent’s chat_history. The async custom-LLM path also uses the atomic _add_to_chat_history_if_not_duplicate helper the sync path used, closing a TOCTOU that could duplicate user messages under concurrency.Safe operations
Caches
Internal caches usethreading.RLock for reentrant locking:
_system_prompt_cache- Cached system prompts_formatted_tools_cache- Cached tool definitions
Rate Limiter
RateLimiter can be shared across threads and agents. Both the sync and async method families are fully locked — see Rate Limiter → Thread Safety & Multi-Agent Use for patterns.
LiteAgent Thread Safety
The lite package also provides thread-safe operations:Implementation Details
Lock Types
Deep-copy support
Agent.deepcopy replaces threading.RLock (__cache_lock) and threading.Lock (_cost_lock) with fresh instances during copy.deepcopy(agent), so the agent is copy-safe on every supported Python version (RLock pickling was broken on CPython < 3.13).Lock Usage Pattern
Why a re-entrant lock?
Nested calls (e.g. a helper that holds the lock and then assignschat_history, which itself acquires the lock) used to deadlock. RLock permits the same thread to re-enter. See PR #1567 for details.
Persistence Orchestrator session cache
PersistenceOrchestrator guards its in-memory session cache with threading.RLock, returns deep copies on read, and (since PR #3792) bounds cache size via a collections.OrderedDict LRU. Size is capped by PRAISONAI_SESSION_CACHE_MAX (default 1024, minimum 1); empty/non-numeric values fall back to the default. Concurrent agents can share an orchestrator without corrupting cached ConversationSession objects and without unbounded memory growth in long-running servers.
Reference: PraisonAI PR #1609 (thread-safe defensive copying) and PR #3792 (bounded
OrderedDict LRU capped by PRAISONAI_SESSION_CACHE_MAX). The session cache uses defensive copying to prevent shared mutable state between concurrent operations, and evicts the least-recently-used session once the cap is reached.DefaultSessionStore — cross-process safety
DefaultSessionStore uses a per-session file lock (fcntl.flock on Unix, msvcrt.locking on Windows) for every write path:
Reads inside the lock guarantee that a metadata merge picks up any messages another worker appended just before. Since PR #1709,
FileLock.__enter__ raises IOError on timeout (previously the lock failure was silent, which could lead to torn writes on a stuck lock). The default lock_timeout is 5.0 seconds — pass DefaultSessionStore(lock_timeout=...) to tune it.
SqliteTranscriptStore — cross-process safety
SqliteTranscriptStore is the default gateway transcript backend as of PraisonAI PR #3409. It replaces the FileLock above with a SQLite transaction: every read-modify-write runs its SELECT + INSERT OR REPLACE inside a single BEGIN IMMEDIATE transaction, which grabs the database RESERVED write lock and holds it until COMMIT.
That closes the multi-gateway lost-append window the process-local lock alone could not: a second gateway process cannot slip a read between this one’s read and its write. Gateway deployments with session.persist: true use this path by default — see SQLite Transcript Store.
Best Practices
Use agent methods
Use agent methods
Call
agent.chat() or agent.start() — these acquire locks internally.Avoid direct list mutation
Avoid direct list mutation
Do not append to
chat_history directly; use agent methods or the locked setter.Clear history safely
Clear history safely
Use
agent.clear_history() rather than assigning an empty list from multiple threads.Serialise mixed sync/async callers
Serialise mixed sync/async callers
Sync and async locks are independent since PR #1567 — add an external lock when both contexts mutate history.
Async Considerations
agent.chat_history is async-aware out of the box — no external asyncio.Lock is required when all calls are inside the same event loop.
PraisonAI’s own fan-out (
PraisonAIAgents.arun_all_tasks) uses return_exceptions=True internally, so it awaits every sibling to completion before re-raising the first exception. Your own asyncio.gather over your tasks still behaves the standard way (raise on first exception) unless you opt in with return_exceptions=True.Verifying Thread Safety
Test thread safety with concurrent access:Multi-team HTTP launch
PraisonAI provides comprehensive thread-safety for HTTP server deployment:- Multiple
Agent/Agentsinstances may call.launch(port=N)concurrently from different threads — registration is atomic. - If two launch calls use the same path on the same port, the second gets an auto-suffixed path (
/path_abc123) and a warning is logged. - Server readiness is signalled deterministically (no fixed sleep);
.launch()returns only after the port is accepting connections. The wait defaults to 5 seconds and is configurable via thePRAISONAI_SERVER_READY_TIMEOUTenvironment variable. If the server doesn’t become ready in time,.launch()still returns and a warning is logged — check server logs for startup errors. aworkflow()state lock is created inside the running async context, so workflows remain stable when invoked under pytest-asyncio or when nested inside another loop.- Per-turn tool tracking (
_turn_tools_used) is now protected by the same lock that guardschat_history, so concurrentchat()/achat()turns on oneAgentno longer corrupt hook or self-improve tool data. No new public API — the buffer stays private; the read/append helpers were made async-safe internally.
Wrapper-layer thread safety (praisonai package)
The praisonai wrapper layer (distinct from the praisonaiagents content above) provides thread-safe OpenAI client management and CLI command discovery.
Per-instance OpenAI client lifecycle
EachBaseAutoGenerator owns its own core OpenAIClient (from praisonaiagents.llm.openai_client), which manages sync and async access internally — no cross-instance sharing, no LRU eviction surprises.
The client is created lazily on first structured-completion call.
__del__ was removed in PR #1736 and the canonical path is now explicit close() or aclose() on the generator, or — better — the context managers. This matters in long-lived server processes that spawn many generators.Thread-safe Typer command discovery
Embeddingpython -m praisonai from multiple threads is now safe. The CLI command discovery uses a double-check lock pattern and doesn’t poison the cache on failure:
Failure-safe cache
A transient discovery error does not lock the CLI into a broken state — the next call retries instead of permanently breaking dispatch. This ensures reliable operation in multi-threaded server environments where temporary import failures might occur.New Thread-Safe Components in PR #1548
AsyncAgentScheduler is now loop-aware. Thestart() method binds its async primitives (asyncio.Event, asyncio.Lock) to the running loop, and stop() raises RuntimeError if called from a different loop than start().
Lazy loaders in praisonai/auto.py are now thread-safe. A single _load_optional(key, loader) helper with a module-level lock replaces the previous unguarded module-level globals.
inbuilt_tools lazy import (PR #1681) now routes through praisonai.auto._load_optional("inbuilt_autogen_tools", ...) instead of a hand-rolled re-entry guard. Negative results are cached, so a missing crewai or autogen install no longer pays the find_spec cost on every attribute access.
Framework availability constants (PR #1780) — Module-level constants on praisonai.agents_generator (AGENTOPS_AVAILABLE, etc.) are resolved lazily via __getattr__. In praisonai.observability.hooks, the eager AGENTOPS_AVAILABLE constant was removed (PR #2062) — use is_agentops_available() instead.
Integration registry (praisonai/integrations/registry.py) now has a per-instance threading.Lock guarding register/unregister/create/list_registered operations.
New Thread-Safe Components in PR #1673
Jobs server singleton init (PR #1771) —get_store() and get_executor() in praisonai/jobs/server.py now use double-checked locking with threading.Lock. This eliminates a TOCTOU race where concurrent cold-start requests could create orphaned JobStore / JobExecutor instances on a fresh process.
InMemoryJobStore — locked reads and async get_stats()
All read methods (get, get_by_idempotency_key, list_jobs, count, get_stats) now hold an asyncio.Lock while reading internal dicts, so concurrent saves cannot tear a read.
AgentScheduler — interruptible retry backoff (sync scheduler)
stop() now becomes responsive within milliseconds even during retry backoff. The sync scheduler also adopts the shared backoff_delay() curve so sync and async retries are identical.
ToolRegistry now holds a threading.Lock around all reads and mutations, matching PluginRegistry / integration registry. Eliminates RuntimeError: dictionary changed size during iteration when registering tools concurrently with iteration. Reference: PR #1673.
MCP registry lazy-loader locks (PR #2738)
All three MCP registries (MCPToolRegistry, MCPResourceRegistry, MCPPromptRegistry) now guard the lazy-loader queue with a threading.Lock using an atomic snapshot-then-run pattern. Concurrent list_all / get calls from stdio and HTTP transports are safe — each loader runs exactly once, regardless of caller count.
Each _ensure_loaded acquires the lock, snapshots _lazy_loaders, clears the pending set, releases the lock, then runs each loader outside the critical section:
Under 10-thread concurrency, loaders can no longer double-run or be skipped mid-iteration. Reference: PR #2738.
Multi-agent context safety (PR #1723)
praisonai.cli.features.interactive_tools._get_shared_runtime() and praisonai.tool_resolver._get_default_resolver() were previously process-wide singletons. They are now per-context via contextvars.ContextVar:
- Concurrent agents in the same process no longer share an
InteractiveRuntime(no LSP/ACP config bleed). - Resolvers anchor to each agent’s CWD, not whichever agent ran first.
- For long-lived daemons that switch projects, call
praisonai.tool_resolver.reset_default_resolver()to force re-anchoring.
cleanup_runtime() and convenience functions like resolve_tool() source-compatible.
Handoff chain isolation under asyncio.gather
Every task spawned by parallel_handoffs (and any asyncio.gather over handoffs) gets its own handoff chain — sibling tasks cannot corrupt each other’s cycle-detection or max-depth state.
_handoff_chain_var to a fresh per-task copy on entry (_handoff_chain_var.set(list(_handoff_chain_var.get() or []))), which — on top of the existing copy-on-write push/pop — keeps sibling handoffs fully isolated. See parallel_handoffs reference for parameters.
- Safety-rejected handoffs are chain-neutral: a handoff blocked by a safety check never consumes a chain slot, so the parent’s cycle and
max_depthcounters stay accurate for its remaining handoffs in the same turn.
Related
Agent Cloning
Clone agents for channel isolation
Rate Limiter
Thread-safe rate limiting across agents

