Skip to main content
The async bridge lets your tools and callbacks move between sync and async without crashing the event loop.
The user calls a sync tool that needs async I/O; the bridge runs the coroutine safely or surfaces a clear error if called from a running loop.

Quick Start

1

From a sync tool

Use run_coroutine_from_any_context to call async code from a sync tool:
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:
3

Detecting the context

Use is_async_context to create dual-mode helpers:

How It Works

The bridge probes for a running event loop using asyncio.get_running_loop(). If no loop exists, it safely creates one with asyncio.run(). If a loop is already running, it raises RuntimeError to prevent deadlocks.

Configuration Options


Common Patterns

Reusing async SDKs from sync tools

Offloading blocking calls from async tools

Context-aware dual-mode helper


Best Practices

Calling run_coroutine_from_any_context inside an async def raises RuntimeError by design. If you’re in a coroutine, use await instead:
Only wrap at the true sync/async boundary. Avoid creating unnecessary bridge calls in the middle of your call stack:
The default 300 seconds is large for most use cases. Tighten for latency-critical tools:
When building utilities that work in both sync and async contexts, check the context first:

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.
These sync wrappers now raise RuntimeError("run_sync() cannot be called from a running event loop; await the coroutine directly instead.") when called from inside an active asyncio loop. Previously they would silently spawn a worker thread. If you call any of these from async code, switch to await request_approval(...) (or the equivalent async method) directly. This is a deliberate fail-fast change — the silent thread spawn was masking architectural bugs in multi-agent setups.PR #1692 — cancellation on timeout (May 2026). When a run_sync() call hits its timeout (default 300 s, or whatever PRAISONAI_RUN_SYNC_TIMEOUT is set to), the underlying coroutine is now actively cancelled on the background loop. The bridge waits up to 1 s for cancellation to propagate before re-raising TimeoutError. This means slow DB queries (SurrealDB, async MySQL), HTTP calls, and subprocess waits now release their connection / socket / pipe instead of leaking. Cancellation also fires on KeyboardInterrupt, SystemExit, and GeneratorExit.
The wrapper-layer bridge (praisonai._async_bridge) creates its background loop lazily on the first run_sync() call. Pure imports do not allocate a loop or thread. Calling the module-level shutdown() before any run_sync() is a safe no-op — it only affects the shared default bridge, not any AsyncBridge() instances you create yourself.

Troubleshooting

RuntimeError: run_coroutine_from_any_context() cannot be called from async context

You’re trying to use the bridge inside a coroutine. Use await instead:

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

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.

PermissionError in approval system

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

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.

When to use a per-instance bridge

API Reference: Environment:
Do not call run_sync from inside async def — use await instead. The function raises RuntimeError if called from within a running event loop to prevent deadlocks.The module-level shutdown() only stops the shared default bridge. Per-instance AsyncBridge objects must be shut down via bridge.shutdown() on each instance.
Used by:
  • CLI approval protocol (ACP/LSP tools)
  • Interactive runtime start/stop operations
  • Deployment scheduler
  • Gateway operations
See also: Approval Protocol and Gateway.

run_sync_or_offload — safe from any calling context

Use run_sync_or_offload() when your code path can be reached from either a normal script or from inside a running event loop (FastAPI, Jupyter, async tests) — one helper, no RuntimeError.
1

Import and call

2

Inside a FastAPI handler

Same helper, no code change — it just works:

Configuration Options

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

How it differs from run_sync

run_sync_or_offload() now bounds thread.join() at timeout + 1.0s. If the worker is still alive it raises TimeoutError("run_sync_or_offload() worker did not complete within {timeout}s"). The extra second covers thread hand-off/teardown so the join does not spuriously time out before the worker records its own error.

Best Practices

The caller’s context isn’t knowable at write time — the same code path can be reached from a plain script, a YAML run, or a Python call inside a running loop. run_sync_or_offload handles all three without a RuntimeError, while still reusing the shared bridge and its connection pools.
run_sync fails loudly inside a loop, which is what you want for a code path that must never be called from one — the RuntimeError surfaces an architectural bug instead of silently offloading.
This helper landed in PraisonAI #3492; its callers (praisonai.auto, persistence.orchestrator, api.agent_invoke) are migrated onto it.

run_sync

Module-level runner for sync-only paths.

scoped_bridge

Per-session bridge that run_sync_or_offload preserves across the offload hop.

Agent-Centric Tools

The Tier-2 _run_sync (PR #3361) mirrors the same design.

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

Import and await

2

Inside a FastAPI handler

await, don’t park — the loop stays responsive while the agent runs:
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

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

Inside a running loop, run_sync_or_offload offloads onto a worker thread and blocks the caller until the coroutine finishes. arun_sync_or_offload awaits instead, so the loop keeps serving other requests.
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.

run_sync_or_offload

Sync sibling for non-async callers.

scoped_bridge

Per-session bridge preserved across the await.

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.

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().

scoped_bridge() context manager

The context manager uses a ContextVar so nested scopes work correctly in async tasks and threads — each concurrent task sees only its own bridge.
When scoped_bridge() creates the bridge for you (no argument), it shuts down with permanent=True on exit. If code tries to call run_sync or submit on that bridge afterward, you get:RuntimeError: AsyncBridge has been shut down and cannot be reused; this usually means a context outlived its scoped_bridge() blockThat guard stops an orphaned loop and thread from outliving the scope that owned them. The shared default bridge always shuts down with permanent=False.

Scope-owned bridge (preferred)

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.

Multi-session server example

API Reference


Async Agents Guide
Thread Safety & Concurrency