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.
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.
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.
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
- From a sync tool
Use
run_coroutine_from_any_contextto 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.
- From an async tool
Use
run_sync_in_executorto 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], ) - Detecting the context
Use
is_async_contextto 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
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:
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(...)andTask(...)return their real result inside a running loop. - Async task callbacks reached via
Task.execute_callback_syncrun 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_SUMMARIZEcontext 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 RuntimeErrorwrapped the worker’sfuture.result(), so a providerRuntimeErrorwas 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:
| Surface | What it is for you |
|---|---|
OpenAIAdapter.chat_completion | Sync OpenAI adapter call inside a running loop |
Agent._execute_backend_sync | Sync agent delegating to an async backend |
call_executor._resolve_value | Sync tool returning an awaitable |
agents._resolve_coroutine_sync | async def sync-marked task handler on astart |
SharedCompute._run_sync | Shared compute from sync code |
ContextAggregator.aggregate_sync | Aggregating context from inside a loop |
compactor (no-loop path) | Context compaction sync path (in-loop naive fallback preserved) |
approval.run_coroutine_safely | Approval callback helper |
eval.run_trials | Trials engine sync entry point |
parallel_executor.execute_sync / execute_turn_sync | Fast parallel tool executor sync path |
lsp_tools._run_lsp | LSP tool runner |
session.title.generate_title | Session auto-title from sync code inside a loop (PR #6239) |
tools.crawl4ai_tools.crawl4ai_sync / crawl4ai_extract_sync | Crawl4AI 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:
| Option | Type | Default | Description | |
|---|---|---|---|---|
coro | Awaitable[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 passes | Effective timeout |
|---|---|
| Nothing (argument omitted) | _default_timeout() — reads PRAISONAI_RUN_SYNC_TIMEOUT, default 300.0 |
timeout=None explicitly | Unbounded 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
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
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
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.
# 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:
# 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:
# 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:
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 callPraisonAI.run()from inside a running event loop, you now get a clearRuntimeErrorinstead of a silent deadlock.- All ~77 wrapper-side
run_synccall sites (gateway, a2u, mcp_server, scheduler) — see PR #1583 for the full list. praisonai.auto.BaseAutoGenerator._structured_completion/_run_coro_syncrunner threads (added in the fix for #3340) — inherit the caller’sscoped_bridge()viacontextvars.copy_context().praisonai_code.cli.features.agent_tools._run_sync(added via PR #3361) — a separate, module-local bridge in the Tier-2praisonai-codepackage that ACP/LSP agent-centric tools route through. Does not importpraisonai._async_bridge(C7 gate), but reads the samePRAISONAI_RUN_SYNC_TIMEOUTenv var and enforces the same 300 s default. Safe under a running loop (offloads to aThreadPoolExecutor), 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},chatREPL/tasks {list, cancel, status}, and thedirect_promptexternal-agent pass-through — so per-loop connection pools survive across invocations instead of a freshasyncio.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.
| Symptom | Cause | Resolution |
|---|---|---|
TimeoutError raised but you also see your coroutine’s finally: block run after the exception | Expected: 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:
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)
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 bridgePer-instance AsyncBridge
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 defaultWhen to use a per-instance bridge
| Situation | Module-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 / Function | Signature | Description | ||
|---|---|---|---|---|
AsyncBridge | class AsyncBridge | Per-instance async runner; create one per tenant or service. | ||
AsyncBridge.run_sync | (self, coro, *, timeout=_UNSET) -> T | Run 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) -> None | Cancel 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.Future | Submit without waiting; returns a concurrent.futures.Future. | ||
AsyncBridge.get | (self) -> asyncio.AbstractEventLoop | Get (lazily spawn) the bridge’s event loop. | ||
run_sync (module-level) | (coro, *, timeout=_UNSET) -> T | Routes 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) -> Any | CLI-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) | () -> None | Shuts down only the shared default bridge. User-owned instances are untouched. | ||
DispatchKind | class DispatchKind(str, enum.Enum) — values READ, WRITE | Explicit “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 to300.0, and late-set env vars (dotenv loaded after import, per-request reconfig) are honoured. An explicittimeout=Noneopts into an unbounded wait (used by the sync-scheduler bridges for long-lived jobs). Read by bothpraisonai._async_bridge(this page) andpraisonai_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).
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)| Signature | Description | |
|---|---|---|
| `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.
# Before
import asyncio
asyncio.run(handler.list_tasks())
# After
from praisonai._async_bridge import run_cli_coro
run_cli_coro(handler.list_tasks())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 invocationMigrated CLI sites
Ten CLI subcommand leaves now dispatch through run_cli_coro() so repeated invocations reuse the background loop:
| Command | Module |
|---|---|
praisonai background {list, status, cancel, clear} | cli/features/background.py |
praisonai managed {list-check, stop} | cli/commands/managed.py |
praisonai standardise | cli/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_prompt | cli/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.
DispatchKind makes read-vs-write intent explicit at the call site instead of depending on a name-string allow-list. The four branches:
| Situation | Behaviour |
|---|---|
| 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. |
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 +READblocks and returns the value.test_no_loop_write_blocks_and_returns_value— no running loop +WRITEruns to completion (fire-and-forget is a running-loop concern).test_running_loop_write_is_tracked_fire_and_forget— running loop +WRITEsubmits, tracks, and returnsNonewhile the write still completes.test_running_loop_failed_write_logs_and_does_not_raise— a failed deferredWRITEis 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.
- 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 - Inside a FastAPI handler — await instead
A sync call inside a running loop now raises
RuntimeError. Make the handlerasyncandawaitthe 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,awaitthe 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
| Option | Type | Default | Description | |
|---|---|---|---|---|
coro | Awaitable[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_name | str | "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:
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
| Helper | Plain sync caller | Inside 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.
# 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.
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
Module-level runner for sync-only paths.
scoped_bridgePer-session bridge preserved across the offload hop when PRAISONAI_ALLOW_LOOP_BLOCKING=true.
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.
- 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 - 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
| Option | Type | Default | Description | |
|---|---|---|---|---|
coro | Awaitable[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.
# 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
Sync sibling for non-async callers.
scoped_bridgePer-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().
- Multi-tenant sync embedders no longer need a manual
scoped_bridge()aroundpraisonai workflow run(or aprocess: workflowYAML dispatched throughgenerate_crew_and_kickoff()) —_run_yaml_workflowwrapsworkflow.start(...)in its ownscoped_bridge().
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 resultscoped_bridge() context manager
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)
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` hereWith 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.
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
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
| Symbol | Signature | Description |
|---|---|---|
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 | () -> AsyncBridge | Returns 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:
from praisonai._async_bridge import async_scoped_bridge
async def handle(...):
async with async_scoped_bridge():
...When to use it
Pick the context manager that matches your caller.
scoped_bridge()— sync callers (CLI,generate_crew_and_kickoff, sync request handlers).async_scoped_bridge()— insideasync def(FastAPI handlers,agenerate_crew_and_kickoff, custom async gateways). Preferred over the sync sibling for anyasync def, because scope exit on the sync context manager parks the loop thread for up to ~10s whileshutdown()waits for cancellation and joins the background thread.
FastAPI multi-tenant handler
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
| Guarantee | What it means |
|---|---|
| Binds its own bridge | Inside the scope, current_bridge() resolves to the bridge this block owns. |
| Teardown never blocks the loop | A slow shutdown() (~0.3s) does not park the loop; concurrent tasks keep advancing. |
| Owned bridge is poisoned on exit | A scope-owned bridge is permanent=True after exit; a leaked context cannot resurrect it. |
| Caller-provided bridge untouched | A bridge passed in as an argument is never shut down — the caller retains ownership. |
API Reference
| Symbol | Signature | Description |
|---|---|---|
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.
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.
| CLI | Routes 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.