Files
aiagent/backend/app/core/streamlined_output.py

373 lines
13 KiB
Python
Raw Normal View History

"""
工具结果流式美化引擎 Streamlined Output
参考 Claude Code:
- src/utils/streamlinedTransform.ts 累积计数 + 文本断点
- src/utils/collapseReadSearch.ts 搜索/读取折叠分组
- src/utils/groupToolUses.ts 同类型工具归并
将工具调用过程转译为自然语言描述让用户看到简洁摘要而非原始 JSON
"""
from __future__ import annotations
import logging
from enum import Enum
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
logger = logging.getLogger(__name__)
# ──────────────────────────── 工具分类 ────────────────────────────
class ToolCategory(str, Enum):
SEARCH = "search" # 搜索类 (grep, glob, web_search)
READ = "read" # 读取类 (file_read, list_files)
WRITE = "write" # 写入类 (file_write, file_edit, deploy)
COMMAND = "command" # 执行类 (bash, code_execute, database)
OTHER = "other" # 其他
# 参考 Claude Code streamlinedTransform.ts L38-50
SEARCH_TOOLS: Set[str] = {
"grep", "search_content", "search_files", "search_code",
"web_search", "web_fetch", "find_files", "rg", "rg_search",
}
READ_TOOLS: Set[str] = {
"file_read", "list_files", "read_file", "read_dir",
"system_info", "entity_search", "knowledge_graph_search",
"browser_use", "image_ocr", "image_vision",
"url_parse", "http_request",
}
WRITE_TOOLS: Set[str] = {
"file_write", "file_edit", "write_file", "edit_file",
"deploy_push", "docker_manage", "notebook_edit",
"pdf_generate", "excel_process",
}
COMMAND_TOOLS: Set[str] = {
"code_execute", "database_query", "git_operation",
"agent_create", "agent_call", "create_task", "task_plan",
"adb_log", "crypto_util", "schedule_create", "schedule_delete",
"project_scaffold", "tool_register",
"feishu_create_doc", "feishu_create_sheet", "feishu_send_approval",
"feishu_upload_file", "send_email",
"bash", "shell", "cmd",
}
def categorize_tool(tool_name: str) -> ToolCategory:
"""根据工具名称分类。"""
if not tool_name:
return ToolCategory.OTHER
name = tool_name.lower().strip()
if name in SEARCH_TOOLS:
return ToolCategory.SEARCH
if name in READ_TOOLS:
return ToolCategory.READ
if name in WRITE_TOOLS:
return ToolCategory.WRITE
if name in COMMAND_TOOLS:
return ToolCategory.COMMAND
return ToolCategory.OTHER
# ──────────────────────────── 计数器 ────────────────────────────
class ToolCounts:
"""累积工具调用计数。"""
__slots__ = ("searches", "reads", "writes", "commands", "other")
def __init__(self):
self.searches: int = 0
self.reads: int = 0
self.writes: int = 0
self.commands: int = 0
self.other: int = 0
def add(self, category: ToolCategory) -> None:
if category == ToolCategory.SEARCH:
self.searches += 1
elif category == ToolCategory.READ:
self.reads += 1
elif category == ToolCategory.WRITE:
self.writes += 1
elif category == ToolCategory.COMMAND:
self.commands += 1
else:
self.other += 1
def has_any(self) -> bool:
return any((self.searches, self.reads, self.writes, self.commands, self.other))
def reset(self) -> None:
self.searches = 0
self.reads = 0
self.writes = 0
self.commands = 0
self.other = 0
# ──────────────────────────── 摘要生成 ────────────────────────────
def _plural_en(n: int, singular: str, plural: str = "") -> str:
"""英文复数(用于工具名)。"""
if n == 1:
return singular
return plural or singular + "s"
def get_tool_summary_text(counts: ToolCounts) -> Optional[str]:
"""生成工具调用累计摘要文本(中文)。
参考 Claude Code streamlinedTransform.ts L73-104
"""
parts: List[str] = []
if counts.searches > 0:
parts.append(f"搜索了 {counts.searches} 个模式")
if counts.reads > 0:
parts.append(f"读取了 {counts.reads} 个文件")
if counts.writes > 0:
parts.append(f"写入了 {counts.writes} 个文件")
if counts.commands > 0:
parts.append(f"执行了 {counts.commands} 条命令")
if counts.other > 0:
parts.append(f"调用了 {counts.other} 个工具")
if not parts:
return None
return "".join(parts)
def get_search_read_summary(
search_count: int,
read_count: int,
is_active: bool = False,
list_count: int = 0,
) -> str:
"""生成搜索/读取操作的摘要文本(用于折叠组)。
参考 Claude Code collapseReadSearch.ts L961-1066
"""
parts: List[str] = []
if search_count > 0:
if is_active:
verb = "正在搜索" if len(parts) == 0 else "搜索"
else:
verb = "已搜索" if len(parts) == 0 else "搜索了"
parts.append(f"{verb} {search_count} 个模式")
if read_count > 0:
if is_active:
verb = "正在读取" if len(parts) == 0 else "读取"
else:
verb = "已读取" if len(parts) == 0 else "读取了"
parts.append(f"{verb} {read_count} 个文件")
if list_count > 0:
if is_active:
verb = "正在列出" if len(parts) == 0 else "列出"
else:
verb = "已列出" if len(parts) == 0 else "列出了"
parts.append(f"{verb} {list_count} 个目录")
text = "".join(parts)
if is_active:
text += ""
return text
# ──────────────────────────── 流式转换器 ────────────────────────────
class StreamlinedTransformer:
"""有状态的流式转换器:在文本消息之间累积工具计数。
参考 Claude Code streamlinedTransform.ts L130-193
用法:
transformer = StreamlinedTransformer()
for event in stream:
transformed = transformer.transform(event)
if transformed:
yield transformed
"""
def __init__(self, enabled: bool = True):
self.enabled = enabled
self._counts = ToolCounts()
self._pending_tool_results: List[Dict[str, Any]] = []
def transform(self, event: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""转换单个 SSE 事件。返回 None 表示该事件应被过滤。"""
if not self.enabled:
return event
event_type = event.get("type", "")
# ── 文本消息:直接输出,重置计数 ──
if event_type in ("message", "final"):
self._counts.reset()
self._pending_tool_results.clear()
return event
# ── 思考/推理:保留(可配置是否过滤) ──
if event_type == "think":
# 保留 think 事件供前端展示推理过程
return event
# ── 工具调用:累积计数 ──
if event_type == "tool_call":
tool_name = event.get("name", "") or event.get("tool_name", "")
category = categorize_tool(tool_name)
self._counts.add(category)
# 转发工具调用事件(带有分类信息)
return {
**event,
"tool_category": category.value,
}
# ── 工具结果:暂存,等下次文本消息时清除 ──
if event_type == "tool_result":
self._pending_tool_results.append(event)
# 发出累计摘要而非原始结果
summary = get_tool_summary_text(self._counts)
if summary:
return {
"type": "streamlined_summary",
"summary": summary,
"counts": {
"searches": self._counts.searches,
"reads": self._counts.reads,
"writes": self._counts.writes,
"commands": self._counts.commands,
"other": self._counts.other,
},
}
return None
# ── 错误 ──
if event_type == "error":
return event
# ── 其他事件类型:保留 ──
return event
def flush(self) -> Optional[Dict[str, Any]]:
"""刷新最终的累计摘要。"""
summary = get_tool_summary_text(self._counts)
if summary:
return {
"type": "streamlined_summary",
"summary": summary,
"counts": {
"searches": self._counts.searches,
"reads": self._counts.reads,
"writes": self._counts.writes,
"commands": self._counts.commands,
"other": self._counts.other,
},
}
return None
def reset(self) -> None:
"""重置所有累积状态。"""
self._counts.reset()
self._pending_tool_results.clear()
# ──────────────────────────── 批量折叠器 ────────────────────────────
class ReadSearchCollapser:
"""折叠连续的搜索/读取操作为单个摘要组。
参考 Claude Code collapseReadSearch.ts 按消息流折叠
连续 search/read 类型的工具使用和结果
规则:
- 连续的 search/read 工具调用会合并为一个组
- writer/command 工具调用会打断组
- 文本消息会打断组
"""
def __init__(self):
self._group: List[Dict[str, Any]] = []
self._search_count = 0
self._read_count = 0
self._list_count = 0
self._is_active = False
def feed_tool_call(self, tool_name: str, tool_input: Optional[Dict] = None) -> Optional[Dict[str, Any]]:
"""处理一个工具调用,如果组被打断则返回 flushed 的组摘要。"""
cat = categorize_tool(tool_name)
if cat in (ToolCategory.SEARCH, ToolCategory.READ):
# 可折叠:加入当前组
if cat == ToolCategory.SEARCH:
self._search_count += 1
else:
self._read_count += 1
self._group.append({"name": tool_name, "input": tool_input})
self._is_active = True
# 返回进行中的摘要
return {
"type": "collapsed_progress",
"summary": get_search_read_summary(
self._search_count, self._read_count,
is_active=True, list_count=self._list_count,
),
"search_count": self._search_count,
"read_count": self._read_count,
"list_count": self._list_count,
}
else:
# 不可折叠:先 flush 组,再返回 None调用方自行处理
flushed = self.flush()
self._group = []
self._is_active = False
return flushed
def flush(self) -> Optional[Dict[str, Any]]:
"""输出当前折叠组的最终摘要。"""
if self._search_count == 0 and self._read_count == 0 and self._list_count == 0:
return None
result = {
"type": "collapsed_group",
"summary": get_search_read_summary(
self._search_count, self._read_count,
is_active=False, list_count=self._list_count,
),
"search_count": self._search_count,
"read_count": self._read_count,
"list_count": self._list_count,
"tool_count": len(self._group),
}
self._search_count = 0
self._read_count = 0
self._list_count = 0
self._group = []
self._is_active = False
return result
def reset(self) -> None:
self._group = []
self._search_count = 0
self._read_count = 0
self._list_count = 0
self._is_active = False
# ──────────────────────────── 工厂函数 ────────────────────────────
def create_streamlined_transformer(enabled: bool = True) -> StreamlinedTransformer:
"""创建 StreamlinedTransformer 实例。"""
return StreamlinedTransformer(enabled=enabled)
def create_read_search_collapser() -> ReadSearchCollapser:
"""创建 ReadSearchCollapser 实例。"""
return ReadSearchCollapser()