Async Bridge

Safely call async code from sync, and sync code from async, without deadlocking the event loop.

The async bridge lets your tools and callbacks move between sync and async without crashing the event loop.

python
from praisonaiagents import Agent

agent = Agent(name="fetcher", instructions="Fetch URLs safely from sync or async tools.")
agent.start("Summarise https://example.com")

The user calls a sync tool that needs async I/O; the bridge runs the coroutine safely whether or not an event loop is already running.

Diagram

Async Bridge

No Loop

Loop Running

📋 Sync Caller

🔍 Loop Check

🚀 asyncio.run + wait_for

🧵 Worker thread
copies contextvars
own loop

✅ Result

An async guardrail on a plain Agent, reached from a FastAPI route, runs on a worker thread with the caller’s contextvars — the timeout is enforced inside that worker’s loop.

python
from fastapi import FastAPI
from praisonaiagents import Agent, TaskOutput

async def content_check(output: TaskOutput):
    # async because it hits a moderation API
    ok = await moderation_service.is_safe(output.raw)
    return (ok, output if ok else "flagged by moderation")

agent = Agent(
    name="writer",
    instructions="Write a short reply.",
    guardrails=content_check,   # async guardrail
)

app = FastAPI()

@app.post("/reply")
async def reply(prompt: str):
    # Before PR #5377 this raised "Agent guardrail validation error".
    # Now the async guardrail runs on a worker thread with the caller's
    # contextvars and returns its real result.
    return {"answer": agent.start(prompt)}

The user posts a prompt from a FastAPI route; the agent’s async guardrail runs on a worker thread with the caller’s contextvars, and the timeout is enforced inside that worker’s loop.

Quick Start

  1. From a sync tool

    Use run_coroutine_from_any_context to call async code from a sync tool:

    python
    from praisonaiagents import Agent
    from praisonaiagents.utils.async_bridge import run_coroutine_from_any_context
    import httpx
    
    async def _fetch(url: str) -> str:
        async with httpx.AsyncClient() as client:
            return (await client.get(url)).text[:500]
    
    def fetch_sync(url: str) -> str:
        """Sync tool that safely reuses an async HTTP client."""
        return run_coroutine_from_any_context(_fetch(url))
    
    agent = Agent(
        name="Researcher",
        instructions="Fetch and summarise web pages",
        tools=[fetch_sync],
    )
    agent.start("Summarise https://example.com")

    The user runs sync code that needs async I/O; the bridge executes coroutines without nested event loops.

  2. From an async tool

    Use run_sync_in_executor to call blocking code from an async tool without blocking the event loop:

    python
    from praisonaiagents import Agent
    from praisonaiagents.utils.async_bridge import run_sync_in_executor
    import time
    
    def blocking_task(duration: int) -> str:
        time.sleep(duration)
        return f"Completed after {duration} seconds"
    
    async def async_tool(duration: int) -> str:
        """Async tool that offloads blocking work."""
        return await run_sync_in_executor(blocking_task, duration)
    
    agent = Agent(
        name="Worker",
        instructions="Handle blocking tasks efficiently",
        tools=[async_tool],
    )
  3. Detecting the context

    Use is_async_context to create dual-mode helpers:

    python
    from praisonaiagents.utils.async_bridge import is_async_context, run_coroutine_from_any_context
    import httpx
    
    async def _async_fetch(url: str) -> str:
        async with httpx.AsyncClient() as client:
            return (await client.get(url)).text
    
    def smart_fetch(url: str) -> str:
        """Context-aware fetch that works in both sync and async."""
        if is_async_context():
            raise RuntimeError("Use await smart_fetch_async(url) in async context")
        return run_coroutine_from_any_context(_async_fetch(url))
    
    async def smart_fetch_async(url: str) -> str:
        """Async version for use in async contexts."""
        return await _async_fetch(url)

How It Works

Diagram
CoroutineWorker ThreadBridgeCallerCoroutineWorker ThreadBridgeCalleralt[No running loop][Running loop]TimeoutError enforced inside the worker loop AND on the caller’s future — a stuck worker cannot pin the caller past timeout.run_coroutine_from_any_context(coro)asyncio.get_running_loop() ?asyncio.run(wait_for(coro, timeout))Resultcontextvars.copy_context()submit(ctx.run, asyncio.run, wait_for(coro, timeout))run on own loop with copied contextResultResult (future.result(timeout))Result

The bridge probes for a running event loop. If no loop exists, it drives the coroutine directly on the caller’s thread via asyncio.run (wrapped in asyncio.wait_for when a timeout is set). If a loop is already running (FastAPI, Jupyter, a bot handler calling agent.chat() from inside async def), it offloads onto a dedicated worker thread with its own loop — never a deadlock, never a RuntimeError. The caller’s contextvars are copied into the worker thread with contextvars.copy_context(), so trace, session, and approval context set by the caller stays visible inside the coroutine.


Which helper do I call?

Pick between await, run_coroutine_from_any_context, and the wrapper-layer run_sync:

Diagram

Yes

No — plain sync

praisonaiagents.*
(guardrail, task callback,
agent tool)

praisonai.*
(CLI, gateway, scheduler)

Am I inside
async def?

await the coroutine directly

Which layer?

run_coroutine_from_any_context

praisonai._async_bridge.run_sync


Async guardrails, callbacks, and approvals inside a running loop (PR #5377)

Prior to PR #5377, run_coroutine_from_any_context raised RuntimeError when reached from inside a running event loop (FastAPI async route, Jupyter cell, Discord/Slack bot handler, any async def calling agent.start(), task.execute_callback_sync(), or an approval callback). That surfaced as a spurious “Agent guardrail validation error” and burned LLM retries on a valid async guardrail. It also masked real summariser errors as an “event loop already running” fallback to naive truncation. From that PR onwards:

  • Async guardrails on Agent(...) and Task(...) return their real result inside a running loop.
  • Async task callbacks reached via Task.execute_callback_sync run to completion instead of raising.
  • Async ApprovalCallback(approve_fn=<async fn>) works from FastAPI / Textual / Jupyter without the caller having to opt in.
  • ContextVars set by the caller (trace ids, session ids, approval context) are visible inside the coroutine.
  • The timeout is enforced. A coroutine that ignores cancellation (e.g. a blocking C call) can no longer pin the caller past timeout — the worker is abandoned (shutdown(wait=False)).
  • LLM_SUMMARIZE context compaction runs the real async summariser under a running loop instead of silently falling back to naive truncation, and provider errors from the summariser now surface instead of being masked.

The five previously-broken sync→async call sites (Agent._process_guardrail, Task._process_guardrail, Task.execute_callback_sync, planning/approval.py, bus/bus.py) all keep their existing code and now work correctly. The sync tool-calling helper run_async_in_sync_context is a thin delegate to run_coroutine_from_any_context(coro, timeout=None).


Internal sync surfaces now routed through the shared bridge (PR #5589)

PR #5589 routes ~11 internal sync→async call sites that used to hand-roll their own asyncio.run + ThreadPoolExecutor dance through the shared run_coroutine_from_any_context helper. The hand-rolled copies had two defects the shared helper does not:

  • Their try/except RuntimeError wrapped the worker’s future.result(), so a provider RuntimeError was replaced by a misleading "asyncio.run() cannot be called from a running event loop". Fixed.
  • They never copied contextvars.copy_context(), so trace / session / approval context set by the caller was invisible inside the coroutine. Fixed — the shared helper copies the context.

If you call agent.start(...) from a FastAPI route and your async guardrail, backend, aggregator, or tool used to lose trace context or report an opaque loop error, PR #5589 fixes that. The converted user-reachable surfaces:

SurfaceWhat it is for you
OpenAIAdapter.chat_completionSync OpenAI adapter call inside a running loop
Agent._execute_backend_syncSync agent delegating to an async backend
call_executor._resolve_valueSync tool returning an awaitable
agents._resolve_coroutine_syncasync def sync-marked task handler on astart
SharedCompute._run_syncShared compute from sync code
ContextAggregator.aggregate_syncAggregating context from inside a loop
compactor (no-loop path)Context compaction sync path (in-loop naive fallback preserved)
approval.run_coroutine_safelyApproval callback helper
eval.run_trialsTrials engine sync entry point
parallel_executor.execute_sync / execute_turn_syncFast parallel tool executor sync path
lsp_tools._run_lspLSP tool runner
session.title.generate_titleSession auto-title from sync code inside a loop (PR #6239)
tools.crawl4ai_tools.crawl4ai_sync / crawl4ai_extract_syncCrawl4AI sync tools inside a loop (PR #6239)

PR #6239 (Issue #6238) removed three more hand-rolled sync-over-async copies in favour of the shared bridge.

Three sites are intentionally not converted because they have distinct semantics: hooks/runner.py (raises on an in-loop call), tools/edit_tools.py (defers to a fallback), and tools/messaging_tools.py (routes via run_coroutine_threadsafe to a registered gateway loop).


Configuration Options

run_coroutine_from_any_context (the praisonaiagents.utils.async_bridge helper) takes two arguments:

OptionTypeDefaultDescription
coroAwaitable[T]—The coroutine to run to completion.
timeout`float \None`300 (5 minutes)Maximum seconds to wait for the coroutine. None disables the timeout entirely — used by the sync tool-calling path (run_async_in_sync_context) so a tool’s own timeout policy governs the wait. On expiry, raises TimeoutError.

Timeout resolution: omitted vs None vs number

The timeout argument on the wrapper-layer helpers — run_sync, run_sync_or_offload, arun_sync_or_offload, and AsyncBridge.run_sync — uses a private _UNSET sentinel as its default, so the bridge can tell “argument omitted” apart from an explicit timeout=None.

Caller passesEffective timeout
Nothing (argument omitted)_default_timeout() — reads PRAISONAI_RUN_SYNC_TIMEOUT, default 300.0
timeout=None explicitlyUnbounded wait (opt-in)
timeout=<number>That value verbatim

The sync-scheduler bridges (praisonai/integration/bridges/schedules_runner.py, praisonai/cli/commands/schedule.py) pass timeout=None so a long-running claimed job is never cancelled after 300 s.


Common Patterns

Reusing async SDKs from sync tools

python
from praisonaiagents import Agent
from praisonaiagents.utils.async_bridge import run_coroutine_from_any_context
import aiofiles

async def _read_file_async(path: str) -> str:
    async with aiofiles.open(path) as f:
        return await f.read()

def read_file(path: str) -> str:
    """Sync wrapper for async file operations."""
    return run_coroutine_from_any_context(_read_file_async(path))

agent = Agent(
    name="FileReader",
    instructions="Process files efficiently",
    tools=[read_file],
)

Offloading blocking calls from async tools

python
import subprocess
from praisonaiagents.utils.async_bridge import run_sync_in_executor

async def run_command(cmd: str) -> str:
    """Run shell command without blocking the event loop."""
    def _run():
        return subprocess.check_output(cmd, shell=True, text=True)

    return await run_sync_in_executor(_run)

Context-aware dual-mode helper

python
from praisonaiagents.utils.async_bridge import is_async_context, run_coroutine_from_any_context

def universal_helper(data):
    """Works in both sync and async contexts."""
    if is_async_context():
        raise RuntimeError("Use await universal_helper_async(data) in async context")

    async def _process():
        # async processing logic
        await asyncio.sleep(0.1)
        return f"Processed: {data}"

    return run_coroutine_from_any_context(_process())

Best Practices

Prefer await when you're already async

Calling run_coroutine_from_any_context inside async def now works — it offloads to a worker thread rather than raising. But await is still the right choice from an existing coroutine: it keeps the work on the caller’s loop and avoids the worker-thread and context-copy overhead the bridge takes on to stay safe.

python
# Good — no bridge overhead, work stays on the caller's loop
async def my_async_tool():
    result = await my_coroutine()

# Works, but takes a worker thread — reserve for sync callers
async def my_async_tool():
    result = run_coroutine_from_any_context(my_coroutine())
Don't wrap everything

Only wrap at the true sync/async boundary. Avoid creating unnecessary bridge calls in the middle of your call stack:

python
# Good - bridge at the boundary
def sync_tool():
    return run_coroutine_from_any_context(async_logic())

# Bad - unnecessary nesting
def sync_tool():
    def inner():
        return run_coroutine_from_any_context(async_logic())
    return inner()
Set a sensible timeout

The default 300 seconds is large for most use cases. Tighten for latency-critical tools:

python
# Good for quick operations
result = run_coroutine_from_any_context(quick_api_call(), timeout=10)

# Good for long operations
result = run_coroutine_from_any_context(model_training(), timeout=3600)
Check is_async_context() for dual-mode helpers

When building utilities that work in both sync and async contexts, check the context first:

python
def smart_helper():
    if is_async_context():
        raise RuntimeError("Use await smart_helper_async() in async context")
    return run_coroutine_from_any_context(async_implementation())

async def smart_helper_async():
    return await async_implementation()

Used by

The following synchronous APIs route through run_sync() and therefore honour PRAISONAI_RUN_SYNC_TIMEOUT consistently:

  • praisonai.bots.WebhookApproval.request_approval_sync()
  • praisonai.bots.HTTPApproval.request_approval_sync()
  • praisonai.integrations.get_available_integrations()
  • praisonai._run_praisonai (added PR #1681) — boots the InteractiveRuntime on the persistent background loop. If you call PraisonAI.run() from inside a running event loop, you now get a clear RuntimeError instead of a silent deadlock.
  • All ~77 wrapper-side run_sync call sites (gateway, a2u, mcp_server, scheduler) — see PR #1583 for the full list.
  • praisonai.auto.BaseAutoGenerator._structured_completion / _run_coro_sync runner threads (added in the fix for #3340) — inherit the caller’s scoped_bridge() via contextvars.copy_context().
  • praisonai_code.cli.features.agent_tools._run_sync (added via PR #3361) — a separate, module-local bridge in the Tier-2 praisonai-code package that ACP/LSP agent-centric tools route through. Does not import praisonai._async_bridge (C7 gate), but reads the same PRAISONAI_RUN_SYNC_TIMEOUT env var and enforces the same 300 s default. Safe under a running loop (offloads to a ThreadPoolExecutor), and returns promptly on timeout without waiting for the abandoned worker.
  • Ten CLI subcommand leaves route through the public run_cli_coro() helper (PR #5322) — background {list, status, cancel, clear}, managed {list-check, stop}, standardise, sandbox {execute, shell}, chat REPL /tasks {list, cancel, status}, and the direct_prompt external-agent pass-through — so per-loop connection pools survive across invocations instead of a fresh asyncio.run(...) per call.

Troubleshooting

RuntimeError: run_coroutine_from_any_context() cannot be called from async context

This specific RuntimeError no longer exists. PR #5377 removed it — previously-broken async guardrails, callbacks, and approvals now succeed inside a running loop by offloading to a worker thread. If you still see this message, you are on an older release; upgrade to the version that ships PR #5377.

My async guardrail / callback / approval now works but I’d rather await it directly

You can. Inside async def code, await the coroutine yourself instead of routing through the bridge. The bridge is there for the sync-only surfaces (Agent.start, Task.execute_callback_sync, sync tool functions) that cannot await.

asyncio.run() cannot be called from a running event loop

This error used to leak from SDK internals before the async bridge was implemented. If you see this on current versions, upgrade to the latest release.

SymptomCauseResolution
TimeoutError raised but you also see your coroutine’s finally: block run after the exceptionExpected: cancellation propagated, cleanup ran.No action needed; this is the new PR #1692 behaviour.

Test reference: praisonai/tests/unit/test_async_bridge.py::TestBridgeIntegration::test_timeout_cancels_coroutine_and_runs_finally — quote this in the page so users can verify the behaviour locally.

TimeoutError: run_sync_or_offload() worker did not complete within {N}s

This timeout branch is only reachable when the caller is inside a running loop and has opted into PRAISONAI_ALLOW_LOOP_BLOCKING=true (the offload path). A plain-sync caller still hits the same TimeoutError via run_sync. The offloaded coroutine did not finish inside timeout + 1s — check for blocking I/O or a missing await, or raise PRAISONAI_RUN_SYNC_TIMEOUT if the work is legitimately long-running. Prefer migrating to await arun_sync_or_offload(...) / await praisonai.arun(...) so the loop is never blocked.

PermissionError in approval system

The approval system now fails fast in async contexts. Configure a non-console backend:

python
from praisonaiagents.approval import get_approval_registry, WebhookBackend

# Configure for async compatibility
get_approval_registry().set_backend(WebhookBackend(url="http://localhost:8080/approve"))

Wrapper Bridge (praisonai._async_bridge)

The wrapper layer provides a module-level run_sync() for CLI scripts and single-tenant servers, plus a public AsyncBridge class when you need an isolated loop per tenant or service.

Module-level (default)

python
from praisonai._async_bridge import run_sync, shutdown

async def async_helper(data: str) -> str:
    await asyncio.sleep(0.1)
    return f"Processed: {data}"

def sync_entry_point(data: str) -> str:
    return run_sync(async_helper(data), timeout=60)

import atexit
atexit.register(shutdown)  # shuts down ONLY the shared default bridge

Per-instance AsyncBridge

python
from praisonai._async_bridge import AsyncBridge

bridge = AsyncBridge()  # per-tenant / per-service instance

async def fetch(url: str) -> str:
    ...

result = bridge.run_sync(fetch("https://example.com"), timeout=10)
bridge.shutdown()  # only this bridge — not the shared default
Diagram

Shared default

📋 run_sync module-level

🌉 _default_bridge

🔁 Default Loop

Per-Instance AsyncBridge

👤 Tenant A

🌉 AsyncBridge A

👤 Tenant B

🌉 AsyncBridge B

🔁 Loop A

🔁 Loop B

When to use a per-instance bridge

SituationModule-level run_sync()Your own AsyncBridge()
CLI script / one-shot job✅—
Single-tenant server✅—
Multi-tenant gateway (loop scoped to tenant lifecycle)—✅
Embedding PraisonAI inside another async framework—✅
Tests needing clean shutdown without affecting others—✅

API Reference:

Class / FunctionSignatureDescription
AsyncBridgeclass AsyncBridgePer-instance async runner; create one per tenant or service.
AsyncBridge.run_sync(self, coro, *, timeout=_UNSET) -> TRun a coroutine on this bridge’s background loop. Omitted → PRAISONAI_RUN_SYNC_TIMEOUT (300 s); None → unbounded; number → verbatim. See Timeout resolution.
AsyncBridge.shutdown(self, timeout=5.0, *, permanent=False) -> NoneCancel pending tasks and stop this bridge’s loop. With permanent=True, the bridge cannot be reused; later run_sync/submit raise RuntimeError. scoped_bridge() uses permanent=True for scope-owned bridges. Since PR #5774: a task that ignores CancelledError no longer makes shutdown() raise — it logs a warning ("AsyncBridge: tasks ignored cancellation for {timeout}s during shutdown"), signals the loop to stop, and if the loop thread does not exit it is left alive rather than calling loop.close() on a still-running loop (which would raise RuntimeError: Cannot close a running loop and, because shutdown() runs inside scoped_bridge.__exit__, mask the caller’s real exception). A leaked daemon loop thread is the deliberate trade-off. PR #2122: releases the bridge lock before awaiting cancellation.
AsyncBridge.submit(self, coro) -> concurrent.futures.FutureSubmit without waiting; returns a concurrent.futures.Future.
AsyncBridge.get(self) -> asyncio.AbstractEventLoopGet (lazily spawn) the bridge’s event loop.
run_sync (module-level)(coro, *, timeout=_UNSET) -> TRoutes through the shared default AsyncBridge. Omitted → PRAISONAI_RUN_SYNC_TIMEOUT (300 s); None → unbounded; number → verbatim. See Timeout resolution.
run_cli_coro (module-level)(coro, *, timeout=None) -> AnyCLI-leaf replacement for bare asyncio.run(...). Routes to run_sync on the shared loop from a sync context; raises RuntimeError inside a running loop. Default timeout=None (unbounded) — CLI commands own their runtime. See run_cli_coro().
shutdown (module-level)() -> NoneShuts down only the shared default bridge. User-owned instances are untouched.
DispatchKindclass DispatchKind(str, enum.Enum) — values READ, WRITEExplicit “how a sync caller consumes the result of a (possibly async) store op” — makes read-vs-write intent visible at the call site instead of relying on a name-string allow-list. READ = caller consumes the returned value (never fire-and-forget); WRITE = caller only cares that it eventually completes.
dispatch_maybe_awaitable`(value: Any, *, kind: DispatchKind, tracker: weakref.WeakSet \None = None, tracker_lock: threading.Lock \None = None, op_name: str = “op”) -> Any`Single owner of the submit / track / done-callback policy used by db-adapter dispatch sites. Not awaitable → returned as-is; no running loop → block on the shared bridge via run_sync; running loop + READ → route through run_sync_or_offload (returns the value on a sync path, fails loudly inside a loop instead of corrupting a read-modify-write with a silent None); running loop + WRITE → submit to the active bridge as tracked fire-and-forget (a warning done-callback logs failures), returns None.

Environment:

  • PRAISONAI_RUN_SYNC_TIMEOUT: Default timeout in seconds (300). Resolved per call (not at import) via _default_timeout(); a malformed value falls back to 300.0, and late-set env vars (dotenv loaded after import, per-request reconfig) are honoured. An explicit timeout=None opts into an unbounded wait (used by the sync-scheduler bridges for long-lived jobs). Read by both praisonai._async_bridge (this page) and praisonai_code.cli.features.agent_tools._run_sync (see Agent-Centric Tools → Timeouts & Cancellation).

Used by:

  • CLI approval protocol (ACP/LSP tools)
  • Interactive runtime start/stop operations
  • Deployment scheduler
  • Gateway operations

See also: Approval Protocol and Gateway.


run_cli_coro() — the CLI-leaf entry point

run_cli_coro() is the public helper a synchronous CLI leaf should call instead of a bare asyncio.run(coro). It routes the coroutine onto the shared background loop so per-loop connection pools survive across invocations, any embedder-installed scoped_bridge() binding is honoured, and no fresh loop is spawned on every call (PR #5322).

python
from praisonai._async_bridge import run_cli_coro

def handle_list() -> None:
    tasks = run_cli_coro(handler.list_tasks())
    for t in tasks:
        print(t)
SignatureDescription
`run_cli_coro(coro, *, timeout: float \None = None) -> Any`Run coro from a sync CLI leaf on the shared background loop. Sync context → routes to run_sync on the shared loop. Running loop → raises RuntimeError (the caller must await coro directly) — never spawns a nested loop.

Migration from asyncio.run()

Replace the bare asyncio.run(...) at a CLI leaf with run_cli_coro(...) — the coroutine now shares the background loop instead of tearing down a fresh one on every invocation.

python
# Before
import asyncio
asyncio.run(handler.list_tasks())

# After
from praisonai._async_bridge import run_cli_coro
run_cli_coro(handler.list_tasks())
Diagram
sequenceDiagram
    participant CLI as praisonai CLI leaf
    participant RCC as run_cli_coro()
    participant Bridge as shared AsyncBridge
    participant Loop as background loop

    CLI->>RCC: run_cli_coro(handler.list_tasks())
    RCC->>RCC: get_running_loop()? → RuntimeError → sync
    RCC->>Bridge: run_sync(coro, timeout=None)
    Bridge->>Loop: schedule on shared loop
    Loop-->>Bridge: result
    Bridge-->>RCC: result
    RCC-->>CLI: result
    Note over Loop: pool + httpx.AsyncClient stay alive<br/>for the next invocation

Migrated CLI sites

Ten CLI subcommand leaves now dispatch through run_cli_coro() so repeated invocations reuse the background loop:

CommandModule
praisonai background {list, status, cancel, clear}cli/features/background.py
praisonai managed {list-check, stop}cli/commands/managed.py
praisonai standardisecli/commands/standardise.py
praisonai sandbox {execute, shell}cli/features/sandbox_cli.py
praisonai chat REPL /tasks {list, cancel, status}cli/legacy/interactive_legacy.py
External-agent pass-through in direct_promptcli/legacy/direct_prompt.py

dispatch_maybe_awaitable — one helper for sync-hook dispatch

dispatch_maybe_awaitable() is the single owner of the “a sync call may return a coroutine; run it correctly” policy. It picks one of four branches from the running-loop state and an explicit DispatchKind.

Diagram

No

Yes

No

Yes

READ

WRITE

value from store method

isawaitable?

✅ Return value as-is

Running loop?

✅ run_sync — block on bridge

DispatchKind?

⚡ run_sync_or_offload
strict inside a loop

🚀 submit → tracked
fire-and-forget, return None

DispatchKind makes read-vs-write intent explicit at the call site instead of depending on a name-string allow-list. The four branches:

SituationBehaviour
Not awaitableReturned as-is.
No running loopBlock on the shared bridge via run_sync.
Running loop + READRoute through run_sync_or_offload — returns the value on a sync path, fails loudly inside a loop instead of corrupting a read-modify-write with a silent None.
Running loop + WRITESubmit to the active bridge as tracked fire-and-forget; a warning done-callback logs failures; returns None.

This is the shared helper the PraisonAIDB._call_store, _merge_and_set, and on_agent_end paths route through — the split policy is no longer hand-rolled per call site (PR #5038). See Async DB Hooks → Shared dispatch for the db-adapter view.

The contract tests in praisonai/tests/unit/test_async_bridge.py::TestDispatchMaybeAwaitable pin the four branches:

  • test_non_awaitable_passthrough — a plain value is returned unchanged.
  • test_no_loop_read_blocks_and_returns_value — no running loop + READ blocks and returns the value.
  • test_no_loop_write_blocks_and_returns_value — no running loop + WRITE runs to completion (fire-and-forget is a running-loop concern).
  • test_running_loop_write_is_tracked_fire_and_forget — running loop + WRITE submits, tracks, and returns None while the write still completes.
  • test_running_loop_failed_write_logs_and_does_not_raise — a failed deferred WRITE is swallowed (logged) via the done-callback, never surfacing to the sync caller.

run_sync_or_offload — strict inside a running loop

Use run_sync_or_offload() on a code path that can be reached from a plain script or from inside a running event loop (FastAPI, Jupyter, async tests). On a plain-sync caller it dispatches to run_sync. Inside a running loop it now raises RuntimeError by default (PR #4261) and steers you to the awaitable siblings — it never silently pins the loop.

Diagram

No

Yes

No — default

Yes — opt-in

🔍 Running loop?

✅ run_sync — shared bridge

🔧 PRAISONAI_ALLOW_LOOP_BLOCKING=true?

❌ RuntimeError
use arun / arun_sync_or_offload

⚠️ offload + join
blocks loop up to timeout+1s

  1. Plain sync caller — works
    python
    from praisonai._async_bridge import run_sync_or_offload
    
    async def fetch(): return 42
    
    value = run_sync_or_offload(fetch())  # no running loop → run_sync
  2. Inside a FastAPI handler — await instead

    A sync call inside a running loop now raises RuntimeError. Make the handler async and await the coroutine directly:

    python
    @app.post("/run")
    async def run(prompt: str):
        return {"answer": await agent.astart(prompt)}

    For an existing sync entry point you cannot convert to async def, await the awaitable sibling:

    python
    from praisonai._async_bridge import arun_sync_or_offload
    
    @app.post("/run")
    async def run(prompt: str):
        return {"answer": await arun_sync_or_offload(agent.astart(prompt))}
        # or: await praisonai.arun(...)

Configuration Options

OptionTypeDefaultDescription
coroAwaitable[T]—The coroutine to run. Refused inside a loop (strict mode) — it is .close()‘d so it does not leak as a “coroutine was never awaited” warning.
timeout`float \None`PRAISONAI_RUN_SYNC_TIMEOUT (300s)Same env var as run_sync. Omitted → resolved per call; None → unbounded; number → verbatim. See Timeout resolution.
thread_namestr"praisonai-sync-offload"Name of the offload worker thread (only used under the PRAISONAI_ALLOW_LOOP_BLOCKING opt-in).

Called from a plain sync caller, it dispatches to the active AsyncBridge via run_sync, sharing its background loop and connection pools. Called from inside a running loop with PRAISONAI_ALLOW_LOOP_BLOCKING=true, it copies the caller’s ContextVars onto a worker thread that hands the coroutine to the same bridge — never a fresh asyncio.new_event_loop() — so a caller-installed scoped_bridge() binding still wins and LiteLLM/HTTPX per-loop connection pools are preserved. Exceptions re-raise on the caller thread.

The strict RuntimeError

In the default (strict) mode, a call from inside a running loop raises with this message — grep for it in a traceback:

text
run_sync_or_offload() would block the running event loop for up to 300s. From an async context, await one of:
  - praisonai.arun(...)
  - adapter.arun(...)
  - praisonai._async_bridge.arun_sync_or_offload(coro)
Set PRAISONAI_ALLOW_LOOP_BLOCKING=true to opt into the legacy blocking behaviour.

How it differs from run_sync

HelperPlain sync callerInside a running loop (default)Inside a running loop (PRAISONAI_ALLOW_LOOP_BLOCKING=true)
run_sync✅ works❌ RuntimeError❌ RuntimeError
run_sync_or_offload✅ works❌ RuntimeError (points at awaitable siblings)⚠️ offloads onto a worker thread + joins (blocks the loop for up to timeout+1s)
arun_sync_or_offload— (must be awaited)✅ non-blocking await; the loop stays responsive✅ non-blocking await

Migration

Any caller that today relies on offload-inside-a-loop must either (a) migrate to the awaitable sibling (await praisonai.arun(...), await adapter.arun(...), or await arun_sync_or_offload(...)), or (b) set PRAISONAI_ALLOW_LOOP_BLOCKING=true as an interim measure. See PR #4261.

Best Practices

Prefer arun / arun_sync_or_offload inside a loop

Inside a running loop, await the awaitable sibling so the loop stays responsive. The sync helper now raises RuntimeError by design rather than parking the loop.

python
# Good — loop stays responsive
answer = await arun_sync_or_offload(agent.astart(prompt))

# Raises RuntimeError inside a running loop (strict default)
answer = run_sync_or_offload(agent.astart(prompt))
Keep run_sync_or_offload for plain-sync surfaces

On a CLI or plain-script entry point there is no running loop, so run_sync_or_offload dispatches to run_sync and reuses the shared bridge and its connection pools.

python
from praisonai._async_bridge import run_sync_or_offload

def surface_entry(prompt: str) -> str:
    return run_sync_or_offload(agent.astart(prompt))
Set PRAISONAI_ALLOW_LOOP_BLOCKING=true only for controlled migration windows

The opt-in restores the pre-#4261 offload-and-join behaviour, which blocks the caller’s event loop for up to timeout + 1s. Use it only as a bounded interim measure while migrating a known-safe path to the awaitable sibling — never as a long-term default.

This helper landed in PraisonAI #3492; the strict-inside-a-loop default landed in PR #4261. Its callers (praisonai.auto, persistence.orchestrator, api.agent_invoke) are migrated onto it.

Related


arun_sync_or_offload — await from an async context

Use arun_sync_or_offload() from async callers (FastAPI/Starlette handlers, Jupyter cells, async tests) — await it instead of parking the loop thread with the sync helper.

Diagram

arun_sync_or_offload

⚡ Async caller

🌉 Shared bridge

🔁 Background loop

✅ await result

  1. Import and await
    python
    from praisonai._async_bridge import arun_sync_or_offload
    
    async def fetch(): return 42
    
    async def handler():
        value = await arun_sync_or_offload(fetch())
        return value
  2. Inside a FastAPI handler

    await, don’t park — the loop stays responsive while the agent runs:

    python
    from fastapi import FastAPI
    from praisonaiagents import Agent
    from praisonai._async_bridge import arun_sync_or_offload
    
    app = FastAPI()
    agent = Agent(name="Analyst", instructions="Answer briefly")
    
    @app.post("/ask")
    async def ask(q: str):
        answer = await arun_sync_or_offload(agent.astart(q))
        return {"answer": answer}

The user calls an async endpoint; the agent’s coroutine runs on the shared background bridge while the request loop stays free to serve other traffic.

Configuration Options

OptionTypeDefaultDescription
coroAwaitable[T]—The coroutine to await.
timeout`float \None`PRAISONAI_RUN_SYNC_TIMEOUT (300 s)Same env var as run_sync. Omitted → resolved per call; None → unbounded; number → verbatim (see Timeout resolution). On expiry the in-flight future is cancelled (fut.cancel()) and asyncio.TimeoutError re-raises.

Unlike run_sync_or_offload, this variant does not park the running loop thread. It submits coro to the active AsyncBridge and awaits the result, so the loop keeps serving other work. The coroutine runs on the shared background bridge loop — never a fresh asyncio.new_event_loop() — so a caller-installed scoped_bridge() binding still wins and per-loop LiteLLM/HTTPX connection pools are preserved. On timeout or cancellation, the in-flight future is cancelled before the exception re-raises.

Best Practices

Await it from async handlers, not run_sync_or_offload

Inside a running loop, run_sync_or_offload raises RuntimeError by default (and only offloads-and-blocks under PRAISONAI_ALLOW_LOOP_BLOCKING=true). arun_sync_or_offload awaits instead, so the loop keeps serving other requests.

python
# Good — loop stays responsive
answer = await arun_sync_or_offload(agent.astart(q))

# Raises RuntimeError inside a running loop (strict default)
answer = run_sync_or_offload(agent.astart(q))
Keep the sync helper for sync-only paths

Plain scripts and CLI entry points can’t await. Use run_sync_or_offload (or run_sync) there and reserve arun_sync_or_offload for code already inside a coroutine.

Related


Per-Session Scoped Bridge

Servers and gateways that handle multiple concurrent sessions need each session to run on its own loop+thread binding. current_bridge() and scoped_bridge() provide ContextVar-backed per-session isolation so sessions never share a bridge accidentally.

Diagram

Scoped Bridge Per Session

👤 Session A

🌉 scoped_bridge A

👤 Session B

🌉 scoped_bridge B

🔁 Loop A

🔁 Loop B

When to use scoped bridges

Use scoped_bridge() inside any request handler that may run concurrently with other handlers — for example a FastAPI endpoint, a Starlette WebSocket handler, or a custom bot session dispatcher.

The isolation now extends into sync-completion runner threads used by AutoGenerator.generate() — a scoped_bridge() set on the caller is honoured inside the worker thread via contextvars.copy_context().

  • Multi-tenant sync embedders no longer need a manual scoped_bridge() around praisonai workflow run (or a process: workflow YAML dispatched through generate_crew_and_kickoff()) — _run_yaml_workflow wraps workflow.start(...) in its own scoped_bridge().
python
from praisonai._async_bridge import AsyncBridge, scoped_bridge, current_bridge

async def handle_request(session_id: str, message: str) -> str:
    """Each concurrent request gets its own isolated bridge."""
    bridge = AsyncBridge()

    async with scoped_bridge(bridge):
        # All code in this block sees `current_bridge()` as `bridge`
        result = bridge.run_sync(process_message(session_id, message))
        return result

scoped_bridge() context manager

python
from praisonai._async_bridge import scoped_bridge, AsyncBridge

bridge = AsyncBridge()

async with scoped_bridge(bridge):
    # current_bridge() returns `bridge` inside this block
    ...

# Outside: current_bridge() returns None (or the enclosing scope's bridge)

The context manager uses a ContextVar so nested scopes work correctly in async tasks and threads — each concurrent task sees only its own bridge.

Scope-owned bridge (preferred)

python
from praisonaiagents import Agent
from praisonai._async_bridge import scoped_bridge, run_sync

def handle_session(session_id: str, prompt: str) -> str:
    with scoped_bridge() as bridge:
        data = run_sync(fetch_session_data(session_id))
        agent = Agent(name="Assistant", instructions=f"Context: {data}")
        return agent.start(prompt)
    # bridge.shutdown(permanent=True) ran automatically — do not reuse `bridge` here

With no argument, scoped_bridge() creates a fresh bridge and tears it down with permanent=True when the with block ends. Internal code that calls module-level run_sync() inside the block uses the scoped bridge via contextvars — no import changes required.

current_bridge() for introspection

current_bridge() returns the bridge bound to the current async task, or None when no scope is active. Use it to inspect which bridge is in use without passing it explicitly through call stacks.

python
from praisonai._async_bridge import current_bridge

def my_util():
    bridge = current_bridge()
    if bridge is None:
        raise RuntimeError("No scoped bridge active — wrap with scoped_bridge()")
    return bridge.run_sync(some_coroutine())

Multi-session server example

python
import asyncio
from praisonaiagents import Agent
from praisonai._async_bridge import AsyncBridge, scoped_bridge

agent = Agent(name="server-agent", instructions="Respond to requests.")

async def handle_session(session_id: str, user_message: str) -> str:
    bridge = AsyncBridge()
    async with scoped_bridge(bridge):
        result = bridge.run_sync(agent.achat(user_message))
        bridge.shutdown()
    return result

async def main():
    sessions = [
        handle_session("user-1", "Hello from session 1"),
        handle_session("user-2", "Hello from session 2"),
    ]
    results = await asyncio.gather(*sessions)
    for r in results:
        print(r)

asyncio.run(main())

API Reference

SymbolSignatureDescription
scoped_bridge(bridge: Optional[AsyncBridge] = None) -> Iterator[AsyncBridge]Context manager. Binds a per-scope bridge via ContextVar. With None, creates and owns a fresh bridge (shut down with permanent=True on exit). With a caller-provided bridge, the caller retains lifecycle ownership.
current_bridge() -> AsyncBridgeReturns the bridge bound by an enclosing scoped_bridge() block, else the shared default.

async_scoped_bridge() — async-safe context manager

async_scoped_bridge() is the async-safe sibling of scoped_bridge() for async def callers — same binding semantics, but the scope-owned bridge is torn down off-loop so scope exit never parks the caller’s event loop.

Wrap your async handler in one line — a stuck coroutine in one tenant’s request can no longer freeze the loop for every other tenant:

python
from praisonai._async_bridge import async_scoped_bridge

async def handle(...):
    async with async_scoped_bridge():
        ...
Diagram

N async requests, N scoped bridges

👤 Request A

🌉 scoped_bridge A

👤 Request B

🌉 scoped_bridge B

🧵 teardown → worker thread

🧵 teardown → worker thread

When to use it

Pick the context manager that matches your caller.

Diagram

No — CLI, sync handler

Yes — FastAPI, async gateway

Inside an
async def?

🔒 scoped_bridge

⚡ async_scoped_bridge

  • scoped_bridge() — sync callers (CLI, generate_crew_and_kickoff, sync request handlers).
  • async_scoped_bridge() — inside async def (FastAPI handlers, agenerate_crew_and_kickoff, custom async gateways). Preferred over the sync sibling for any async def, because scope exit on the sync context manager parks the loop thread for up to ~10s while shutdown() waits for cancellation and joins the background thread.

FastAPI multi-tenant handler

python
from fastapi import FastAPI
from praisonaiagents import Agent
from praisonai._async_bridge import async_scoped_bridge

app = FastAPI()

@app.post("/tenants/{tenant_id}/ask")
async def ask(tenant_id: str, prompt: str):
    # Per-request isolated bridge — a stuck coroutine here cannot
    # park the loop for other tenants.
    async with async_scoped_bridge():
        agent = Agent(name=f"agent-{tenant_id}", instructions="Answer briefly")
        return {"answer": await agent.astart(prompt)}

Teardown internals

A scope-owned bridge (no argument) is torn down via asyncio.to_thread(bridge.shutdown, permanent=True) under asyncio.shield, so the blocking cancel-and-join runs on a worker thread instead of the event loop. asyncio.shield keeps that teardown running to completion even when the surrounding scope is being cancelled, so the loop+thread is neither leaked nor blocked. A caller-provided bridge is left untouched — the caller owns its lifecycle.

Behavioural contract

GuaranteeWhat it means
Binds its own bridgeInside the scope, current_bridge() resolves to the bridge this block owns.
Teardown never blocks the loopA slow shutdown() (~0.3s) does not park the loop; concurrent tasks keep advancing.
Owned bridge is poisoned on exitA scope-owned bridge is permanent=True after exit; a leaked context cannot resurrect it.
Caller-provided bridge untouchedA bridge passed in as an argument is never shut down — the caller retains ownership.

API Reference

SymbolSignatureDescription
async_scoped_bridge(bridge: Optional[AsyncBridge] = None) -> AsyncContextManager[AsyncBridge]Async-safe context manager. Same binding semantics as scoped_bridge(). A scope-owned bridge (no argument) is torn down via asyncio.to_thread(bridge.shutdown, permanent=True) under asyncio.shield, so scope exit — normal, error, or cancellation — never parks the caller’s event loop. A caller-provided bridge is left untouched (caller owns lifecycle).

Per-run auto-scoping (sequential, hierarchical, workflow)

Both generate_crew_and_kickoff() (sync) and agenerate_crew_and_kickoff() (async) now auto-wrap each run in a per-run scoped bridge — the sync path uses scoped_bridge(), the async path uses async_scoped_bridge(). Every run isolates its run_sync-driven work onto its own loop+thread, so a stuck coroutine in one agent/tenant can no longer park the shared default loop for the rest.

Each entry point has two arms — a sequential/hierarchical arm and a YAML-workflow arm — so there are four corners in total. All four are now symmetric.

The same per-run scoping now covers process: workflow on the sync path. _run_yaml_workflow (invoked by praisonai workflow run ... and AgentsGenerator.generate_crew_and_kickoff() when the YAML’s process: is workflow) wraps workflow.start(...) in scoped_bridge(). The async workflow path (_arun_yaml_workflow) already runs on the caller’s loop and remains unchanged.

Diagram

Async — async_scoped_bridge()

👤 sequential / hierarchical
agenerate_crew_and_kickoff

🌉 async_scoped_bridge

👤 YAML workflow
agenerate_crew_and_kickoff

🌉 async_scoped_bridge

🔁 own loop

🔁 own loop

Sync — scoped_bridge()

👤 sequential / hierarchical
generate_crew_and_kickoff

🌉 scoped_bridge

👤 YAML workflow
_run_yaml_workflow — new #5360

🌉 scoped_bridge

🔁 own loop

🔁 own loop

PR #5360 closes the last asymmetry: the sync YAML workflow arm now wraps its _run_yaml_workflow(config) call in scoped_bridge(), matching the other three corners so a stuck coroutine in one tenant’s YAML workflow can no longer park the shared default loop for the rest.

Callers doing multi-tenant runs no longer need to add their own scoped_bridge() around either entry point — with this PR that is now true for all four corners — a stuck coroutine in one run cannot affect another. On the async path this closes a live multi-tenancy regression: praisonai serve, the gateway, and FastAPI-embed deployments no longer let a stuck coroutine in one tenant park the shared default loop+thread for the others. The earlier reasoning that async callers await arun directly did not hold in practice — adapter internals, user tools, the sync DeliveryRouter._finalize_delivery, and BlueprintAgent.start() (via run_sync(...)) all reach back into the sync bridge, which previously resolved to the process-default bridge under the async path.

See Wrapper → Lifecycle / cleanup for the embedder view.


CLI leaves

Every CLI leaf in praisonai that owns a top-level coroutine routes it through run_cli_coro() instead of a bare asyncio.run(...). This keeps the AsyncBridge contract (per-loop pools, embedder scoped_bridge()) honored end to end.

CLIRoutes through run_cli_coro?
praisonai app (AgentOS serve)✅
praisonai acp serve✅ (since PraisonAI PR #5643)
praisonai sandbox …✅
praisonai background …✅

Adding a new CLI entry point that owns a top-level coroutine? Use from praisonai._async_bridge import run_cli_coro and avoid asyncio.run at the leaf — a grep -rn "asyncio.run(" src/praisonai/praisonai/ outside _async_bridge.py must stay at zero.


Related