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:
Xubin Ren
2026-05-13 06:27:53 +00:00
co-authored by Cursor
parent 01fa362c03
commit a6b059d379
15 changed files with 504 additions and 13 deletions
+19 -1
View File
@@ -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)