diff --git a/app/agent/callback/__init__.py b/app/agent/callback/__init__.py index ed07996ad..c8517904b 100644 --- a/app/agent/callback/__init__.py +++ b/app/agent/callback/__init__.py @@ -76,7 +76,7 @@ class StreamingHandler: self._allow_dispatch_without_context = False # 非啰嗦模式下的待输出工具统计,等下一段文本到来时再统一补一句摘要 self._pending_tool_stats: dict[str, dict[str, Any]] = {} - # 本轮已写入缓冲区的工具摘要行,供 Telegram 富文本渲染时做区分样式 + # 本轮已写入缓冲区的工具展示行,供 Telegram 富文本渲染时做区分样式 self._tool_summaries: set[str] = set() def set_dispatch_policy(self, allow_dispatch_without_context: bool = False) -> None: @@ -108,6 +108,24 @@ class StreamingHandler: self._buffer += emitted return emitted + def emit_tool_message(self, message: str) -> str: + """ + 将啰嗦模式的逐条工具提示写入缓冲区,并登记为独立展示行。 + + Telegram Rich Markdown 依赖登记结果把工具提示渲染成引用块; + 其他渠道仍消费相同的纯文本缓冲内容。 + """ + normalized_message = str(message or "").strip() + if not normalized_message: + return "" + + tool_message = f"⚙️ => {normalized_message}" + with self._lock: + self._tool_summaries.update( + line for line in tool_message.splitlines() if line + ) + return self.emit(f"\n\n{tool_message}\n\n") + async def take(self) -> str: """ 获取当前已积累的消息内容,获取后清空缓冲区。 @@ -539,7 +557,7 @@ class StreamingHandler: def _quote_tool_summary_lines(self, text: str) -> str: """ - 将缓冲区中的工具摘要整行转换为 Markdown 引用块。 + 将缓冲区中的工具展示行转换为 Markdown 引用块。 富文本会把普通段落间的空行折叠成紧凑排版,引用块作为独立 block 类型 渲染,保证工具执行信息在 Telegram 上始终与正文有可辨识的视觉分隔。 diff --git a/app/agent/tools/base.py b/app/agent/tools/base.py index 734a03b23..7520a6d3a 100644 --- a/app/agent/tools/base.py +++ b/app/agent/tools/base.py @@ -53,6 +53,10 @@ else: """追加流式文本并返回实际追加内容。""" ... + def emit_tool_message(self, message: str) -> str: + """追加逐条工具提示并登记其展示语义。""" + ... + async def take(self) -> str: """取出并清空当前缓冲内容。""" ... @@ -434,7 +438,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta): if self._stream_handler.is_auto_flushing: # 渠道支持编辑:工具消息追加到 buffer,由定时刷新推送 if tool_message: - self._stream_handler.emit(f"\n\n⚙️ => {tool_message}\n\n") + self._stream_handler.emit_tool_message(tool_message) else: allow_dispatch_without_context = self._agent_context.get( "should_dispatch_reply", False diff --git a/tests/test_agent_tool_streaming.py b/tests/test_agent_tool_streaming.py index f88f17659..35001912c 100644 --- a/tests/test_agent_tool_streaming.py +++ b/tests/test_agent_tool_streaming.py @@ -429,6 +429,34 @@ class TestAgentToolStreaming: assert text == "(查看了 1 个目录)\n\n" assert rich_message == "> (查看了 1 个目录)\n\n" + def test_verbose_tool_message_is_quoted_for_telegram(self): + """校验 Telegram 啰嗦模式逐条工具提示也使用引用块。""" + + async def _run(): + tool = DummyTool(session_id="session-1", user_id="10001") + handler = StreamingHandler() + handler._channel = NotificationChannel.Telegram.value + handler._source = "telegram" + handler._streaming_enabled = True + handler.emit("前置内容") + flush_task = asyncio.create_task(asyncio.sleep(60)) + handler._flush_task = flush_task + tool.set_stream_handler(handler) + + try: + with patch.object(settings, "AI_AGENT_VERBOSE", True): + await tool._arun() + return handler._buffer, handler._get_rich_message(handler._buffer) + finally: + flush_task.cancel() + await asyncio.gather(flush_task, return_exceptions=True) + handler._flush_task = None + + text, rich_message = asyncio.run(_run()) + + assert text == "前置内容\n\n⚙️ => run test tool\n\n" + assert rich_message == "前置内容\n\n> ⚙️ => run test tool\n\n" + def test_rich_message_keeps_body_text_unquoted_for_telegram(self): """校验 Telegram 富文本只转换工具摘要行,正文保持原样。""" handler = StreamingHandler()