diff --git a/app/agent/callback/__init__.py b/app/agent/callback/__init__.py index 698453b71..94616b006 100644 --- a/app/agent/callback/__init__.py +++ b/app/agent/callback/__init__.py @@ -127,6 +127,10 @@ class StreamingHandler: ) return self.emit(f"\n\n{tool_message}\n\n") + def _is_verbose_mode(self) -> bool: + """判断当前是否启用逐条工具调用展示。""" + return bool(get_runtime_setting("AI_AGENT_VERBOSE")) + def report_tool_call( self, tool_name: str, @@ -140,7 +144,7 @@ class StreamingHandler: 相同的详细模式语义,避免无条件调用 ``record_tool_call`` 后只显示汇总。 """ safe_message = sanitize_for_host(tool_message) if tool_message else tool_message - if get_runtime_setting("AI_AGENT_VERBOSE") and safe_message: + if self._is_verbose_mode() and safe_message: return self.emit_tool_message(str(safe_message)) self.record_tool_call( tool_name=tool_name, diff --git a/app/agent/web.py b/app/agent/web.py index e668c4c66..aff20a047 100644 --- a/app/agent/web.py +++ b/app/agent/web.py @@ -43,6 +43,11 @@ class _WebAgentStreamingHandlerMixin: """更新当前 Web 请求的结构化工具生命周期回调。""" self._on_tool_event = on_tool_event + def _uses_structured_tool_events(self) -> bool: + """仅在啰嗦模式下使用 WebAgent 的逐条工具生命周期事件。""" + # ``StreamingHandler`` 是运行时组合进当前 MRO 的基础实现。 + return bool(self._on_tool_event and super()._is_verbose_mode()) # type: ignore[misc] + def _publish_tool_event(self, event: dict[str, Any]) -> None: """向 Web SSE 发布结构化工具生命周期事件,并隔离回调异常。""" if not self._on_tool_event: @@ -58,7 +63,7 @@ class _WebAgentStreamingHandlerMixin: tool_message: Optional[str] = None, ) -> str: """发布真实工具开始事件,调用方随后用同一 ID 收口结果。""" - if not self._on_tool_event: + if not self._uses_structured_tool_events(): return "" tool_id = f"tool-{uuid.uuid4().hex}" self._publish_tool_event( @@ -92,7 +97,7 @@ class _WebAgentStreamingHandlerMixin: tool_kwargs: Optional[dict[str, Any]] = None, ) -> str: """为没有 MoviePilotTool 包装层的内部工具建立完整生命周期。""" - if self._on_tool_event: + if self._uses_structured_tool_events(): del tool_kwargs return self.tool_call_started(tool_name, tool_message) return str(super().report_tool_call( # type: ignore[misc] @@ -107,8 +112,8 @@ class _WebAgentStreamingHandlerMixin: tool_message: Optional[str] = None, tool_kwargs: Optional[dict[str, Any]] = None, ) -> None: - """记录 Web 工具调用;结构化生命周期模式下不再生成重复文本摘要。""" - if self._on_tool_event: + """记录 Web 工具调用;详细模式使用生命周期事件,其他模式保留计数汇总。""" + if self._uses_structured_tool_events(): return # 该方法由运行时组合进 MRO 的 StreamingHandler 提供。 super().record_tool_call( # type: ignore[misc] @@ -116,7 +121,10 @@ class _WebAgentStreamingHandlerMixin: tool_message=tool_message, tool_kwargs=tool_kwargs, ) - self.flush_pending_tool_summary() + # 结构化回调存在但未启用详细模式时,等下一段正文或流结束后一次性输出 + # 多种工具的计数,避免每个调用各自生成一条摘要。 + if not self._on_tool_event: + self.flush_pending_tool_summary() def emit_tool_message(self, message: str) -> str: """将工具摘要压成一行,防止多行参数被 SSE 文本解析器拆入正文。 @@ -138,7 +146,7 @@ class _WebAgentStreamingHandlerMixin: def flush_pending_tool_summary(self) -> str: """输出延迟聚合的工具摘要。""" - if self._on_tool_event: + if self._uses_structured_tool_events(): return "" # 该方法由运行时组合进 MRO 的 StreamingHandler 提供。 emitted = super().flush_pending_tool_summary() # type: ignore[misc] diff --git a/tests/test_agent_tool_streaming.py b/tests/test_agent_tool_streaming.py index 608c48321..fed41299c 100644 --- a/tests/test_agent_tool_streaming.py +++ b/tests/test_agent_tool_streaming.py @@ -554,14 +554,15 @@ class TestAgentToolStreaming: user_id="1", username="admin", ) - tool_id = handler.tool_call_started("search", "查询媒体") - handler.emit_tool_message("查询媒体") - handler.record_tool_call( - tool_name="search", - tool_message="查询媒体", - tool_kwargs={"query": "MoviePilot"}, - ) - handler.tool_call_finished(tool_id, "done") + with patch("app.agent.callback.get_runtime_setting", return_value=True): + tool_id = handler.tool_call_started("search", "查询媒体") + handler.emit_tool_message("查询媒体") + handler.record_tool_call( + tool_name="search", + tool_message="查询媒体", + tool_kwargs={"query": "MoviePilot"}, + ) + handler.tool_call_finished(tool_id, "done") return tool_id, emitted, tool_events tool_id, emitted, tool_events = asyncio.run(_run()) @@ -579,6 +580,70 @@ class TestAgentToolStreaming: {"type": "tool", "status": "done", "tool_id": tool_id}, ] + def test_web_streaming_handler_aggregates_tools_when_not_verbose(self): + """WebAgent 关闭啰嗦模式时应隐藏逐条事件并汇总各类工具次数。""" + + async def _run(): + emitted = [] + tool_events = [] + handler = _get_web_agent_streaming_handler_type()( + emitted.append, + tool_events.append, + ) + await handler.start_streaming( + channel=NotificationChannel.WebAgent.value, + source="web-agent", + user_id="1", + username="admin", + ) + with patch("app.agent.callback.get_runtime_setting", return_value=False): + assert handler.tool_call_started("search", "查询媒体") == "" + handler.report_tool_call( + tool_name="search_web", + tool_message="搜索网络内容", + tool_kwargs={"query": "MoviePilot"}, + ) + handler.report_tool_call( + tool_name="read_file", + tool_message="读取文件", + tool_kwargs={"file_path": "/tmp/app.py"}, + ) + handler.emit("查询完成") + return emitted, tool_events + + emitted, tool_events = asyncio.run(_run()) + + assert tool_events == [] + assert emitted == ["(执行了 1 次搜索,读取了 1 个文件)\n\n查询完成"] + + def test_web_streaming_handler_aggregates_normal_tool_when_not_verbose(self): + """普通工具在 WebAgent 非啰嗦模式下也应走计数汇总路径。""" + + async def _run(): + emitted = [] + tool_events = [] + handler = _get_web_agent_streaming_handler_type()( + emitted.append, + tool_events.append, + ) + await handler.start_streaming( + channel=NotificationChannel.WebAgent.value, + source="web-agent", + user_id="1", + username="admin", + ) + tool = DummyTool(session_id="session-1", user_id="1") + tool.set_stream_handler(handler) + with patch.object(settings, "AI_AGENT_VERBOSE", False): + await tool._arun() + handler.emit("查询完成") + return emitted, tool_events + + emitted, tool_events = asyncio.run(_run()) + + assert tool_events == [] + assert emitted == ["(调用了 1 次工具)\n\n查询完成"] + def test_rich_message_keeps_body_text_unquoted_for_telegram(self): """校验 Telegram 富文本只转换工具摘要行,正文保持原样。""" handler = StreamingHandler()