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

# Thread-Safe Agent State

> Thread-safe chat history and cache management

PraisonAI protects chat history, caches, and session state when agents run from multiple threads or async tasks.

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents import Agent
import threading

agent = Agent(name="Assistant", instructions="You are helpful.")

def worker(prompt):
    agent.chat(prompt)

threads = [threading.Thread(target=worker, args=(f"Question {i}",)) for i in range(5)]
for t in threads:
    t.start()
for t in threads:
    t.join()
```

The user drives one agent from several threads; locks keep chat history and caches consistent without corrupted state.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    subgraph "Thread Safety"
        T1[🧵 Thread 1] --> Lock[🔒 AsyncSafeState]
        T2[🧵 Thread 2] --> Lock
        Lock --> History[💬 chat_history]
    end

    classDef thread fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef lock fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef shared fill:#10B981,stroke:#7C90A0,color:#fff

    class T1,T2 thread
    class Lock lock
    class History shared
```

<Note>
  **Shared credential pool.** The [`FailoverManager`](/docs/features/failover) 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](/docs/features/failover#thread-safety) for details.
</Note>

## How It Works

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant User
    participant Agent
    participant Feature as Thread-Safe Agent State

    User->>Agent: Request
    Agent->>Feature: Process request
    Feature-->>Agent: Result    Agent-->>User: Response
```

<Note>
  **`DualLock` real mutual exclusion (PraisonAI [PR #5338](https://github.com/MervinPraison/PraisonAI/pull/5338)):** `DualLock.sync()` and `DualLock.async_lock()` now back onto one shared `threading.Lock`, so a sync caller and a concurrent async caller are genuinely mutually exclusive. Same-thread `sync()` reentrancy is preserved via an explicit owner/depth guard, and the async acquire is offloaded to a worker thread so the event loop is never blocked while waiting for the mutex. Older releases used a `threading.RLock` for `sync()` and a per-event-loop `asyncio.Lock` for `async_lock()`, and the two never coordinated — upgrade to pick up the fix.
</Note>

<Note>
  **`AsyncSafeState.__enter__` reentrancy fix (PraisonAI [PR #5370](https://github.com/MervinPraison/PraisonAI/pull/5370)):** The backward-compatible `with state:` sync context-manager entry point now routes through `DualLock.sync()` instead of acquiring the underlying `threading.Lock` directly. This closes a self-deadlock: any same-thread code path that entered `AsyncSafeState` via `__enter__` and then triggered a nested `chat_history` mutation (which acquires the same lock via `DualLock.sync()`) would previously hang on the plain `threading.Lock`. After the fix, same-thread nested acquisitions are treated as re-entries, matching the `DualLock` reentrancy guarantee documented for #5338. No user-facing API changes — upgrade to pick up the fix.
</Note>

<Note>
  `AsyncSafeState.async_lock()` and `__aenter__` now fail fast with `RuntimeError("Cannot access AsyncSafeState during synchronous deepcopy")` when a synchronous deepcopy is holding the mutex, instead of blocking on it — preserving the documented "deepcopy fences off async access" contract even under the shared mutex.
</Note>

## Quick Start

<Steps>
  <Step title="Multi-threaded Chat">
    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    from praisonaiagents import Agent
    import threading

    agent = Agent(name="ThreadSafeAgent", instructions="You are helpful.")

    def worker(prompt):
        response = agent.chat(prompt)
        print(f"Response: {response[:50]}...")

    threads = [threading.Thread(target=worker, args=(f"Question {i}",)) for i in range(5)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    ```
  </Step>

  <Step title="Async Concurrent Tasks">
    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    import asyncio
    from praisonaiagents import Agent

    agent = Agent(name="AsyncAgent")

    async def async_chat(prompt):
        return agent.chat(prompt)

    async def main():
        results = await asyncio.gather(*[async_chat(f"Question {i}") for i in range(5)])

    asyncio.run(main())
    ```
  </Step>
</Steps>

<Warning>
  **Behaviour change in PR #1548**: `run_sync()` now raises `RuntimeError` when called from inside a running event loop. Previously it would auto-fallback to a background loop.

  ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
  # Before PR #1548 (worked, but unsafe)
  async def handler():
      result = run_sync(some_coro())  # silently used background loop

  # After PR #1548 (raises RuntimeError)
  async def handler():
      result = await some_coro()  # use this from async context
  ```
</Warning>

## Thread-Safe Components

### Chat History

The `chat_history` property is now fully thread-safe with automatic locking. The SDK protects chat history mutations through internal helper methods and a locked setter:

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents import Agent
import threading

agent = Agent(
    name="ThreadSafeAgent",
    instructions="You are helpful."
)

def worker(prompt):
    # Safe to call from multiple threads
    response = agent.chat(prompt)
    print(f"Response: {response[:50]}...")

# Create multiple threads
threads = [
    threading.Thread(target=worker, args=(f"Question {i}",))
    for i in range(5)
]

# Start all threads
for t in threads:
    t.start()

# Wait for completion
for t in threads:
    t.join()
```

#### What changed in PR #1488

<Note>
  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 append; also records the message into the current turn's per-turn ownership tracker (when one is active) so rollback can identify-and-remove only this turn's messages. This is the append path every real `chat()` / `achat()` turn uses.
  * `_rollback_chat_history_to(length)` — thread-safe, per-turn ownership-aware rollback. Identifies-and-removes messages by object identity when a turn is tracked (even an empty-but-tracked turn), and falls back to positional truncation only for untracked callers.
  * `_replace_chat_history(new_history)` - Thread-safe full replacement
  * `chat_history` setter now acquires the `AsyncSafeState` lock for assignments
</Note>

#### What changed in PR #3769 — per-turn rollback ownership

<Note>
  **Fix reference:** [PraisonAI PR #3769](https://github.com/MervinPraison/PraisonAI/pull/3769).
</Note>

Concurrent `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.

**Follow-up in PR #4593.** The ownership tracker described above only actually caught real turns after PR #4593. Before that, the tracker was fed only from `_add_to_chat_history`, but every `chat()` / `achat()` turn appends via `_append_to_chat_history`, so `_rollback_chat_history_to` always took the unsafe positional-delete branch and an empty-but-tracked turn also fell through (`if owned:` is falsy for `[]`). PR #4593 wires `_append_to_chat_history` into the tracker and switches the rollback check to `if owned is not None:`, so concurrent-turn rollback safety now behaves as this section describes. Reference: [PraisonAI PR #4593](https://github.com/MervinPraison/PraisonAI/pull/4593), fixes [#4592](https://github.com/MervinPraison/PraisonAI/issues/4592).

**Completed on the default (OpenAI) path in PR #5247.** PR #3769's ownership tracking was only wired into the **custom-LLM** branch; the default (OpenAI) `chat()` / `achat()` branches still fell through to the positional `del chat_history[n:]` rollback and could erase a concurrent turn's messages. If your workflow shares one Agent across concurrent `chat()` / `achat()` turns on the default OpenAI path (the most common setup), you are now safe — a failing turn removes only its own messages, never a concurrent turn's. Both branches now begin tracking before the turn: the sync branch ends it in a `finally` (after rollback reads the ownership list), and the async branch relies on the next turn's `_clear_turn_tracking()`, matching the async custom-LLM branch. As a bonus, a terminal `achat()` / `arun()` / `astart()` no longer leaves its completed ownership list registered in the live-turn registry. Reference: [PraisonAI PR #5247](https://github.com/MervinPraison/PraisonAI/pull/5247), closes [#5149](https://github.com/MervinPraison/PraisonAI/issues/5149).

<Note>
  Sharing one `Agent` across concurrent turns? [Model Fallback → Shared-Agent async safety](/docs/features/model-fallback#shared-agent-async-safety) keeps each turn's model, cost, and observability attribution correct even while another turn is mid-fallback — so both the chat-history and model-fallback guarantees hold together.
</Note>

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

#### What changed in PR #5121 — OpenAI-native path TOCTOU parity

<Note>
  **Fix reference:** [PraisonAI PR #5121](https://github.com/MervinPraison/PraisonAI/pull/5121).
</Note>

PR #3769 closed the read-then-append TOCTOU on the **custom-LLM** branches only. The OpenAI-native sync `_chat_impl` and async `_achat_impl` paths were still on the unsafe pattern: a concurrent turn's user message that landed at `chat_history[-1]` between the read and the append was mistaken for this turn's own duplicate, **silently dropping this turn's user message** from the record.

PR #5121 routes both OpenAI-native paths through the same atomic `_add_to_chat_history_if_not_duplicate("user", …)` helper. Concurrent same-prompt turns on the OpenAI-native path no longer drop the user message.

All four dispatch branches now share the atomic helper:

| Dispatch path | Atomic helper | Per-turn rollback ownership |
| - | - | - |
| Sync custom-LLM | ✅ (PR #3769) | ✅ (PR #3769) |
| Async custom-LLM | ✅ (PR #3769) | ✅ (PR #3769) |
| Sync OpenAI-native (`_chat_impl`) | ✅ (PR #5121) | ✅ (PR #5247) |
| Async OpenAI-native (`_achat_impl`) | ✅ (PR #5121) | ✅ (PR #5247) |

No branch is left on the positional-rollback fallback — the default (OpenAI) path and the custom-LLM path now share the same rollback semantics.

<Note>
  **Persistence ordering.** The sync path calls `_persist_message("user", normalized_content)` only when the helper actually appended (not a duplicate); the async native path calls the helper unconditionally, and the persistence line at that point is unchanged in PR #5121.
</Note>

#### What changed in PR #1514

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

#### What changed in PR #3769

<Note>
  **Concurrent-turn rollback safety (PraisonAI #3769 + #5247):** 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. As of PR #5247 the guarantee holds on both the custom-LLM branches and the default (OpenAI) branches — no branch is left on the positional-rollback fallback.
</Note>

#### Safe operations

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
# These operations are now thread-safe out of the box:
agent.chat_history = []  # Full replacement - uses locked setter
agent.chat("Hello")      # Appends safely via internal methods

# Reading is always safe:
history = agent.chat_history
print(f"History has {len(history)} messages")
```

### Caches

Internal caches use `threading.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](/docs/docs/features/rate-limiter#thread-safety--multi-agent-use) for patterns.

### Telemetry Counting

Telemetry counting is thread-safe as of PraisonAI [#4235](https://github.com/MervinPraison/PraisonAI/pull/4235): the re-entrancy guard is a per-agent `contextvars.ContextVar`, so concurrent `chat()`/`run()`/`start()` calls on one shared agent are each counted independently. See [Telemetry — Concurrent-execution accuracy](/docs/features/telemetry#concurrent-execution-accuracy).

### Concurrent token/cost attribution on a shared LLM (PraisonAI #5233)

A single `LLM` instance can back two `Agent`s (`Agent(llm=shared_llm)` on both). Under `PraisonAIAgents.arun_all_tasks` those tasks are dispatched via `asyncio.gather` on one thread — so before this fix, task A's `set_current_agent("Researcher")` was overwritten by task B's `set_current_agent("Writer")` on the **same** LLM object, and when A's awaited response returned it read `"Writer"`. A's tokens got attributed to B in the collector's `by_agent` map.

`LLM.current_agent_name` is now backed by a per-instance `contextvars.ContextVar` (`self._current_agent_name_var`), exposed via a backward-compatible property + `set_current_agent()` method. `asyncio.gather` copies the context per task at dispatch time, so each concurrently-gathered coroutine keeps its own value across the `await`. This guarantee was further extended in PR [#5357](https://github.com/MervinPraison/PraisonAI/pull/5357).

Per-instance (not class-level) is deliberate: two distinct `LLM` instances — including an original and its `copy.deepcopy` clone — must keep independent attribution state within the same context, otherwise the last writer's agent name would leak across LLMs. (Same reasoning as `_tools_scope_depths` in `agents/agents.py`.)

**User impact:** no API change. If you use the `Agent(llm=shared_llm)` pattern (single `LLM` object shared across a team), your per-agent `by_agent` totals in `get_token_collector().get_session_summary()` and `PraisonAIAgents.get_token_usage_summary()` are now correct under concurrent execution.

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents import Agent, Task, PraisonAIAgents
from praisonaiagents.llm import LLM

# One LLM instance shared across two agents
shared_llm = LLM(model="gpt-4o-mini")

researcher = Agent(name="Researcher", llm=shared_llm, instructions="Research the topic")
writer     = Agent(name="Writer",     llm=shared_llm, instructions="Write from the research")

r = Task(name="research", description="Research quantum computing", agent=researcher, async_execution=True)
w = Task(name="write",    description="Write an article",           agent=writer,     async_execution=True, context=[r])

team = PraisonAIAgents(agents=[researcher, writer], tasks=[r, w])
team.start()  # arun_all_tasks under the hood — concurrent asyncio.gather

# by_agent now correctly attributes tokens to Researcher vs Writer
print(team.get_token_usage_summary()["by_agent"])
```

See also [Token Tracking — Multi-agent breakdown](/docs/features/token-tracking#multi-agent-breakdown).

<Note>
  **Fix reference:** [PraisonAI PR #5233](https://github.com/MervinPraison/PraisonAI/pull/5233), fixes [#5052](https://github.com/MervinPraison/PraisonAI/issues/5052). Extended in PR [#5357](https://github.com/MervinPraison/PraisonAI/pull/5357).
</Note>

## LiteAgent Thread Safety

The lite package also provides thread-safe operations:

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents.lite import LiteAgent, create_openai_llm_fn
import threading

llm_fn = create_openai_llm_fn(model="gpt-4o-mini")
agent = LiteAgent(name="LiteThreadSafe", llm_fn=llm_fn)

def concurrent_chat(message):
    return agent.chat(message)

# Safe concurrent access
with threading.ThreadPoolExecutor(max_workers=5) as executor:
    futures = [executor.submit(concurrent_chat, f"Q{i}") for i in range(10)]
    results = [f.result() for f in futures]
```

## Implementation Details

### Lock Types

| Component | Lock Type | Reason |
| - | - | - |
| `chat_history` | `AsyncSafeState` (DualLock: one shared `threading.Lock` for sync + async, [PR #5338](https://github.com/MervinPraison/PraisonAI/pull/5338)) | Genuine cross-context mutual exclusion; same-thread reentrant via owner/depth guard; async acquire offloaded so the event loop never blocks |
| Handoff history seeding | per-target-agent `DualLock` attached lazily as `agent._handoff_seed_lock` | Serialises seed + chat per target agent so overlapping handoffs to one specialist can't interleave `chat_history` — see [Handoff seeding](#handoff-history-seeding) |
| `_cache_lock` | `threading.RLock` | Allows reentrant access from cached helpers |
| `PersistenceOrchestrator._cache_lock` | `threading.RLock` | Protects `_session_cache` reads/writes; the store is a bounded `OrderedDict` LRU (not a plain dict), and reads return `deepcopy()` to prevent shared mutable state |
| `RateLimiter` (sync) | `threading.Lock` | Protects `_tokens`, `_api_tokens`, and refill state from races in multi-threaded acquire calls |
| `RateLimiter` (async) | `asyncio.Lock` | Same protection for coroutine contexts |
| `praisonaiagents.output.status._output_lock` | `threading.Lock` | Guards the status singleton's timing dicts, `_ai_call_count`, and session token/cost accumulators — see [Output singleton lock](#output-singleton-tracestatus-lock-pr-5219) |
| `praisonaiagents.output.trace._output_lock` | `threading.Lock` | Guards the trace singleton's `_llm_start_time` / `_tool_start_times` — see [Output singleton lock](#output-singleton-tracestatus-lock-pr-5219) |

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

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
# Internal implementation pattern
class Agent:
    def __init__(self):
        self.__chat_history_state = AsyncSafeState([])
        self._cache_lock = threading.RLock()
    
    @property
    def _history_lock(self):
        return self.__chat_history_state
    
    def _append_to_chat_history(self, message):
        from .memory_mixin import _active_turn_owned
        with self._history_lock.lock():
            self._history_lock.value.append(message)
            owned = _active_turn_owned.get()
            if owned is not None:
                owned.append(message)
```

### Why a re-entrant lock?

Nested calls (e.g. a helper that holds the lock and then assigns `chat_history`, which itself acquires the lock) used to deadlock. `RLock` permits the same thread to re-enter. See [PR #1567](https://github.com/MervinPraison/PraisonAI/pull/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.

The session cache stays orchestrator-owned, but after PR [#4394](https://github.com/MervinPraison/PraisonAI/pull/4394) the orchestrator's message-write and session-end store operations delegate to `PraisonAIDB` (through the internal `_call_store` / `_dispatch_async` helpers). The adapter's "lazy store initialisation is race-free" guarantee therefore now covers both persistence entry points.

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents import Agent
from praisonai.persistence import PersistenceOrchestrator
import threading

# Create shared orchestrator
orchestrator = PersistenceOrchestrator()

def agent_worker(agent_name, session_id):
    agent = Agent(name=agent_name)
    
    # Safe: orchestrator returns deep copy of cached session
    history = orchestrator.on_agent_start(agent, session_id=session_id)
    
    # Multiple agents can safely read/update sessions concurrently
    orchestrator.on_message(session_id, "user", "Hello from " + agent_name)
    orchestrator.on_agent_end(agent, session_id)

# Multiple threads can safely share the orchestrator
threads = [
    threading.Thread(target=agent_worker, args=(f"Agent{i}", "session-123"))
    for i in range(3)
]

for t in threads:
    t.start()
for t in threads:
    t.join()
```

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

### DefaultSessionStore — cross-process safety

`DefaultSessionStore` uses a per-session **file lock** (`fcntl.flock` on Unix, `msvcrt.locking` on Windows) for every write path:

| Method | Locked read-modify-write | Atomic write |
| - | - | - |
| `add_message()` | ✅ | ✅ |
| `add_user_message()` / `add_assistant_message()` | ✅ | ✅ |
| `clear_session()` | ✅ | ✅ |
| `update_session_metadata()` | ✅ *(since PR #1709)* | ✅ |
| `set_agent_info()` | in-process lock + atomic write | ✅ |
| `delete_session()` | in-process lock | n/a |

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.

<Warning>
  **Behaviour change in PR #1709**: `praisonaiagents.session.store.FileLock.__enter__` now raises `IOError` instead of returning silently on lock-acquisition timeout. If you have custom code that uses `FileLock` directly from `praisonaiagents.session.store`, wrap it in a `try/except IOError` or raise the timeout via `DefaultSessionStore(lock_timeout=...)`.
</Warning>

<Note>
  There are **two distinct `FileLock` classes** with different histories — do not conflate them:

  | Class | Used by | Timeout behaviour |
  | - | - | - |
  | `praisonaiagents.session.store.FileLock` | `DefaultSessionStore` (above) | Raises `IOError` on acquisition timeout (PR #1709) |
  | `praisonaiagents.storage.base.FileLock` | `BaseJSONStore` and downstream learn stores | mtime-based staleness + heartbeat (PR #5136, below) |
</Note>

### storage.base.FileLock — mtime staleness + heartbeat

`praisonaiagents.storage.base.FileLock` backs `BaseJSONStore` and every JSON-backed store built on it (including the [learned-memory store](/docs/features/learn)). It judges staleness by the lock **file's** mtime age, not by how long any individual waiter has queued.

| Rule | Behaviour |
| - | - |
| Staleness measure | `time.time() - lock_path.stat().st_mtime` — the lock file's own age |
| Heartbeat | A daemon thread started in `__enter__` refreshes the lock file's mtime every `max(timeout/3, 0.001)` seconds via `os.utime`; `__exit__` stops it |
| Reclaim | Only a truly abandoned lock (holder crashed without running `__exit__` or heartbeating) ages past `timeout` and is reclaimed |

This closes a corruption bug: previously, staleness was measured as `time.time() - start`, where `start` was when *this waiter* began waiting. A live holder legitimately in a long critical section (large JSON write, slow disk) had its lock file `unlink()`ed out from under it as soon as one waiter's `timeout` elapsed — admitting two writers into the same read-modify-write save concurrently and silently clobbering one another's writes. The mtime + heartbeat scheme keeps a long-held lock exclusive. Added in [PR #5136](https://github.com/MervinPraison/PraisonAI/pull/5136).

### BaseJSONStore — atomic save even on serialization failure

`BaseJSONStore._save` writes to a `.tmp` staging file next to the store and `os.replace`s it over the real file so partial content is never observable. As of [PR #5582](https://github.com/MervinPraison/PraisonAI/pull/5582), the atomic guarantee extends to **failures inside `json.dump` itself** — a circular dict, a UTF-8-unencodable surrogate string, or any other serialization or encoding error.

| Case | Guarantee |
| - | - |
| `json.dump` succeeds | `.tmp` is atomically renamed over the real store (unchanged) |
| `json.dump` raises (circular dict, `"\ud800"`, etc.) | Original error propagates, the pre-existing store is left intact, and the `.tmp` staging file is removed — no partial artifact left behind |
| `os.replace` after a successful dump raises | Same as before — the `.tmp` is removed, the original store is left intact |

Previously, `temp_path = f.name` was assigned **after** `json.dump` succeeded, so a serialization failure left `temp_path` unbound and the existing cleanup branch couldn't identify the staging file. Both locked and unlocked save paths were affected. Fix reference: [PraisonAI PR #5582](https://github.com/MervinPraison/PraisonAI/pull/5582), fixes [#5580](https://github.com/MervinPraison/PraisonAI/issues/5580).

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant Caller
    participant Store as BaseJSONStore
    participant Tmp as session.json.tmp
    participant Real as session.json

    Caller->>Store: save(data_with_circular_ref)
    Store->>Tmp: create NamedTemporaryFile
    Store->>Store: temp_path = f.name  (recorded first)
    Store->>Tmp: json.dump(data, …)
    Tmp-->>Store: ValueError: Circular reference
    Store->>Tmp: os.unlink(temp_path)   ← cleanup now identifies the file
    Store-->>Caller: re-raise original error
    Note over Real: untouched — no partial write ever reached it

    classDef caller fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef store fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef tmp fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef real fill:#10B981,stroke:#7C90A0,color:#fff

    class Caller caller
    class Store store
    class Tmp tmp
    class Real real
```

<Note>
  This matches the behaviour `AsyncBaseJSONStore.save_async` already had — the sync path now has parity. No API change; upgrade to pick up the fix.
</Note>

### BaseJSONStore — read failures block ordinary saves on the same instance

`BaseJSONStore` and `AsyncBaseJSONStore` share a per-instance read-failure guard: when `_load()` / `load_async()` catches an `UnicodeDecodeError`, a `json.JSONDecodeError`, or a non-missing `IOError`, the instance returns defaults **and** sets `_read_failed = True`. A subsequent `save()` / `save_async()` then raises `OSError: Refusing to overwrite unreadable record: <path>` for as long as the source file still exists. Neighbouring stores and other keys on a shared backend are unaffected — the guard is per-instance / per-path.

Clearing the guard requires one of these:

| Trigger | API |
| - | - |
| Explicit atomic reset | `store.clear()` — writes `_default_data()` through `_reset`, rolls back on failure |
| Delete the source file (and reset the cache) | `store.delete()` |
| Successfully reload (bytes were repaired out-of-band) | `store.load()` / `await store.load_async()` returning real data |

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant Caller
    participant Store as BaseJSONStore
    participant Disk as session.json (bad bytes)

    Caller->>Store: load()
    Store->>Disk: open + json.load
    Disk-->>Store: UnicodeDecodeError
    Store-->>Caller: {} (defaults)
    Note over Store: _read_failed = True

    Caller->>Store: save({"ok": 1})
    Store->>Disk: exists? yes
    Store-->>Caller: OSError<br/>Refusing to overwrite<br/>unreadable record

    Caller->>Store: clear()   OR   delete()   OR   fix bytes + load()
    Note over Store: _read_failed = False
    Caller->>Store: save({"ok": 1})
    Store->>Disk: tmp + os.replace
    Disk-->>Store: ok

    classDef caller fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef store fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef disk fill:#F59E0B,stroke:#7C90A0,color:#fff

    class Caller caller
    class Store store
    class Disk disk
```

**Backend-delegated stores:** when `BaseJSONStore` is constructed with a `FileBackend` (`BaseJSONStore(path, backend=FileBackend(...))`), the per-path guard lives on the **backend**, not the store. The contract is the same from the caller's point of view: the first failed `load()` makes further `save()` calls on that key refuse to overwrite the file until the key is deleted, successfully reloaded, or the file is removed out-of-band.

**Cross-instance / cross-process:** no guarantee is claimed. A fresh instance pointed at the same bad file goes through the same recovery on its first `load()`.

Fix references: [PraisonAI PR #5578](https://github.com/MervinPraison/PraisonAI/pull/5578), [PR #5579](https://github.com/MervinPraison/PraisonAI/pull/5579), fixes [#5577](https://github.com/MervinPraison/PraisonAI/issues/5577).

<Note>
  `clear()` is the one path that intentionally overwrites an unreadable file. It routes through `_reset`, which captures the previous cache and guard state and restores them if the write fails — so a failed reset leaves the instance exactly as it was.
</Note>

### 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](/docs/features/sqlite-transcript-store).

## Best Practices

<AccordionGroup>
  <Accordion title="Use agent methods">
    Call `agent.chat()` or `agent.start()` — these acquire locks internally.

    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    response = agent.chat("Hello")
    ```
  </Accordion>

  <Accordion title="Avoid direct list mutation">
    Do not append to `chat_history` directly; use agent methods or the locked setter.

    ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    # Bad — bypasses locks
    agent.chat_history.append({"role": "user", "content": "Hello"})

    # Good
    agent.chat("Hello")
    ```
  </Accordion>

  <Accordion title="Clear history safely">
    Use `agent.clear_history()` rather than assigning an empty list from multiple threads.
  </Accordion>

  <Accordion title="Serialise mixed sync/async callers">
    Sync and async locks are independent since PR #1567 — add an external lock when both contexts mutate history.
  </Accordion>
</AccordionGroup>

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

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
import asyncio
from praisonaiagents import Agent

agent = Agent(name="AsyncAgent")

async def async_chat(prompt):
    # No external lock needed - AsyncSafeState handles this
    return agent.chat(prompt)

async def main():
    tasks = [async_chat(f"Question {i}") for i in range(5)]
    results = await asyncio.gather(*tasks)
```

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

<Note>
  As of [PR #5338](https://github.com/MervinPraison/PraisonAI/pull/5338), `DualLock.sync()` and `DualLock.async_lock()` back onto **one shared `threading.Lock`**, so a sync caller and a concurrent async caller are genuinely mutually exclusive across contexts. Same-thread `sync()` reentrancy is preserved via an owner/depth guard, and async acquisition is offloaded to a worker thread so the event loop is never blocked. Older releases (PR #1567) used independent locks that did not coordinate across contexts.
</Note>

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    subgraph "Sync Context"
        SyncCaller[👨‍💻 Sync Caller] --> SharedLock[🔒 shared threading.Lock]
    end

    subgraph "Async Context"
        AsyncCaller[🚀 Async Caller] --> Worker[🧵 Worker thread]
        Worker --> SharedLock
    end

    SharedLock --> ChatHistory[💬 chat_history]

    classDef sync fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef async fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef shared fill:#10B981,stroke:#7C90A0,color:#fff

    class SyncCaller sync
    class AsyncCaller,Worker async
    class SharedLock,ChatHistory shared
```

An external lock **is** still useful for serialising chat-history mutations from a thread pool that mixes sync and async callers:

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
import asyncio
import threading
from praisonaiagents import Agent

agent = Agent(name="MixedAgent")
external_lock = threading.Lock()

def sync_worker(prompt):
    with external_lock:
        return agent.chat(prompt)

async def async_worker(prompt):
    # Convert to sync context for coordination
    loop = asyncio.get_event_loop()
    with external_lock:
        return await loop.run_in_executor(None, agent.chat, prompt)
```

## Verifying Thread Safety

Test thread safety with concurrent access:

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
import threading
from praisonaiagents.lite import LiteAgent

def test_thread_safety():
    agent = LiteAgent(
        name="Test",
        llm_fn=lambda m: "Response"
    )
    
    errors = []
    
    def worker():
        try:
            for _ in range(100):
                agent.chat("Test")
        except Exception as e:
            errors.append(e)
    
    threads = [threading.Thread(target=worker) for _ in range(10)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    
    assert len(errors) == 0, f"Thread safety errors: {errors}"
    print("Thread safety test passed!")

test_thread_safety()
```

### Multi-team HTTP launch

PraisonAI's launch server is now fully thread-safe **and** unified across `Agent.launch()`, `AgentTeam.launch()`, and `PraisonAIAgents.launch()`. All three share one process-wide `_server_registry` in `praisonaiagents/agent/server_registry.py`, so mixing them on the same port is safe.

* Multiple `Agent` / `Agents` instances 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 the `PRAISONAI_SERVER_READY_TIMEOUT` environment variable. If the server doesn't become ready in time, `.launch()` still returns and a warning is logged — check server logs for startup errors.
* A bind failure (`Address already in use`, missing FastAPI/uvicorn) clears the port's `_started` flag so a later `.launch()` call re-attempts the bind. The launcher returns the true bound state, so callers can distinguish a live server from a poisoned port.
* `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 guards `chat_history`, so concurrent `chat()` / `achat()` turns on one `Agent` no 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.

<Note>
  **Fix reference:** [PraisonAI PR #5204](https://github.com/MervinPraison/PraisonAI/pull/5204) unified the launch registry across `Agent.launch()` and `AgentTeam.launch()` and replaced `Agent.launch()`'s heuristic sleeps with the shared readiness `Event`.
</Note>

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
import threading
from praisonaiagents import AgentTeam

def launch_team(team_name, port, path):
    team = AgentTeam(name=team_name)
    team.launch(port=port, path=path)

# Safe concurrent launches
thread1 = threading.Thread(target=launch_team, args=("TeamA", 8000, "/team_a"))
thread2 = threading.Thread(target=launch_team, args=("TeamB", 8000, "/team_b"))

thread1.start()
thread2.start()

thread1.join()
thread2.join()

# Both teams available at:
# - http://localhost:8000/team_a
# - http://localhost:8000/team_b
```

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

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    subgraph "Per-Instance OpenAI Client"
        G1[🤖 BaseAutoGenerator A] --> L1[🔒 Instance Lock] --> C1[📡 Core OpenAIClient A]
        G2[🤖 BaseAutoGenerator B] --> L2[🔒 Instance Lock] --> C2[📡 Core OpenAIClient B]
        G1 -.close().-> X1[♻️ client.close]
        G2 -.close().-> X2[♻️ client.close]
    end

    classDef generator fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef lock fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef client fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef cleanup fill:#10B981,stroke:#7C90A0,color:#fff

    class G1,G2 generator
    class L1,L2 lock
    class C1,C2 client
    class X1,X2 cleanup
```

### Per-instance OpenAI client lifecycle

Each `BaseAutoGenerator` 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.

`OpenAIClient` is also **loop-aware**: concurrent sync callers each on their own `asyncio.run` loop each get an `AsyncOpenAI` bound to their own loop, and the drop/rebuild is serialised under an internal `threading.Lock`, so no caller ever observes a transient `None`. Added in [PraisonAI PR #5682](https://github.com/MervinPraison/PraisonAI/pull/5682).

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
import os
from praisonai import PraisonAI

# Each PraisonAI instance constructs its own auto-generator, which owns
# a single OpenAI client. Two concurrent instances cannot evict each
# other's client — even with identical API keys.
team_a = PraisonAI(auto="Draft a marketing plan", api_key=os.getenv("OPENAI_API_KEY"))
team_b = PraisonAI(auto="Draft a technical doc", api_key=os.getenv("OPENAI_API_KEY"))

team_a.run()  # safe to run in parallel threads
team_b.run()
```

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

For power users building generators directly:

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
import os
from praisonai.auto import BaseAutoGenerator

# Sync context manager
with BaseAutoGenerator(config_list=[{
    "model": "gpt-4o-mini",
    "api_key": os.getenv("OPENAI_API_KEY"),
    "base_url": None,
}]) as gen:
    # _get_core_client() is lock-guarded; calling it from multiple threads
    # on the same instance returns the same core OpenAIClient.
    result = gen._structured_completion(MyModel, messages=[...])

# Async context manager
async with BaseAutoGenerator(config_list=[{
    "model": "gpt-4o-mini",
    "api_key": os.getenv("OPENAI_API_KEY"),
    "base_url": None,
}]) as gen:
    result = await gen._structured_completion_async(MyModel, messages=[...], is_async=True)
```

<Warning>
  **Behaviour change in PR #1681**: the module-level functions `praisonai.auto._get_openai_client(api_key, base_url)` and the `_openai_clients` / `_openai_clients_lock` globals **have been removed**. If you imported them, switch to constructing an `OpenAI` client yourself or call `BaseAutoGenerator(...).\_get_openai_client()`. Each generator now owns exactly one client; the previous bug — an in-use client being evicted from a process-wide LRU and closed while other threads still held a reference — is no longer possible.

  **Behaviour change in PR #1736**: `__del__` was removed and async support was added. New methods include `aclose`, `__aenter__`/`__aexit__`, and `_structured_completion_async` (originally added as `_astructured_completion`, renamed and consolidated on the live path — the shorter alias was removed as dead code in [PR #3991](https://github.com/MervinPraison/PraisonAI/pull/3991)). Use context managers or explicit cleanup instead of relying on destructors.

  **Behaviour change in PR for #2963**: `BaseAutoGenerator` now owns a single `_core_client: OpenAIClient` (the core-owned client) instead of separate `_openai_client` / `_async_openai_client` attributes. The methods `_get_openai_client()` and `_get_async_openai_client()` were consolidated into `_get_core_client()`. The public surface (`close`, `aclose`, `__enter__`/`__exit__`, `__aenter__`/`__aexit__`, `_structured_completion`, `_structured_completion_async`) is unchanged as of PR #2963. `_astructured_completion` was later removed as dead code in [PR #3991](https://github.com/MervinPraison/PraisonAI/pull/3991) — call `_structured_completion_async` instead. If you called the previous private methods directly, switch to `_get_core_client()`.
</Warning>

### Thread-safe Typer command discovery

Embedding `python -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:

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
import threading
import subprocess

def run_cli_command(command):
    # Safe to call from multiple threads
    result = subprocess.run(
        ["python", "-m", "praisonai"] + command,
        capture_output=True, text=True
    )
    return result.stdout

# Multiple threads can safely use the CLI
threads = [
    threading.Thread(target=run_cli_command, args=(["--version"],))
    for _ in range(5)
]

for t in threads:
    t.start()
for t in threads:
    t.join()
```

### 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. The `start()` 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.

**`PraisonAIDB` background writes** (PR [#4861](https://github.com/MervinPraison/PraisonAI/pull/4861)) — fire-and-forget write tracking (`_bg_writes` / `_bg_writes_lock`) is now **per-instance**, not a process-global set. Each adapter's `close()` / `aclose()` / `flush_pending_writes()` waits only on its own in-flight writes. Previously a single process-global `_BG_WRITES` was shared across every `PraisonAIDB` instance, so one tenant's stuck backend could consume another tenant's 5-second flush budget — or silently discard the caller's own writes when it timed out. The per-instance lock also removes unnecessary serialisation across concurrent multi-tenant `close()` calls. See [Async DB Hooks](/docs/features/async-db-hooks).

The adapter's async init/close is also **cross-loop safe**: a shared `PraisonAIDB` no longer caches an event-loop-bound `asyncio.Lock` on the instance, so its `_ainit_stores` / `aclose` can be awaited from any per-session loop (serialisation runs off-loop via `asyncio.to_thread` under the sync `threading.Lock`) without `RuntimeError: bound to a different event loop`. See [Per-Session Scoped Bridge](/docs/features/async-bridge#per-session-scoped-bridge).

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

**AgentTeam / Workflow run guard (PR [#5656](https://github.com/MervinPraison/PraisonAI/pull/5656))** — Both `AgentTeam` and `Workflow` lazily mint their per-instance run lock through a single shared helper using double-checked locking under a module-level guard, so two threads racing into the first `start()` / `astart()` / `run()` on a fresh instance observe the same lock object.

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

<Warning>
  **Breaking change:** `get_stats()` is now a coroutine. Update your code:

  ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
  # Before PR #1673
  stats = store.get_stats()

  # After PR #1673
  stats = await store.get_stats()
  ```
</Warning>

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

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonai.scheduler import AgentScheduler

scheduler = AgentScheduler(agent, "Hourly news check")
scheduler.start("hourly", max_retries=3)
# ... later, from another thread or signal handler:
scheduler.stop()  # returns within ms, even if a backoff sleep was in progress
```

**ToolRegistry — thread-safe registry operations**

`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](https://github.com/MervinPraison/PraisonAI/pull/1673).

<Warning>
  **Breaking change (PR #2248):** The four AutoGen-specific methods that were added in PR #1780 (`register_autogen_adapter`, `get_autogen_adapter`, `list_autogen_adapters`, `register_builtin_autogen_adapters`) have been **removed** from `ToolRegistry`. Migrate to `AutoGenAdapter` from `praisonai.framework_adapters` or use `praisonai.persistence.factory` for persistence. See [ToolRegistry AutoGen Migration](/docs/features/tool-registry-autogen-migration) for replacement patterns.
</Warning>

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

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    S[stdio caller] --> L[🔒 lock → snapshot → clear]
    H1[HTTP caller] --> L
    H2[HTTP caller 2] --> L
    L --> R[▶️ run each loader once]

    classDef caller fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef lock fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef run fill:#10B981,stroke:#7C90A0,color:#fff
    class S,H1,H2 caller
    class L lock
    class R run
```

Under 10-thread concurrency, loaders can no longer double-run or be skipped mid-iteration. Reference: [PR #2738](https://github.com/MervinPraison/PraisonAI/pull/2738).

### PluginManager (PR #5204)

`PluginManager`'s "thread-safe for concurrent access" contract is now fully honoured. Every method that iterates or mutates `_plugins` / `_enabled` acquires the same `RLock`:

| Method | Lock behaviour |
| - | - |
| `register()` | already held the lock — unchanged |
| `unregister()` | now holds the lock while mutating; plugin `on_shutdown()` runs outside |
| `enable()` / `disable()` | now hold the lock while mutating |
| `execute_hook()` | snapshots `(name, plugin)` pairs under the lock, then dispatches outside |

Concurrent `register()` / `unregister()` on one thread and `execute_hook()` on another can no longer raise `RuntimeError: dictionary changed size during iteration`. Because `_lock` is an `RLock`, a plugin whose `on_shutdown()` re-enters the manager (e.g. via `shutdown()`) is safe.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    T1[register / unregister thread] --> L[🔒 RLock]
    T2[execute_hook thread] --> L
    L --> SNAP[📸 snapshot enabled pairs]
    SNAP --> D[▶️ dispatch outside lock]
    D --> OK[✅ no dictionary-changed-size error]

    classDef caller fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef lock fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef snap fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef ok fill:#10B981,stroke:#7C90A0,color:#fff

    class T1,T2 caller
    class L lock
    class SNAP,D snap
    class OK ok
```

Fix reference: [PraisonAI PR #5204](https://github.com/MervinPraison/PraisonAI/pull/5204).

### Managed agents (PR #5230)

**Managed agents** — `AnthropicManagedAgent` / `LocalManagedAgent` guard cloud-resource creation and local session persistence with per-instance locks; concurrent `execute()` calls on a shared instance never double-create. Agent / environment / session creation uses double-checked locking, so only one of any resource is ever created — no orphaned cloud agents, no torn on-disk session JSON.

See [Managed Agent Lifecycle → Thread safety](/docs/features/managed-agent-lifecycle#thread-safety) for a runnable concurrent-`execute()` example.

Fix reference: [PraisonAI PR #5230](https://github.com/MervinPraison/PraisonAI/pull/5230).

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

This eliminated the singleton-config-bleed and first-caller-CWD-wins bugs while keeping `cleanup_runtime()` and convenience functions like `resolve_tool()` source-compatible.

User tool loading is safe under concurrent builds too: `_safe_loader.load_user_module` registers each load under a per-load-unique `sys.modules` key, so two tenants loading their own `tools.py` never share the process-global slot. The entry is dropped after exec on both success and exception, so a long-lived multi-tenant `praisonai serve` process does not accumulate one `sys.modules` entry per reload — a failed exec still removes only its own entry, never another tenant's live module. See [Concurrent loads stay isolated](/docs/features/local-tools-loading#concurrent-loads-stay-isolated). Fix references: [PraisonAI PR #5248](https://github.com/MervinPraison/PraisonAI/pull/5248) + [#5239](https://github.com/MervinPraison/PraisonAI/pull/5239) (closes [#5157](https://github.com/MervinPraison/PraisonAI/issues/5157) Gap 3).

### Handoff history seeding

A handoff seeds the filtered conversation onto the **target** agent's `chat_history` for the duration of the transfer, then restores it. Because that history is shared mutable state on the target, overlapping handoffs to the *same* target agent (e.g. two `parallel_handoffs` entries pointing at one specialist) would interleave their seed-and-restore steps and leak one caller's context into another's.

Seeding is now serialised per target agent by a `DualLock` attached lazily as `agent._handoff_seed_lock`:

* **Sync path** holds the lock's threading (`RLock`) side around apply-seed → `chat()` → restore.
* **Async path** holds the lock's per-loop `asyncio.Lock` side **and** the sync side for the whole seeded `achat()` turn. The async side alone would not exclude a concurrent *cross-thread sync* handoff to the same target, so both sides are held together.

If a target agent cannot hold the attribute (e.g. `__slots__`), a transient lock is used and behaviour falls back to the pre-lock path — the common case stays fully guarded. No public API changed; the guarantee is automatic. See [Agent Handoffs](/docs/features/handoffs#concurrent-handoffs-to-the-same-target) and [Multi-Agent Context Safety](/docs/features/multi-agent-context-safety).

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

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
import asyncio
from praisonaiagents import Agent
from praisonaiagents import parallel_handoffs

billing = Agent(name="Billing", instructions="Handle billing inquiries.")
refund = Agent(name="Refunds", instructions="Process refund requests.")
tech = Agent(name="TechSupport", instructions="Solve technical problems.")
triage = Agent(name="Triage", instructions="Route inquiries to specialists.")

async def main():
    results = await parallel_handoffs(
        triage,
        targets=[
            (billing, "Why was I charged twice?"),
            (refund, "I want a refund for order #42"),
            (tech, "The app crashes on upload"),
        ],
    )
    for r in results:
        print(r.target_agent, r.success)

asyncio.run(main())
```

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant G as parallel_handoffs
    participant T1 as Task 1 chain
    participant T2 as Task 2 chain
    participant T3 as Task 3 chain

    G->>T1: rebind fresh chain copy
    G->>T2: rebind fresh chain copy
    G->>T3: rebind fresh chain copy
    Note over T1,T3: Each task mutates its own list — no shared state
```

Each gathered task rebinds `_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](/docs/sdk/reference/praisonaiagents/functions/parallel_handoffs) 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_depth` counters stay accurate for its remaining handoffs in the same turn.

### Output singleton (trace/status) lock (PR #5219)

`output="status"` and `output="trace"` route through a shared per-process singleton in `praisonaiagents.output.status` / `praisonaiagents.output.trace`. Both modules declared a `_output_lock` that, before this fix, was **never acquired**.

<Note>
  **Fix reference:** [PraisonAI PR #5219](https://github.com/MervinPraison/PraisonAI/pull/5219), fixes [#5162](https://github.com/MervinPraison/PraisonAI/issues/5162).
</Note>

The lock is now actually acquired around every read-modify-write on the singleton's shared state:

* the timing dicts `_llm_start_time`, `_tool_start_times`, and `_agent_start_times`,
* the call counter `_ai_call_count`,
* the session accumulators `_session_tokens_in`, `_session_tokens_out`, `_session_cost`, and `_session_llm_calls`.

Concrete before/after: an `asyncio.gather` over two `agent.achat(...)`s used to let one agent's `llm_end` compute its duration from the *other* agent's `_llm_start_time` (or crash on `None` once the first `llm_end` reset it), and the session token summary silently under-counted because `+=` on shared counters raced. After the fix, both the per-call timings routed through the singleton and the aggregate token/cost totals stay consistent under concurrency.

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
import asyncio
from praisonaiagents import Agent

# Distinct names → accurate per-agent status/trace attribution (see the note below).
writer = Agent(name="Writer", instructions="Write a blurb.", output="status")
summarizer = Agent(name="Summarizer", instructions="Summarise notes.", output="status")

async def main():
    # The shared status singleton's metrics no longer corrupt under gather.
    await asyncio.gather(
        writer.achat("Blurb for the new dashboard"),
        summarizer.achat("Summarise v2.0 release notes"),
    )

asyncio.run(main())
```

<Note>
  The fix removes the data-race corruption on the singleton — it does **not** remove the cross-agent aliasing when two concurrent tool calls share a tool name, or two default-named `Agent()` instances share the name `"Agent"`. `_tool_start_times` and `_agent_start_times` are still keyed by name only, so two concurrent `read_file` calls can still pop each other's timestamps. Give each agent a distinct `name=` for accurate per-agent status/trace attribution.
</Note>

Cross-links: [Output](/docs/features/output#concurrent-execution-pr-5219) and [Output Styles](/docs/features/output-styles#concurrent-execution-pr-5219).

***

## Related

<CardGroup cols={2}>
  <Card title="Agent Cloning" icon="copy" href="/docs/features/agent-cloning">
    Clone agents for channel isolation
  </Card>

  <Card title="Rate Limiter" icon="gauge" href="/docs/features/rate-limiter">
    Thread-safe rate limiting across agents
  </Card>

  <Card title="Output" icon="display" href="/docs/features/output">
    output="status" / "trace" presets and their concurrency guarantees
  </Card>

  <Card title="Import Safety (Python)" icon="shield-check" href="/docs/features/python-import-safety">
    Fork-safe imports and untouched host state
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.