refactor: decouple webui runtime state via events
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
"""Runtime event bus for agent state notifications.
|
||||
|
||||
This bus is separate from :mod:`nanobot.bus.queue`: message bus events are
|
||||
user/chat delivery, while runtime events are in-process state notifications
|
||||
that optional subscribers such as WebUI adapters may render.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import inspect
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeEventContext:
|
||||
"""Routing context common to turn-scoped runtime events."""
|
||||
|
||||
channel: str
|
||||
chat_id: str
|
||||
session_key: str
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionTurnStarted:
|
||||
"""A user/system turn has loaded its session and is about to build context."""
|
||||
|
||||
context: RuntimeEventContext
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TurnRunStatusChanged:
|
||||
"""Visible run status changed for a turn."""
|
||||
|
||||
context: RuntimeEventContext
|
||||
status: str
|
||||
started_at: float | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TurnCompleted:
|
||||
"""A turn has delivered its final user-visible response."""
|
||||
|
||||
context: RuntimeEventContext
|
||||
latency_ms: int | None = None
|
||||
runtime: Any | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GoalStateChanged:
|
||||
"""A session's sustained-goal state changed."""
|
||||
|
||||
context: RuntimeEventContext
|
||||
session_metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeModelChanged:
|
||||
"""The active runtime model/preset changed."""
|
||||
|
||||
model: str
|
||||
model_preset: str | None
|
||||
|
||||
|
||||
RuntimeEvent = (
|
||||
SessionTurnStarted
|
||||
| TurnRunStatusChanged
|
||||
| TurnCompleted
|
||||
| GoalStateChanged
|
||||
| RuntimeModelChanged
|
||||
)
|
||||
RuntimeEventHandler = Callable[[RuntimeEvent], Awaitable[None] | None]
|
||||
|
||||
|
||||
class RuntimeEventBus:
|
||||
"""Small in-process pub/sub bus for runtime state.
|
||||
|
||||
Subscribers run in registration order. ``publish`` awaits async handlers so
|
||||
callers can preserve ordering when a runtime event must follow a user
|
||||
message. ``publish_nowait`` is available for synchronous call sites.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._handlers: list[RuntimeEventHandler] = []
|
||||
|
||||
def subscribe(self, handler: RuntimeEventHandler) -> Callable[[], None]:
|
||||
self._handlers.append(handler)
|
||||
|
||||
def _unsubscribe() -> None:
|
||||
with contextlib.suppress(ValueError):
|
||||
self._handlers.remove(handler)
|
||||
|
||||
return _unsubscribe
|
||||
|
||||
async def publish(self, event: RuntimeEvent) -> None:
|
||||
for handler in list(self._handlers):
|
||||
try:
|
||||
result = handler(event)
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
except Exception:
|
||||
logger.exception("runtime event handler failed for {}", type(event).__name__)
|
||||
|
||||
def publish_nowait(self, event: RuntimeEvent) -> None:
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
logger.debug("dropping runtime event without a running loop: {}", type(event).__name__)
|
||||
return
|
||||
loop.create_task(self.publish(event))
|
||||
Reference in New Issue
Block a user