refactor(bus): type outbound runtime events

This commit is contained in:
chengyongru
2026-07-01 20:17:00 +08:00
committed by Xubin Ren
parent f6d1dba32a
commit 5f4cfbcb16
45 changed files with 1206 additions and 741 deletions
+35 -23
View File
@@ -31,6 +31,13 @@ from nanobot.agent.tools.message import MessageTool
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.self import MyTool
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.outbound_events import (
RetryWaitEvent,
StreamDeltaEvent,
StreamedResponseEvent,
StreamEndEvent,
outbound_message_for_event,
)
from nanobot.bus.progress import build_bus_progress_callback
from nanobot.bus.queue import MessageBus
from nanobot.bus.runtime_events import (
@@ -567,14 +574,12 @@ class AgentLoop:
"""Build a retry-wait callback that publishes to the message bus."""
async def _on_retry_wait(content: str) -> None:
meta = dict(msg.metadata or {})
meta["_retry_wait"] = True
await self.bus.publish_outbound(
OutboundMessage(
outbound_message_for_event(
channel=msg.channel,
chat_id=msg.chat_id,
content=content,
metadata=meta,
event=RetryWaitEvent(content=content),
metadata=msg.metadata,
)
)
@@ -999,26 +1004,31 @@ class AgentLoop:
return f"{stream_base_id}:{stream_segment}"
async def on_stream(delta: str) -> None:
meta = dict(msg.metadata or {})
meta["_stream_delta"] = True
meta["_stream_id"] = _current_stream_id()
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id,
content=delta,
metadata=meta,
))
await self.bus.publish_outbound(
outbound_message_for_event(
channel=msg.channel,
chat_id=msg.chat_id,
event=StreamDeltaEvent(
content=delta,
stream_id=_current_stream_id(),
),
metadata=msg.metadata,
)
)
async def on_stream_end(*, resuming: bool = False) -> None:
nonlocal stream_segment
meta = dict(msg.metadata or {})
meta["_stream_end"] = True
meta["_resuming"] = resuming
meta["_stream_id"] = _current_stream_id()
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id,
content="",
metadata=meta,
))
await self.bus.publish_outbound(
outbound_message_for_event(
channel=msg.channel,
chat_id=msg.chat_id,
event=StreamEndEvent(
stream_id=_current_stream_id(),
resuming=resuming,
),
metadata=msg.metadata,
)
)
stream_segment += 1
response = await self._process_message(
@@ -1371,9 +1381,10 @@ class AgentLoop:
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview)
event = None
meta = dict(msg.metadata or {})
if on_stream is not None and stop_reason not in {"error", "tool_error"}:
meta["_streamed"] = True
event = StreamedResponseEvent()
if turn_latency_ms is not None:
meta["latency_ms"] = int(turn_latency_ms)
@@ -1381,6 +1392,7 @@ class AgentLoop:
channel=msg.channel,
chat_id=msg.chat_id,
content=final_content,
event=event,
metadata=meta,
)
+8 -4
View File
@@ -2,7 +2,10 @@
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from nanobot.bus.outbound_events import OutboundEvent
# Optional ``OutboundMessage.metadata`` key for structured, channel-agnostic UI
# payloads. Value is JSON-serializable with at least ``kind``; rich clients may
@@ -39,9 +42,9 @@ class InboundMessage:
class OutboundMessage:
"""Message to send to a chat channel.
``metadata`` can carry routing (``message_id``, …), trace flags (``_progress``),
and optional ``OUTBOUND_META_AGENT_UI`` blobs for rich clients; non-WebUI
channels may ignore unknown keys.
``event`` carries internal runtime/UI semantics. ``metadata`` is reserved
for channel routing context (``message_id``, thread ids, etc.) and optional
``OUTBOUND_META_AGENT_UI`` blobs for rich clients.
"""
channel: str
@@ -51,3 +54,4 @@ class OutboundMessage:
media: list[str] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict)
buttons: list[list[str]] = field(default_factory=list)
event: "OutboundEvent | None" = None
+127
View File
@@ -0,0 +1,127 @@
"""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
@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
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."""
return msg.event
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 ""
+12 -15
View File
@@ -10,7 +10,8 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import Any
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.events import InboundMessage
from nanobot.bus.outbound_events import ProgressEvent, outbound_message_for_event
from nanobot.bus.queue import MessageBus
@@ -29,23 +30,19 @@ def build_bus_progress_callback(
reasoning: bool = False,
reasoning_end: bool = False,
) -> None:
meta = dict(msg.metadata or {})
meta["_progress"] = True
meta["_tool_hint"] = tool_hint
if reasoning:
meta["_reasoning_delta"] = True
if reasoning_end:
meta["_reasoning_end"] = True
if tool_events:
meta["_tool_events"] = tool_events
if file_edit_events:
meta["_file_edit_events"] = file_edit_events
await bus.publish_outbound(
OutboundMessage(
outbound_message_for_event(
channel=msg.channel,
chat_id=msg.chat_id,
content=content,
metadata=meta,
event=ProgressEvent(
content=content,
tool_hint=tool_hint,
reasoning_delta=reasoning,
reasoning_end=reasoning_end,
tool_events=tool_events,
file_edit_events=file_edit_events,
),
metadata=msg.metadata,
)
)
+37 -17
View File
@@ -101,20 +101,33 @@ class BaseChannel(ABC):
"""
pass
async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None:
async def send_delta(
self,
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
stream_end: bool = False,
resuming: bool = False,
) -> None:
"""Deliver a streaming text chunk.
Override in subclasses to enable streaming. Implementations should
raise on delivery failure so the channel manager can retry.
Streaming contract: ``_stream_delta`` is a chunk, ``_stream_end`` ends
the current segment, and stateful implementations must key buffers by
``_stream_id`` rather than only by ``chat_id``.
Stateful implementations should key buffers by ``stream_id`` rather
than only by ``chat_id`` when it is provided.
"""
pass
async def send_reasoning_delta(
self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None
self,
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
) -> None:
"""Stream a chunk of model reasoning/thinking content.
@@ -123,15 +136,17 @@ class BaseChannel(ABC):
subtext, WebUI italic bubble, ...) override to render reasoning
as a subordinate trace that updates in place as the model thinks.
Streaming contract mirrors :meth:`send_delta`: ``_reasoning_delta``
is a chunk, ``_reasoning_end`` ends the current reasoning segment,
and stateful implementations should key buffers by ``_stream_id``
rather than only by ``chat_id``.
Streaming contract mirrors :meth:`send_delta`: stateful implementations
should key buffers by ``stream_id`` rather than only by ``chat_id``.
"""
return
async def send_reasoning_end(
self, chat_id: str, metadata: dict[str, Any] | None = None
self,
chat_id: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
) -> None:
"""Mark the end of a reasoning stream segment.
@@ -165,13 +180,18 @@ class BaseChannel(ABC):
"""
if not msg.content:
return
meta = dict(msg.metadata or {})
meta.setdefault("_reasoning_delta", True)
await self.send_reasoning_delta(msg.chat_id, msg.content, meta)
end_meta = dict(meta)
end_meta.pop("_reasoning_delta", None)
end_meta["_reasoning_end"] = True
await self.send_reasoning_end(msg.chat_id, end_meta)
stream_id = getattr(msg.event, "stream_id", None)
await self.send_reasoning_delta(
msg.chat_id,
msg.content,
msg.metadata,
stream_id=stream_id,
)
await self.send_reasoning_end(
msg.chat_id,
msg.metadata,
stream_id=stream_id,
)
@property
def supports_streaming(self) -> bool:
+11 -6
View File
@@ -13,6 +13,7 @@ from typing import TYPE_CHECKING, Any, Literal
from pydantic import Field
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.command.builtin import build_help_text
@@ -458,7 +459,7 @@ class DiscordChannel(BaseChannel):
self.logger.warning("client not ready; dropping outbound message")
return
is_progress = bool((msg.metadata or {}).get("_progress"))
is_progress = isinstance(msg.event, ProgressEvent)
try:
await client.send_outbound(msg)
@@ -471,7 +472,14 @@ class DiscordChannel(BaseChannel):
await self._clear_reactions(msg.chat_id)
async def send_delta(
self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None
self,
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
stream_end: bool = False,
resuming: bool = False,
) -> None:
"""Progressive Discord delivery: send once, then edit until the stream ends."""
client = self._client
@@ -479,10 +487,7 @@ class DiscordChannel(BaseChannel):
self.logger.warning("client not ready; dropping stream delta")
return
meta = metadata or {}
stream_id = meta.get("_stream_id")
if meta.get("_stream_end"):
if stream_end:
buf = self._stream_bufs.get(chat_id)
if not buf or buf.message is None or not buf.text:
return
+2 -1
View File
@@ -23,6 +23,7 @@ from loguru import logger
from pydantic import Field
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir
@@ -218,7 +219,7 @@ class EmailChannel(BaseChannel):
return
# Skip progress messages to prevent sending an empty email after each tool call
if (msg.metadata or {}).get("_progress"):
if isinstance(msg.event, ProgressEvent):
self.logger.debug("Skip progress message to {}", msg.chat_id)
return
+18 -9
View File
@@ -22,6 +22,7 @@ from rich.panel import Panel
from rich.text import Text
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir
@@ -1797,14 +1798,19 @@ class FeishuChannel(BaseChannel):
return self._stream_update_text_sync(card_id, content, sequence), sequence
async def send_delta(
self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None
self,
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
stream_end: bool = False,
resuming: bool = False,
) -> None:
"""Progressive streaming via CardKit: create card on first delta, stream-update on subsequent.
Supported metadata keys:
_stream_end: Finalize the streaming card.
_tool_hint: Delta is a formatted tool hint (for display only).
message_id: Original message id (used with _stream_end for reaction cleanup).
message_id: Original message id (used with stream end for reaction cleanup).
chat_type: "group" or "p2p" — controls reply-in-thread for streaming cards.
"""
if not self._client:
@@ -1815,14 +1821,14 @@ class FeishuChannel(BaseChannel):
rid_type = "chat_id" if chat_id.startswith("oc_") else "open_id"
# --- stream end: final update or fallback ---
if meta.get("_stream_end"):
if stream_end:
message_id = meta.get("message_id")
# Only finalize the OnIt -> DONE reaction transition on the truly
# final stream end. _resuming=True means the agent will keep
# final stream end. resuming=True means the agent will keep
# working (more tool-call rounds), so leave the reaction state
# in place — otherwise the OnIt indicator disappears prematurely
# and the DONE reaction fires after every tool call.
if message_id and not meta.get("_resuming"):
if message_id and not resuming:
reaction_id = self._reaction_ids.pop(message_id, None)
if reaction_id:
await self._remove_reaction(message_id, reaction_id)
@@ -1965,7 +1971,9 @@ class FeishuChannel(BaseChannel):
# Handle tool hint messages. When a streaming card is active for
# this chat, inline the hint into the card instead of sending a
# separate message so the user experience stays cohesive.
if msg.metadata.get("_tool_hint"):
progress_event = msg.event if isinstance(msg.event, ProgressEvent) else None
if progress_event and progress_event.tool_hint:
hint = (msg.content or "").strip()
if not hint:
return
@@ -1976,6 +1984,7 @@ class FeishuChannel(BaseChannel):
await self.send_delta(
msg.chat_id,
"\n\n" + self._format_tool_hint_delta(hint) + "\n\n",
metadata=msg.metadata,
)
return
# No active streaming card — send as a regular interactive card
@@ -2009,7 +2018,7 @@ class FeishuChannel(BaseChannel):
reply_message_id: str | None = None
_msg_id = msg.metadata.get("message_id")
has_thread_id = msg.metadata.get("thread_id")
if self.config.reply_to_message and not msg.metadata.get("_progress", False):
if self.config.reply_to_message and progress_event is None:
reply_message_id = _msg_id
# For topic group messages, always reply to keep context in thread
elif has_thread_id:
+92 -50
View File
@@ -12,6 +12,16 @@ from typing import TYPE_CHECKING, Any
from loguru import logger
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import (
ProgressEvent,
RetryWaitEvent,
RuntimeModelUpdatedEvent,
StreamDeltaEvent,
StreamedResponseEvent,
StreamEndEvent,
outbound_event_from_message,
replace_outbound_event,
)
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.schema import Config
@@ -266,7 +276,7 @@ class ChannelManager:
def _should_suppress_outbound(self, msg: OutboundMessage) -> bool:
metadata = msg.metadata or {}
if metadata.get("_progress"):
if isinstance(outbound_event_from_message(msg), ProgressEvent):
return False
fingerprint = self._fingerprint_content(msg.content)
if not fingerprint:
@@ -305,57 +315,59 @@ class ChannelManager:
timeout=1.0
)
if (
msg.metadata.get("_reasoning_delta")
or msg.metadata.get("_reasoning_end")
or msg.metadata.get("_reasoning")
event = outbound_event_from_message(msg)
progress_event = event if isinstance(event, ProgressEvent) else None
if progress_event and (
progress_event.reasoning_delta
or progress_event.reasoning_end
or progress_event.reasoning
):
# Reasoning rides its own plugin channel: only delivered
# when the destination channel opts in via ``show_reasoning``
# and overrides the streaming primitives. Channels without
# a low-emphasis UI affordance keep the base no-op and the
# content silently drops here. ``_reasoning`` (one-shot)
# is accepted for backward compatibility with hooks that
# haven't migrated to delta/end yet.
# content silently drops here.
channel = self.channels.get(msg.channel)
if channel is not None and channel.show_reasoning:
await self._send_with_retry(channel, msg)
continue
if msg.metadata.get("_progress"):
if msg.metadata.get("_tool_hint") and not self._should_send_progress(
if progress_event:
if progress_event.tool_hint and not self._should_send_progress(
msg.channel, tool_hint=True,
):
continue
if not msg.metadata.get("_tool_hint") and not self._should_send_progress(
if not progress_event.tool_hint and not self._should_send_progress(
msg.channel, tool_hint=False,
):
continue
if msg.metadata.get("_retry_wait"):
if isinstance(event, RetryWaitEvent):
continue
if (
msg.metadata.get("_runtime_model_updated")
isinstance(event, RuntimeModelUpdatedEvent)
and msg.channel == "websocket"
and "websocket" not in self.channels
):
continue
# Coalesce consecutive _stream_delta messages for the same (channel, chat_id)
# Coalesce consecutive stream delta messages for the same (channel, chat_id)
# to reduce API calls and improve streaming latency
if msg.metadata.get("_stream_delta") and not msg.metadata.get("_stream_end"):
if isinstance(event, StreamDeltaEvent):
msg, extra_pending = self._coalesce_stream_deltas(msg)
pending.extend(extra_pending)
event = outbound_event_from_message(msg)
channel = self.channels.get(msg.channel)
if channel:
# Duplicate suppression is scoped to a known source message
# so repeated content from separate turns is still delivered.
if (
not msg.metadata.get("_stream_delta")
and not msg.metadata.get("_stream_end")
and not msg.metadata.get("_streamed")
not isinstance(
event,
StreamDeltaEvent | StreamEndEvent | StreamedResponseEvent,
)
):
if self._should_suppress_outbound(msg):
logger.info("Suppressing duplicate outbound message to {}:{}", msg.channel, msg.chat_id)
@@ -372,31 +384,53 @@ class ChannelManager:
@staticmethod
async def _send_once(channel: BaseChannel, msg: OutboundMessage) -> None:
"""Send one outbound message without retry policy."""
if msg.metadata.get("_reasoning_end"):
await channel.send_reasoning_end(msg.chat_id, msg.metadata)
elif msg.metadata.get("_reasoning_delta"):
await channel.send_reasoning_delta(msg.chat_id, msg.content, msg.metadata)
elif msg.metadata.get("_reasoning"):
# Back-compat: one-shot reasoning. BaseChannel translates this
# to a single delta + end pair so plugins only implement the
# streaming primitives.
event = outbound_event_from_message(msg)
if isinstance(event, ProgressEvent) and event.reasoning_end:
await channel.send_reasoning_end(
msg.chat_id,
msg.metadata,
stream_id=event.stream_id,
)
elif isinstance(event, ProgressEvent) and event.reasoning_delta:
await channel.send_reasoning_delta(
msg.chat_id,
msg.content,
msg.metadata,
stream_id=event.stream_id,
)
elif isinstance(event, ProgressEvent) and event.reasoning:
# BaseChannel translates one-shot reasoning to a single delta +
# end pair so plugins only implement the streaming primitives.
await channel.send_reasoning(msg)
elif msg.metadata.get("_file_edit_events"):
edits = msg.metadata.get("_file_edit_events")
elif isinstance(event, ProgressEvent) and event.file_edit_events:
await channel.send_file_edit_events(
msg.chat_id,
edits if isinstance(edits, list) else [],
event.file_edit_events,
msg.metadata,
)
elif msg.metadata.get("_stream_delta") or msg.metadata.get("_stream_end"):
await channel.send_delta(msg.chat_id, msg.content, msg.metadata)
elif not msg.metadata.get("_streamed"):
elif isinstance(event, StreamDeltaEvent):
await channel.send_delta(
msg.chat_id,
msg.content,
msg.metadata,
stream_id=event.stream_id,
)
elif isinstance(event, StreamEndEvent):
await channel.send_delta(
msg.chat_id,
msg.content,
msg.metadata,
stream_id=event.stream_id,
stream_end=True,
resuming=event.resuming,
)
elif not isinstance(event, StreamedResponseEvent):
await channel.send(msg)
def _coalesce_stream_deltas(
self, first_msg: OutboundMessage
) -> tuple[OutboundMessage, list[OutboundMessage]]:
"""Merge consecutive _stream_delta messages for the same (channel, chat_id, _stream_id).
"""Merge consecutive stream deltas for the same (channel, chat_id, stream_id).
This reduces the number of API calls when the queue has accumulated multiple
deltas, which happens when LLM generates faster than the channel can process.
@@ -404,10 +438,15 @@ class ChannelManager:
Returns:
tuple of (merged_message, list_of_non_matching_messages)
"""
first_metadata = first_msg.metadata or {}
target_key = (first_msg.channel, first_msg.chat_id, first_metadata.get("_stream_id"))
first_event = outbound_event_from_message(first_msg)
first_stream_id = first_event.stream_id if isinstance(first_event, StreamDeltaEvent) else None
target_key = (first_msg.channel, first_msg.chat_id, first_stream_id)
combined_content = first_msg.content
final_metadata = dict(first_msg.metadata or {})
final_event: StreamDeltaEvent | StreamEndEvent = (
first_event
if isinstance(first_event, StreamDeltaEvent)
else StreamDeltaEvent(stream_id=first_stream_id)
)
non_matching: list[OutboundMessage] = []
# Only merge consecutive deltas. As soon as we hit any other message,
@@ -419,21 +458,29 @@ class ChannelManager:
break
# Check if this message belongs to the same stream
next_metadata = next_msg.metadata or {}
next_event = outbound_event_from_message(next_msg)
next_stream_id = (
next_event.stream_id
if isinstance(next_event, StreamDeltaEvent | StreamEndEvent)
else None
)
same_target = (
next_msg.channel,
next_msg.chat_id,
next_metadata.get("_stream_id"),
next_stream_id,
) == target_key
is_delta = next_metadata.get("_stream_delta")
is_end = next_metadata.get("_stream_end")
is_delta = isinstance(next_event, StreamDeltaEvent)
is_end = isinstance(next_event, StreamEndEvent)
if same_target and is_delta and not final_metadata.get("_stream_end"):
if same_target and (is_delta or (is_end and next_msg.content)):
# Accumulate content
combined_content += next_msg.content
# If we see _stream_end, remember it and stop coalescing this stream
if is_end:
final_metadata["_stream_end"] = True
# If we see stream_end, remember it and stop coalescing this stream
if isinstance(next_event, StreamEndEvent):
final_event = StreamEndEvent(
stream_id=next_stream_id,
resuming=next_event.resuming,
)
# Stream ended - stop coalescing this stream
break
else:
@@ -441,12 +488,7 @@ class ChannelManager:
non_matching.append(next_msg)
break
merged = OutboundMessage(
channel=first_msg.channel,
chat_id=first_msg.chat_id,
content=combined_content,
metadata=final_metadata,
)
merged = replace_outbound_event(first_msg, final_event, content=combined_content)
return merged, non_matching
async def _send_with_retry(self, channel: BaseChannel, msg: OutboundMessage) -> None:
+13 -4
View File
@@ -49,6 +49,7 @@ except ImportError as e:
) from e
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_data_dir, get_media_dir
@@ -504,7 +505,7 @@ class MatrixChannel(BaseChannel):
text = msg.content or ""
candidates = self._collect_outbound_media_candidates(msg.media)
relates_to = self._build_thread_relates_to(msg.metadata)
is_progress = bool((msg.metadata or {}).get("_progress"))
is_progress = isinstance(msg.event, ProgressEvent)
try:
failures: list[str] = []
if candidates:
@@ -528,11 +529,19 @@ class MatrixChannel(BaseChannel):
if not is_progress:
await self._stop_typing_keepalive(msg.chat_id, clear_typing=True)
async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None:
meta = metadata or {}
async def send_delta(
self,
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
stream_end: bool = False,
resuming: bool = False,
) -> None:
relates_to = self._build_thread_relates_to(metadata)
if meta.get("_stream_end"):
if stream_end:
buf = self._stream_bufs.pop(chat_id, None)
if not buf or not buf.event_id or not buf.text:
return
+2 -1
View File
@@ -18,6 +18,7 @@ import httpx
from pydantic import Field, computed_field, field_validator
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir
@@ -539,7 +540,7 @@ class SignalChannel(BaseChannel):
async def send(self, msg: OutboundMessage) -> None:
"""Send a message through Signal."""
is_progress_message = bool(msg.metadata.get("_progress"))
is_progress_message = isinstance(msg.event, ProgressEvent)
try:
plain_text, text_styles = _markdown_to_signal(msg.content)
if not plain_text and not msg.media:
+3 -2
View File
@@ -14,6 +14,7 @@ from slack_sdk.web.async_client import AsyncWebClient
from slackify_markdown import slackify_markdown
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir
@@ -164,7 +165,7 @@ class SlackChannel(BaseChannel):
# only makes sense within the originating conversation.
thread_ts_param = thread_ts if thread_ts and target_chat_id == origin_chat_id else None
is_progress = (msg.metadata or {}).get("_progress", False)
is_progress = isinstance(msg.event, ProgressEvent)
if is_progress and not msg.content:
pass # skip empty progress messages (e.g. tool-event-only updates)
elif msg.content or not (msg.media or []):
@@ -190,7 +191,7 @@ class SlackChannel(BaseChannel):
self.logger.exception("Failed to upload file {}", media_path)
# Update reaction emoji when the final (non-progress) response is sent
if not (msg.metadata or {}).get("_progress"):
if not is_progress:
event = slack_meta.get("event", {})
await self._update_react_emoji(origin_chat_id, event.get("ts"))
+17 -6
View File
@@ -26,6 +26,7 @@ from telegram.ext import Application, CallbackQueryHandler, ContextTypes, Messag
from telegram.request import HTTPXRequest
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.command.builtin import build_help_text
@@ -36,7 +37,7 @@ from nanobot.utils.helpers import split_message
TELEGRAM_MAX_MESSAGE_LEN = 4000 # Telegram message character limit
# Telegram's actual API limit is 4096; we split raw markdown at 4000 as a
# safety margin for mid-stream edits (plain text). For _stream_end, we split
# safety margin for mid-stream edits (plain text). On stream end, we split
# raw markdown into chunks whose rendered HTML fits Telegram's true 4096-char
# boundary so the final rendered message never overflows.
TELEGRAM_HTML_MAX_LEN = 4096
@@ -706,8 +707,10 @@ class TelegramChannel(BaseChannel):
self.logger.warning("bot not running")
return
progress_event = msg.event if isinstance(msg.event, ProgressEvent) else None
# Only stop typing indicator and remove reaction for final responses
if not msg.metadata.get("_progress", False):
if progress_event is None:
self._stop_typing(msg.chat_id)
if reply_to_message_id := msg.metadata.get("message_id"):
with suppress(ValueError):
@@ -792,7 +795,7 @@ class TelegramChannel(BaseChannel):
# Send text content
if msg.content and msg.content != "[empty message]":
render_as_blockquote = bool(msg.metadata.get("_tool_hint"))
render_as_blockquote = bool(progress_event and progress_event.tool_hint)
buttons = getattr(msg, "buttons", None) or []
reply_markup = self._build_keyboard(buttons) if buttons else None
text = msg.content
@@ -887,15 +890,23 @@ class TelegramChannel(BaseChannel):
def _is_not_modified_error(exc: Exception) -> bool:
return isinstance(exc, BadRequest) and "message is not modified" in str(exc).lower()
async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None:
async def send_delta(
self,
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
stream_end: bool = False,
resuming: bool = False,
) -> None:
"""Progressive message editing: send on first delta, edit on subsequent ones."""
if not self._app:
return
meta = metadata or {}
int_chat_id = int(chat_id)
stream_id = meta.get("_stream_id")
if meta.get("_stream_end"):
if stream_end:
buf = self._stream_bufs.get(chat_id)
if not buf or not buf.message_id or not buf.text:
return
+58 -52
View File
@@ -19,6 +19,16 @@ from websockets.exceptions import ConnectionClosed
from websockets.http11 import Request as WsRequest
from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage
from nanobot.bus.outbound_events import (
GoalStateSyncEvent,
GoalStatusEvent,
ProgressEvent,
RuntimeModelUpdatedEvent,
SessionUpdatedEvent,
TurnEndEvent,
outbound_event_from_message,
outbound_message_for_event,
)
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir
@@ -148,16 +158,13 @@ def publish_runtime_model_update(
model_preset: str | None,
) -> None:
"""Enqueue a runtime model snapshot for websocket subscribers (fan-out in-channel)."""
bus.outbound.put_nowait(OutboundMessage(
channel="websocket",
chat_id="*",
content="",
metadata={
"_runtime_model_updated": True,
"model": model,
"model_preset": model_preset,
},
))
bus.outbound.put_nowait(
outbound_message_for_event(
channel="websocket",
chat_id="*",
event=RuntimeModelUpdatedEvent(model=model, model_preset=model_preset),
)
)
def _parse_inbound_payload(raw: str) -> str | None:
@@ -851,70 +858,63 @@ class WebSocketChannel(BaseChannel):
raise
async def send(self, msg: OutboundMessage) -> None:
if msg.metadata.get("_runtime_model_updated"):
event = outbound_event_from_message(msg)
progress_event = event if isinstance(event, ProgressEvent) else None
if isinstance(event, RuntimeModelUpdatedEvent):
await self.send_runtime_model_updated(
model_name=msg.metadata.get("model"),
model_preset=msg.metadata.get("model_preset"),
model_name=event.model,
model_preset=event.model_preset,
)
return
# Snapshot the subscriber set so ConnectionClosed cleanups mid-iteration are safe.
conns = list(self._subs.get(msg.chat_id, ()))
if not conns:
if (
msg.metadata.get("_progress")
or msg.metadata.get("_file_edit_events")
or msg.metadata.get("_turn_end")
or msg.metadata.get("_session_updated")
or msg.metadata.get("_goal_status")
or msg.metadata.get("_goal_state_sync")
if isinstance(
event,
ProgressEvent
| TurnEndEvent
| SessionUpdatedEvent
| GoalStatusEvent
| GoalStateSyncEvent,
):
self.logger.debug("no active subscribers for chat_id={}", msg.chat_id)
else:
self.logger.warning("no active subscribers for chat_id={}", msg.chat_id)
if msg.metadata.get("_goal_state_sync"):
if isinstance(event, GoalStateSyncEvent):
if conns:
blob = msg.metadata.get("goal_state")
await self.send_goal_state(msg.chat_id, blob if isinstance(blob, dict) else {"active": False})
await self.send_goal_state(msg.chat_id, event.goal_state or {"active": False})
return
if msg.metadata.get("_goal_status"):
if isinstance(event, GoalStatusEvent):
if conns:
status = msg.metadata.get("goal_status")
if status in ("running", "idle"):
started_raw = msg.metadata.get("started_at", msg.metadata.get("goal_started_at"))
if event.status in ("running", "idle"):
await self.send_goal_status(
msg.chat_id,
status,
started_at=float(started_raw) if isinstance(started_raw, int | float) else None,
event.status,
started_at=event.started_at,
)
return
# Signal that the agent has fully finished processing the current turn.
if msg.metadata.get("_turn_end"):
lat = msg.metadata.get("latency_ms")
lat_i = int(lat) if isinstance(lat, (int, float)) else None
gs = msg.metadata.get("goal_state")
gs_blob = gs if isinstance(gs, dict) else None
if isinstance(event, TurnEndEvent):
await self.send_turn_end(
msg.chat_id,
latency_ms=lat_i,
goal_state=gs_blob,
latency_ms=event.latency_ms,
goal_state=event.goal_state,
metadata=msg.metadata,
)
await self.send_session_updated(msg.chat_id, scope="thread")
return
if msg.metadata.get("_session_updated"):
if isinstance(event, SessionUpdatedEvent):
if conns:
scope = msg.metadata.get("_session_update_scope")
await self.send_session_updated(
msg.chat_id,
scope=scope if isinstance(scope, str) else None,
scope=event.scope,
)
return
if msg.metadata.get("_file_edit_events"):
edits = msg.metadata.get("_file_edit_events")
if progress_event and progress_event.file_edit_events:
await self.send_file_edit_events(
msg.chat_id,
edits if isinstance(edits, list) else [],
progress_event.file_edit_events,
msg.metadata,
)
return
@@ -939,17 +939,17 @@ class WebSocketChannel(BaseChannel):
lat = msg.metadata.get("latency_ms")
if isinstance(lat, (int, float)):
payload["latency_ms"] = int(lat)
if msg.metadata.get("_tool_events"):
payload["tool_events"] = msg.metadata["_tool_events"]
if progress_event and progress_event.tool_events:
payload["tool_events"] = progress_event.tool_events
agent_ui = msg.metadata.get(OUTBOUND_META_AGENT_UI)
if agent_ui is not None:
payload["agent_ui"] = agent_ui
# Mark intermediate agent breadcrumbs (tool-call hints, generic
# progress strings) so WS clients can render them as subordinate
# trace rows rather than conversational replies.
if msg.metadata.get("_tool_hint"):
if progress_event and progress_event.tool_hint:
payload["kind"] = "tool_hint"
elif msg.metadata.get("_progress"):
elif progress_event:
payload["kind"] = "progress"
phase = "activity" if payload.get("kind") in ("tool_hint", "progress") else "answer"
self._transcripts.prepare_and_append(
@@ -971,6 +971,8 @@ class WebSocketChannel(BaseChannel):
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
) -> None:
"""Push one chunk of model reasoning. Mirrors ``send_delta`` shape so
clients receive a stream that opens, updates in place, and closes
@@ -986,7 +988,6 @@ class WebSocketChannel(BaseChannel):
"chat_id": chat_id,
"text": delta,
}
stream_id = meta.get("_stream_id")
if stream_id is not None:
body["stream_id"] = stream_id
self._transcripts.prepare_and_append(
@@ -1005,6 +1006,8 @@ class WebSocketChannel(BaseChannel):
self,
chat_id: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
) -> None:
"""Close the current reasoning stream segment for in-place renderers."""
conns = list(self._subs.get(chat_id, ()))
@@ -1013,7 +1016,6 @@ class WebSocketChannel(BaseChannel):
"event": "reasoning_end",
"chat_id": chat_id,
}
stream_id = meta.get("_stream_id")
if stream_id is not None:
body["stream_id"] = stream_id
self._transcripts.prepare_and_append(
@@ -1057,11 +1059,15 @@ class WebSocketChannel(BaseChannel):
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
stream_end: bool = False,
resuming: bool = False,
) -> None:
conns = list(self._subs.get(chat_id, ()))
meta = metadata or {}
stream_key = (chat_id, str(meta.get("_stream_id") or ""))
if meta.get("_stream_end"):
stream_key = (chat_id, str(stream_id or ""))
if stream_end:
body: dict[str, Any] = {"event": "stream_end", "chat_id": chat_id}
buffered = self._stream_text_buffers.pop(stream_key, [])
if delta:
@@ -1077,8 +1083,8 @@ class WebSocketChannel(BaseChannel):
"text": delta,
}
self._stream_text_buffers.setdefault(stream_key, []).append(delta)
if meta.get("_stream_id") is not None:
body["stream_id"] = meta["_stream_id"]
if stream_id is not None:
body["stream_id"] = stream_id
self._transcripts.prepare_and_append(
chat_id,
body,
+2 -1
View File
@@ -13,6 +13,7 @@ from typing import Any
from pydantic import Field
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir
@@ -497,7 +498,7 @@ class WecomChannel(BaseChannel):
try:
content = (msg.content or "").strip()
is_progress = bool(msg.metadata.get("_progress"))
is_progress = isinstance(msg.event, ProgressEvent)
# Get the stored frame for this chat
frame = self._chat_frames.get(msg.chat_id)
+23 -14
View File
@@ -29,6 +29,7 @@ from loguru import logger
from pydantic import Field
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir, get_runtime_subdir
@@ -1101,11 +1102,13 @@ class WeixinChannel(BaseChannel):
raise RuntimeError("WeChat client not initialized or not authenticated")
self._assert_session_active()
is_progress = bool((msg.metadata or {}).get("_progress", False))
event = getattr(msg, "event", None)
progress_event = event if isinstance(event, ProgressEvent) else None
is_progress = progress_event is not None
# Buffer tool hints to coalesce consecutive ones and avoid burning
# WeChat iLink rate-limit quota (~7 msgs / 5 min).
if is_progress and (msg.metadata or {}).get("_tool_hint"):
if progress_event and progress_event.tool_hint:
if not self.send_tool_hints:
return
self._pending_tool_hints.setdefault(msg.chat_id, []).append(msg.content)
@@ -1118,7 +1121,7 @@ class WeixinChannel(BaseChannel):
# Reasoning deltas are invisible in WeChat (there is no reasoning
# UI). Skip them entirely — do not send and do not flush buffer.
if is_progress and (msg.metadata or {}).get("_reasoning_delta"):
if progress_event and (progress_event.reasoning_delta or progress_event.reasoning):
self.logger.debug(
"Dropped invisible reasoning delta for {}", msg.chat_id
)
@@ -1232,40 +1235,46 @@ class WeixinChannel(BaseChannel):
await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_CANCEL)
async def send_delta(
self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None
self,
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
stream_end: bool = False,
resuming: bool = False,
) -> None:
"""Deliver a streamed reply to WeChat.
WeChat iLink has no native incremental delivery, and the manager
bypasses :meth:`send` for the ``_streamed`` final answer. So we
accumulate the content deltas here and flush the full reply as a
single message at ``_stream_end`` otherwise a streamed reply would
never reach the user. Reasoning deltas are invisible in WeChat and are
dropped.
accumulate content deltas and flush the full reply as a single message
at stream end. Reasoning deltas are invisible in WeChat and are dropped.
"""
meta = metadata or {}
if meta.get("_reasoning_delta") or meta.get("_reasoning"):
return
is_end = meta.get("_stream_end")
# Accumulate intermediate deltas. The _stream_end message's own content
is_end = stream_end or bool(meta.get("_stream_end"))
buffer_key = stream_id or chat_id
# Accumulate intermediate deltas. The stream_end message's own content
# (present when the manager coalesces deltas into the end message) is
# folded into `full` below instead of appended here, so a send retry
# recomputes the same `full` from an unchanged buffer rather than
# double-counting that delta.
if delta and not is_end:
self._stream_buffers.setdefault(chat_id, []).append(delta)
self._stream_buffers.setdefault(buffer_key, []).append(delta)
if not is_end:
return
full = ("".join(self._stream_buffers.get(chat_id, [])) + (delta or "")).strip()
full = ("".join(self._stream_buffers.get(buffer_key, [])) + (delta or "")).strip()
await self._flush_tool_hints(chat_id)
if full:
# Send before clearing the buffer: if the send raises, the buffer is
# left intact so ChannelManager._send_with_retry can re-deliver the
# same _stream_end message instead of silently losing the reply.
# same stream_end message instead of silently losing the reply.
await self.send(
OutboundMessage(channel=self.name, chat_id=chat_id, content=full)
)
self._stream_buffers.pop(chat_id, None)
self._stream_buffers.pop(buffer_key, None)
async def _start_typing(self, chat_id: str, context_token: str = "") -> None:
"""Start typing indicator immediately when a message is received."""
+25 -14
View File
@@ -50,6 +50,14 @@ from rich.text import Text # noqa: E402
from nanobot import __logo__, __version__ # noqa: E402
from nanobot.agent.loop import AgentLoop # noqa: E402
from nanobot.bus.outbound_events import ( # noqa: E402
ProgressEvent,
RetryWaitEvent,
StreamDeltaEvent,
StreamedResponseEvent,
StreamEndEvent,
outbound_event_from_message,
)
from nanobot.cli.gateway import create_gateway_app # noqa: E402
from nanobot.cli.stream import StreamRenderer, ThinkingSpinner # noqa: E402
from nanobot.config.paths import get_workspace_path, is_default_workspace # noqa: E402
@@ -461,25 +469,25 @@ async def _maybe_print_interactive_progress(
renderer: StreamRenderer | None = None,
reasoning_buffer: _ReasoningBuffer | None = None,
) -> bool:
metadata = msg.metadata or {}
if metadata.get("_retry_wait"):
event = outbound_event_from_message(msg)
if isinstance(event, RetryWaitEvent):
await _print_interactive_progress_line(msg.content, thinking, renderer)
return True
if not metadata.get("_progress"):
if not isinstance(event, ProgressEvent):
return False
reasoning_buffer = reasoning_buffer or _ReasoningBuffer()
if metadata.get("_reasoning_end"):
if event.reasoning_end:
if channels_config and not channels_config.show_reasoning:
reasoning_buffer.clear()
else:
_flush_cli_reasoning(reasoning_buffer, thinking, renderer)
return True
is_tool_hint = metadata.get("_tool_hint", False)
is_reasoning = metadata.get("_reasoning", False) or metadata.get("_reasoning_delta", False)
is_tool_hint = event.tool_hint
is_reasoning = event.reasoning or event.reasoning_delta
if is_reasoning:
if channels_config and not channels_config.show_reasoning:
reasoning_buffer.clear()
@@ -1456,7 +1464,7 @@ def agent(
bus_task = asyncio.create_task(agent_loop.run())
turn_done = asyncio.Event()
turn_done.set()
turn_response: list[tuple[str, dict]] = []
turn_response: list[Any] = []
renderer: StreamRenderer | None = None
reasoning_buffer = _ReasoningBuffer()
@@ -1464,18 +1472,19 @@ def agent(
while True:
try:
msg = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
event = outbound_event_from_message(msg)
if msg.metadata.get("_stream_delta"):
if isinstance(event, StreamDeltaEvent):
if renderer:
await renderer.on_delta(msg.content)
continue
if msg.metadata.get("_stream_end"):
if isinstance(event, StreamEndEvent):
if renderer:
await renderer.on_end(
resuming=msg.metadata.get("_resuming", False),
resuming=event.resuming,
)
continue
if msg.metadata.get("_streamed"):
if isinstance(event, StreamedResponseEvent):
turn_done.set()
continue
@@ -1490,7 +1499,7 @@ def agent(
if not turn_done.is_set():
if msg.content:
turn_response.append((msg.content, dict(msg.metadata or {})))
turn_response.append(msg)
turn_done.set()
elif msg.content:
await _print_interactive_response(
@@ -1543,8 +1552,10 @@ def agent(
await turn_done.wait()
if turn_response:
content, meta = turn_response[0]
if content and not meta.get("_streamed"):
response_msg = turn_response[0]
content = response_msg.content
meta = response_msg.metadata
if content and not isinstance(response_msg.event, StreamedResponseEvent):
if renderer:
await renderer.close()
print_kwargs: dict[str, Any] = {}
-4
View File
@@ -29,10 +29,6 @@ _GOAL_CONTINUATION_SENDER = "system:continuation"
_GOAL_CONTINUATION_ROUNDS_KEY = "_sustained_goal_continuation_rounds"
_MAX_GOAL_CONTINUATION_ROUNDS = 12
_STRIPPED_INBOUND_META_KEYS = {
"_stream_id",
"_stream_delta",
"_stream_end",
"_resuming",
INTERNAL_CONTINUATION_PENDING_META,
}
+57 -49
View File
@@ -11,7 +11,15 @@ from typing import Any
from loguru import logger
from nanobot.bus import progress as bus_progress
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.events import InboundMessage
from nanobot.bus.outbound_events import (
GoalStateSyncEvent,
GoalStatusEvent,
RuntimeModelUpdatedEvent,
SessionUpdatedEvent,
TurnEndEvent,
outbound_message_for_event,
)
from nanobot.bus.queue import MessageBus
from nanobot.bus.runtime_events import (
GoalStateChanged,
@@ -206,26 +214,22 @@ async def publish_turn_run_status(
if msg.channel != "websocket":
return
cid = str(msg.chat_id)
meta: dict[str, Any] = {
**dict(msg.metadata or {}),
"_goal_status": True,
"goal_status": status,
}
started_at_event: float | None = None
if status == "running":
if isinstance(started_at, int | float) and started_at > 0:
t0 = float(started_at)
else:
t0 = time.time()
meta["started_at"] = t0
started_at_event = t0
_WEBSOCKET_TURN_WALL_STARTED_AT[cid] = t0
else:
_WEBSOCKET_TURN_WALL_STARTED_AT.pop(cid, None)
await bus.publish_outbound(
OutboundMessage(
outbound_message_for_event(
channel=msg.channel,
chat_id=cid,
content="",
metadata=meta,
event=GoalStatusEvent(status=status, started_at=started_at_event),
metadata=msg.metadata,
),
)
@@ -318,28 +322,25 @@ class WebuiTurnCoordinator:
if not cid:
return
await self.bus.publish_outbound(
OutboundMessage(
outbound_message_for_event(
channel=event.context.channel,
chat_id=cid,
content="",
metadata={
"_goal_state_sync": True,
"goal_state": goal_state_ws_blob(event.session_metadata),
},
event=GoalStateSyncEvent(
goal_state=goal_state_ws_blob(event.session_metadata),
),
metadata=event.context.metadata,
),
)
async def _handle_runtime_model_changed(self, event: RuntimeModelChanged) -> None:
await self.bus.publish_outbound(
OutboundMessage(
outbound_message_for_event(
channel="websocket",
chat_id="*",
content="",
metadata={
"_runtime_model_updated": True,
"model": event.model,
"model_preset": event.model_preset,
},
event=RuntimeModelUpdatedEvent(
model=event.model,
model_preset=event.model_preset,
),
)
)
@@ -374,17 +375,18 @@ class WebuiTurnCoordinator:
if msg.channel != "websocket":
return
turn_metadata: dict[str, Any] = {**msg.metadata, "_turn_end": True}
if latency_ms is not None:
turn_metadata["latency_ms"] = int(latency_ms)
session = self.sessions.get_or_create(session_key)
turn_metadata["goal_state"] = goal_state_ws_blob(session.metadata)
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content="",
metadata=turn_metadata,
))
await self.bus.publish_outbound(
outbound_message_for_event(
channel=msg.channel,
chat_id=msg.chat_id,
event=TurnEndEvent(
latency_ms=latency_ms,
goal_state=goal_state_ws_blob(session.metadata),
),
metadata=msg.metadata,
)
)
self._schedule_title_update(msg, session_key=session_key)
def _schedule_title_update(self, msg: InboundMessage, *, session_key: str) -> None:
@@ -404,16 +406,11 @@ class WebuiTurnCoordinator:
model=title_llm.model,
)
if generated:
await self.bus.publish_outbound(OutboundMessage(
await self._publish_session_metadata_updated(
channel=msg.channel,
chat_id=msg.chat_id,
content="",
metadata={
**msg.metadata,
"_session_updated": True,
"_session_update_scope": "metadata",
},
))
metadata=msg.metadata,
)
self.schedule_background(_generate_title_and_notify())
@@ -438,15 +435,26 @@ class WebuiTurnCoordinator:
model=title_llm.model,
)
if generated:
await self.bus.publish_outbound(OutboundMessage(
await self._publish_session_metadata_updated(
channel=event.context.channel,
chat_id=event.context.chat_id,
content="",
metadata={
**event.context.metadata,
"_session_updated": True,
"_session_update_scope": "metadata",
},
))
metadata=event.context.metadata,
)
self.schedule_background(_generate_title_and_notify())
async def _publish_session_metadata_updated(
self,
*,
channel: str,
chat_id: str,
metadata: dict[str, Any],
) -> None:
await self.bus.publish_outbound(
outbound_message_for_event(
channel=channel,
chat_id=chat_id,
event=SessionUpdatedEvent(scope="metadata"),
metadata=metadata,
)
)