Async Safety

How knowledge search, memory writes, and the in-memory adapter behave under concurrent async tasks

Under async execution, PraisonAI dispatches blocking I/O to worker threads so parallel tasks actually run in parallel, and the built-in in-memory store stays safe under concurrent writes.

python
import asyncio
from praisonaiagents import Agent

# Two unnamed agents in the same process โ€” skill grants stay scoped per instance.
reviewer = Agent(skills=["./skills/file-reviewer"])
worker   = Agent(instructions="Do a job.")

async def main():
    await reviewer.astart("Review report.pdf")   # knowledge.search runs on a worker thread
    await worker.astart("Summarise the review")  # store_in_memory in the callback also off-loop

asyncio.run(main())

Both knowledge.search(...) and task-callback memory writes are offloaded automatically โ€” you don’t call anything new. What you see is parallel async tasks that share a knowledge base or memory now actually running in parallel.

Diagram

Async offload

โšก Event Loop

๐Ÿงต Worker Thread

๐Ÿ“š Knowledge search

๐Ÿง  Memory write

๐Ÿ›ก๏ธ Guardrail validate

โœ… Loop stays responsive

Quick Start

  1. Parallel agents sharing knowledge

    An asyncio.gather(...) of agents that query the same knowledge base runs in parallel โ€” no lookup blocks the loop.

    python
    import asyncio
    from praisonaiagents import Agent
    
    agent = Agent(
        name="Research Assistant",
        instructions="Answer questions using the knowledge base.",
        knowledge=["handbook.pdf"],
    )
    
    async def main():
        await asyncio.gather(
            agent.astart("Summarise the onboarding policy"),
            agent.astart("What is the refund window?"),
        )
    
    asyncio.run(main())
  2. Parallel tasks writing memory

    Task-callback memory writes offload to worker threads, so a fan-out of tasks doesn’t stall on one slow write.

    python
    import asyncio
    from praisonaiagents import Agent, Task, PraisonAIAgents
    
    worker = Agent(name="Worker", instructions="Do the work.", memory=True)
    
    agents = PraisonAIAgents(
        agents=[worker],
        tasks=[
            Task(description="Task A", agent=worker, async_execution=True),
            Task(description="Task B", agent=worker, async_execution=True),
            Task(description="Task C", agent=worker, async_execution=True),
        ],
        # process defaults to "sequential"; async_execution on tasks fans them out.
    )
    
    asyncio.run(agents.astart())
  3. Parallel agents with guardrails

    An asyncio.gather(...) of agents where one carries a string guardrail runs in parallel โ€” the guardrail’s validation LLM call offloads to a worker thread instead of stalling the fan-out.

    python
    import asyncio
    from praisonaiagents import Agent
    
    agent1 = Agent(
        name="Writer",
        instructions="Write a short blurb.",
        guardrail="Ensure the blurb makes no unverifiable claims.",
    )
    agent2 = Agent(
        name="Summarizer",
        instructions="Summarise the release notes.",
    )
    
    async def main():
        await asyncio.gather(
            agent1.astart("Blurb for the new dashboard"),
            agent2.astart("Summarise v2.0 release notes"),
        )
    
    asyncio.run(main())

How It Works

Diagram
sequenceDiagram
    participant Loop as Event Loop
    participant Agent
    participant Worker as Worker Thread
    participant Knowledge
    participant Memory

    Agent->>Loop: astart(...)
    Loop->>Worker: to_thread(knowledge.search)
    Worker->>Knowledge: embedding + vector search
    Knowledge-->>Worker: results
    Worker-->>Loop: results (event loop stayed responsive)
    Loop->>Worker: to_thread(store_in_memory)
    Worker->>Memory: embedding + DB write (RLock-guarded)
    Memory-->>Worker: id
    Worker-->>Loop: done
    Loop-->>Agent: continues next coroutine

What runs off the event loop

Call siteOffloaded via
Knowledge.search inside Agent._achat_implasyncio.to_thread
Task.store_in_memory inside Task.execute_callbackasyncio.to_thread
_validate_with_guardrail inside Agent._aapply_guardrail_with_retry and _apply_task_guardrail inside Agents._aapply_task_guardrailloop.run_in_executor (with copy_context_to_callable)
_goal_gate_async on the async autonomous loop โ€” see Goal Loopasyncio.to_thread (cancellation-safe via asyncio.shield)
_auto_save_session inside Agent.astart, Agent._adelegate_to_backend, and each iteration of Agent.run_autonomous_async (PR #5258)asyncio.to_thread
_persist_message("user", ...) inside Agent._achat_impl (PR #5879)asyncio.to_thread (through _apersist_message)

Both were synchronous embedding + vector-store / DB calls that previously blocked the event loop for the whole coroutine lifetime. An asyncio.gather(...) fan-out would serialise on the slowest lookup or write instead of running concurrently. Offloading them to threads keeps the loop free.

A string / LLMGuardrail output guardrail fires a blocking LLM call under the hood to validate the agent’s output. On the async path this call is now offloaded via loop.run_in_executor(...), so parallel tasks don’t serialise on one task’s guardrail round-trip (PR #4469). Contextvars are preserved across the offload with copy_context_to_callable, so trace emission and session context stay intact.

Session auto-save (Agent(memory=MemoryConfig(auto_save="โ€ฆ"))) does a FileLock + JSON read-modify-write on the process-wide DefaultSessionStore singleton. Before PR #5258 that synchronous write ran inline on the async call sites, so one async agent’s auto-save blocked every other async agent sharing the loop โ€” a fast agent gathered alongside a slow-writing one would visibly stall. The write is now offloaded via asyncio.to_thread at every async call site, and an internal lock serialises the shared _auto_save_last_index bookkeeping so two concurrent async runs sharing one Agent can’t double-persist the same turns.

Thread-safe in-memory adapter

The built-in InMemoryAdapter guards its store, search, delete, and reset methods with a re-entrant lock (RLock). Once writes move off the event loop, parallel async writes from an asyncio.gather(...) fan-out cannot produce duplicate ids or lose entries.

If you write a custom adapter that will be shared across async agents, do the same โ€” guard its mutating methods with a lock.

python
import threading

class MyAdapter:
    def __init__(self):
        self._lock = threading.RLock()
        self._store = {}

    def store_long_term(self, memory_id, content, **kwargs):
        with self._lock:
            self._store[memory_id] = content

Best Practices

Fan out with asyncio.gather without fear

Knowledge search and memory writes offload to threads, so a gather(...) of agents that share a knowledge base or memory runs in parallel instead of serialising on the slowest task.

Make custom adapters thread-safe

Custom memory adapters used from async task callbacks receive writes from multiple worker threads. Guard mutating methods with a lock, as the built-in InMemoryAdapter does with an RLock.

Guardrails don't stall async fan-outs

String / LLMGuardrail output and task guardrails offload their blocking LLM call to a worker thread on the async path, so an asyncio.gather(...) of agents that share a guardrail no longer serialises on one task’s validation LLM round-trip. Custom guardrail objects that implement synchronous validate_output get the same offload for free.

async def guardrails work on the sync path too

Since PraisonAI PR #5305, passing an async def guardrail to Agent(guardrails=...) or Task(guardrails=...) works on both the sync and async execution paths. _process_guardrail detects a coroutine function and runs it via run_coroutine_from_any_context. Before #5305 the sync path called the coroutine function synchronously โ€” the returned coroutine was never awaited, GuardrailResult.from_tuple crashed on it, and the run was reported as a validation failure. Retries were wasted on a guardrail that had never actually executed.

Nothing to configure

The offload is automatic and transparent. There is no flag to enable or disable it โ€” the sync path is unchanged and the async path always stays responsive.


Related