mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 09:28:34 +00:00
236 lines
6.9 KiB
Python
236 lines
6.9 KiB
Python
"""Typed outbound events carried by :class:`OutboundMessage`.
|
|
|
|
The message bus still transports :class:`nanobot.bus.events.OutboundMessage`
|
|
because channels need chat routing fields. Runtime/UI semantics live on the
|
|
message's explicit ``event`` field rather than in reserved metadata flags.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Mapping
|
|
from dataclasses import dataclass, replace
|
|
from typing import Any
|
|
|
|
from nanobot.bus.events import OutboundMessage
|
|
|
|
|
|
class OutboundEvent:
|
|
"""Marker base for internal outbound runtime events."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ProgressEvent(OutboundEvent):
|
|
content: str = ""
|
|
tool_hint: bool = False
|
|
reasoning: bool = False
|
|
reasoning_delta: bool = False
|
|
reasoning_end: bool = False
|
|
stream_id: str | None = None
|
|
tool_events: list[dict[str, Any]] | None = None
|
|
file_edit_events: list[dict[str, Any]] | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RetryWaitEvent(OutboundEvent):
|
|
content: str = ""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class StreamDeltaEvent(OutboundEvent):
|
|
content: str = ""
|
|
stream_id: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class StreamEndEvent(OutboundEvent):
|
|
content: str = ""
|
|
stream_id: str | None = None
|
|
resuming: bool = False
|
|
merge_next: bool = False
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class StreamedResponseEvent(OutboundEvent):
|
|
pass
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TurnEndEvent(OutboundEvent):
|
|
latency_ms: int | None = None
|
|
goal_state: dict[str, Any] | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class GoalStatusEvent(OutboundEvent):
|
|
status: str
|
|
started_at: float | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class GoalStateSyncEvent(OutboundEvent):
|
|
goal_state: dict[str, Any]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SessionUpdatedEvent(OutboundEvent):
|
|
scope: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RuntimeModelUpdatedEvent(OutboundEvent):
|
|
model: str | None
|
|
model_preset: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TurnModelUpdatedEvent(OutboundEvent):
|
|
"""The fallback model currently handling one chat turn."""
|
|
|
|
model: str
|
|
|
|
|
|
def outbound_message_for_event(
|
|
*,
|
|
channel: str,
|
|
chat_id: str,
|
|
event: OutboundEvent,
|
|
content: str | None = None,
|
|
metadata: Mapping[str, Any] | None = None,
|
|
) -> OutboundMessage:
|
|
"""Build an :class:`OutboundMessage` for a typed event."""
|
|
|
|
return OutboundMessage(
|
|
channel=channel,
|
|
chat_id=chat_id,
|
|
content=_event_content(event) if content is None else content,
|
|
event=event,
|
|
metadata=dict(metadata or {}),
|
|
)
|
|
|
|
|
|
def outbound_event_from_message(msg: OutboundMessage) -> OutboundEvent | None:
|
|
"""Return the typed outbound event carried by *msg*, if any."""
|
|
|
|
if msg.event is not None:
|
|
return msg.event
|
|
return _legacy_event_from_metadata(msg)
|
|
|
|
|
|
def replace_outbound_event(
|
|
msg: OutboundMessage,
|
|
event: OutboundEvent,
|
|
*,
|
|
content: str | None = None,
|
|
) -> OutboundMessage:
|
|
"""Return *msg* with a new event and optional content."""
|
|
|
|
return replace(
|
|
msg,
|
|
content=_event_content(event) if content is None else content,
|
|
event=event,
|
|
)
|
|
|
|
|
|
def _event_content(event: OutboundEvent) -> str:
|
|
if isinstance(event, ProgressEvent | RetryWaitEvent | StreamDeltaEvent | StreamEndEvent):
|
|
return event.content
|
|
return ""
|
|
|
|
|
|
def _legacy_event_from_metadata(msg: OutboundMessage) -> OutboundEvent | None:
|
|
"""Bridge pre-typed outbound metadata flags into typed events.
|
|
|
|
New code should set ``OutboundMessage.event`` directly. The fallback keeps
|
|
older in-process extensions and channel plugins from losing runtime events
|
|
while they migrate off reserved metadata flags.
|
|
"""
|
|
|
|
meta = msg.metadata or {}
|
|
if meta.get("_runtime_model_updated"):
|
|
return RuntimeModelUpdatedEvent(
|
|
model=_metadata_str(meta, "model"),
|
|
model_preset=_metadata_str(meta, "model_preset"),
|
|
)
|
|
if meta.get("_goal_state_sync"):
|
|
goal_state = meta.get("goal_state")
|
|
return GoalStateSyncEvent(goal_state if isinstance(goal_state, dict) else {"active": False})
|
|
if meta.get("_goal_status"):
|
|
status = meta.get("goal_status")
|
|
if not isinstance(status, str) or not status:
|
|
return None
|
|
return GoalStatusEvent(
|
|
status=status,
|
|
started_at=_metadata_float(meta, "started_at", "goal_started_at"),
|
|
)
|
|
if meta.get("_turn_end"):
|
|
goal_state = meta.get("goal_state")
|
|
return TurnEndEvent(
|
|
latency_ms=_metadata_int(meta, "latency_ms"),
|
|
goal_state=goal_state if isinstance(goal_state, dict) else None,
|
|
)
|
|
if meta.get("_session_updated"):
|
|
return SessionUpdatedEvent(scope=_metadata_str(meta, "_session_update_scope"))
|
|
if meta.get("_retry_wait"):
|
|
return RetryWaitEvent(content=msg.content)
|
|
if meta.get("_stream_end"):
|
|
return StreamEndEvent(
|
|
content=msg.content,
|
|
stream_id=_metadata_str(meta, "_stream_id"),
|
|
resuming=bool(meta.get("_resuming")),
|
|
merge_next=bool(meta.get("_merge_next")),
|
|
)
|
|
if meta.get("_stream_delta"):
|
|
return StreamDeltaEvent(
|
|
content=msg.content,
|
|
stream_id=_metadata_str(meta, "_stream_id"),
|
|
)
|
|
if meta.get("_streamed"):
|
|
return StreamedResponseEvent()
|
|
if (
|
|
meta.get("_progress")
|
|
or meta.get("_reasoning_delta")
|
|
or meta.get("_reasoning_end")
|
|
or meta.get("_reasoning")
|
|
or meta.get("_file_edit_events")
|
|
or meta.get("_tool_events")
|
|
):
|
|
tool_events = meta.get("_tool_events")
|
|
file_edit_events = meta.get("_file_edit_events")
|
|
return ProgressEvent(
|
|
content=msg.content,
|
|
tool_hint=bool(meta.get("_tool_hint")),
|
|
reasoning=bool(meta.get("_reasoning")),
|
|
reasoning_delta=bool(meta.get("_reasoning_delta")),
|
|
reasoning_end=bool(meta.get("_reasoning_end")),
|
|
stream_id=_metadata_str(meta, "_stream_id"),
|
|
tool_events=tool_events if isinstance(tool_events, list) else None,
|
|
file_edit_events=file_edit_events if isinstance(file_edit_events, list) else None,
|
|
)
|
|
return None
|
|
|
|
|
|
def _metadata_str(meta: Mapping[str, Any], key: str) -> str | None:
|
|
value = meta.get(key)
|
|
return value if isinstance(value, str) and value else None
|
|
|
|
|
|
def _metadata_int(meta: Mapping[str, Any], key: str) -> int | None:
|
|
value = meta.get(key)
|
|
if isinstance(value, bool):
|
|
return None
|
|
if isinstance(value, int):
|
|
return value
|
|
if isinstance(value, float) and value.is_integer():
|
|
return int(value)
|
|
return None
|
|
|
|
|
|
def _metadata_float(meta: Mapping[str, Any], *keys: str) -> float | None:
|
|
for key in keys:
|
|
value = meta.get(key)
|
|
if isinstance(value, bool):
|
|
continue
|
|
if isinstance(value, int | float):
|
|
return float(value)
|
|
return None
|