"""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 TYPE_CHECKING, Any from loguru import logger from nanobot.bus.events import InboundMessage if TYPE_CHECKING: from nanobot.utils.llm_runtime import LLMRuntime @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) attributes: 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 TurnRuntimeAdmitted: """The immutable model runtime selected for one admitted turn.""" context: RuntimeEventContext runtime: LLMRuntime @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: LLMRuntime | None = None @dataclass(frozen=True) class SessionTurnPersisted: """A completed turn has been written to local session storage.""" context: RuntimeEventContext turn_id: str sender_id: str @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 | TurnRuntimeAdmitted | SessionTurnPersisted | TurnRunStatusChanged | TurnCompleted | GoalStateChanged | RuntimeModelChanged ) RuntimeEventType = ( type[SessionTurnStarted] | type[TurnRuntimeAdmitted] | type[SessionTurnPersisted] | type[TurnRunStatusChanged] | type[TurnCompleted] | type[GoalStateChanged] | type[RuntimeModelChanged] ) RuntimeEventHandler = Callable[[Any], Awaitable[None] | None] _HandlerEntry = tuple[RuntimeEventType | None, RuntimeEventHandler] 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[_HandlerEntry] = [] def subscribe( self, handler: RuntimeEventHandler, event_type: RuntimeEventType | None = None, ) -> Callable[[], None]: entry = (event_type, handler) self._handlers.append(entry) def _unsubscribe() -> None: with contextlib.suppress(ValueError): self._handlers.remove(entry) return _unsubscribe async def publish(self, event: RuntimeEvent) -> None: for event_type, handler in list(self._handlers): if event_type is not None and not isinstance(event, event_type): continue 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)) class RuntimeEventPublisher: """Convenience publisher for turn-scoped runtime events. Agent code should decide when state transitions happen; this helper owns the mechanics of building event contexts and carrying per-turn metadata. """ def __init__(self, bus: RuntimeEventBus | None = None) -> None: self.bus = bus or RuntimeEventBus() self._turn_latency_ms: dict[str, int] = {} self._turn_runtime: dict[str, LLMRuntime] = {} @staticmethod def _context( *, channel: str, chat_id: str, session_key: str, metadata: dict[str, Any] | None, attributes: dict[str, Any] | None = None, ) -> RuntimeEventContext: return RuntimeEventContext( channel=channel, chat_id=chat_id, session_key=session_key, metadata=dict(metadata or {}), attributes=dict(attributes or {}), ) def record_turn_runtime(self, session_key: str, runtime: LLMRuntime) -> None: self._turn_runtime[session_key] = runtime def record_turn_latency(self, session_key: str, latency_ms: int | None) -> None: if latency_ms is not None: self._turn_latency_ms[session_key] = int(latency_ms) def clear_turn(self, session_key: str) -> None: self._turn_latency_ms.pop(session_key, None) self._turn_runtime.pop(session_key, None) async def session_turn_started( self, msg: InboundMessage, session_key: str, ) -> None: await self.bus.publish( SessionTurnStarted( context=self._context( channel=msg.channel, chat_id=msg.chat_id, session_key=session_key, metadata=msg.metadata, ) ) ) async def turn_runtime_admitted( self, msg: InboundMessage, session_key: str, runtime: LLMRuntime, ) -> None: """Record and publish the runtime selected for one turn.""" self.record_turn_runtime(session_key, runtime) await self.bus.publish( TurnRuntimeAdmitted( context=self._context( channel=msg.channel, chat_id=msg.chat_id, session_key=session_key, metadata=msg.metadata, ), runtime=runtime, ) ) async def run_status_changed( self, msg: InboundMessage, session_key: str, status: str, *, started_at: float | None = None, ) -> None: await self.bus.publish( TurnRunStatusChanged( context=self._context( channel=msg.channel, chat_id=msg.chat_id, session_key=session_key, metadata=msg.metadata, ), status=status, started_at=started_at, ) ) async def session_turn_persisted( self, msg: InboundMessage, session_key: str, *, turn_id: str, attributes: dict[str, Any] | None = None, ) -> None: await self.bus.publish( SessionTurnPersisted( context=self._context( channel=msg.channel, chat_id=msg.chat_id, session_key=session_key, metadata=msg.metadata, attributes=attributes, ), turn_id=turn_id, sender_id=msg.sender_id, ) ) async def turn_completed( self, *, channel: str, chat_id: str, session_key: str, metadata: dict[str, Any] | None, ) -> None: await self.bus.publish( TurnCompleted( context=self._context( channel=channel, chat_id=chat_id, session_key=session_key, metadata=metadata, ), latency_ms=self._turn_latency_ms.pop(session_key, None), runtime=self._turn_runtime.pop(session_key, None), ) ) def runtime_model_changed(self, model: str, model_preset: str | None) -> None: self.bus.publish_nowait( RuntimeModelChanged(model=model, model_preset=model_preset) )