AsyncEventBridge bridges sync EventEmitter to asyncio consumers. FastAPI app with session management, discussion control, roles/providers. WebSocket for real-time events + inject, SSE for read-only streaming. Minimal dark-theme HTML/JS UI. `meeting-room serve` CLI subcommand. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
247 lines
7.6 KiB
Python
247 lines
7.6 KiB
Python
"""EventEmitter and event types for the Meeting Room discussion engine.
|
|
|
|
Synchronous event bus with AsyncEventBridge for forwarding events
|
|
to asyncio consumers (WebSocket / SSE).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import threading
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from typing import Any, Callable
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Event type constants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
DISCUSSION_START = "discussion_start"
|
|
ROUND_START = "round_start"
|
|
AGENT_TURN_START = "agent_turn_start"
|
|
AGENT_MESSAGE = "agent_message"
|
|
TOOL_CALL = "tool_call"
|
|
TOOL_RESULT = "tool_result"
|
|
AGENT_ERROR = "agent_error"
|
|
ROUND_END = "round_end"
|
|
DISCUSSION_END = "discussion_end"
|
|
BOSS_TURN = "boss_turn"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Event dataclass
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class Event:
|
|
"""A single event emitted by the discussion engine."""
|
|
|
|
type: str
|
|
data: dict[str, Any] = field(default_factory=dict)
|
|
timestamp: datetime = field(default_factory=datetime.now)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# EventEmitter
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Type alias for handler callbacks
|
|
Handler = Callable[[Event], None]
|
|
|
|
|
|
class EventEmitter:
|
|
"""Synchronous pub/sub event bus.
|
|
|
|
Usage::
|
|
|
|
bus = EventEmitter()
|
|
|
|
def on_message(event: Event):
|
|
print(event.data)
|
|
|
|
bus.on(AGENT_MESSAGE, on_message)
|
|
bus.emit(AGENT_MESSAGE, text="Hello", agent="Alice")
|
|
bus.off(AGENT_MESSAGE, on_message)
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._handlers: dict[str, list[Handler]] = {}
|
|
|
|
# -- subscription --------------------------------------------------------
|
|
|
|
def on(self, event_type: str, handler: Handler) -> Handler:
|
|
"""Subscribe *handler* to *event_type*.
|
|
|
|
Returns the handler so callers can use ``off`` with the same
|
|
reference, or chain calls.
|
|
"""
|
|
self._handlers.setdefault(event_type, []).append(handler)
|
|
return handler
|
|
|
|
def off(self, event_type: str, handler: Handler) -> None:
|
|
"""Unsubscribe *handler* from *event_type*.
|
|
|
|
Silently ignores unknown event types or missing handlers.
|
|
"""
|
|
handlers = self._handlers.get(event_type)
|
|
if handlers is None:
|
|
return
|
|
try:
|
|
handlers.remove(handler)
|
|
except ValueError:
|
|
pass
|
|
# Clean up empty lists to avoid unbounded growth
|
|
if not handlers:
|
|
del self._handlers[event_type]
|
|
|
|
# -- emission ------------------------------------------------------------
|
|
|
|
def emit(self, event_type: str, **data: Any) -> Event:
|
|
"""Create an Event and call every subscribed handler synchronously.
|
|
|
|
Handlers that raise exceptions are caught and logged; the emitter
|
|
never crashes due to a bad handler.
|
|
|
|
Returns the Event that was emitted (useful for testing).
|
|
"""
|
|
event = Event(type=event_type, data=data)
|
|
handlers = self._handlers.get(event_type, [])
|
|
for handler in handlers:
|
|
try:
|
|
handler(event)
|
|
except Exception:
|
|
logger.exception(
|
|
"Handler %r raised for event %s",
|
|
handler,
|
|
event_type,
|
|
)
|
|
return event
|
|
|
|
# -- introspection -------------------------------------------------------
|
|
|
|
def handlers(self, event_type: str) -> list[Handler]:
|
|
"""Return a *copy* of the handler list for *event_type*."""
|
|
return list(self._handlers.get(event_type, []))
|
|
|
|
def clear(self) -> None:
|
|
"""Remove all subscriptions."""
|
|
self._handlers.clear()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AsyncEventBridge
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# All event types the bridge forwards.
|
|
_ALL_EVENT_TYPES = [
|
|
DISCUSSION_START,
|
|
ROUND_START,
|
|
AGENT_TURN_START,
|
|
AGENT_MESSAGE,
|
|
TOOL_CALL,
|
|
TOOL_RESULT,
|
|
AGENT_ERROR,
|
|
ROUND_END,
|
|
DISCUSSION_END,
|
|
BOSS_TURN,
|
|
]
|
|
|
|
|
|
class AsyncEventBridge:
|
|
"""Bridges synchronous EventEmitter events to asyncio consumers.
|
|
|
|
Subscribes to all event types on a sync ``EventEmitter`` and forwards
|
|
each event into ``asyncio.Queue`` objects — one per consumer. Designed
|
|
for running ``DiscussionEngine`` in a worker thread via
|
|
``asyncio.to_thread()`` while WebSocket / SSE handlers await events
|
|
on the async side.
|
|
|
|
Usage::
|
|
|
|
bus = EventEmitter()
|
|
bridge = AsyncEventBridge(bus)
|
|
bridge.start(asyncio.get_event_loop())
|
|
|
|
# In an async handler:
|
|
queue = bridge.subscribe()
|
|
try:
|
|
while True:
|
|
event = await queue.get()
|
|
await websocket.send_json(event)
|
|
finally:
|
|
bridge.unsubscribe(queue)
|
|
|
|
# Run engine in a thread:
|
|
await asyncio.to_thread(engine.run, scenario)
|
|
|
|
# Cleanup:
|
|
bridge.stop()
|
|
"""
|
|
|
|
def __init__(self, event_bus: EventEmitter) -> None:
|
|
self._bus = event_bus
|
|
self._loop: asyncio.AbstractEventLoop | None = None
|
|
self._queues: list[asyncio.Queue[dict]] = []
|
|
self._lock = threading.Lock()
|
|
self._handler = self._on_sync_event
|
|
|
|
# -- lifecycle -----------------------------------------------------------
|
|
|
|
def start(self, loop: asyncio.AbstractEventLoop) -> None:
|
|
"""Subscribe to all event types on the bus."""
|
|
self._loop = loop
|
|
for event_type in _ALL_EVENT_TYPES:
|
|
self._bus.on(event_type, self._handler)
|
|
|
|
def stop(self) -> None:
|
|
"""Unsubscribe from all event types."""
|
|
for event_type in _ALL_EVENT_TYPES:
|
|
self._bus.off(event_type, self._handler)
|
|
|
|
# -- consumer management ------------------------------------------------
|
|
|
|
def subscribe(self) -> asyncio.Queue[dict]:
|
|
"""Create a consumer queue that receives all events.
|
|
|
|
Call from async context. The caller owns the queue and must
|
|
call ``unsubscribe()`` when done.
|
|
"""
|
|
q: asyncio.Queue[dict] = asyncio.Queue(maxsize=1000)
|
|
with self._lock:
|
|
self._queues.append(q)
|
|
return q
|
|
|
|
def unsubscribe(self, q: asyncio.Queue[dict]) -> None:
|
|
"""Remove a consumer queue. Safe to call multiple times."""
|
|
with self._lock:
|
|
try:
|
|
self._queues.remove(q)
|
|
except ValueError:
|
|
pass
|
|
|
|
# -- internal ------------------------------------------------------------
|
|
|
|
def _on_sync_event(self, event: Event) -> None:
|
|
"""Sync handler called by EventEmitter on any thread."""
|
|
payload = {
|
|
"type": event.type,
|
|
"data": event.data,
|
|
"timestamp": event.timestamp.isoformat(),
|
|
}
|
|
if self._loop is None or not self._loop.is_running():
|
|
return
|
|
with self._lock:
|
|
queues = list(self._queues)
|
|
for q in queues:
|
|
self._loop.call_soon_threadsafe(self._safe_put, q, payload)
|
|
|
|
@staticmethod
|
|
def _safe_put(q: asyncio.Queue[dict], payload: dict) -> None:
|
|
"""Put payload into queue, dropping if full."""
|
|
try:
|
|
q.put_nowait(payload)
|
|
except asyncio.QueueFull:
|
|
logger.warning("AsyncEventBridge: dropping event, queue full") |