refactor(bus): type outbound runtime events
This commit is contained in:
+35
-23
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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] = {}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user