refactor(reasoning): make channel plugins own reasoning rendering
Reasoning was being shipped to every channel as a generic progress message with a `_reasoning: true` flag. Two problems with that: 1. Channels without a low-emphasis UI primitive (Telegram, Slack, Discord, Feishu...) would dump raw model thoughts as ordinary replies, polluting the conversation. 2. The agent loop double-gated by inspecting `channels_config`, which coupled the loop to display policy. Treat reasoning as its own plugin action — `BaseChannel.send_reasoning` defaults to a documented no-op; channels that have a fitting affordance override. ChannelManager routes `_reasoning` outbounds to that method only when the channel opts in via `show_reasoning` (camelCase alias `showReasoning` mirrors `sendProgress`). Plugins that don't override silently drop reasoning — "no fit, no leak" is the contract. Reference implementation lands for WebSocket / WebUI: a new `kind: "reasoning"` frame, parked on the active assistant bubble as a collapsible `Thinking` group above the answer. CLI keeps its existing direct path (it doesn't go through the bus). `ChannelsConfig.show_reasoning` flips to `true` by default — only adapted channels surface anything, others stay quiet. Loop net diff is -3 lines: the `channels_config.show_reasoning` check moves out, leaving emit_reasoning a one-liner that publishes and trusts the channel to decide. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -28,6 +28,7 @@ class BaseChannel(ABC):
|
||||
transcription_language: str | None = None
|
||||
send_progress: bool = True
|
||||
send_tool_hints: bool = False
|
||||
show_reasoning: bool = True
|
||||
|
||||
def __init__(self, config: Any, bus: MessageBus):
|
||||
"""
|
||||
@@ -120,6 +121,18 @@ class BaseChannel(ABC):
|
||||
"""
|
||||
pass
|
||||
|
||||
async def send_reasoning(self, msg: OutboundMessage) -> None:
|
||||
"""Surface model reasoning/thinking content.
|
||||
|
||||
Default is no-op. Channels with a native low-emphasis primitive
|
||||
(Slack context block, Telegram expandable blockquote, Discord
|
||||
subtext, WebUI italic bubble, ...) override to render reasoning
|
||||
as a subordinate trace. Channels without a suitable affordance
|
||||
keep this no-op: silently dropping is better than leaking raw
|
||||
model thoughts as regular conversational messages.
|
||||
"""
|
||||
return
|
||||
|
||||
@property
|
||||
def supports_streaming(self) -> bool:
|
||||
"""True when config enables streaming AND this subclass implements send_delta."""
|
||||
|
||||
@@ -36,6 +36,7 @@ _SEND_RETRY_DELAYS = (1, 2, 4)
|
||||
_BOOL_CAMEL_ALIASES: dict[str, str] = {
|
||||
"send_progress": "sendProgress",
|
||||
"send_tool_hints": "sendToolHints",
|
||||
"show_reasoning": "showReasoning",
|
||||
}
|
||||
|
||||
class ChannelManager:
|
||||
@@ -104,6 +105,9 @@ class ChannelManager:
|
||||
channel.send_tool_hints = self._resolve_bool_override(
|
||||
section, "send_tool_hints", self.config.channels.send_tool_hints,
|
||||
)
|
||||
channel.show_reasoning = self._resolve_bool_override(
|
||||
section, "show_reasoning", self.config.channels.show_reasoning,
|
||||
)
|
||||
self.channels[name] = channel
|
||||
logger.info("{} channel enabled", cls.display_name)
|
||||
except Exception as e:
|
||||
@@ -279,6 +283,18 @@ class ChannelManager:
|
||||
timeout=1.0
|
||||
)
|
||||
|
||||
if msg.metadata.get("_reasoning"):
|
||||
# Reasoning rides its own plugin channel: only delivered when
|
||||
# the destination channel both opts in (``show_reasoning``)
|
||||
# and overrides ``send_reasoning``. Channels without a
|
||||
# low-emphasis UI primitive keep the base no-op and the
|
||||
# content silently drops here rather than leak as a
|
||||
# conversational reply.
|
||||
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(
|
||||
msg.channel, tool_hint=True,
|
||||
@@ -329,7 +345,9 @@ class ChannelManager:
|
||||
@staticmethod
|
||||
async def _send_once(channel: BaseChannel, msg: OutboundMessage) -> None:
|
||||
"""Send one outbound message without retry policy."""
|
||||
if msg.metadata.get("_stream_delta") or msg.metadata.get("_stream_end"):
|
||||
if msg.metadata.get("_reasoning"):
|
||||
await channel.send_reasoning(msg)
|
||||
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"):
|
||||
await channel.send(msg)
|
||||
|
||||
@@ -1487,6 +1487,30 @@ class WebSocketChannel(BaseChannel):
|
||||
for connection in conns:
|
||||
await self._safe_send_to(connection, raw, label=" ")
|
||||
|
||||
async def send_reasoning(self, msg: OutboundMessage) -> None:
|
||||
"""Stream model reasoning as a subordinate trace frame.
|
||||
|
||||
Renders as ``kind=reasoning`` alongside the existing ``tool_hint`` /
|
||||
``progress`` frames; the WebUI mounts these on the active assistant
|
||||
bubble rather than as a conversational reply.
|
||||
"""
|
||||
conns = list(self._subs.get(msg.chat_id, ()))
|
||||
if not conns:
|
||||
return
|
||||
if not msg.content:
|
||||
return
|
||||
payload: dict[str, Any] = {
|
||||
"event": "message",
|
||||
"chat_id": msg.chat_id,
|
||||
"text": msg.content,
|
||||
"kind": "reasoning",
|
||||
}
|
||||
if msg.reply_to:
|
||||
payload["reply_to"] = msg.reply_to
|
||||
raw = json.dumps(payload, ensure_ascii=False)
|
||||
for connection in conns:
|
||||
await self._safe_send_to(connection, raw, label=" reasoning ")
|
||||
|
||||
async def send_delta(
|
||||
self,
|
||||
chat_id: str,
|
||||
|
||||
Reference in New Issue
Block a user