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
+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."""