Files
PraisonAIUI/examples/python/_shared/stream_bridge.py
T
c4384fd818 fix: thread-safe stream bridge for agent streaming (fixes #68) (#93)
* feat: add StreamBridge for thread-safe async streaming (fixes #68)

- Create StreamBridge abstraction in examples/_shared/ with thread-safe async-sync bridge
- Fix examples 07, 08, 13 to use call_soon_threadsafe instead of unsafe put_nowait
- Fix example 07 lifecycle ordering bug by concurrent event draining
- Add comprehensive unit tests for StreamBridge functionality
- All examples now use consistent thread-safe streaming pattern

Co-authored-by: Mervin Praison <[email protected]>

* fix: resolve thread-safety and performance issues in StreamBridge

- Initialize asyncio.Queue in __init__ to ensure thread-safe creation
- Remove race condition by eliminating lazy initialization
- Fix 1.0-second performance delay in provider example using sentinel pattern
- Remove unused imports and StreamBridge instance in provider
- Update tests to match new initialization behavior
- All tests pass and linting clean

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-06-15 15:46:15 +01:00

122 lines
4.0 KiB
Python

"""StreamBridge — Thread-safe async-sync bridge for agent streaming.
Solves the concurrency architecture issue where PraisonAI agent execution
runs on worker threads while PraisonAIUI chat runs on asyncio. Token streaming
crosses this boundary via callbacks that must be thread-safe.
Usage:
bridge = StreamBridge()
# In agent callback (worker thread):
bridge.emit_token(token)
# In asyncio handler:
async for token in bridge.consume():
await aiui.stream_token(token)
"""
import asyncio
import threading
from typing import Any, AsyncIterator, Callable
class StreamBridge:
"""Thread-safe bridge for streaming tokens from worker threads to asyncio."""
def __init__(self, loop: asyncio.AbstractEventLoop | None = None):
"""Initialize the bridge.
Args:
loop: Event loop to use. If None, uses the current running loop.
"""
if loop is None:
try:
loop = asyncio.get_running_loop()
except RuntimeError:
raise RuntimeError("No running event loop found. StreamBridge must be initialized from an async context (or pass an explicit loop).")
self._loop = loop
self._queue = asyncio.Queue()
self._cancelled = False
self._lock = threading.Lock()
def emit_token(self, token: str) -> None:
"""Emit a token from a worker thread (thread-safe).
Args:
token: Token to emit, or None to signal end of stream.
"""
with self._lock:
if self._cancelled:
return
# Use call_soon_threadsafe for thread-safe queue operations
self._loop.call_soon_threadsafe(self._queue.put_nowait, token)
def emit_end(self) -> None:
"""Signal end of stream (thread-safe)."""
self.emit_token(None)
async def consume(self, timeout: float = 120.0) -> AsyncIterator[str]:
"""Consume tokens asynchronously.
Args:
timeout: Timeout in seconds for each token.
Yields:
Tokens as they arrive. Stops when None is received (end signal).
"""
while not self._cancelled:
try:
token = await asyncio.wait_for(self._queue.get(), timeout=timeout)
except asyncio.TimeoutError:
break
if token is None: # End of stream sentinel
break
yield token
def cancel(self) -> None:
"""Cancel the bridge and stop token emission (thread-safe)."""
with self._lock:
self._cancelled = True
# Signal any waiting consumers
self._loop.call_soon_threadsafe(self._queue.put_nowait, None)
def emitter_callback(self) -> Callable[[Any], None]:
"""Create a callback function for agent stream emitters.
Returns:
Callback function that can be passed to agent.stream_emitter.add_callback()
"""
def callback(event):
"""Handle stream events from PraisonAI agent."""
try:
from praisonaiagents.streaming.events import StreamEventType
if event.type == StreamEventType.DELTA_TEXT and event.content:
self.emit_token(event.content)
elif event.type == StreamEventType.FIRST_TOKEN and event.content:
self.emit_token(event.content)
elif event.type == StreamEventType.STREAM_END:
self.emit_end()
except (ImportError, AttributeError):
# Fallback for other event types or missing dependencies
if hasattr(event, 'content') and event.content:
self.emit_token(event.content)
return callback
def create_stream_bridge(loop: asyncio.AbstractEventLoop | None = None) -> StreamBridge:
"""Create a new StreamBridge instance.
Args:
loop: Event loop to use. If None, uses the current running loop.
Returns:
New StreamBridge instance.
"""
return StreamBridge(loop)