diff --git a/.agents/memory/remaining_long_files_to_split.md b/.agents/memory/remaining_long_files_to_split.md index e3cdaf5..660a7ee 100644 --- a/.agents/memory/remaining_long_files_to_split.md +++ b/.agents/memory/remaining_long_files_to_split.md @@ -5,11 +5,11 @@ description: 当需要继续拆分项目中的过长单个文件时,应该索 # 仍需拆分的长文件清单 -> 更新于 2026-06-20。 -> 已完成的拆分:`server/chat/`、`server/status/`、`server/tasks/`、`utils/context_manager/`、`utils/conversation_manager/`、`modules/file_manager/`、`modules/persistent_terminal/`、`modules/terminal_ops/`、`modules/mcp_client_manager/`、`core/main_terminal_parts/context/`、前端 `static/src/app/methods/{ui,message,upload,taskPolling,conversation}/`。 +> 更新于 2026-06-21。 +> 已完成的拆分:`server/chat/`、`server/status/`、`server/tasks/`、`utils/context_manager/`、`utils/conversation_manager/`、`utils/api_client/`、`utils/tool_result_formatter/`、`modules/file_manager/`、`modules/persistent_terminal/`、`modules/terminal_ops/`、`modules/mcp_client_manager/`、`core/main_terminal_parts/context/`、`core/main_terminal_parts/tools_definition/`、前端 `static/src/app/methods/{ui,message,upload,taskPolling,conversation}/`。 > `static/src/components/chat/monitor/MonitorDirector.ts` 按用户要求不参与拆分。 -下列文件仍是单个文件且超过约 500 行,后续可视维护需求继续拆分。优先拆**业务逻辑重、复用价值高、边界清晰**的文件;Vue SFC 文件因模板-样式-脚本耦合较深,拆分风险较高,建议先抽离 composable / 子组件,而非直接切分 `.vue`。 +下列文件仍是单个文件且超过约 500 行,**但 500 行只是参考线,不是硬性指标**。是否拆分取决于可维护性——优先拆**业务逻辑重、复用价值高、边界清晰**的文件;边界模糊、耦合紧密或改动频率低的文件,保持现状更划算。Vue SFC 文件因模板-样式-脚本耦合较深,拆分风险较高,建议先抽离 composable / 子组件,而非直接切分 `.vue`。 ## 后端 Python(高优先级) @@ -19,9 +19,6 @@ description: 当需要继续拆分项目中的过长单个文件时,应该索 | `server/chat_flow_task_main.py` | ~1739 | 拆为消息处理、guidance 队列、流式响应、任务生命周期、历史保存等 mixin | | `server/conversation.py` | ~1981 | 拆为对话 CRUD、回顾(review)、压缩(compression)、待办联动等模块 | | `server/chat_flow_tool_loop.py` | ~1391 | 按循环阶段拆:参数检查、工具调用、结果合并、循环终止 | -| `core/main_terminal_parts/tools_definition.py` | ~1195 | 按工具类别拆:文件、终端、搜索、网络、子智能体、自定义工具等 | -| `utils/tool_result_formatter.py` | ~1198 | 按工具类型拆 formatter | -| `utils/api_client.py` | ~1296 | 拆为 provider 基类、流式处理、重试/降级、工具调用解析 | | `server/app_legacy.py` | ~1431 | 它是 legacy 入口,改动频繁则优先拆为注册/中间件/路由聚合 | ## 后端 Python(中优先级) diff --git a/AGENTS.md b/AGENTS.md index de51a27..ca58307 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Repository Guidelines (Code-Verified) -> Last verified against current codebase: 2026-06-20 +> Last verified against current codebase: 2026-06-21 > Scope: `/Users/jojo/Desktop/agents/正在修复中/agents` 这份文档基于当前仓库实际代码重写。若与未来代码冲突,以代码为准并及时更新本文档。 @@ -15,10 +15,10 @@ - `web_server.py`: 兼容入口,已标记 deprecated,但仍可启动 - **后端核心目录** - `server/`: Flask 业务主线(chat/task/status 已拆分为子包:`server/chat/`、`server/status/`、`server/tasks/`;REST 任务轮询为主,Socket.IO 主要用于兼容与实时辅助通道) - - `core/`: 终端与工具编排(`main_terminal.py`、`web_terminal.py`、`main_terminal_parts/*`;其中 `main_terminal_parts/context/` 已拆分为 base + mixin 子包) + - `core/`: 终端与工具编排(`main_terminal.py`、`web_terminal.py`、`main_terminal_parts/*`;其中 `main_terminal_parts/context/` 和 `main_terminal_parts/tools_definition` 已拆分为 base + mixin 子包) - `modules/`: 可复用能力模块(terminal/file/memory/sub_agent/upload_security/user 等;`file_manager`、`persistent_terminal`、`terminal_ops`、`mcp_client_manager` 已拆分为子包) - `config/`: 配置拆分(`api.py`, `limits.py`, `terminal.py`, `paths.py` ...),由 `config/__init__.py` 聚合并加载 `.env` - - `utils/`: API client、日志、上下文与对话工具等公共函数(`context_manager`、`conversation_manager` 已拆分为子包) + - `utils/`: API client、日志、上下文与对话工具等公共函数(`api_client`、`tool_result_formatter`、`context_manager`、`conversation_manager` 已拆分为子包;原入口文件保留为兼容入口) - **前端目录** - `static/src/`: Vue 3 + TS 前端 - `cli/src/`: React 19 + Ink 6 + TypeScript CLI 前端(正在重写中) @@ -141,6 +141,17 @@ ## 5) 风格与质量 +### 文件长度与拆分 + +- **单文件最好不超过 1000 行**;超过时应优先考虑合理拆分,避免维护成本快速上升。 +- **行数不是硬性指标**:文件是否拆分取决于**可维护性**,而不是必须小于某个固定行数。 +- 当单一文件超过约 500 行时,应评估是否具备以下拆分价值: + - 业务逻辑过重、职责不单一; + - 存在清晰的边界(如按工具类型、按资源、按生命周期阶段); + - 拆分后能提高复用性、可读性或测试便利性。 +- **不要为了拆分而拆分**。边界模糊、耦合紧密或改动频率低的文件,保持现状可能更划算。 +- Vue SFC 因模板、样式、脚本耦合较深,拆分风险较高;优先抽离 composable 和子组件,而非直接切分 `.vue` 文件。 + - Python:4 空格、`snake_case` 函数、`PascalCase` 类,新增公共函数尽量补 type hints。 - Vue/TS:保持现有代码风格,不做无关风格清洗。 - CLI React/TS:保留现有 Ink 渲染方式与光标修正逻辑,不要轻易重写输入框定位策略。 diff --git a/CLAUDE.md b/CLAUDE.md index 297f1ea..d62f588 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - `conversation.py` / `files.py` / `auth.py` / `admin.py` / `api_v1.py` / `context.py` - 对话、文件、认证、管理、API v1、请求上下文接口 **core/** -- `MainTerminal` (`main_terminal.py`) - CLI 主终端,处理用户输入和 AI 对话循环;逻辑拆分到 `core/main_terminal_parts/*`(commands / context / tools / tools_execution / tools_policy / tools_read 等) +- `MainTerminal` (`main_terminal.py`) - CLI 主终端,处理用户输入和 AI 对话循环;逻辑拆分到 `core/main_terminal_parts/*`(commands / context / tools_definition / tools_execution / tools_policy / tools_read 等) - `WebTerminal` (`web_terminal.py`) - Web 终端,继承 MainTerminal,支持轮询任务与实时通道兼容能力 - `tool_config.py` - 定义所有工具的配置和分类 @@ -48,7 +48,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - `upload_security.py` - 上传文件隔离扫描 **utils/** -- `api_client.py` - 与 OpenAI-compatible API 交互,支持 thinking mode(首次思考,后续快速) +- `api_client.py` - 兼容入口,实际实现已迁移到 `utils/api_client/` 子包(mixin 组织) +- `tool_result_formatter.py` - 兼容入口,实际实现已迁移到 `utils/tool_result_formatter/` 子包 - `terminal_factory.py` - 终端类型工厂 **static/** diff --git a/core/main_terminal_parts/tools_definition.py b/core/main_terminal_parts/tools_definition.py index 85bc2e1..4045547 100644 --- a/core/main_terminal_parts/tools_definition.py +++ b/core/main_terminal_parts/tools_definition.py @@ -1,1195 +1,4 @@ -import asyncio -import json -from datetime import datetime -from pathlib import Path -from typing import Any, Dict, List, Optional, Set +# 兼容入口,已迁移到 core.main_terminal_parts.tools_definition 包 +from core.main_terminal_parts.tools_definition import MainTerminalToolsDefinitionMixin -try: - from config import ( - OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION, - MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE, - MAX_READ_FILE_CHARS, READ_TOOL_DEFAULT_MAX_CHARS, - READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER, - READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER, - READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES, - READ_TOOL_MAX_FILE_SIZE, - TERMINAL_SANDBOX_MOUNT_PATH, - TERMINAL_SANDBOX_MODE, - TERMINAL_SANDBOX_CPUS, - TERMINAL_SANDBOX_MEMORY, - PROJECT_MAX_STORAGE_MB, - CUSTOM_TOOLS_ENABLED, - ) -except ImportError: - import sys - project_root = Path(__file__).resolve().parents[2] - if str(project_root) not in sys.path: - sys.path.insert(0, str(project_root)) - from config import ( - OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION, - MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE, - MAX_READ_FILE_CHARS, READ_TOOL_DEFAULT_MAX_CHARS, - READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER, - READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER, - READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES, - READ_TOOL_MAX_FILE_SIZE, - TERMINAL_SANDBOX_MOUNT_PATH, - TERMINAL_SANDBOX_MODE, - TERMINAL_SANDBOX_CPUS, - TERMINAL_SANDBOX_MEMORY, - PROJECT_MAX_STORAGE_MB, - CUSTOM_TOOLS_ENABLED, - ) - -from modules.file_manager import FileManager -from modules.search_engine import SearchEngine -from modules.terminal_ops import TerminalOperator -from modules.memory_manager import MemoryManager -from modules.terminal_manager import TerminalManager -from modules.todo_manager import TodoManager -from modules.sub_agent import SubAgentManager -from modules.webpage_extractor import extract_webpage_content, tavily_extract -from modules.ocr_client import OCRClient -from modules.easter_egg_manager import EasterEggManager -from modules.personalization_manager import ( - load_personalization_config, - build_personalization_prompt, -) -from modules.skills_manager import ( - get_skills_catalog, - build_skills_list, - merge_enabled_skills, - build_skills_prompt, -) -from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category -from modules.custom_tool_executor import CustomToolExecutor -from modules.mcp_server_registry import MCPServerRegistry, build_default_mcp_category - -try: - from config.limits import THINKING_FAST_INTERVAL -except ImportError: - THINKING_FAST_INTERVAL = 10 - -from modules.container_monitor import collect_stats, inspect_state -from core.tool_config import TOOL_CATEGORIES -from utils.api_client import DeepSeekClient -from utils.context_manager import ContextManager -from utils.tool_result_formatter import format_tool_result_for_context -from utils.logger import setup_logger -from config.model_profiles import ( - get_model_profile, - get_model_prompt_replacements, - get_model_context_window, - model_supports_image, - model_supports_video, -) - -logger = setup_logger(__name__) -DISABLE_LENGTH_CHECK = True - -class MainTerminalToolsDefinitionMixin: - @staticmethod - def _mcp_disabled_message() -> str: - return "当前为docker模式,MCP仅支持宿主机模式" - - def _is_mcp_disabled_in_docker_mode(self) -> bool: - session = getattr(self, "container_session", None) - return bool(session and getattr(session, "mode", None) == "docker") - - def _inject_intent(self, properties: Dict[str, Any]) -> Dict[str, Any]: - """在工具参数中注入 intent(简短意图说明),仅当开关启用时。 - - 字段含义:要求模型用不超过15个中文字符对即将执行的动作做简要说明,供前端展示。 - """ - if not self.tool_intent_enabled: - return properties - if not isinstance(properties, dict): - return properties - intent_field = { - "intent": { - "type": "string", - "description": "用不超过15个字向用户说明你要做什么,例如:等待下载完成/创建日志文件" - } - } - # 将 intent 放在最前面以提高模型关注度 - return {**intent_field, **properties} - - def _apply_intent_to_tools(self, tools: List[Dict]) -> List[Dict]: - """遍历工具列表,为缺少 intent 的工具补充字段(开关启用时生效)。""" - if not self.tool_intent_enabled: - return tools - intent_field = { - "intent": { - "type": "string", - "description": "用不超过15个字向用户说明你要做什么,例如:等待下载完成/创建日志文件/搜索最新新闻" - } - } - for tool in tools: - func = tool.get("function") or {} - tool_name = str(func.get("name") or "").strip() - # MCP 扩展工具参数需与远端 schema 严格一致,不注入 intent - if tool_name.startswith("mcp__"): - continue - params = func.get("parameters") or {} - if not isinstance(params, dict): - continue - if params.get("type") != "object": - continue - props = params.get("properties") - if not isinstance(props, dict): - continue - # 补充 intent 属性 - if "intent" not in props: - params["properties"] = {**intent_field, **props} - # 将 intent 加入必填 - required_list = params.get("required") - if isinstance(required_list, list): - if "intent" not in required_list: - required_list.insert(0, "intent") - params["required"] = required_list - else: - params["required"] = ["intent"] - return tools - - def _build_custom_tools(self) -> List[Dict]: - if not (self.custom_tools_enabled and getattr(self, "user_role", "user") == "admin"): - return [] - try: - definitions = self.custom_tool_registry.reload() - except Exception: - definitions = self.custom_tool_registry.list_tools() - if not definitions: - # 更新分类为空列表,避免旧缓存 - if "custom" in self.tool_categories_map: - self.tool_categories_map["custom"].tools = [] - self._refresh_disabled_tools() - return [] - - tools: List[Dict] = [] - tool_ids: List[str] = [] - for item in definitions: - tool_id = item.get("id") - if not tool_id: - continue - if item.get("invalid_id"): - # 跳过不合法的工具 ID,避免供应商严格校验时报错 - continue - tool_ids.append(tool_id) - params = item.get("parameters") or {"type": "object", "properties": {}} - if isinstance(params, dict) and params.get("type") != "object": - params = {"type": "object", "properties": {}} - required = item.get("required") - if isinstance(required, list): - params = dict(params) - params["required"] = required - - tools.append({ - "type": "function", - "function": { - "name": tool_id, - "description": item.get("description") or f"自定义工具: {tool_id}", - "parameters": params - } - }) - - # 覆盖 custom 分类的工具列表 - if "custom" in self.tool_categories_map: - self.tool_categories_map["custom"].tools = tool_ids - self._refresh_disabled_tools() - - return tools - - def _build_mcp_tools(self) -> List[Dict]: - if not getattr(self, "mcp_tools_enabled", False): - if hasattr(self, "tool_categories_map") and "mcp" in self.tool_categories_map: - self.tool_categories_map["mcp"].tools = ["list_mcp_servers"] - stale_keys = [ - key for key in list(getattr(self, "tool_categories_map", {}).keys()) - if isinstance(key, str) and key.startswith("mcp_server__") - ] - for key in stale_keys: - self.tool_categories_map.pop(key, None) - self.tool_category_states.pop(key, None) - self._refresh_disabled_tools() - self.mcp_tool_alias_map = {} - return [] - if self._is_mcp_disabled_in_docker_mode(): - self.mcp_tool_alias_map = {} - if "mcp" in self.tool_categories_map: - self.tool_categories_map["mcp"].tools = ["list_mcp_servers"] - elif getattr(self, "mcp_tools_enabled", False): - default_mcp_cat = build_default_mcp_category() - self.tool_categories_map["mcp"] = type(next(iter(TOOL_CATEGORIES.values())))( - label=default_mcp_cat["label"], - tools=["list_mcp_servers"], - default_enabled=True, - silent_when_disabled=False, - ) - stale_keys = [ - key for key in list(getattr(self, "tool_categories_map", {}).keys()) - if isinstance(key, str) and key.startswith("mcp_server__") - ] - for key in stale_keys: - self.tool_categories_map.pop(key, None) - self.tool_category_states.pop(key, None) - self._refresh_disabled_tools() - return [] - # 类别层面的禁用优先生效(即使策略里尚未填充具体工具列表) - mcp_cat = getattr(self, "tool_categories_map", {}).get("mcp") - if mcp_cat is not None: - mcp_enabled = self.tool_category_states.get("mcp", getattr(mcp_cat, "default_enabled", True)) - forced = getattr(self, "admin_forced_category_states", {}).get("mcp") - if isinstance(forced, bool): - mcp_enabled = forced - if not mcp_enabled: - self.mcp_tool_alias_map = {} - self.tool_categories_map["mcp"].tools = ["list_mcp_servers"] - stale_keys = [ - key for key in list(getattr(self, "tool_categories_map", {}).keys()) - if isinstance(key, str) and key.startswith("mcp_server__") - ] - for key in stale_keys: - self.tool_categories_map.pop(key, None) - self.tool_category_states.pop(key, None) - self._refresh_disabled_tools() - return [] - manager = getattr(self, "mcp_client_manager", None) - if manager is None: - self.mcp_tool_alias_map = {} - return [] - try: - tool_defs, alias_map = manager.build_openai_tools( - ensure_discovery=True, - discovery_timeout_seconds=10, - ) - except Exception as exc: - logger.warning("构建 MCP 工具列表失败: %s", exc) - tool_defs, alias_map = [], {} - - # 保存映射,供执行层按 alias 分发到具体 server/tool - alias_map = alias_map or {} - if "mcp" in self.tool_categories_map: - self.tool_categories_map["mcp"].tools = ["list_mcp_servers"] - elif getattr(self, "mcp_tools_enabled", False): - default_mcp_cat = build_default_mcp_category() - self.tool_categories_map["mcp"] = type(next(iter(TOOL_CATEGORIES.values())))( - label=default_mcp_cat["label"], - tools=["list_mcp_servers"], - default_enabled=True, - silent_when_disabled=False, - ) - server_aliases: Dict[str, List[str]] = {} - for alias, binding in alias_map.items(): - sid = str(getattr(binding, "server_id", "") or "").strip() - if not sid: - continue - server_aliases.setdefault(sid, []).append(alias) - - enabled_servers = [] - try: - enabled_servers = list(self.mcp_server_registry.list_enabled_servers()) - except Exception: - enabled_servers = [] - - active_server_category_ids = set() - for server in enabled_servers: - sid = str(server.get("id") or "").strip() - if not sid: - continue - cat_id = MCPServerRegistry.make_server_category_id(sid) - active_server_category_ids.add(cat_id) - aliases = list(server_aliases.get(sid, [])) - existing = self.tool_categories_map.get(cat_id) - label = MCPServerRegistry.make_server_category_label(server) - if existing is not None: - existing.label = label - existing.tools = aliases - else: - self.tool_categories_map[cat_id] = type(next(iter(TOOL_CATEGORIES.values())))( - label=label, - tools=aliases, - default_enabled=True, - silent_when_disabled=False, - ) - if cat_id not in self.tool_category_states: - self.tool_category_states[cat_id] = True - - stale_keys = [ - key for key in list(self.tool_categories_map.keys()) - if isinstance(key, str) - and key.startswith("mcp_server__") - and key not in active_server_category_ids - ] - for key in stale_keys: - self.tool_categories_map.pop(key, None) - self.tool_category_states.pop(key, None) - - if hasattr(self, "_prune_runtime_tool_overrides"): - self._prune_runtime_tool_overrides() - if hasattr(self, "_apply_runtime_tool_overrides"): - self._apply_runtime_tool_overrides() - - filtered_tools: List[Dict[str, Any]] = [] - filtered_alias_map: Dict[str, Any] = {} - for tool_def in tool_defs: - alias = str(((tool_def or {}).get("function") or {}).get("name") or "").strip() - if not alias: - continue - binding = alias_map.get(alias) - if binding is None: - continue - sid = str(getattr(binding, "server_id", "") or "").strip() - if not sid: - continue - cat_id = MCPServerRegistry.make_server_category_id(sid) - cat = self.tool_categories_map.get(cat_id) - cat_enabled = self.tool_category_states.get( - cat_id, - getattr(cat, "default_enabled", True) if cat is not None else True, - ) - forced = getattr(self, "admin_forced_category_states", {}).get(cat_id) - if isinstance(forced, bool): - cat_enabled = forced - if not cat_enabled: - continue - filtered_tools.append(tool_def) - filtered_alias_map[alias] = binding - - self.mcp_tool_alias_map = filtered_alias_map - self._refresh_disabled_tools() - return filtered_tools - - def define_tools(self) -> List[Dict]: - """定义可用工具(添加确认工具)""" - now = datetime.now() - current_time = f"{now.year}年{now.month}月{now.day}日 {now.hour}点(24小时制)" - - tools = [ - { - "type": "function", - "function": { - "name": "sleep", - "description": "等待工具。三种模式三选一:1) seconds:短暂延迟;2) wait_sub_agent_ids:等待指定子智能体全部结束并直接返回结果;3) wait_runcommand_id:等待指定后台 run_command 结束并直接返回结果。若同时提供多个等待参数会报错。", - "parameters": { - "type": "object", - "properties": self._inject_intent({ - "seconds": { - "type": "number", - "description": "等待的秒数,可以是小数(如0.2秒)。建议范围:0.1-10秒" - }, - "wait_sub_agent_ids": { - "type": "array", - "items": {"type": "integer"}, - "description": "等待这些子智能体全部结束后返回(可提供一个或多个编号)。" - }, - "wait_runcommand_id": { - "type": "string", - "description": "等待指定后台 run_command 的 command_id 结束后返回。" - }, - "reason": { - "type": "string", - "description": "等待的原因说明(可选)" - } - }), - "required": [] - } - } - }, - { - "type": "function", - "function": { - "name": "ask_user", - "description": "向用户提问并阻塞等待回答。当开发/执行任务中遇到会影响实现方向、产品行为、数据安全或用户偏好的关键不确定性时使用。优先把问题设计成可选择题:默认应提供 2-4 个清晰选项,并把推荐项放第一位;只有用户必须提供具体文本、路径、命名等无法合理枚举的开放问题时,才不要提供 options。前端会弹出问题窗口,用户可以选择预设选项,也始终可以直接打字补充或改写回答;拿到回答后工具才返回,模型才能继续下一步。调用 ask_user 时不要同时调用其他执行工具;如需多个相互独立的问题,可以在同一轮并行调用多个 ask_user。", - "parameters": { - "type": "object", - "properties": self._inject_intent({ - "question": { - "type": "string", - "description": "要问用户的核心问题。必须具体、简短、可回答;优先配合 options 让用户点选,而不是要求用户从零输入。" - }, - "context": { - "type": "string", - "description": "可选。说明为什么需要确认,以及不同选择会影响什么。" - }, - "options": { - "type": "array", - "description": "强烈建议提供。给用户的预设选项,通常 2-4 个;如果有推荐项,放第一位并在 label 中标注“推荐”。每个选项应互斥、短、能直接决策,并用 description 说明影响或取舍。除非问题必须让用户输入具体文本/路径/名称,否则不要省略 options。用户始终可以不选预设项而直接打字回答。", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "选项稳定 ID,例如 modal / sidebar / skip / keep_current。" - }, - "label": { - "type": "string", - "description": "展示给用户的短标签。" - }, - "description": { - "type": "string", - "description": "一句话说明该选项的影响或取舍。" - } - }, - "required": ["id", "label"] - } - } - }), - "required": ["question"] - } - } - }, - { - "type": "function", - "function": { - "name": "write_file", - "description": "将内容写入本地文件系统;append 为 False 时覆盖原文件,True 时追加到末尾。", - "parameters": { - "type": "object", - "properties": self._inject_intent({ - "file_path": { - "type": "string", - "description": "要写入的相对路径" - }, - "content": { - "type": "string", - "description": "要写入文件的内容" - }, - "append": { - "type": "boolean", - "description": "是否追加到文件而不是覆盖它", - "default": False - } - }), - "required": ["file_path", "content"] - } - } - }, - { - "type": "function", - "function": { - "name": "read_skill", - "description": "按 skill 名称读取 .agents/skills//SKILL.md 内容;内部等价于 read_file 的 read 模式,并返回解析后的 path。", - "parameters": { - "type": "object", - "properties": self._inject_intent({ - "skill_name": { - "type": "string", - "description": "skill 名称(支持 skill id 或技能名称)" - } - }), - "required": ["skill_name"] - } - } - }, - { - "type": "function", - "function": { - "name": "create_skill", - "description": "验证并归档一个已创建好的 skill 文件夹。当你觉得本次工作的流程、经验或踩坑值得沉淀为 skill,便于以后面对类似问题时高效工作,或用户主动要求“把今天/本次经历转化为 skill”时,应先阅读 skill-creator,按规范创建对应 skill 文件夹与 SKILL.md,再调用本工具验证并归档。source_dir 可为当前工作区相对路径、工作区内绝对路径,宿主机模式也支持电脑上其它位置的绝对路径。验证通过后移动到正式 skills 库;不覆盖已存在 skill。", - "parameters": { - "type": "object", - "properties": self._inject_intent({ - "source_dir": { - "type": "string", - "description": "要归档的 skill 文件夹路径;skill 名称使用该文件夹名称。" - } - }), - "required": ["source_dir"] - } - } - }, - { - "type": "function", - "function": { - "name": "read_file", - "description": "读取/搜索/抽取 UTF-8 文本文件内容。通过 type 参数选择 read(阅读)、search(搜索)、extract(具体行段),支持限制返回字符数。若文件非 UTF-8 或过大,请改用 run_command 调用合适的解析工具或 Python 解释器。", - "parameters": { - "type": "object", - "properties": self._inject_intent({ - "path": {"type": "string", "description": "文件路径"}, - "type": { - "type": "string", - "enum": ["read", "search", "extract"], - "description": "读取模式:read=阅读、search=搜索、extract=按行抽取" - }, - "max_chars": { - "type": "integer", - "description": "返回内容的最大字符数,默认与 config 一致" - }, - "start_line": { - "type": "integer", - "description": "[read] 可选的起始行号(1开始)" - }, - "end_line": { - "type": "integer", - "description": "[read] 可选的结束行号(>=start_line)" - }, - "query": { - "type": "string", - "description": "[search] 搜索关键词" - }, - "max_matches": { - "type": "integer", - "description": "[search] 最多返回多少条命中(默认5,最大50)" - }, - "context_before": { - "type": "integer", - "description": "[search] 命中行向上追加的行数(默认1,最大3)" - }, - "context_after": { - "type": "integer", - "description": "[search] 命中行向下追加的行数(默认1,最大5)" - }, - "case_sensitive": { - "type": "boolean", - "description": "[search] 是否区分大小写,默认 false" - }, - "segments": { - "type": "array", - "description": "[extract] 需要抽取的行区间", - "items": { - "type": "object", - "properties": { - "label": { - "type": "string", - "description": "该片段的标签(可选)" - }, - "start_line": { - "type": "integer", - "description": "起始行号(>=1)" - }, - "end_line": { - "type": "integer", - "description": "结束行号(>=start_line)" - } - }, - "required": ["start_line", "end_line"] - }, - "minItems": 1 - } - }), - "required": ["path", "type"] - } - } - }, - { - "type": "function", - "function": { - "name": "edit_file", - "description": "在文件中按顺序执行一组或多组精确字符串替换;建议先使用 read_file 获取最新内容以确保精确匹配。任意一组失败时不会写入文件。", - "parameters": { - "type": "object", - "properties": self._inject_intent({ - "file_path": { - "type": "string", - "description": "要修改文件的相对路径" - }, - "replacements": { - "type": "array", - "description": "替换项数组,按数组顺序依次执行。每一项包含 old_string/new_string,可选 replace_all;replace_all 未提供时默认为 false。", - "minItems": 1, - "items": { - "type": "object", - "properties": { - "old_string": { - "type": "string", - "description": "要替换的文本(需与文件内容精确匹配,保留缩进;建议提供至少3行提升定位稳定性。需要批量替换的场景可以单行或不足一行)" - }, - "new_string": { - "type": "string", - "description": "用于替换的新文本(必须不同于 old_string)" - }, - "replace_all": { - "type": "boolean", - "enum": [True, False], - "description": "是否替换该 old_string 的所有匹配内容。false=仅替换首个匹配,true=替换全部匹配;默认 false。" - } - }, - "required": ["old_string", "new_string"] - } - } - }), - "required": ["file_path", "replacements"] - } - } - }, - { - "type": "function", - "function": { - "name": "vlm_analyze", - "description": "使用大参数视觉语言模型(Qwen3.5)理解图片:文字、物体、布局、表格等,仅支持本地路径。", - "parameters": { - "type": "object", - "properties": self._inject_intent({ - "path": {"type": "string", "description": "项目内的图片相对路径"}, - "prompt": {"type": "string", "description": "传递给 VLM 的中文提示词,如“请总结这张图的内容”“表格的总金额是多少”“图中是什么车?”。"} - }), - "required": ["path", "prompt"] - } - } - }, - { - "type": "function", - "function": { - "name": "terminal_session", - "description": "管理持久化终端会话,可打开、关闭、列出或切换终端。请在授权工作区内执行命令,禁止启动需要完整 TTY 的程序(python REPL、vim、top 等)。", - "parameters": { - "type": "object", - "properties": self._inject_intent({ - "action": { - "type": "string", - "enum": ["open", "close", "list", "reset"], - "description": "操作类型:open-打开新终端,close-关闭终端,list-列出所有终端,reset-重置终端" - }, - "session_name": { - "type": "string", - "description": "终端会话名称(open、close、reset时需要)" - }, - "working_dir": { - "type": "string", - "description": "工作目录,相对于项目路径(open时可选)" - } - }), - "required": ["action"] - } - } - }, - { - "type": "function", - "function": { - "name": "terminal_input", - "description": "向指定终端发送命令或输入。禁止启动会占用终端界面的程序(python/node/nano/vim 等);如遇卡死请结合 terminal_snapshot 并使用 terminal_session 的 reset 恢复。output_wait 必填:本次收集/等待输出的最大时长(秒,1-300),不会封装命令、不会强杀进程;在窗口内若检测到命令已完成会提前返回,否则到时返回已产生的输出并保持命令继续运行。需要强制超时终止请使用 run_command。\n用法建议:\n1) 短命令多次执行可设 5-10 秒;\n2) 只需确认启动的长任务(下载/clone/pip/启动服务)可设约 30 秒,运行期间可做其他事情,后续用 terminal_snapshot 判断状态/是否完成;\n3) 必须等待结果再继续的任务(大表/文件批量处理、前端构建、批量测试、压缩/解压、数据导出/统计)设足够覆盖实际执行时间;\n4) 可能长时间无输出的任务(遍历海量文件/转码/打包)短窗口不一定有输出,可适当加长或加完成标记;\n5) 长时间无输出且怀疑卡死时,使用 terminal_session 的 reset 重置终端;\n6) 需要仅发送回车时,可传入一个空格字符作为 command(等效按下 Enter)。\n若不确定上一条命令是否结束,先用 terminal_snapshot 确认后再继续输入。", - "parameters": { - "type": "object", - "properties": self._inject_intent({ - "command": { - "type": "string", - "description": "要执行的命令或发送的输入" - }, - "session_name": { - "type": "string", - "description": "目标终端会话名称(必填)" - }, - "output_wait": { - "type": "number", - "description": "等待/收集输出的最大秒数(1-300,必填);非命令超时,到点即返回已产生输出并保持命令运行" - } - }), - "required": ["command", "output_wait", "session_name"] - } - } - }, - { - "type": "function", - "function": { - "name": "terminal_snapshot", - "description": "获取指定终端最近的输出快照,用于判断当前状态。默认返回末尾的50行,可通过参数调整。", - "parameters": { - "type": "object", - "properties": self._inject_intent({ - "session_name": { - "type": "string", - "description": "目标终端会话名称(可选,默认活动终端)" - }, - "lines": { - "type": "integer", - "description": "返回的最大行数(可选)" - }, - "max_chars": { - "type": "integer", - "description": "返回的最大字符数(可选)" - } - }) - } - } - }, - { - "type": "function", - "function": { - "name": "web_search", - "description": f"当现有资料不足时搜索外部信息(当前时间 {current_time})。调用前说明目的,精准撰写 query,并合理设置时间/主题参数;避免重复或无意义的搜索。", - "parameters": { - "type": "object", - "properties": self._inject_intent({ - "query": { - "type": "string", - "description": "搜索查询内容(不要包含日期或时间范围)" - }, - "max_results": { - "type": "integer", - "description": "最大结果数,可选" - }, - "topic": { - "type": "string", - "description": "搜索主题,可选值:general(默认)/news/finance" - }, - "time_range": { - "type": "string", - "description": "相对时间范围,可选 day/week/month/year,支持缩写 d/w/m/y;与 days 和 start_date/end_date 互斥" - }, - "days": { - "type": "integer", - "description": "最近 N 天,仅当 topic=news 时可用;与 time_range、start_date/end_date 互斥" - }, - "start_date": { - "type": "string", - "description": "开始日期,YYYY-MM-DD;必须与 end_date 同时提供,与 time_range、days 互斥" - }, - "end_date": { - "type": "string", - "description": "结束日期,YYYY-MM-DD;必须与 start_date 同时提供,与 time_range、days 互斥" - }, - "country": { - "type": "string", - "description": "国家过滤,仅 topic=general 可用,使用英文小写国名" - }, - "include_domains": { - "type": "array", - "description": "仅包含这些域名(可选,最多300个)", - "items": { - "type": "string" - } - } - }), - "required": ["query"] - } - } - }, - { - "type": "function", - "function": { - "name": "extract_webpage", - "description": "在 web_search 结果不够详细时提取网页正文。调用前说明用途,注意提取内容会消耗大量 token,超过80000字符将被拒绝。", - "parameters": { - "type": "object", - "properties": self._inject_intent({ - "url": {"type": "string", "description": "要提取内容的网页URL"} - }), - "required": ["url"] - } - } - }, - { - "type": "function", - "function": { - "name": "save_webpage", - "description": "提取网页内容并保存为纯文本文件,适合需要长期留存的长文档。请提供网址与目标路径(含 .txt 后缀),落地后请通过终端命令查看。", - "parameters": { - "type": "object", - "properties": self._inject_intent({ - "url": {"type": "string", "description": "要保存的网页URL"}, - "target_path": {"type": "string", "description": "保存位置,包含文件名,相对于项目根目录"} - }), - "required": ["url", "target_path"] - } - } - }, - { - "type": "function", - "function": { - "name": "list_mcp_servers", - "description": "列出当前已配置的 MCP 服务与工具映射(mcp__... 别名)。可选刷新远程 tools/list 缓存后再返回。", - "parameters": { - "type": "object", - "properties": self._inject_intent({ - "refresh": { - "type": "boolean", - "description": "是否先同步 MCP 服务工具缓存(默认 false)" - }, - "server_id": { - "type": "string", - "description": "仅查看指定 MCP 服务(可选)" - }, - "include_disabled": { - "type": "boolean", - "description": "是否包含已禁用服务(默认 false)" - } - }), - "required": [] - } - } - }, - { - "type": "function", - "function": { - "name": "run_command", - "description": "执行一次性终端命令,适合查看文件信息(file/ls/stat/iconv 等)、转换编码或调用 CLI 工具。禁止启动交互式程序。必须提供 timeout。前台模式(run_in_background=false,默认)上限120秒,超时会打断;后台模式(run_in_background=true)上限3600秒,会先等待5秒返回已有输出并继续在后台运行,完成后由系统通知。", - "parameters": { - "type": "object", - "properties": self._inject_intent({ - "command": {"type": "string", "description": "终端命令"}, - "timeout": { - "type": "number", - "description": "超时时长(秒),必填。前台最大120;后台最大3600。" - }, - "run_in_background": { - "type": "boolean", - "description": "是否后台运行。true 时先等待5秒返回已有输出,并在后台持续执行直至结束。" - } - }), - "required": ["command", "timeout"] - } - } - }, - { - "type": "function", - "function": { - "name": "update_memory", - "description": "按条目管理总体长期记忆(自动编号,跨项目通用)。append/replace/delete。当用户提到个人信息、偏好、跨项目习惯,或用户主动要求「记住xxx」时,应积极主动地调用本工具。不记录密钥、隐私、未确认猜测。与特定项目绑定的技术约定用 update_project_memory。", - "parameters": { - "type": "object", - "properties": self._inject_intent({ - "content": {"type": "string", "description": "条目内容。append/replace 时必填"}, - "operation": {"type": "string", "enum": ["append", "replace", "delete"], "description": "操作类型"}, - "index": {"type": "integer", "description": "要替换/删除的序号(从1开始)"} - }), - "required": ["operation"] - } - } - }, - { - "type": "function", - "function": { - "name": "recall_project_memory", - "description": "读取指定的项目记忆文件,返回完整内容(含 frontmatter)。项目记忆存储在 .agents/memory/ 目录下。", - "parameters": { - "type": "object", - "properties": self._inject_intent({ - "name": { - "type": "string", - "description": "记忆名称,对应 .agents/memory/{name}.md 文件名" - } - }), - "required": ["name"] - } - } - }, - { - "type": "function", - "function": { - "name": "update_project_memory", - "description": "创建或覆盖项目记忆文件。当你发现有关当前项目的重要约定/决策/坑、用户表现出对项目的偏好,或用户主动要求「在当前项目里,下次要xxx/记住xxx」时,应积极主动地调用本工具。记忆名称用英文下划线,描述格式为'当xxxx时,应该索引本记忆'。不记录可从代码直接推断的或一次性信息。", - "parameters": { - "type": "object", - "properties": self._inject_intent({ - "name": { - "type": "string", - "description": "记忆名称,同时作为文件名 .agents/memory/{name}.md。建议英文+下划线,如 docker_compose" - }, - "description": { - "type": "string", - "description": "记忆简述,格式为'当xxxx时,应该索引本记忆',用于在 prompt 中作为索引展示(≤100字)" - }, - "content": { - "type": "string", - "description": "记忆正文(Markdown),记录具体内容" - } - }), - "required": ["name", "description", "content"] - } - } - }, - { - "type": "function", - "function": { - "name": "conversation_search", - "description": "在当前工作区的历史对话中搜索或列出对话,返回对话标题与 id。最多提供 3 个关键词,命中任一关键词即可;如果不提供关键词,则按时间列出最近的历史对话。可提供日期范围和最大返回数量。不能跨工作区搜索,也不会搜索当前对话。", - "parameters": { - "type": "object", - "properties": self._inject_intent({ - "keywords": { - "type": "array", - "description": "搜索关键词列表,最多3个;仅搜索标题和首条用户消息。可为空。", - "items": {"type": "string"}, - "maxItems": 3 - }, - "query": {"type": "string", "description": "兼容字段:单个搜索关键词,可为空;优先使用 keywords"}, - "start_date": {"type": "string", "description": "开始日期 YYYY-MM-DD,可选"}, - "end_date": {"type": "string", "description": "结束日期 YYYY-MM-DD,可选"}, - "limit": {"type": "integer", "description": "最大返回数量,默认10,最大100"} - }), - "required": [] - } - } - }, - { - "type": "function", - "function": { - "name": "conversation_review", - "description": "按 id 回顾当前工作区内的历史对话。mode=read 时直接返回回顾内容;若内容超过 50000 字符,将自动保存到 .agents/review/ 并提示分段或查找阅读。mode=save 时保存 Markdown 文件到 .agents/review/ 并返回路径。若 id 不属于当前工作区,将返回不存在或不属于当前工作区。", - "parameters": { - "type": "object", - "properties": self._inject_intent({ - "conversation_id": {"type": "string", "description": "要回顾的对话 id,例如 conv_20250924_210942_114"}, - "mode": { - "type": "string", - "enum": ["read", "save"], - "description": "必要参数。read=直接返回回顾内容;save=保存为 .agents/review/ 下的 Markdown 文件并返回路径。" - } - }), - "required": ["conversation_id", "mode"] - } - } - }, - { - "type": "function", - "function": { - "name": "todo_create", - "description": "创建待办列表,最多 8 条任务;若已有列表将被覆盖。当用户提出稍微复杂的要求、预计需要超过 3 个步骤时,应积极创建待办事项。", - "parameters": { - "type": "object", - "properties": self._inject_intent({ - "overview": {"type": "string", "description": "一句话概述待办清单要完成的目标,50 字以内。"}, - "tasks": { - "type": "array", - "description": "任务列表,1~8 条,每条写清“动词+对象+目标”。", - "items": { - "type": "object", - "properties": { - "title": {"type": "string", "description": "单个任务描述,写成可执行的步骤"} - }, - "required": ["title"] - }, - "minItems": 1, - "maxItems": 8 - } - }), - "required": ["overview", "tasks"] - } - } - }, - { - "type": "function", - "function": { - "name": "todo_update_task", - "description": "批量勾选或取消任务(支持单个或多个任务);全部勾选时提示所有任务已完成。", - "parameters": { - "type": "object", - "properties": self._inject_intent({ - "task_index": {"type": "integer", "description": "任务序号(1-8),兼容旧参数"}, - "task_indices": { - "type": "array", - "items": {"type": "integer"}, - "minItems": 1, - "maxItems": 8, - "description": "要更新的任务序号列表(1-8),可一次勾选多个" - }, - "completed": {"type": "boolean", "description": "true=打勾,false=取消"} - }), - "required": ["completed"] - } - } - }, - { - "type": "function", - "function": { - "name": "close_sub_agent", - "description": "强制关闭指定子智能体,适用于长时间无响应、超时或卡死的任务。使用前请确认必要的日志/文件已保留,操作会立即终止该任务。", - "parameters": { - "type": "object", - "properties": self._inject_intent({ - "task_id": {"type": "string", "description": "子智能体任务ID"}, - "agent_id": {"type": "integer", "description": "子智能体编号(1~5),若缺少 task_id 可用"} - }) - } - } - }, - { - "type": "function", - "function": { - "name": "create_sub_agent", - "description": "创建一个子智能体来处理独立任务。子智能体拥有完整的工具能力(读写文件、执行命令、搜索网页等),与主智能体共享工作区。\n\n适用场景:\n1. 可以独立完成的任务(生成文档、代码分析、测试执行)\n2. 需要大量工具调用的任务(批量文件处理、数据收集)\n3. 可以并行处理的子任务(多模块开发、多方面分析)\n4. 刚开始修改一个项目、需要先找到某个功能模块的修改位置时,可优先创建子智能体快速了解代码定位与相关上下文\n\n何时使用后台运行:\n- 任务耗时较长(预计超过5分钟)\n- 你可以继续处理其他工作,不需要立即使用结果\n- 多个独立任务可以并行执行\n\n何时使用阻塞运行(默认):\n- 任务较快(几分钟内完成)\n- 后续工作依赖子智能体的结果\n- 需要立即查看和使用输出\n\n重要限制:\n- 最多同时运行 5 个子智能体\n- 禁止多个子智能体操作相同的文件或目录(会导致冲突)\n- 禁止子智能体间的工作有重叠(如同时修改同一模块、同时测试同一功能)\n- 每个子智能体应该有明确独立的职责范围", - "parameters": { - "type": "object", - "properties": self._inject_intent({ - "agent_id": { - "type": "integer", - "description": "子智能体编号(1-99),用于标识和管理。同一对话中每个编号只能使用一次。建议按顺序分配:1、2、3..." - }, - "summary": { - "type": "string", - "description": "任务简短摘要(10-30字),用于显示和跟踪。例如:'生成API文档'、'分析性能瓶颈'、'编写单元测试'。" - }, - "task": { - "type": "string", - "description": "详细的任务描述,必须包括:\n1. 任务目标:要完成什么\n2. 具体要求:如何完成、注意事项\n3. 交付内容:在交付目录生成哪些文件\n4. 工作范围:明确指定操作的文件/目录范围,避免与其他子智能体冲突\n\n示例:'分析 src/api/ 目录下的所有 Python 文件,检查代码质量问题(复杂度、重复代码、潜在bug),生成分析报告 analysis.md 到交付目录。'" - }, - "deliverables_dir": { - "type": "string", - "description": "交付文件夹的相对路径(相对于项目根目录)。子智能体会将所有结果文件放在此目录。\n\n要求:必须是不存在的新目录;若目录不存在会自动创建;若目录已存在(无论是否为空)将报错。\n\n留空则使用默认路径:sub_agent_results/agent_{agent_id}\n\n示例:'docs/api'、'reports/performance'、'tests/generated'" - }, - "run_in_background": { - "type": "boolean", - "description": "是否后台运行。\n\ntrue(后台):立即返回,子智能体在后台执行,完成后会通知你。适合耗时任务或可以并行处理的任务。\n\nfalse(阻塞,默认):等待子智能体完成后返回结果。适合快速任务或后续工作依赖结果的情况。" - }, - "timeout_seconds": { - "type": "integer", - "description": "超时时间(秒),范围 60-3600。超时后子智能体会被强制终止,已生成的部分结果会保留。默认 600 秒(10分钟)。" - }, - "thinking_mode": { - "type": "string", - "enum": ["fast", "thinking"], - "description": "子智能体思考模式,根据任务复杂度选择:\n\nfast(快速模式)- 适合简单明确的任务:\n- 网络信息搜集和整理\n- 批量文件读取和简单处理\n- 执行已知的命令序列\n- 生成简单的文档或报告\n- 数据格式转换\n\nthinking(思考模式)- 适合复杂任务:\n- 代码架构分析和重构设计\n- 复杂算法实现和优化\n- 多步骤问题诊断和调试\n- 技术方案选型和对比\n- 需要深度推理的代码审查\n\n不填则使用默认模式。" - } - }), - "required": ["agent_id", "summary", "task", "deliverables_dir", "thinking_mode"] - } - } - }, - { - "type": "function", - "function": { - "name": "terminate_sub_agent", - "description": "强制终止正在运行的子智能体。用于:\n1. 任务不再需要\n2. 子智能体陷入死循环或执行错误\n3. 用户要求停止\n\n终止后无法恢复,但已生成的部分结果会保留在交付目录。", - "parameters": { - "type": "object", - "properties": self._inject_intent({ - "agent_id": { - "type": "integer", - "description": "要终止的子智能体编号。" - } - }), - "required": ["agent_id"] - } - } - }, - { - "type": "function", - "function": { - "name": "get_sub_agent_status", - "description": "查询一个或多个子智能体的当前状态和工作进度。用于检查后台运行的子智能体是否完成、当前在做什么、使用了哪些工具。", - "parameters": { - "type": "object", - "properties": self._inject_intent({ - "agent_ids": { - "type": "array", - "items": {"type": "integer"}, - "description": "要查询的子智能体编号列表。必须指定至少一个编号。例如:[1] 或 [1, 2, 3]。" - } - }), - "required": ["agent_ids"] - } - } - }, - { - "type": "function", - "function": { - "name": "trigger_easter_egg", - "description": "触发隐藏彩蛋,用于展示非功能性特效。需指定 effect 参数,例如 flood(灌水)或 snake(贪吃蛇)。", - "parameters": { - "type": "object", - "properties": self._inject_intent({ - "effect": { - "type": "string", - "description": "彩蛋标识,目前支持 flood(灌水)与 snake(贪吃蛇)。" - } - }), - "required": ["effect"] - } - } - }, - { - "type": "function", - "function": { - "name": "manage_personalization", - "description": "管理用户个性化设置。支持读取所有配置,或更新单个字段。可修改字段:self_identify(AI自称,最多20字)、user_name(AI如何称呼用户,最多20字)、profession(用户职业,最多20字)、tone(交流语气,最多20字)、considerations(注意事项列表,字符串数组,最多10项每项最多50字)、theme(主题配色:classic-经典/light-明亮/dark-暗黑)、communication_style(交流风格:default-标准AI风格/human_like-拟人聊天风格/auto-自动选择交流风格)、conversation_continuity(对话连续性:high-高/medium-中/low-低)。更新时会自动验证格式,验证通过后立即保存并生效,新主题会立即应用到界面。", - "parameters": { - "type": "object", - "properties": self._inject_intent({ - "action": { - "type": "string", - "enum": ["read", "update"], - "description": "操作类型:read读取所有配置,update更新单个字段" - }, - "field": { - "type": "string", - "enum": ["self_identify", "user_name", "profession", "tone", "considerations", "theme", "communication_style", "conversation_continuity"], - "description": "要更新的字段名(仅action=update时需要)" - }, - "value": { - "description": "新值(仅action=update时需要)。注意事项需提供字符串数组,其他字段提供字符串" - } - }), - "required": ["action"] - } - } - } - ] - # 多模态模型自带能力,不再暴露 vlm_analyze,改为 view_image / view_video - model_key = getattr(self, "model_key", None) - if model_key and (model_supports_image(model_key) or model_supports_video(model_key)): - tools = [ - tool for tool in tools - if (tool.get("function") or {}).get("name") != "vlm_analyze" - ] - if model_supports_image(model_key): - tools.append({ - "type": "function", - "function": { - "name": "view_image", - "description": "将指定本地图片附加到工具结果中(tool 消息携带 image_url),便于模型主动查看图片内容。", - "parameters": { - "type": "object", - "properties": self._inject_intent({ - "path": { - "type": "string", - "description": "项目内的图片相对路径(不要以 /workspace 开头);宿主机模式可用绝对路径。支持 png/jpg/webp/gif/bmp/svg。" - } - }), - "required": ["path"] - } - } - }) - if model_supports_video(model_key): - tools.append({ - "type": "function", - "function": { - "name": "view_video", - "description": "将指定本地视频附加到工具结果中(tool 消息携带 video_url),便于模型查看视频内容。", - "parameters": { - "type": "object", - "properties": self._inject_intent({ - "path": { - "type": "string", - "description": "项目内的视频相对路径(不要以 /workspace 开头);宿主机模式可用绝对路径。支持 mp4/mov/mkv/avi/webm。" - } - }), - "required": ["path"] - } - } - }) - # 附加自定义工具(仅管理员可见) - custom_tools = self._build_custom_tools() - if custom_tools: - tools.extend(custom_tools) - # 附加 MCP 工具(由管理员统一配置) - mcp_tools = self._build_mcp_tools() - if mcp_tools: - tools.extend(mcp_tools) - if self.disabled_tools: - tools = [ - tool for tool in tools - if tool.get("function", {}).get("name") not in self.disabled_tools - ] - - # 调试日志:记录工具列表 - tool_names = [t.get("function", {}).get("name") for t in tools] - logger.info("[define_tools] 可用工具列表: %s", tool_names) - print(f"[DEBUG] define_tools: 工具数量={len(tools)}") - - # 检查personalization分类状态 - if hasattr(self, 'tool_categories_map') and 'personalization' in self.tool_categories_map: - cat = self.tool_categories_map['personalization'] - state = self.tool_category_states.get('personalization', cat.default_enabled) - print(f"[DEBUG] personalization分类: default_enabled={cat.default_enabled}, current_state={state}") - else: - print(f"[DEBUG] personalization分类不在tool_categories_map中") - - if "manage_personalization" in tool_names: - logger.info("[define_tools] manage_personalization 工具已启用") - print("[DEBUG] manage_personalization 在可用工具列表中") - else: - logger.warning("[define_tools] manage_personalization 工具未找到!disabled_tools=%s", self.disabled_tools) - print(f"[DEBUG] manage_personalization 不在可用工具列表中!disabled_tools={self.disabled_tools}") - - return self._apply_intent_to_tools(tools) +__all__ = ['MainTerminalToolsDefinitionMixin'] diff --git a/core/main_terminal_parts/tools_definition/__init__.py b/core/main_terminal_parts/tools_definition/__init__.py new file mode 100644 index 0000000..7529452 --- /dev/null +++ b/core/main_terminal_parts/tools_definition/__init__.py @@ -0,0 +1,24 @@ +from core.main_terminal_parts.tools_definition.base import ToolsDefinitionBaseMixin +from core.main_terminal_parts.tools_definition.custom_mcp import ToolsDefinitionCustomMcpMixin +from core.main_terminal_parts.tools_definition.core_tools import ToolsDefinitionCoreToolsMixin +from core.main_terminal_parts.tools_definition.file_tools import ToolsDefinitionFileToolsMixin +from core.main_terminal_parts.tools_definition.terminal_tools import ToolsDefinitionTerminalToolsMixin +from core.main_terminal_parts.tools_definition.search_web_tools import ToolsDefinitionSearchWebToolsMixin +from core.main_terminal_parts.tools_definition.agent_tools import ToolsDefinitionAgentToolsMixin +from core.main_terminal_parts.tools_definition.context_tools import ToolsDefinitionContextToolsMixin +from core.main_terminal_parts.tools_definition.misc_tools import ToolsDefinitionMiscToolsMixin +from core.main_terminal_parts.tools_definition.main import ToolsDefinitionMainMixin + +class MainTerminalToolsDefinitionMixin( + ToolsDefinitionBaseMixin, + ToolsDefinitionCustomMcpMixin, + ToolsDefinitionCoreToolsMixin, + ToolsDefinitionFileToolsMixin, + ToolsDefinitionTerminalToolsMixin, + ToolsDefinitionSearchWebToolsMixin, + ToolsDefinitionAgentToolsMixin, + ToolsDefinitionContextToolsMixin, + ToolsDefinitionMiscToolsMixin, + ToolsDefinitionMainMixin, +): + pass diff --git a/core/main_terminal_parts/tools_definition/agent_tools.py b/core/main_terminal_parts/tools_definition/agent_tools.py new file mode 100644 index 0000000..0c3189d --- /dev/null +++ b/core/main_terminal_parts/tools_definition/agent_tools.py @@ -0,0 +1,190 @@ +import asyncio +import json +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Set + +try: + from config import ( + OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION, + MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE, + MAX_READ_FILE_CHARS, READ_TOOL_DEFAULT_MAX_CHARS, + READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER, + READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER, + READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES, + READ_TOOL_MAX_FILE_SIZE, + TERMINAL_SANDBOX_MOUNT_PATH, + TERMINAL_SANDBOX_MODE, + TERMINAL_SANDBOX_CPUS, + TERMINAL_SANDBOX_MEMORY, + PROJECT_MAX_STORAGE_MB, + CUSTOM_TOOLS_ENABLED, + ) +except ImportError: + import sys + project_root = Path(__file__).resolve().parents[2] + if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) + from config import ( + OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION, + MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE, + MAX_READ_FILE_CHARS, READ_TOOL_DEFAULT_MAX_CHARS, + READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER, + READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER, + READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES, + READ_TOOL_MAX_FILE_SIZE, + TERMINAL_SANDBOX_MOUNT_PATH, + TERMINAL_SANDBOX_MODE, + TERMINAL_SANDBOX_CPUS, + TERMINAL_SANDBOX_MEMORY, + PROJECT_MAX_STORAGE_MB, + CUSTOM_TOOLS_ENABLED, + ) + +from modules.file_manager import FileManager +from modules.search_engine import SearchEngine +from modules.terminal_ops import TerminalOperator +from modules.memory_manager import MemoryManager +from modules.terminal_manager import TerminalManager +from modules.todo_manager import TodoManager +from modules.sub_agent import SubAgentManager +from modules.webpage_extractor import extract_webpage_content, tavily_extract +from modules.ocr_client import OCRClient +from modules.easter_egg_manager import EasterEggManager +from modules.personalization_manager import ( + load_personalization_config, + build_personalization_prompt, +) +from modules.skills_manager import ( + get_skills_catalog, + build_skills_list, + merge_enabled_skills, + build_skills_prompt, +) +from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category +from modules.custom_tool_executor import CustomToolExecutor +from modules.mcp_server_registry import MCPServerRegistry, build_default_mcp_category + +try: + from config.limits import THINKING_FAST_INTERVAL +except ImportError: + THINKING_FAST_INTERVAL = 10 + +from modules.container_monitor import collect_stats, inspect_state +from core.tool_config import TOOL_CATEGORIES +from utils.api_client import DeepSeekClient +from utils.context_manager import ContextManager +from utils.tool_result_formatter import format_tool_result_for_context +from utils.logger import setup_logger +from config.model_profiles import ( + get_model_profile, + get_model_prompt_replacements, + get_model_context_window, + model_supports_image, + model_supports_video, +) + +logger = setup_logger(__name__) +DISABLE_LENGTH_CHECK = True + + + +class ToolsDefinitionAgentToolsMixin: + def _build_agent_tools(self) -> List[Dict]: + return [ + { + "type": "function", + "function": { + "name": "close_sub_agent", + "description": "强制关闭指定子智能体,适用于长时间无响应、超时或卡死的任务。使用前请确认必要的日志/文件已保留,操作会立即终止该任务。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "task_id": {"type": "string", "description": "子智能体任务ID"}, + "agent_id": {"type": "integer", "description": "子智能体编号(1~5),若缺少 task_id 可用"} + }) + } + } + }, + + { + "type": "function", + "function": { + "name": "create_sub_agent", + "description": "创建一个子智能体来处理独立任务。子智能体拥有完整的工具能力(读写文件、执行命令、搜索网页等),与主智能体共享工作区。\n\n适用场景:\n1. 可以独立完成的任务(生成文档、代码分析、测试执行)\n2. 需要大量工具调用的任务(批量文件处理、数据收集)\n3. 可以并行处理的子任务(多模块开发、多方面分析)\n4. 刚开始修改一个项目、需要先找到某个功能模块的修改位置时,可优先创建子智能体快速了解代码定位与相关上下文\n\n何时使用后台运行:\n- 任务耗时较长(预计超过5分钟)\n- 你可以继续处理其他工作,不需要立即使用结果\n- 多个独立任务可以并行执行\n\n何时使用阻塞运行(默认):\n- 任务较快(几分钟内完成)\n- 后续工作依赖子智能体的结果\n- 需要立即查看和使用输出\n\n重要限制:\n- 最多同时运行 5 个子智能体\n- 禁止多个子智能体操作相同的文件或目录(会导致冲突)\n- 禁止子智能体间的工作有重叠(如同时修改同一模块、同时测试同一功能)\n- 每个子智能体应该有明确独立的职责范围", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "agent_id": { + "type": "integer", + "description": "子智能体编号(1-99),用于标识和管理。同一对话中每个编号只能使用一次。建议按顺序分配:1、2、3..." + }, + "summary": { + "type": "string", + "description": "任务简短摘要(10-30字),用于显示和跟踪。例如:'生成API文档'、'分析性能瓶颈'、'编写单元测试'。" + }, + "task": { + "type": "string", + "description": "详细的任务描述,必须包括:\n1. 任务目标:要完成什么\n2. 具体要求:如何完成、注意事项\n3. 交付内容:在交付目录生成哪些文件\n4. 工作范围:明确指定操作的文件/目录范围,避免与其他子智能体冲突\n\n示例:'分析 src/api/ 目录下的所有 Python 文件,检查代码质量问题(复杂度、重复代码、潜在bug),生成分析报告 analysis.md 到交付目录。'" + }, + "deliverables_dir": { + "type": "string", + "description": "交付文件夹的相对路径(相对于项目根目录)。子智能体会将所有结果文件放在此目录。\n\n要求:必须是不存在的新目录;若目录不存在会自动创建;若目录已存在(无论是否为空)将报错。\n\n留空则使用默认路径:sub_agent_results/agent_{agent_id}\n\n示例:'docs/api'、'reports/performance'、'tests/generated'" + }, + "run_in_background": { + "type": "boolean", + "description": "是否后台运行。\n\ntrue(后台):立即返回,子智能体在后台执行,完成后会通知你。适合耗时任务或可以并行处理的任务。\n\nfalse(阻塞,默认):等待子智能体完成后返回结果。适合快速任务或后续工作依赖结果的情况。" + }, + "timeout_seconds": { + "type": "integer", + "description": "超时时间(秒),范围 60-3600。超时后子智能体会被强制终止,已生成的部分结果会保留。默认 600 秒(10分钟)。" + }, + "thinking_mode": { + "type": "string", + "enum": ["fast", "thinking"], + "description": "子智能体思考模式,根据任务复杂度选择:\n\nfast(快速模式)- 适合简单明确的任务:\n- 网络信息搜集和整理\n- 批量文件读取和简单处理\n- 执行已知的命令序列\n- 生成简单的文档或报告\n- 数据格式转换\n\nthinking(思考模式)- 适合复杂任务:\n- 代码架构分析和重构设计\n- 复杂算法实现和优化\n- 多步骤问题诊断和调试\n- 技术方案选型和对比\n- 需要深度推理的代码审查\n\n不填则使用默认模式。" + } + }), + "required": ["agent_id", "summary", "task", "deliverables_dir", "thinking_mode"] + } + } + }, + + { + "type": "function", + "function": { + "name": "terminate_sub_agent", + "description": "强制终止正在运行的子智能体。用于:\n1. 任务不再需要\n2. 子智能体陷入死循环或执行错误\n3. 用户要求停止\n\n终止后无法恢复,但已生成的部分结果会保留在交付目录。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "agent_id": { + "type": "integer", + "description": "要终止的子智能体编号。" + } + }), + "required": ["agent_id"] + } + } + }, + + { + "type": "function", + "function": { + "name": "get_sub_agent_status", + "description": "查询一个或多个子智能体的当前状态和工作进度。用于检查后台运行的子智能体是否完成、当前在做什么、使用了哪些工具。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "agent_ids": { + "type": "array", + "items": {"type": "integer"}, + "description": "要查询的子智能体编号列表。必须指定至少一个编号。例如:[1] 或 [1, 2, 3]。" + } + }), + "required": ["agent_ids"] + } + } + }, + + ] diff --git a/core/main_terminal_parts/tools_definition/base.py b/core/main_terminal_parts/tools_definition/base.py new file mode 100644 index 0000000..b5d2cc6 --- /dev/null +++ b/core/main_terminal_parts/tools_definition/base.py @@ -0,0 +1,154 @@ +import asyncio +import json +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Set + +try: + from config import ( + OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION, + MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE, + MAX_READ_FILE_CHARS, READ_TOOL_DEFAULT_MAX_CHARS, + READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER, + READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER, + READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES, + READ_TOOL_MAX_FILE_SIZE, + TERMINAL_SANDBOX_MOUNT_PATH, + TERMINAL_SANDBOX_MODE, + TERMINAL_SANDBOX_CPUS, + TERMINAL_SANDBOX_MEMORY, + PROJECT_MAX_STORAGE_MB, + CUSTOM_TOOLS_ENABLED, + ) +except ImportError: + import sys + project_root = Path(__file__).resolve().parents[2] + if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) + from config import ( + OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION, + MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE, + MAX_READ_FILE_CHARS, READ_TOOL_DEFAULT_MAX_CHARS, + READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER, + READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER, + READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES, + READ_TOOL_MAX_FILE_SIZE, + TERMINAL_SANDBOX_MOUNT_PATH, + TERMINAL_SANDBOX_MODE, + TERMINAL_SANDBOX_CPUS, + TERMINAL_SANDBOX_MEMORY, + PROJECT_MAX_STORAGE_MB, + CUSTOM_TOOLS_ENABLED, + ) + +from modules.file_manager import FileManager +from modules.search_engine import SearchEngine +from modules.terminal_ops import TerminalOperator +from modules.memory_manager import MemoryManager +from modules.terminal_manager import TerminalManager +from modules.todo_manager import TodoManager +from modules.sub_agent import SubAgentManager +from modules.webpage_extractor import extract_webpage_content, tavily_extract +from modules.ocr_client import OCRClient +from modules.easter_egg_manager import EasterEggManager +from modules.personalization_manager import ( + load_personalization_config, + build_personalization_prompt, +) +from modules.skills_manager import ( + get_skills_catalog, + build_skills_list, + merge_enabled_skills, + build_skills_prompt, +) +from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category +from modules.custom_tool_executor import CustomToolExecutor +from modules.mcp_server_registry import MCPServerRegistry, build_default_mcp_category + +try: + from config.limits import THINKING_FAST_INTERVAL +except ImportError: + THINKING_FAST_INTERVAL = 10 + +from modules.container_monitor import collect_stats, inspect_state +from core.tool_config import TOOL_CATEGORIES +from utils.api_client import DeepSeekClient +from utils.context_manager import ContextManager +from utils.tool_result_formatter import format_tool_result_for_context +from utils.logger import setup_logger +from config.model_profiles import ( + get_model_profile, + get_model_prompt_replacements, + get_model_context_window, + model_supports_image, + model_supports_video, +) + +logger = setup_logger(__name__) +DISABLE_LENGTH_CHECK = True + + + +class ToolsDefinitionBaseMixin: + @staticmethod + def _mcp_disabled_message() -> str: + return "当前为docker模式,MCP仅支持宿主机模式" + + def _is_mcp_disabled_in_docker_mode(self) -> bool: + session = getattr(self, "container_session", None) + return bool(session and getattr(session, "mode", None) == "docker") + + def _inject_intent(self, properties: Dict[str, Any]) -> Dict[str, Any]: + """在工具参数中注入 intent(简短意图说明),仅当开关启用时。 + + 字段含义:要求模型用不超过15个中文字符对即将执行的动作做简要说明,供前端展示。 + """ + if not self.tool_intent_enabled: + return properties + if not isinstance(properties, dict): + return properties + intent_field = { + "intent": { + "type": "string", + "description": "用不超过15个字向用户说明你要做什么,例如:等待下载完成/创建日志文件" + } + } + # 将 intent 放在最前面以提高模型关注度 + return {**intent_field, **properties} + + def _apply_intent_to_tools(self, tools: List[Dict]) -> List[Dict]: + """遍历工具列表,为缺少 intent 的工具补充字段(开关启用时生效)。""" + if not self.tool_intent_enabled: + return tools + intent_field = { + "intent": { + "type": "string", + "description": "用不超过15个字向用户说明你要做什么,例如:等待下载完成/创建日志文件/搜索最新新闻" + } + } + for tool in tools: + func = tool.get("function") or {} + tool_name = str(func.get("name") or "").strip() + # MCP 扩展工具参数需与远端 schema 严格一致,不注入 intent + if tool_name.startswith("mcp__"): + continue + params = func.get("parameters") or {} + if not isinstance(params, dict): + continue + if params.get("type") != "object": + continue + props = params.get("properties") + if not isinstance(props, dict): + continue + # 补充 intent 属性 + if "intent" not in props: + params["properties"] = {**intent_field, **props} + # 将 intent 加入必填 + required_list = params.get("required") + if isinstance(required_list, list): + if "intent" not in required_list: + required_list.insert(0, "intent") + params["required"] = required_list + else: + params["required"] = ["intent"] + return tools diff --git a/core/main_terminal_parts/tools_definition/context_tools.py b/core/main_terminal_parts/tools_definition/context_tools.py new file mode 100644 index 0000000..bd2fc2a --- /dev/null +++ b/core/main_terminal_parts/tools_definition/context_tools.py @@ -0,0 +1,278 @@ +import asyncio +import json +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Set + +try: + from config import ( + OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION, + MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE, + MAX_READ_FILE_CHARS, READ_TOOL_DEFAULT_MAX_CHARS, + READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER, + READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER, + READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES, + READ_TOOL_MAX_FILE_SIZE, + TERMINAL_SANDBOX_MOUNT_PATH, + TERMINAL_SANDBOX_MODE, + TERMINAL_SANDBOX_CPUS, + TERMINAL_SANDBOX_MEMORY, + PROJECT_MAX_STORAGE_MB, + CUSTOM_TOOLS_ENABLED, + ) +except ImportError: + import sys + project_root = Path(__file__).resolve().parents[2] + if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) + from config import ( + OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION, + MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE, + MAX_READ_FILE_CHARS, READ_TOOL_DEFAULT_MAX_CHARS, + READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER, + READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER, + READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES, + READ_TOOL_MAX_FILE_SIZE, + TERMINAL_SANDBOX_MOUNT_PATH, + TERMINAL_SANDBOX_MODE, + TERMINAL_SANDBOX_CPUS, + TERMINAL_SANDBOX_MEMORY, + PROJECT_MAX_STORAGE_MB, + CUSTOM_TOOLS_ENABLED, + ) + +from modules.file_manager import FileManager +from modules.search_engine import SearchEngine +from modules.terminal_ops import TerminalOperator +from modules.memory_manager import MemoryManager +from modules.terminal_manager import TerminalManager +from modules.todo_manager import TodoManager +from modules.sub_agent import SubAgentManager +from modules.webpage_extractor import extract_webpage_content, tavily_extract +from modules.ocr_client import OCRClient +from modules.easter_egg_manager import EasterEggManager +from modules.personalization_manager import ( + load_personalization_config, + build_personalization_prompt, +) +from modules.skills_manager import ( + get_skills_catalog, + build_skills_list, + merge_enabled_skills, + build_skills_prompt, +) +from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category +from modules.custom_tool_executor import CustomToolExecutor +from modules.mcp_server_registry import MCPServerRegistry, build_default_mcp_category + +try: + from config.limits import THINKING_FAST_INTERVAL +except ImportError: + THINKING_FAST_INTERVAL = 10 + +from modules.container_monitor import collect_stats, inspect_state +from core.tool_config import TOOL_CATEGORIES +from utils.api_client import DeepSeekClient +from utils.context_manager import ContextManager +from utils.tool_result_formatter import format_tool_result_for_context +from utils.logger import setup_logger +from config.model_profiles import ( + get_model_profile, + get_model_prompt_replacements, + get_model_context_window, + model_supports_image, + model_supports_video, +) + +logger = setup_logger(__name__) +DISABLE_LENGTH_CHECK = True + + + +class ToolsDefinitionContextToolsMixin: + def _build_context_tools(self) -> List[Dict]: + return [ + { + "type": "function", + "function": { + "name": "update_memory", + "description": "按条目管理总体长期记忆(自动编号,跨项目通用)。append/replace/delete。当用户提到个人信息、偏好、跨项目习惯,或用户主动要求「记住xxx」时,应积极主动地调用本工具。不记录密钥、隐私、未确认猜测。与特定项目绑定的技术约定用 update_project_memory。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "content": {"type": "string", "description": "条目内容。append/replace 时必填"}, + "operation": {"type": "string", "enum": ["append", "replace", "delete"], "description": "操作类型"}, + "index": {"type": "integer", "description": "要替换/删除的序号(从1开始)"} + }), + "required": ["operation"] + } + } + }, + + { + "type": "function", + "function": { + "name": "recall_project_memory", + "description": "读取指定的项目记忆文件,返回完整内容(含 frontmatter)。项目记忆存储在 .agents/memory/ 目录下。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "name": { + "type": "string", + "description": "记忆名称,对应 .agents/memory/{name}.md 文件名" + } + }), + "required": ["name"] + } + } + }, + + { + "type": "function", + "function": { + "name": "update_project_memory", + "description": "创建或覆盖项目记忆文件。当你发现有关当前项目的重要约定/决策/坑、用户表现出对项目的偏好,或用户主动要求「在当前项目里,下次要xxx/记住xxx」时,应积极主动地调用本工具。记忆名称用英文下划线,描述格式为'当xxxx时,应该索引本记忆'。不记录可从代码直接推断的或一次性信息。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "name": { + "type": "string", + "description": "记忆名称,同时作为文件名 .agents/memory/{name}.md。建议英文+下划线,如 docker_compose" + }, + "description": { + "type": "string", + "description": "记忆简述,格式为'当xxxx时,应该索引本记忆',用于在 prompt 中作为索引展示(≤100字)" + }, + "content": { + "type": "string", + "description": "记忆正文(Markdown),记录具体内容" + } + }), + "required": ["name", "description", "content"] + } + } + }, + + { + "type": "function", + "function": { + "name": "conversation_search", + "description": "在当前工作区的历史对话中搜索或列出对话,返回对话标题与 id。最多提供 3 个关键词,命中任一关键词即可;如果不提供关键词,则按时间列出最近的历史对话。可提供日期范围和最大返回数量。不能跨工作区搜索,也不会搜索当前对话。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "keywords": { + "type": "array", + "description": "搜索关键词列表,最多3个;仅搜索标题和首条用户消息。可为空。", + "items": {"type": "string"}, + "maxItems": 3 + }, + "query": {"type": "string", "description": "兼容字段:单个搜索关键词,可为空;优先使用 keywords"}, + "start_date": {"type": "string", "description": "开始日期 YYYY-MM-DD,可选"}, + "end_date": {"type": "string", "description": "结束日期 YYYY-MM-DD,可选"}, + "limit": {"type": "integer", "description": "最大返回数量,默认10,最大100"} + }), + "required": [] + } + } + }, + + { + "type": "function", + "function": { + "name": "conversation_review", + "description": "按 id 回顾当前工作区内的历史对话。mode=read 时直接返回回顾内容;若内容超过 50000 字符,将自动保存到 .agents/review/ 并提示分段或查找阅读。mode=save 时保存 Markdown 文件到 .agents/review/ 并返回路径。若 id 不属于当前工作区,将返回不存在或不属于当前工作区。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "conversation_id": {"type": "string", "description": "要回顾的对话 id,例如 conv_20250924_210942_114"}, + "mode": { + "type": "string", + "enum": ["read", "save"], + "description": "必要参数。read=直接返回回顾内容;save=保存为 .agents/review/ 下的 Markdown 文件并返回路径。" + } + }), + "required": ["conversation_id", "mode"] + } + } + }, + + { + "type": "function", + "function": { + "name": "todo_create", + "description": "创建待办列表,最多 8 条任务;若已有列表将被覆盖。当用户提出稍微复杂的要求、预计需要超过 3 个步骤时,应积极创建待办事项。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "overview": {"type": "string", "description": "一句话概述待办清单要完成的目标,50 字以内。"}, + "tasks": { + "type": "array", + "description": "任务列表,1~8 条,每条写清“动词+对象+目标”。", + "items": { + "type": "object", + "properties": { + "title": {"type": "string", "description": "单个任务描述,写成可执行的步骤"} + }, + "required": ["title"] + }, + "minItems": 1, + "maxItems": 8 + } + }), + "required": ["overview", "tasks"] + } + } + }, + + { + "type": "function", + "function": { + "name": "todo_update_task", + "description": "批量勾选或取消任务(支持单个或多个任务);全部勾选时提示所有任务已完成。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "task_index": {"type": "integer", "description": "任务序号(1-8),兼容旧参数"}, + "task_indices": { + "type": "array", + "items": {"type": "integer"}, + "minItems": 1, + "maxItems": 8, + "description": "要更新的任务序号列表(1-8),可一次勾选多个" + }, + "completed": {"type": "boolean", "description": "true=打勾,false=取消"} + }), + "required": ["completed"] + } + } + }, + + { + "type": "function", + "function": { + "name": "manage_personalization", + "description": "管理用户个性化设置。支持读取所有配置,或更新单个字段。可修改字段:self_identify(AI自称,最多20字)、user_name(AI如何称呼用户,最多20字)、profession(用户职业,最多20字)、tone(交流语气,最多20字)、considerations(注意事项列表,字符串数组,最多10项每项最多50字)、theme(主题配色:classic-经典/light-明亮/dark-暗黑)、communication_style(交流风格:default-标准AI风格/human_like-拟人聊天风格/auto-自动选择交流风格)、conversation_continuity(对话连续性:high-高/medium-中/low-低)。更新时会自动验证格式,验证通过后立即保存并生效,新主题会立即应用到界面。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "action": { + "type": "string", + "enum": ["read", "update"], + "description": "操作类型:read读取所有配置,update更新单个字段" + }, + "field": { + "type": "string", + "enum": ["self_identify", "user_name", "profession", "tone", "considerations", "theme", "communication_style", "conversation_continuity"], + "description": "要更新的字段名(仅action=update时需要)" + }, + "value": { + "description": "新值(仅action=update时需要)。注意事项需提供字符串数组,其他字段提供字符串" + } + }), + "required": ["action"] + } + } + } + + ] diff --git a/core/main_terminal_parts/tools_definition/core_tools.py b/core/main_terminal_parts/tools_definition/core_tools.py new file mode 100644 index 0000000..7eb6dc8 --- /dev/null +++ b/core/main_terminal_parts/tools_definition/core_tools.py @@ -0,0 +1,170 @@ +import asyncio +import json +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Set + +try: + from config import ( + OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION, + MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE, + MAX_READ_FILE_CHARS, READ_TOOL_DEFAULT_MAX_CHARS, + READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER, + READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER, + READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES, + READ_TOOL_MAX_FILE_SIZE, + TERMINAL_SANDBOX_MOUNT_PATH, + TERMINAL_SANDBOX_MODE, + TERMINAL_SANDBOX_CPUS, + TERMINAL_SANDBOX_MEMORY, + PROJECT_MAX_STORAGE_MB, + CUSTOM_TOOLS_ENABLED, + ) +except ImportError: + import sys + project_root = Path(__file__).resolve().parents[2] + if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) + from config import ( + OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION, + MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE, + MAX_READ_FILE_CHARS, READ_TOOL_DEFAULT_MAX_CHARS, + READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER, + READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER, + READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES, + READ_TOOL_MAX_FILE_SIZE, + TERMINAL_SANDBOX_MOUNT_PATH, + TERMINAL_SANDBOX_MODE, + TERMINAL_SANDBOX_CPUS, + TERMINAL_SANDBOX_MEMORY, + PROJECT_MAX_STORAGE_MB, + CUSTOM_TOOLS_ENABLED, + ) + +from modules.file_manager import FileManager +from modules.search_engine import SearchEngine +from modules.terminal_ops import TerminalOperator +from modules.memory_manager import MemoryManager +from modules.terminal_manager import TerminalManager +from modules.todo_manager import TodoManager +from modules.sub_agent import SubAgentManager +from modules.webpage_extractor import extract_webpage_content, tavily_extract +from modules.ocr_client import OCRClient +from modules.easter_egg_manager import EasterEggManager +from modules.personalization_manager import ( + load_personalization_config, + build_personalization_prompt, +) +from modules.skills_manager import ( + get_skills_catalog, + build_skills_list, + merge_enabled_skills, + build_skills_prompt, +) +from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category +from modules.custom_tool_executor import CustomToolExecutor +from modules.mcp_server_registry import MCPServerRegistry, build_default_mcp_category + +try: + from config.limits import THINKING_FAST_INTERVAL +except ImportError: + THINKING_FAST_INTERVAL = 10 + +from modules.container_monitor import collect_stats, inspect_state +from core.tool_config import TOOL_CATEGORIES +from utils.api_client import DeepSeekClient +from utils.context_manager import ContextManager +from utils.tool_result_formatter import format_tool_result_for_context +from utils.logger import setup_logger +from config.model_profiles import ( + get_model_profile, + get_model_prompt_replacements, + get_model_context_window, + model_supports_image, + model_supports_video, +) + +logger = setup_logger(__name__) +DISABLE_LENGTH_CHECK = True + + + +class ToolsDefinitionCoreToolsMixin: + def _build_core_tools(self) -> List[Dict]: + return [ + { + "type": "function", + "function": { + "name": "sleep", + "description": "等待工具。三种模式三选一:1) seconds:短暂延迟;2) wait_sub_agent_ids:等待指定子智能体全部结束并直接返回结果;3) wait_runcommand_id:等待指定后台 run_command 结束并直接返回结果。若同时提供多个等待参数会报错。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "seconds": { + "type": "number", + "description": "等待的秒数,可以是小数(如0.2秒)。建议范围:0.1-10秒" + }, + "wait_sub_agent_ids": { + "type": "array", + "items": {"type": "integer"}, + "description": "等待这些子智能体全部结束后返回(可提供一个或多个编号)。" + }, + "wait_runcommand_id": { + "type": "string", + "description": "等待指定后台 run_command 的 command_id 结束后返回。" + }, + "reason": { + "type": "string", + "description": "等待的原因说明(可选)" + } + }), + "required": [] + } + } + }, + + { + "type": "function", + "function": { + "name": "ask_user", + "description": "向用户提问并阻塞等待回答。当开发/执行任务中遇到会影响实现方向、产品行为、数据安全或用户偏好的关键不确定性时使用。优先把问题设计成可选择题:默认应提供 2-4 个清晰选项,并把推荐项放第一位;只有用户必须提供具体文本、路径、命名等无法合理枚举的开放问题时,才不要提供 options。前端会弹出问题窗口,用户可以选择预设选项,也始终可以直接打字补充或改写回答;拿到回答后工具才返回,模型才能继续下一步。调用 ask_user 时不要同时调用其他执行工具;如需多个相互独立的问题,可以在同一轮并行调用多个 ask_user。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "question": { + "type": "string", + "description": "要问用户的核心问题。必须具体、简短、可回答;优先配合 options 让用户点选,而不是要求用户从零输入。" + }, + "context": { + "type": "string", + "description": "可选。说明为什么需要确认,以及不同选择会影响什么。" + }, + "options": { + "type": "array", + "description": "强烈建议提供。给用户的预设选项,通常 2-4 个;如果有推荐项,放第一位并在 label 中标注“推荐”。每个选项应互斥、短、能直接决策,并用 description 说明影响或取舍。除非问题必须让用户输入具体文本/路径/名称,否则不要省略 options。用户始终可以不选预设项而直接打字回答。", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "选项稳定 ID,例如 modal / sidebar / skip / keep_current。" + }, + "label": { + "type": "string", + "description": "展示给用户的短标签。" + }, + "description": { + "type": "string", + "description": "一句话说明该选项的影响或取舍。" + } + }, + "required": ["id", "label"] + } + } + }), + "required": ["question"] + } + } + }, + + ] diff --git a/core/main_terminal_parts/tools_definition/custom_mcp.py b/core/main_terminal_parts/tools_definition/custom_mcp.py new file mode 100644 index 0000000..b694cfa --- /dev/null +++ b/core/main_terminal_parts/tools_definition/custom_mcp.py @@ -0,0 +1,299 @@ +import asyncio +import json +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Set + +try: + from config import ( + OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION, + MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE, + MAX_READ_FILE_CHARS, READ_TOOL_DEFAULT_MAX_CHARS, + READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER, + READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER, + READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES, + READ_TOOL_MAX_FILE_SIZE, + TERMINAL_SANDBOX_MOUNT_PATH, + TERMINAL_SANDBOX_MODE, + TERMINAL_SANDBOX_CPUS, + TERMINAL_SANDBOX_MEMORY, + PROJECT_MAX_STORAGE_MB, + CUSTOM_TOOLS_ENABLED, + ) +except ImportError: + import sys + project_root = Path(__file__).resolve().parents[2] + if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) + from config import ( + OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION, + MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE, + MAX_READ_FILE_CHARS, READ_TOOL_DEFAULT_MAX_CHARS, + READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER, + READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER, + READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES, + READ_TOOL_MAX_FILE_SIZE, + TERMINAL_SANDBOX_MOUNT_PATH, + TERMINAL_SANDBOX_MODE, + TERMINAL_SANDBOX_CPUS, + TERMINAL_SANDBOX_MEMORY, + PROJECT_MAX_STORAGE_MB, + CUSTOM_TOOLS_ENABLED, + ) + +from modules.file_manager import FileManager +from modules.search_engine import SearchEngine +from modules.terminal_ops import TerminalOperator +from modules.memory_manager import MemoryManager +from modules.terminal_manager import TerminalManager +from modules.todo_manager import TodoManager +from modules.sub_agent import SubAgentManager +from modules.webpage_extractor import extract_webpage_content, tavily_extract +from modules.ocr_client import OCRClient +from modules.easter_egg_manager import EasterEggManager +from modules.personalization_manager import ( + load_personalization_config, + build_personalization_prompt, +) +from modules.skills_manager import ( + get_skills_catalog, + build_skills_list, + merge_enabled_skills, + build_skills_prompt, +) +from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category +from modules.custom_tool_executor import CustomToolExecutor +from modules.mcp_server_registry import MCPServerRegistry, build_default_mcp_category + +try: + from config.limits import THINKING_FAST_INTERVAL +except ImportError: + THINKING_FAST_INTERVAL = 10 + +from modules.container_monitor import collect_stats, inspect_state +from core.tool_config import TOOL_CATEGORIES +from utils.api_client import DeepSeekClient +from utils.context_manager import ContextManager +from utils.tool_result_formatter import format_tool_result_for_context +from utils.logger import setup_logger +from config.model_profiles import ( + get_model_profile, + get_model_prompt_replacements, + get_model_context_window, + model_supports_image, + model_supports_video, +) + +logger = setup_logger(__name__) +DISABLE_LENGTH_CHECK = True + + + +class ToolsDefinitionCustomMcpMixin: + def _build_custom_tools(self) -> List[Dict]: + if not (self.custom_tools_enabled and getattr(self, "user_role", "user") == "admin"): + return [] + try: + definitions = self.custom_tool_registry.reload() + except Exception: + definitions = self.custom_tool_registry.list_tools() + if not definitions: + # 更新分类为空列表,避免旧缓存 + if "custom" in self.tool_categories_map: + self.tool_categories_map["custom"].tools = [] + self._refresh_disabled_tools() + return [] + + tools: List[Dict] = [] + tool_ids: List[str] = [] + for item in definitions: + tool_id = item.get("id") + if not tool_id: + continue + if item.get("invalid_id"): + # 跳过不合法的工具 ID,避免供应商严格校验时报错 + continue + tool_ids.append(tool_id) + params = item.get("parameters") or {"type": "object", "properties": {}} + if isinstance(params, dict) and params.get("type") != "object": + params = {"type": "object", "properties": {}} + required = item.get("required") + if isinstance(required, list): + params = dict(params) + params["required"] = required + + tools.append({ + "type": "function", + "function": { + "name": tool_id, + "description": item.get("description") or f"自定义工具: {tool_id}", + "parameters": params + } + }) + + # 覆盖 custom 分类的工具列表 + if "custom" in self.tool_categories_map: + self.tool_categories_map["custom"].tools = tool_ids + self._refresh_disabled_tools() + + return tools + + def _build_mcp_tools(self) -> List[Dict]: + if not getattr(self, "mcp_tools_enabled", False): + if hasattr(self, "tool_categories_map") and "mcp" in self.tool_categories_map: + self.tool_categories_map["mcp"].tools = ["list_mcp_servers"] + stale_keys = [ + key for key in list(getattr(self, "tool_categories_map", {}).keys()) + if isinstance(key, str) and key.startswith("mcp_server__") + ] + for key in stale_keys: + self.tool_categories_map.pop(key, None) + self.tool_category_states.pop(key, None) + self._refresh_disabled_tools() + self.mcp_tool_alias_map = {} + return [] + if self._is_mcp_disabled_in_docker_mode(): + self.mcp_tool_alias_map = {} + if "mcp" in self.tool_categories_map: + self.tool_categories_map["mcp"].tools = ["list_mcp_servers"] + elif getattr(self, "mcp_tools_enabled", False): + default_mcp_cat = build_default_mcp_category() + self.tool_categories_map["mcp"] = type(next(iter(TOOL_CATEGORIES.values())))( + label=default_mcp_cat["label"], + tools=["list_mcp_servers"], + default_enabled=True, + silent_when_disabled=False, + ) + stale_keys = [ + key for key in list(getattr(self, "tool_categories_map", {}).keys()) + if isinstance(key, str) and key.startswith("mcp_server__") + ] + for key in stale_keys: + self.tool_categories_map.pop(key, None) + self.tool_category_states.pop(key, None) + self._refresh_disabled_tools() + return [] + # 类别层面的禁用优先生效(即使策略里尚未填充具体工具列表) + mcp_cat = getattr(self, "tool_categories_map", {}).get("mcp") + if mcp_cat is not None: + mcp_enabled = self.tool_category_states.get("mcp", getattr(mcp_cat, "default_enabled", True)) + forced = getattr(self, "admin_forced_category_states", {}).get("mcp") + if isinstance(forced, bool): + mcp_enabled = forced + if not mcp_enabled: + self.mcp_tool_alias_map = {} + self.tool_categories_map["mcp"].tools = ["list_mcp_servers"] + stale_keys = [ + key for key in list(getattr(self, "tool_categories_map", {}).keys()) + if isinstance(key, str) and key.startswith("mcp_server__") + ] + for key in stale_keys: + self.tool_categories_map.pop(key, None) + self.tool_category_states.pop(key, None) + self._refresh_disabled_tools() + return [] + manager = getattr(self, "mcp_client_manager", None) + if manager is None: + self.mcp_tool_alias_map = {} + return [] + try: + tool_defs, alias_map = manager.build_openai_tools( + ensure_discovery=True, + discovery_timeout_seconds=10, + ) + except Exception as exc: + logger.warning("构建 MCP 工具列表失败: %s", exc) + tool_defs, alias_map = [], {} + + # 保存映射,供执行层按 alias 分发到具体 server/tool + alias_map = alias_map or {} + if "mcp" in self.tool_categories_map: + self.tool_categories_map["mcp"].tools = ["list_mcp_servers"] + elif getattr(self, "mcp_tools_enabled", False): + default_mcp_cat = build_default_mcp_category() + self.tool_categories_map["mcp"] = type(next(iter(TOOL_CATEGORIES.values())))( + label=default_mcp_cat["label"], + tools=["list_mcp_servers"], + default_enabled=True, + silent_when_disabled=False, + ) + server_aliases: Dict[str, List[str]] = {} + for alias, binding in alias_map.items(): + sid = str(getattr(binding, "server_id", "") or "").strip() + if not sid: + continue + server_aliases.setdefault(sid, []).append(alias) + + enabled_servers = [] + try: + enabled_servers = list(self.mcp_server_registry.list_enabled_servers()) + except Exception: + enabled_servers = [] + + active_server_category_ids = set() + for server in enabled_servers: + sid = str(server.get("id") or "").strip() + if not sid: + continue + cat_id = MCPServerRegistry.make_server_category_id(sid) + active_server_category_ids.add(cat_id) + aliases = list(server_aliases.get(sid, [])) + existing = self.tool_categories_map.get(cat_id) + label = MCPServerRegistry.make_server_category_label(server) + if existing is not None: + existing.label = label + existing.tools = aliases + else: + self.tool_categories_map[cat_id] = type(next(iter(TOOL_CATEGORIES.values())))( + label=label, + tools=aliases, + default_enabled=True, + silent_when_disabled=False, + ) + if cat_id not in self.tool_category_states: + self.tool_category_states[cat_id] = True + + stale_keys = [ + key for key in list(self.tool_categories_map.keys()) + if isinstance(key, str) + and key.startswith("mcp_server__") + and key not in active_server_category_ids + ] + for key in stale_keys: + self.tool_categories_map.pop(key, None) + self.tool_category_states.pop(key, None) + + if hasattr(self, "_prune_runtime_tool_overrides"): + self._prune_runtime_tool_overrides() + if hasattr(self, "_apply_runtime_tool_overrides"): + self._apply_runtime_tool_overrides() + + filtered_tools: List[Dict[str, Any]] = [] + filtered_alias_map: Dict[str, Any] = {} + for tool_def in tool_defs: + alias = str(((tool_def or {}).get("function") or {}).get("name") or "").strip() + if not alias: + continue + binding = alias_map.get(alias) + if binding is None: + continue + sid = str(getattr(binding, "server_id", "") or "").strip() + if not sid: + continue + cat_id = MCPServerRegistry.make_server_category_id(sid) + cat = self.tool_categories_map.get(cat_id) + cat_enabled = self.tool_category_states.get( + cat_id, + getattr(cat, "default_enabled", True) if cat is not None else True, + ) + forced = getattr(self, "admin_forced_category_states", {}).get(cat_id) + if isinstance(forced, bool): + cat_enabled = forced + if not cat_enabled: + continue + filtered_tools.append(tool_def) + filtered_alias_map[alias] = binding + + self.mcp_tool_alias_map = filtered_alias_map + self._refresh_disabled_tools() + return filtered_tools diff --git a/core/main_terminal_parts/tools_definition/file_tools.py b/core/main_terminal_parts/tools_definition/file_tools.py new file mode 100644 index 0000000..72abf57 --- /dev/null +++ b/core/main_terminal_parts/tools_definition/file_tools.py @@ -0,0 +1,291 @@ +import asyncio +import json +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Set + +try: + from config import ( + OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION, + MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE, + MAX_READ_FILE_CHARS, READ_TOOL_DEFAULT_MAX_CHARS, + READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER, + READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER, + READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES, + READ_TOOL_MAX_FILE_SIZE, + TERMINAL_SANDBOX_MOUNT_PATH, + TERMINAL_SANDBOX_MODE, + TERMINAL_SANDBOX_CPUS, + TERMINAL_SANDBOX_MEMORY, + PROJECT_MAX_STORAGE_MB, + CUSTOM_TOOLS_ENABLED, + ) +except ImportError: + import sys + project_root = Path(__file__).resolve().parents[2] + if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) + from config import ( + OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION, + MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE, + MAX_READ_FILE_CHARS, READ_TOOL_DEFAULT_MAX_CHARS, + READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER, + READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER, + READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES, + READ_TOOL_MAX_FILE_SIZE, + TERMINAL_SANDBOX_MOUNT_PATH, + TERMINAL_SANDBOX_MODE, + TERMINAL_SANDBOX_CPUS, + TERMINAL_SANDBOX_MEMORY, + PROJECT_MAX_STORAGE_MB, + CUSTOM_TOOLS_ENABLED, + ) + +from modules.file_manager import FileManager +from modules.search_engine import SearchEngine +from modules.terminal_ops import TerminalOperator +from modules.memory_manager import MemoryManager +from modules.terminal_manager import TerminalManager +from modules.todo_manager import TodoManager +from modules.sub_agent import SubAgentManager +from modules.webpage_extractor import extract_webpage_content, tavily_extract +from modules.ocr_client import OCRClient +from modules.easter_egg_manager import EasterEggManager +from modules.personalization_manager import ( + load_personalization_config, + build_personalization_prompt, +) +from modules.skills_manager import ( + get_skills_catalog, + build_skills_list, + merge_enabled_skills, + build_skills_prompt, +) +from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category +from modules.custom_tool_executor import CustomToolExecutor +from modules.mcp_server_registry import MCPServerRegistry, build_default_mcp_category + +try: + from config.limits import THINKING_FAST_INTERVAL +except ImportError: + THINKING_FAST_INTERVAL = 10 + +from modules.container_monitor import collect_stats, inspect_state +from core.tool_config import TOOL_CATEGORIES +from utils.api_client import DeepSeekClient +from utils.context_manager import ContextManager +from utils.tool_result_formatter import format_tool_result_for_context +from utils.logger import setup_logger +from config.model_profiles import ( + get_model_profile, + get_model_prompt_replacements, + get_model_context_window, + model_supports_image, + model_supports_video, +) + +logger = setup_logger(__name__) +DISABLE_LENGTH_CHECK = True + + + +class ToolsDefinitionFileToolsMixin: + def _build_file_tools(self) -> List[Dict]: + return [ + { + "type": "function", + "function": { + "name": "write_file", + "description": "将内容写入本地文件系统;append 为 False 时覆盖原文件,True 时追加到末尾。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "file_path": { + "type": "string", + "description": "要写入的相对路径" + }, + "content": { + "type": "string", + "description": "要写入文件的内容" + }, + "append": { + "type": "boolean", + "description": "是否追加到文件而不是覆盖它", + "default": False + } + }), + "required": ["file_path", "content"] + } + } + }, + + { + "type": "function", + "function": { + "name": "read_skill", + "description": "按 skill 名称读取 .agents/skills//SKILL.md 内容;内部等价于 read_file 的 read 模式,并返回解析后的 path。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "skill_name": { + "type": "string", + "description": "skill 名称(支持 skill id 或技能名称)" + } + }), + "required": ["skill_name"] + } + } + }, + + { + "type": "function", + "function": { + "name": "create_skill", + "description": "验证并归档一个已创建好的 skill 文件夹。当你觉得本次工作的流程、经验或踩坑值得沉淀为 skill,便于以后面对类似问题时高效工作,或用户主动要求“把今天/本次经历转化为 skill”时,应先阅读 skill-creator,按规范创建对应 skill 文件夹与 SKILL.md,再调用本工具验证并归档。source_dir 可为当前工作区相对路径、工作区内绝对路径,宿主机模式也支持电脑上其它位置的绝对路径。验证通过后移动到正式 skills 库;不覆盖已存在 skill。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "source_dir": { + "type": "string", + "description": "要归档的 skill 文件夹路径;skill 名称使用该文件夹名称。" + } + }), + "required": ["source_dir"] + } + } + }, + + { + "type": "function", + "function": { + "name": "read_file", + "description": "读取/搜索/抽取 UTF-8 文本文件内容。通过 type 参数选择 read(阅读)、search(搜索)、extract(具体行段),支持限制返回字符数。若文件非 UTF-8 或过大,请改用 run_command 调用合适的解析工具或 Python 解释器。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "path": {"type": "string", "description": "文件路径"}, + "type": { + "type": "string", + "enum": ["read", "search", "extract"], + "description": "读取模式:read=阅读、search=搜索、extract=按行抽取" + }, + "max_chars": { + "type": "integer", + "description": "返回内容的最大字符数,默认与 config 一致" + }, + "start_line": { + "type": "integer", + "description": "[read] 可选的起始行号(1开始)" + }, + "end_line": { + "type": "integer", + "description": "[read] 可选的结束行号(>=start_line)" + }, + "query": { + "type": "string", + "description": "[search] 搜索关键词" + }, + "max_matches": { + "type": "integer", + "description": "[search] 最多返回多少条命中(默认5,最大50)" + }, + "context_before": { + "type": "integer", + "description": "[search] 命中行向上追加的行数(默认1,最大3)" + }, + "context_after": { + "type": "integer", + "description": "[search] 命中行向下追加的行数(默认1,最大5)" + }, + "case_sensitive": { + "type": "boolean", + "description": "[search] 是否区分大小写,默认 false" + }, + "segments": { + "type": "array", + "description": "[extract] 需要抽取的行区间", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "该片段的标签(可选)" + }, + "start_line": { + "type": "integer", + "description": "起始行号(>=1)" + }, + "end_line": { + "type": "integer", + "description": "结束行号(>=start_line)" + } + }, + "required": ["start_line", "end_line"] + }, + "minItems": 1 + } + }), + "required": ["path", "type"] + } + } + }, + + { + "type": "function", + "function": { + "name": "edit_file", + "description": "在文件中按顺序执行一组或多组精确字符串替换;建议先使用 read_file 获取最新内容以确保精确匹配。任意一组失败时不会写入文件。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "file_path": { + "type": "string", + "description": "要修改文件的相对路径" + }, + "replacements": { + "type": "array", + "description": "替换项数组,按数组顺序依次执行。每一项包含 old_string/new_string,可选 replace_all;replace_all 未提供时默认为 false。", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "old_string": { + "type": "string", + "description": "要替换的文本(需与文件内容精确匹配,保留缩进;建议提供至少3行提升定位稳定性。需要批量替换的场景可以单行或不足一行)" + }, + "new_string": { + "type": "string", + "description": "用于替换的新文本(必须不同于 old_string)" + }, + "replace_all": { + "type": "boolean", + "enum": [True, False], + "description": "是否替换该 old_string 的所有匹配内容。false=仅替换首个匹配,true=替换全部匹配;默认 false。" + } + }, + "required": ["old_string", "new_string"] + } + } + }), + "required": ["file_path", "replacements"] + } + } + }, + + { + "type": "function", + "function": { + "name": "save_webpage", + "description": "提取网页内容并保存为纯文本文件,适合需要长期留存的长文档。请提供网址与目标路径(含 .txt 后缀),落地后请通过终端命令查看。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "url": {"type": "string", "description": "要保存的网页URL"}, + "target_path": {"type": "string", "description": "保存位置,包含文件名,相对于项目根目录"} + }), + "required": ["url", "target_path"] + } + } + }, + + ] diff --git a/core/main_terminal_parts/tools_definition/main.py b/core/main_terminal_parts/tools_definition/main.py new file mode 100644 index 0000000..ffa9ae5 --- /dev/null +++ b/core/main_terminal_parts/tools_definition/main.py @@ -0,0 +1,180 @@ +import asyncio +import json +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Set + +try: + from config import ( + OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION, + MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE, + MAX_READ_FILE_CHARS, READ_TOOL_DEFAULT_MAX_CHARS, + READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER, + READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER, + READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES, + READ_TOOL_MAX_FILE_SIZE, + TERMINAL_SANDBOX_MOUNT_PATH, + TERMINAL_SANDBOX_MODE, + TERMINAL_SANDBOX_CPUS, + TERMINAL_SANDBOX_MEMORY, + PROJECT_MAX_STORAGE_MB, + CUSTOM_TOOLS_ENABLED, + ) +except ImportError: + import sys + project_root = Path(__file__).resolve().parents[2] + if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) + from config import ( + OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION, + MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE, + MAX_READ_FILE_CHARS, READ_TOOL_DEFAULT_MAX_CHARS, + READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER, + READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER, + READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES, + READ_TOOL_MAX_FILE_SIZE, + TERMINAL_SANDBOX_MOUNT_PATH, + TERMINAL_SANDBOX_MODE, + TERMINAL_SANDBOX_CPUS, + TERMINAL_SANDBOX_MEMORY, + PROJECT_MAX_STORAGE_MB, + CUSTOM_TOOLS_ENABLED, + ) + +from modules.file_manager import FileManager +from modules.search_engine import SearchEngine +from modules.terminal_ops import TerminalOperator +from modules.memory_manager import MemoryManager +from modules.terminal_manager import TerminalManager +from modules.todo_manager import TodoManager +from modules.sub_agent import SubAgentManager +from modules.webpage_extractor import extract_webpage_content, tavily_extract +from modules.ocr_client import OCRClient +from modules.easter_egg_manager import EasterEggManager +from modules.personalization_manager import ( + load_personalization_config, + build_personalization_prompt, +) +from modules.skills_manager import ( + get_skills_catalog, + build_skills_list, + merge_enabled_skills, + build_skills_prompt, +) +from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category +from modules.custom_tool_executor import CustomToolExecutor +from modules.mcp_server_registry import MCPServerRegistry, build_default_mcp_category + +try: + from config.limits import THINKING_FAST_INTERVAL +except ImportError: + THINKING_FAST_INTERVAL = 10 + +from modules.container_monitor import collect_stats, inspect_state +from core.tool_config import TOOL_CATEGORIES +from utils.api_client import DeepSeekClient +from utils.context_manager import ContextManager +from utils.tool_result_formatter import format_tool_result_for_context +from utils.logger import setup_logger +from config.model_profiles import ( + get_model_profile, + get_model_prompt_replacements, + get_model_context_window, + model_supports_image, + model_supports_video, +) + +logger = setup_logger(__name__) +DISABLE_LENGTH_CHECK = True + + + +class ToolsDefinitionMainMixin: + def define_tools(self) -> List[Dict]: + """定义可用工具(添加确认工具)""" + tools: List[Dict] = [] + tools.extend(self._build_core_tools()) + tools.extend(self._build_file_tools()) + tools.extend(self._build_terminal_tools()) + tools.extend(self._build_search_web_tools()) + tools.extend(self._build_agent_tools()) + tools.extend(self._build_context_tools()) + tools.extend(self._build_misc_tools()) + # 多模态模型自带能力,不再暴露 vlm_analyze,改为 view_image / view_video + model_key = getattr(self, "model_key", None) + if model_key and (model_supports_image(model_key) or model_supports_video(model_key)): + tools = [ + tool for tool in tools + if (tool.get("function") or {}).get("name") != "vlm_analyze" + ] + if model_supports_image(model_key): + tools.append({ + "type": "function", + "function": { + "name": "view_image", + "description": "将指定本地图片附加到工具结果中(tool 消息携带 image_url),便于模型主动查看图片内容。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "path": { + "type": "string", + "description": "项目内的图片相对路径(不要以 /workspace 开头);宿主机模式可用绝对路径。支持 png/jpg/webp/gif/bmp/svg。" + } + }), + "required": ["path"] + } + } + }) + if model_supports_video(model_key): + tools.append({ + "type": "function", + "function": { + "name": "view_video", + "description": "将指定本地视频附加到工具结果中(tool 消息携带 video_url),便于模型查看视频内容。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "path": { + "type": "string", + "description": "项目内的视频相对路径(不要以 /workspace 开头);宿主机模式可用绝对路径。支持 mp4/mov/mkv/avi/webm。" + } + }), + "required": ["path"] + } + } + }) + # 附加自定义工具(仅管理员可见) + custom_tools = self._build_custom_tools() + if custom_tools: + tools.extend(custom_tools) + # 附加 MCP 工具(由管理员统一配置) + mcp_tools = self._build_mcp_tools() + if mcp_tools: + tools.extend(mcp_tools) + if self.disabled_tools: + tools = [ + tool for tool in tools + if tool.get("function", {}).get("name") not in self.disabled_tools + ] + + # 调试日志:记录工具列表 + tool_names = [t.get("function", {}).get("name") for t in tools] + logger.info("[define_tools] 可用工具列表: %s", tool_names) + print(f"[DEBUG] define_tools: 工具数量={len(tools)}") + + # 检查personalization分类状态 + if hasattr(self, 'tool_categories_map') and 'personalization' in self.tool_categories_map: + cat = self.tool_categories_map['personalization'] + state = self.tool_category_states.get('personalization', cat.default_enabled) + print(f"[DEBUG] personalization分类: default_enabled={cat.default_enabled}, current_state={state}") + else: + print(f"[DEBUG] personalization分类不在tool_categories_map中") + + if "manage_personalization" in tool_names: + logger.info("[define_tools] manage_personalization 工具已启用") + print("[DEBUG] manage_personalization 在可用工具列表中") + else: + logger.warning("[define_tools] manage_personalization 工具未找到!disabled_tools=%s", self.disabled_tools) + print(f"[DEBUG] manage_personalization 不在可用工具列表中!disabled_tools={self.disabled_tools}") + + return self._apply_intent_to_tools(tools) diff --git a/core/main_terminal_parts/tools_definition/misc_tools.py b/core/main_terminal_parts/tools_definition/misc_tools.py new file mode 100644 index 0000000..b7c565f --- /dev/null +++ b/core/main_terminal_parts/tools_definition/misc_tools.py @@ -0,0 +1,155 @@ +import asyncio +import json +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Set + +try: + from config import ( + OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION, + MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE, + MAX_READ_FILE_CHARS, READ_TOOL_DEFAULT_MAX_CHARS, + READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER, + READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER, + READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES, + READ_TOOL_MAX_FILE_SIZE, + TERMINAL_SANDBOX_MOUNT_PATH, + TERMINAL_SANDBOX_MODE, + TERMINAL_SANDBOX_CPUS, + TERMINAL_SANDBOX_MEMORY, + PROJECT_MAX_STORAGE_MB, + CUSTOM_TOOLS_ENABLED, + ) +except ImportError: + import sys + project_root = Path(__file__).resolve().parents[2] + if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) + from config import ( + OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION, + MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE, + MAX_READ_FILE_CHARS, READ_TOOL_DEFAULT_MAX_CHARS, + READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER, + READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER, + READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES, + READ_TOOL_MAX_FILE_SIZE, + TERMINAL_SANDBOX_MOUNT_PATH, + TERMINAL_SANDBOX_MODE, + TERMINAL_SANDBOX_CPUS, + TERMINAL_SANDBOX_MEMORY, + PROJECT_MAX_STORAGE_MB, + CUSTOM_TOOLS_ENABLED, + ) + +from modules.file_manager import FileManager +from modules.search_engine import SearchEngine +from modules.terminal_ops import TerminalOperator +from modules.memory_manager import MemoryManager +from modules.terminal_manager import TerminalManager +from modules.todo_manager import TodoManager +from modules.sub_agent import SubAgentManager +from modules.webpage_extractor import extract_webpage_content, tavily_extract +from modules.ocr_client import OCRClient +from modules.easter_egg_manager import EasterEggManager +from modules.personalization_manager import ( + load_personalization_config, + build_personalization_prompt, +) +from modules.skills_manager import ( + get_skills_catalog, + build_skills_list, + merge_enabled_skills, + build_skills_prompt, +) +from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category +from modules.custom_tool_executor import CustomToolExecutor +from modules.mcp_server_registry import MCPServerRegistry, build_default_mcp_category + +try: + from config.limits import THINKING_FAST_INTERVAL +except ImportError: + THINKING_FAST_INTERVAL = 10 + +from modules.container_monitor import collect_stats, inspect_state +from core.tool_config import TOOL_CATEGORIES +from utils.api_client import DeepSeekClient +from utils.context_manager import ContextManager +from utils.tool_result_formatter import format_tool_result_for_context +from utils.logger import setup_logger +from config.model_profiles import ( + get_model_profile, + get_model_prompt_replacements, + get_model_context_window, + model_supports_image, + model_supports_video, +) + +logger = setup_logger(__name__) +DISABLE_LENGTH_CHECK = True + + + +class ToolsDefinitionMiscToolsMixin: + def _build_misc_tools(self) -> List[Dict]: + return [ + { + "type": "function", + "function": { + "name": "vlm_analyze", + "description": "使用大参数视觉语言模型(Qwen3.5)理解图片:文字、物体、布局、表格等,仅支持本地路径。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "path": {"type": "string", "description": "项目内的图片相对路径"}, + "prompt": {"type": "string", "description": "传递给 VLM 的中文提示词,如“请总结这张图的内容”“表格的总金额是多少”“图中是什么车?”。"} + }), + "required": ["path", "prompt"] + } + } + }, + + { + "type": "function", + "function": { + "name": "list_mcp_servers", + "description": "列出当前已配置的 MCP 服务与工具映射(mcp__... 别名)。可选刷新远程 tools/list 缓存后再返回。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "refresh": { + "type": "boolean", + "description": "是否先同步 MCP 服务工具缓存(默认 false)" + }, + "server_id": { + "type": "string", + "description": "仅查看指定 MCP 服务(可选)" + }, + "include_disabled": { + "type": "boolean", + "description": "是否包含已禁用服务(默认 false)" + } + }), + "required": [] + } + } + }, + + { + "type": "function", + "function": { + "name": "trigger_easter_egg", + "description": "触发隐藏彩蛋,用于展示非功能性特效。需指定 effect 参数,例如 flood(灌水)或 snake(贪吃蛇)。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "effect": { + "type": "string", + "description": "彩蛋标识,目前支持 flood(灌水)与 snake(贪吃蛇)。" + } + }), + "required": ["effect"] + } + } + }, + + ] diff --git a/core/main_terminal_parts/tools_definition/search_web_tools.py b/core/main_terminal_parts/tools_definition/search_web_tools.py new file mode 100644 index 0000000..f235b89 --- /dev/null +++ b/core/main_terminal_parts/tools_definition/search_web_tools.py @@ -0,0 +1,163 @@ +import asyncio +import json +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Set + +try: + from config import ( + OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION, + MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE, + MAX_READ_FILE_CHARS, READ_TOOL_DEFAULT_MAX_CHARS, + READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER, + READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER, + READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES, + READ_TOOL_MAX_FILE_SIZE, + TERMINAL_SANDBOX_MOUNT_PATH, + TERMINAL_SANDBOX_MODE, + TERMINAL_SANDBOX_CPUS, + TERMINAL_SANDBOX_MEMORY, + PROJECT_MAX_STORAGE_MB, + CUSTOM_TOOLS_ENABLED, + ) +except ImportError: + import sys + project_root = Path(__file__).resolve().parents[2] + if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) + from config import ( + OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION, + MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE, + MAX_READ_FILE_CHARS, READ_TOOL_DEFAULT_MAX_CHARS, + READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER, + READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER, + READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES, + READ_TOOL_MAX_FILE_SIZE, + TERMINAL_SANDBOX_MOUNT_PATH, + TERMINAL_SANDBOX_MODE, + TERMINAL_SANDBOX_CPUS, + TERMINAL_SANDBOX_MEMORY, + PROJECT_MAX_STORAGE_MB, + CUSTOM_TOOLS_ENABLED, + ) + +from modules.file_manager import FileManager +from modules.search_engine import SearchEngine +from modules.terminal_ops import TerminalOperator +from modules.memory_manager import MemoryManager +from modules.terminal_manager import TerminalManager +from modules.todo_manager import TodoManager +from modules.sub_agent import SubAgentManager +from modules.webpage_extractor import extract_webpage_content, tavily_extract +from modules.ocr_client import OCRClient +from modules.easter_egg_manager import EasterEggManager +from modules.personalization_manager import ( + load_personalization_config, + build_personalization_prompt, +) +from modules.skills_manager import ( + get_skills_catalog, + build_skills_list, + merge_enabled_skills, + build_skills_prompt, +) +from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category +from modules.custom_tool_executor import CustomToolExecutor +from modules.mcp_server_registry import MCPServerRegistry, build_default_mcp_category + +try: + from config.limits import THINKING_FAST_INTERVAL +except ImportError: + THINKING_FAST_INTERVAL = 10 + +from modules.container_monitor import collect_stats, inspect_state +from core.tool_config import TOOL_CATEGORIES +from utils.api_client import DeepSeekClient +from utils.context_manager import ContextManager +from utils.tool_result_formatter import format_tool_result_for_context +from utils.logger import setup_logger +from config.model_profiles import ( + get_model_profile, + get_model_prompt_replacements, + get_model_context_window, + model_supports_image, + model_supports_video, +) + +logger = setup_logger(__name__) +DISABLE_LENGTH_CHECK = True + + + +class ToolsDefinitionSearchWebToolsMixin: + def _build_search_web_tools(self) -> List[Dict]: + return [ + { + "type": "function", + "function": { + "name": "web_search", + "description": "当现有资料不足时搜索外部信息。调用前说明目的,精准撰写 query,并合理设置时间/主题参数;避免重复或无意义的搜索。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "query": { + "type": "string", + "description": "搜索查询内容(不要包含日期或时间范围)" + }, + "max_results": { + "type": "integer", + "description": "最大结果数,可选" + }, + "topic": { + "type": "string", + "description": "搜索主题,可选值:general(默认)/news/finance" + }, + "time_range": { + "type": "string", + "description": "相对时间范围,可选 day/week/month/year,支持缩写 d/w/m/y;与 days 和 start_date/end_date 互斥" + }, + "days": { + "type": "integer", + "description": "最近 N 天,仅当 topic=news 时可用;与 time_range、start_date/end_date 互斥" + }, + "start_date": { + "type": "string", + "description": "开始日期,YYYY-MM-DD;必须与 end_date 同时提供,与 time_range、days 互斥" + }, + "end_date": { + "type": "string", + "description": "结束日期,YYYY-MM-DD;必须与 start_date 同时提供,与 time_range、days 互斥" + }, + "country": { + "type": "string", + "description": "国家过滤,仅 topic=general 可用,使用英文小写国名" + }, + "include_domains": { + "type": "array", + "description": "仅包含这些域名(可选,最多300个)", + "items": { + "type": "string" + } + } + }), + "required": ["query"] + } + } + }, + + { + "type": "function", + "function": { + "name": "extract_webpage", + "description": "在 web_search 结果不够详细时提取网页正文。调用前说明用途,注意提取内容会消耗大量 token,超过80000字符将被拒绝。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "url": {"type": "string", "description": "要提取内容的网页URL"} + }), + "required": ["url"] + } + } + }, + + ] diff --git a/core/main_terminal_parts/tools_definition/terminal_tools.py b/core/main_terminal_parts/tools_definition/terminal_tools.py new file mode 100644 index 0000000..9ab0909 --- /dev/null +++ b/core/main_terminal_parts/tools_definition/terminal_tools.py @@ -0,0 +1,196 @@ +import asyncio +import json +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Set + +try: + from config import ( + OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION, + MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE, + MAX_READ_FILE_CHARS, READ_TOOL_DEFAULT_MAX_CHARS, + READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER, + READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER, + READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES, + READ_TOOL_MAX_FILE_SIZE, + TERMINAL_SANDBOX_MOUNT_PATH, + TERMINAL_SANDBOX_MODE, + TERMINAL_SANDBOX_CPUS, + TERMINAL_SANDBOX_MEMORY, + PROJECT_MAX_STORAGE_MB, + CUSTOM_TOOLS_ENABLED, + ) +except ImportError: + import sys + project_root = Path(__file__).resolve().parents[2] + if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) + from config import ( + OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION, + MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE, + MAX_READ_FILE_CHARS, READ_TOOL_DEFAULT_MAX_CHARS, + READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER, + READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER, + READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES, + READ_TOOL_MAX_FILE_SIZE, + TERMINAL_SANDBOX_MOUNT_PATH, + TERMINAL_SANDBOX_MODE, + TERMINAL_SANDBOX_CPUS, + TERMINAL_SANDBOX_MEMORY, + PROJECT_MAX_STORAGE_MB, + CUSTOM_TOOLS_ENABLED, + ) + +from modules.file_manager import FileManager +from modules.search_engine import SearchEngine +from modules.terminal_ops import TerminalOperator +from modules.memory_manager import MemoryManager +from modules.terminal_manager import TerminalManager +from modules.todo_manager import TodoManager +from modules.sub_agent import SubAgentManager +from modules.webpage_extractor import extract_webpage_content, tavily_extract +from modules.ocr_client import OCRClient +from modules.easter_egg_manager import EasterEggManager +from modules.personalization_manager import ( + load_personalization_config, + build_personalization_prompt, +) +from modules.skills_manager import ( + get_skills_catalog, + build_skills_list, + merge_enabled_skills, + build_skills_prompt, +) +from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category +from modules.custom_tool_executor import CustomToolExecutor +from modules.mcp_server_registry import MCPServerRegistry, build_default_mcp_category + +try: + from config.limits import THINKING_FAST_INTERVAL +except ImportError: + THINKING_FAST_INTERVAL = 10 + +from modules.container_monitor import collect_stats, inspect_state +from core.tool_config import TOOL_CATEGORIES +from utils.api_client import DeepSeekClient +from utils.context_manager import ContextManager +from utils.tool_result_formatter import format_tool_result_for_context +from utils.logger import setup_logger +from config.model_profiles import ( + get_model_profile, + get_model_prompt_replacements, + get_model_context_window, + model_supports_image, + model_supports_video, +) + +logger = setup_logger(__name__) +DISABLE_LENGTH_CHECK = True + + + +class ToolsDefinitionTerminalToolsMixin: + def _build_terminal_tools(self) -> List[Dict]: + return [ + { + "type": "function", + "function": { + "name": "terminal_session", + "description": "管理持久化终端会话,可打开、关闭、列出或切换终端。请在授权工作区内执行命令,禁止启动需要完整 TTY 的程序(python REPL、vim、top 等)。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "action": { + "type": "string", + "enum": ["open", "close", "list", "reset"], + "description": "操作类型:open-打开新终端,close-关闭终端,list-列出所有终端,reset-重置终端" + }, + "session_name": { + "type": "string", + "description": "终端会话名称(open、close、reset时需要)" + }, + "working_dir": { + "type": "string", + "description": "工作目录,相对于项目路径(open时可选)" + } + }), + "required": ["action"] + } + } + }, + + { + "type": "function", + "function": { + "name": "terminal_input", + "description": "向指定终端发送命令或输入。禁止启动会占用终端界面的程序(python/node/nano/vim 等);如遇卡死请结合 terminal_snapshot 并使用 terminal_session 的 reset 恢复。output_wait 必填:本次收集/等待输出的最大时长(秒,1-300),不会封装命令、不会强杀进程;在窗口内若检测到命令已完成会提前返回,否则到时返回已产生的输出并保持命令继续运行。需要强制超时终止请使用 run_command。\n用法建议:\n1) 短命令多次执行可设 5-10 秒;\n2) 只需确认启动的长任务(下载/clone/pip/启动服务)可设约 30 秒,运行期间可做其他事情,后续用 terminal_snapshot 判断状态/是否完成;\n3) 必须等待结果再继续的任务(大表/文件批量处理、前端构建、批量测试、压缩/解压、数据导出/统计)设足够覆盖实际执行时间;\n4) 可能长时间无输出的任务(遍历海量文件/转码/打包)短窗口不一定有输出,可适当加长或加完成标记;\n5) 长时间无输出且怀疑卡死时,使用 terminal_session 的 reset 重置终端;\n6) 需要仅发送回车时,可传入一个空格字符作为 command(等效按下 Enter)。\n若不确定上一条命令是否结束,先用 terminal_snapshot 确认后再继续输入。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "command": { + "type": "string", + "description": "要执行的命令或发送的输入" + }, + "session_name": { + "type": "string", + "description": "目标终端会话名称(必填)" + }, + "output_wait": { + "type": "number", + "description": "等待/收集输出的最大秒数(1-300,必填);非命令超时,到点即返回已产生输出并保持命令运行" + } + }), + "required": ["command", "output_wait", "session_name"] + } + } + }, + + { + "type": "function", + "function": { + "name": "terminal_snapshot", + "description": "获取指定终端最近的输出快照,用于判断当前状态。默认返回末尾的50行,可通过参数调整。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "session_name": { + "type": "string", + "description": "目标终端会话名称(可选,默认活动终端)" + }, + "lines": { + "type": "integer", + "description": "返回的最大行数(可选)" + }, + "max_chars": { + "type": "integer", + "description": "返回的最大字符数(可选)" + } + }) + } + } + }, + + { + "type": "function", + "function": { + "name": "run_command", + "description": "执行一次性终端命令,适合查看文件信息(file/ls/stat/iconv 等)、转换编码或调用 CLI 工具。禁止启动交互式程序。必须提供 timeout。前台模式(run_in_background=false,默认)上限120秒,超时会打断;后台模式(run_in_background=true)上限3600秒,会先等待5秒返回已有输出并继续在后台运行,完成后由系统通知。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "command": {"type": "string", "description": "终端命令"}, + "timeout": { + "type": "number", + "description": "超时时长(秒),必填。前台最大120;后台最大3600。" + }, + "run_in_background": { + "type": "boolean", + "description": "是否后台运行。true 时先等待5秒返回已有输出,并在后台持续执行直至结束。" + } + }), + "required": ["command", "timeout"] + } + } + }, + + ] diff --git a/static/src/components/chat/actions/ToolAction.vue b/static/src/components/chat/actions/ToolAction.vue index 6f4ae54..301ef59 100644 --- a/static/src/components/chat/actions/ToolAction.vue +++ b/static/src/components/chat/actions/ToolAction.vue @@ -395,12 +395,24 @@ function renderEditFile(result: any, args: any): string { rawAfterLines.length >= 2 && String(rawAfterLines[rawAfterLines.length - 1]?.text ?? '') === '' ? rawAfterLines.slice(0, -1) : rawAfterLines; + + // 编辑后新增/删除行数不同会导致后续行号偏移,after context 需按新文件位置重新编号 + const contextOldStartLine = Number(context?.old_start_line ?? detail.matched_lines?.[0]); + const contextOldEndLine = Number(context?.old_end_line ?? contextOldStartLine); + const oldLineCount = Number.isFinite(contextOldStartLine) && Number.isFinite(contextOldEndLine) + ? Math.max(1, contextOldEndLine - contextOldStartLine + 1) + : Math.max(1, (oldText ? oldText.split('\n').length : 1)); + const newLineCount = newText === '' ? 0 : newText.split('\n').length; + const afterContextOffset = newLineCount - oldLineCount; + beforeLines.forEach((item: any) => { rows.push({ lineNo: Number(item.line), marker: ' ', text: String(item.text ?? ''), className: '' }); }); rows.push(...buildPairRows(context)); afterLines.forEach((item: any) => { - rows.push({ lineNo: Number(item.line), marker: ' ', text: String(item.text ?? ''), className: '' }); + const rawLineNo = Number(item.line); + const lineNo = Number.isFinite(rawLineNo) ? rawLineNo + afterContextOffset : rawLineNo; + rows.push({ lineNo, marker: ' ', text: String(item.text ?? ''), className: '' }); }); const numberedRows = rows.filter((row) => typeof row.lineNo === 'number' && Number.isFinite(row.lineNo)); hunks.push({ @@ -433,14 +445,8 @@ function renderEditFile(result: any, args: any): string { const markerOrder: Record = { ' ': 0, '-': 1, '+': 2 }; mergedHunks.forEach((hunk, hunkIdx) => { if (hunkIdx > 0) renderDiffLine(null, ' ', '⋮', 'diff-separator'); - const changedLines = new Set( - hunk.rows - .filter((row: any) => (row.marker === '-' || row.marker === '+') && typeof row.lineNo === 'number') - .map((row: any) => row.lineNo) - ); const seenContextLines = new Set(); hunk.rows - .filter((row: any) => !(row.marker === ' ' && changedLines.has(row.lineNo))) .filter((row: any) => { if (row.marker !== ' ' || typeof row.lineNo !== 'number') return true; if (seenContextLines.has(row.lineNo)) return false; diff --git a/static/src/components/panels/TerminalPanel.vue b/static/src/components/panels/TerminalPanel.vue index 642b9ca..e460851 100644 --- a/static/src/components/panels/TerminalPanel.vue +++ b/static/src/components/panels/TerminalPanel.vue @@ -347,7 +347,7 @@ async function initSocket() { socket.on('terminal_input', (data: any) => { console.log('[TerminalPanel] terminal_input:', data.session); - // terminal_output 已包含完整的 prompt + 命令回显,此处不重复写入 + // 原样显示后端输出,此处不重复写入 // 仅用于新终端自动切换 if (!activeSession.value && data.session) switchToSession(data.session); }); @@ -391,8 +391,6 @@ async function initSocket() { } console.log('[TerminalPanel] handleHistory:', s, 'rawLen:', raw.length, 'payloadLen:', payload.length, 'isActive:', s === activeSession.value); if (payload) { - const esc = String.fromCharCode(27); - payload = payload.replace(new RegExp(`${esc}\\[1;32m➜.*?${esc}\\[0m\\n?`, 'g'), ''); sessionLogs.value[s] = payload; } // 无论历史是否为空,都要标记加载完成;否则新建终端在收到第一条输出前 diff --git a/utils/api_client.py b/utils/api_client.py index 0db484b..30de877 100644 --- a/utils/api_client.py +++ b/utils/api_client.py @@ -1,1296 +1,4 @@ -# ========== api_client.py ========== -# utils/api_client.py - OpenAI-compatible API 客户端(支持Web模式) +# 兼容入口,已迁移到 utils.api_client 包 +from utils.api_client import DeepSeekClient -import httpx -import json -import asyncio -import base64 -import mimetypes -import os -from typing import List, Dict, Optional, AsyncGenerator, Any -from pathlib import Path -from datetime import datetime -from pathlib import Path -from typing import Tuple -try: - from config import ( - OUTPUT_FORMATS, - DEFAULT_RESPONSE_MAX_TOKENS, - LOGS_DIR, - ) -except ImportError: - import sys - from pathlib import Path - project_root = Path(__file__).resolve().parents[1] - if str(project_root) not in sys.path: - sys.path.insert(0, str(project_root)) - from config import ( - OUTPUT_FORMATS, - DEFAULT_RESPONSE_MAX_TOKENS, - LOGS_DIR, - ) - -from utils.log_rotation import append_line, prune_dir - - -def _api_dump_enabled() -> bool: - """API 请求体落盘开关,默认关闭。""" - return str(os.environ.get("AGENT_API_DUMP_ENABLED", "")).strip().lower() in {"1", "true", "yes", "on"} - -class DeepSeekClient: # legacy name; generic OpenAI-compatible client - def __init__(self, thinking_mode: bool = True, web_mode: bool = False): - # 模型 API 配置(端点 / 密钥 / 模型 ID)由 apply_profile 从 custom_models 注入; - # 这里仅初始化为空,未应用任何模型档案前不可直接发请求。 - self.fast_api_config = { - "base_url": "", - "api_key": "", - "model_id": "" - } - self.thinking_api_config = { - "base_url": "", - "api_key": "", - "model_id": "" - } - self.fast_max_tokens = None - self.thinking_max_tokens = None - self.fast_extra_params: Dict = {} - self.thinking_extra_params: Dict = {} - # 上下文预算(由上层在每次请求前设置) - self.current_context_tokens: int = 0 - self.max_context_tokens: Optional[int] = None - self.default_context_window: Optional[int] = None - self.thinking_mode = thinking_mode # True=智能思考模式, False=快速模式 - self.deep_thinking_mode = False # 深度思考模式:整轮都使用思考模型 - self.deep_thinking_session = False # 当前任务是否处于深度思考会话 - self.web_mode = web_mode # Web模式标志,用于禁用print输出 - # 兼容旧代码路径 - self.api_base_url = self.fast_api_config["base_url"] - self.api_key = self.fast_api_config["api_key"] - self.model_id = self.fast_api_config["model_id"] - self.model_key = None # 由宿主终端注入,便于做模型兼容处理 - self.model_multimodal = "none" # 由模型 profile 注入,model_key 不可用时用于能力兜底 - self.project_path: Optional[str] = None - # 每个任务的独立状态 - self.current_task_first_call = True # 当前任务是否是第一次调用 - self.current_task_thinking = "" # 当前任务的思考内容 - self.force_thinking_next_call = False # 单次强制思考 - self.skip_thinking_next_call = False # 单次强制快速 - self.last_call_used_thinking = False # 最近一次调用是否使用思考模型 - # 最近一次API错误详情 - self.last_error_info: Optional[Dict[str, Any]] = None - # 请求体落盘目录(跟随 LOGS_DIR,默认 ~/.agents//logs/api_requests) - self.request_dump_dir = Path(LOGS_DIR) / "api_requests" - self.debug_log_path = Path(LOGS_DIR) / "api_debug.log" - - def _debug_log(self, payload: Dict[str, Any]) -> None: - if not _api_dump_enabled(): - return - try: - entry = { - "ts": datetime.now().isoformat(), - **payload - } - append_line(self.debug_log_path, json.dumps(entry, ensure_ascii=False)) - except Exception: - pass - - def _resolve_env_value(self, name: str) -> Optional[str]: - """从环境变量获取配置(已由 config/__init__.py 统一注入)。""" - value = os.environ.get(name) - if value is None: - return None - value = value.strip() - return value or None - - def _print(self, message: str, end: str = "\n", flush: bool = False): - """安全的打印函数,在Web模式下不输出""" - if not self.web_mode: - print(message, end=end, flush=flush) - - def _format_read_file_result(self, data: Dict) -> str: - """根据读取模式格式化 read_file 工具结果。""" - if not isinstance(data, dict): - return json.dumps(data, ensure_ascii=False) - if not data.get("success"): - return json.dumps(data, ensure_ascii=False) - - read_type = data.get("type", "read") - truncated_note = "(内容已截断)" if data.get("truncated") else "" - path = data.get("path", "未知路径") - max_chars = data.get("max_chars") - max_note = f"(max_chars={max_chars})" if max_chars else "" - - if read_type == "read": - line_start = data.get("line_start") - line_end = data.get("line_end") - char_count = data.get("char_count", len(data.get("content", "") or "")) - header = f"读取 {path} 行 {line_start}~{line_end},返回 {char_count} 字符 {max_note}{truncated_note}".strip() - content = data.get("content", "") - return f"{header}\n```\n{content}\n```" - - if read_type == "search": - query = data.get("query", "") - actual = data.get("actual_matches", 0) - returned = data.get("returned_matches", 0) - case_hint = "区分大小写" if data.get("case_sensitive") else "不区分大小写" - header = ( - f"在 {path} 中搜索 \"{query}\",返回 {returned}/{actual} 条结果({case_hint})" - f" {max_note}{truncated_note}" - ).strip() - match_texts = [] - for idx, match in enumerate(data.get("matches", []), 1): - match_note = "(片段截断)" if match.get("truncated") else "" - hits = match.get("hits") or [] - hit_text = ", ".join(str(h) for h in hits) if hits else "无" - label = match.get("id") or f"match_{idx}" - snippet = match.get("snippet", "") - match_texts.append( - f"[{label}] 行 {match.get('line_start')}~{match.get('line_end')} 命中行: {hit_text}{match_note}\n```\n{snippet}\n```" - ) - if not match_texts: - match_texts.append("未找到匹配内容。") - return "\n".join([header] + match_texts) - - def _build_content_with_images(self, text: str, images: List[str], videos: Optional[List[Any]] = None) -> Any: - """将文本与图片/视频路径拼成多模态 content(用于 tool 消息)。""" - videos = videos or [] - if not images and not videos: - return text - parts: List[Dict[str, Any]] = [] - extra_videos: List[Any] = [] - if text: - parts.append({"type": "text", "text": text}) - base_path = Path(self.project_path or ".") - for path in images: - try: - abs_path = (base_path / path).resolve() - if not abs_path.exists() or not abs_path.is_file(): - continue - mime, _ = mimetypes.guess_type(abs_path.name) - if mime and mime.startswith("video/"): - extra_videos.append(path) - continue - if mime and not mime.startswith("image/"): - continue - if not mime: - mime = "image/png" - data = abs_path.read_bytes() - b64 = base64.b64encode(data).decode("utf-8") - parts.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}}) - except Exception: - continue - for item in [*videos, *extra_videos]: - try: - if isinstance(item, dict): - path = item.get("path") or "" - else: - path = item - if not path: - continue - abs_path = (base_path / path).resolve() - if not abs_path.exists() or not abs_path.is_file(): - continue - mime, _ = mimetypes.guess_type(abs_path.name) - if not mime: - mime = "video/mp4" - data = abs_path.read_bytes() - b64 = base64.b64encode(data).decode("utf-8") - payload: Dict[str, Any] = { - "type": "video_url", - "video_url": {"url": f"data:{mime};base64,{b64}"} - } - if isinstance(item, dict) and item.get("fps") is not None: - payload["fps"] = item.get("fps") - parts.append(payload) - except Exception: - continue - return parts if parts else text - - if read_type == "extract": - segments = data.get("segments", []) - header = ( - f"从 {path} 抽取 {len(segments)} 个片段 {max_note}{truncated_note}" - ).strip() - seg_texts = [] - for idx, segment in enumerate(segments, 1): - seg_note = "(片段截断)" if segment.get("truncated") else "" - label = segment.get("label") or f"segment_{idx}" - snippet = segment.get("content", "") - seg_texts.append( - f"[{label}] 行 {segment.get('line_start')}~{segment.get('line_end')}{seg_note}\n```\n{snippet}\n```" - ) - if not seg_texts: - seg_texts.append("未提供可抽取的片段。") - return "\n".join([header] + seg_texts) - - return json.dumps(data, ensure_ascii=False) - - def _extract_reasoning_delta(self, delta: Dict[str, Any]) -> str: - """统一提取思考内容,兼容 reasoning_content / reasoning_details。""" - if not isinstance(delta, dict): - return "" - if "reasoning_content" in delta: - return delta.get("reasoning_content") or "" - details = delta.get("reasoning_details") - if isinstance(details, list): - parts: List[str] = [] - for item in details: - if isinstance(item, dict): - text = item.get("text") - if text: - parts.append(text) - if parts: - return "".join(parts) - return "" - - def _merge_system_messages(self, messages: List[Dict]) -> List[Dict]: - """ - 仅合并最开头连续的 system 消息(系统提示),后续插入的 system 消息保持原样。 - """ - if not messages: - return messages - - merged_contents: List[str] = [] - idx = 0 - while idx < len(messages) and messages[idx].get("role") == "system": - content = messages[idx].get("content", "") - if isinstance(content, str): - merged_contents.append(content) - else: - merged_contents.append(json.dumps(content, ensure_ascii=False)) - idx += 1 - - if not merged_contents: - return messages - - merged = { - "role": "system", - "content": "\n\n".join(c for c in merged_contents if c) - } - return [merged] + messages[idx:] - - @staticmethod - def _normalize_multimodal_capability(value: Any) -> str: - text = str(value or "none").strip().lower() - if text == "video,image": - return "image,video" - if text in {"none", "image", "video", "image,video"}: - return text - return "none" - - @staticmethod - def _sanitize_content_for_capability( - content: Any, - *, - supports_image: bool, - supports_video: bool, - ) -> Any: - """根据模型多模态能力裁剪 content,避免向纯文本模型发送 image_url/video_url。""" - if not isinstance(content, list): - return content - - kept_parts: List[Dict[str, Any]] = [] - text_segments: List[str] = [] - dropped_media = False - unsupported_media_notice = "当前模型无查看图片/视频能力,无法返回结果" - - for part in content: - if not isinstance(part, dict): - text = str(part).strip() - if text: - text_segments.append(text) - continue - - ctype = str(part.get("type") or "").strip().lower() - if ctype == "text": - text = str(part.get("text") or "") - if text: - text_segments.append(text) - kept_parts.append({"type": "text", "text": text}) - continue - if ctype == "image_url": - if supports_image: - kept_parts.append(part) - else: - dropped_media = True - continue - if ctype == "video_url": - if supports_video: - kept_parts.append(part) - else: - dropped_media = True - continue - - # 其他未知/暂不支持类型统一忽略(纯文本模型下只保留 text) - continue - - # 纯文本模型:统一回退为字符串,规避供应商对 content part 类型的严格校验 - if not supports_image and not supports_video: - text_payload = "\n".join(seg for seg in text_segments if seg).strip() - if dropped_media: - if text_payload: - if unsupported_media_notice in text_payload: - return text_payload - return f"{text_payload}\n\n{unsupported_media_notice}" - return unsupported_media_notice - return text_payload - - # 半多模态模型(例如只支持图片):尽量保留可支持的 part - if kept_parts: - if len(kept_parts) == 1 and kept_parts[0].get("type") == "text": - return kept_parts[0].get("text", "") - return kept_parts - return "\n".join(seg for seg in text_segments if seg).strip() - - def _sanitize_messages_for_model_capability(self, messages: List[Dict]) -> List[Dict]: - model_key = str(self.model_key or "").strip() - supports_image = False - supports_video = False - capability_source = "profile_fallback" - - if model_key: - try: - from config.model_profiles import get_model_capabilities - caps = get_model_capabilities(model_key) - supports_image = bool(caps.get("supports_image")) - supports_video = bool(caps.get("supports_video")) - capability_source = "model_key" - except Exception: - capability_source = "profile_fallback" - - if capability_source != "model_key": - multimodal = self._normalize_multimodal_capability(self.model_multimodal) - supports_image = multimodal in {"image", "image,video"} - supports_video = multimodal in {"video", "image,video"} - - if supports_image and supports_video: - return messages - - changed = 0 - sanitized: List[Dict[str, Any]] = [] - for message in messages or []: - if not isinstance(message, dict): - continue - msg_copy = dict(message) - original_content = msg_copy.get("content") - new_content = self._sanitize_content_for_capability( - original_content, - supports_image=supports_image, - supports_video=supports_video, - ) - if new_content != original_content: - changed += 1 - msg_copy["content"] = new_content - sanitized.append(msg_copy) - - if changed: - self._debug_log( - { - "event": "sanitize_messages_for_model_capability", - "model_key": model_key, - "capability_source": capability_source, - "supports_image": supports_image, - "supports_video": supports_video, - "changed_messages": changed, - } - ) - return sanitized - - def _sanitize_message_fields_for_api(self, messages: List[Dict]) -> List[Dict]: - """移除只供本地 UI/持久化使用、OpenAI-compatible API 不接受的消息字段。""" - sanitized: List[Dict[str, Any]] = [] - stripped_fields: Dict[str, int] = {} - - allowed_common = {"role", "content", "name"} - allowed_by_role = { - "assistant": allowed_common | {"tool_calls", "reasoning_content"}, - "tool": allowed_common | {"tool_call_id"}, - "user": allowed_common, - "system": allowed_common, - } - - for message in messages or []: - if not isinstance(message, dict): - continue - role = str(message.get("role") or "").strip() - allowed = allowed_by_role.get(role, allowed_common) - clean: Dict[str, Any] = {} - for key, value in message.items(): - if key not in allowed: - stripped_fields[key] = stripped_fields.get(key, 0) + 1 - continue - if key == "tool_calls" and not value: - stripped_fields[key] = stripped_fields.get(key, 0) + 1 - continue - clean[key] = value - sanitized.append(clean) - - if stripped_fields: - self._debug_log( - { - "event": "sanitize_message_fields_for_api", - "stripped_fields": stripped_fields, - } - ) - return sanitized - - def set_deep_thinking_mode(self, enabled: bool): - """配置深度思考模式(持续使用思考模型)。""" - self.deep_thinking_mode = bool(enabled) - if not enabled: - self.deep_thinking_session = False - - def start_new_task(self, force_deep: bool = False): - """开始新任务(重置任务级别的状态)""" - self.current_task_first_call = True - self.current_task_thinking = "" - self.force_thinking_next_call = False - self.skip_thinking_next_call = False - self.last_call_used_thinking = False - self.deep_thinking_session = bool(force_deep) or bool(self.deep_thinking_mode) - - def _build_headers(self, api_key: str) -> Dict[str, str]: - return { - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json" - } - - def _select_api_config(self, use_thinking: bool) -> Dict[str, str]: - """ - 根据当前模式选择API配置,确保缺失字段回退到默认模型。 - """ - config = self.thinking_api_config if use_thinking else self.fast_api_config - fallback = self.fast_api_config - return { - "base_url": config.get("base_url") or fallback["base_url"], - "api_key": config.get("api_key") or fallback["api_key"], - "model_id": config.get("model_id") or fallback["model_id"] - } - - def _dump_request_payload(self, payload: Dict, api_config: Dict, headers: Dict) -> Optional[Path]: - """ - 将本次请求的payload、headers、配置落盘,便于排查400等错误。 - 返回写入的文件路径;落盘开关关闭(默认)时返回 None。 - """ - if not _api_dump_enabled(): - return None - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") - filename = f"req_{timestamp}.json" - path = self.request_dump_dir / filename - try: - self.request_dump_dir.mkdir(parents=True, exist_ok=True) - headers_sanitized = {} - for k, v in headers.items(): - headers_sanitized[k] = "***" if k.lower() == "authorization" else v - data = { - "timestamp": datetime.now().isoformat(), - "api_config": {k: api_config.get(k) for k in ["base_url", "model_id"]}, - "headers": headers_sanitized, - "payload": payload - } - path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") - # 按份保留:只留最近 N 个请求落盘文件 - prune_dir(self.request_dump_dir, pattern="req_*.json") - except Exception as exc: - self._print(f"{OUTPUT_FORMATS['warning']} 请求体落盘失败: {exc}") - return path - - def _mark_request_error(self, dump_path: Path, status_code: int = None, error_text: str = None): - """ - 在已有请求文件中追加错误标记,便于快速定位。 - """ - if not dump_path or not dump_path.exists(): - return - try: - data = json.loads(dump_path.read_text(encoding="utf-8")) - data["error"] = { - "status_code": status_code, - "message": error_text, - "marked_at": datetime.now().isoformat() - } - dump_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") - except Exception as exc: - self._print(f"{OUTPUT_FORMATS['warning']} 标记请求错误失败: {exc}") - - - def apply_profile(self, profile: Dict): - """ - 动态应用模型配置 - profile 示例: - { - "fast": {"base_url": "...", "api_key": "...", "model_id": "...", "max_tokens": 8192}, - "thinking": {...} 或 None, - "supports_thinking": True/False, - "fast_only": True/False - } - """ - if not profile or "fast" not in profile: - raise ValueError("无效的模型配置") - fast = profile["fast"] or {} - thinking = profile.get("thinking") or fast - self.fast_api_config = { - "base_url": fast.get("base_url") or self.fast_api_config.get("base_url"), - "api_key": fast.get("api_key") or self.fast_api_config.get("api_key"), - "model_id": fast.get("model_id") or self.fast_api_config.get("model_id") - } - self.thinking_api_config = { - "base_url": thinking.get("base_url") or self.thinking_api_config.get("base_url"), - "api_key": thinking.get("api_key") or self.thinking_api_config.get("api_key"), - "model_id": thinking.get("model_id") or self.thinking_api_config.get("model_id") - } - self.fast_max_tokens = fast.get("max_tokens") - self.thinking_max_tokens = thinking.get("max_tokens") - self.fast_extra_params = fast.get("extra_params") or {} - self.thinking_extra_params = thinking.get("extra_params") or {} - self.model_multimodal = self._normalize_multimodal_capability(profile.get("multimodal")) - self.default_context_window = profile.get("context_window") or fast.get("context_window") - # 同步旧字段 - self.api_base_url = self.fast_api_config["base_url"] - self.api_key = self.fast_api_config["api_key"] - self.model_id = self.fast_api_config["model_id"] - try: - self._debug_log({ - "event": "apply_profile", - "model_key": self.model_key, - "fast_model_id": self.fast_api_config.get("model_id"), - "thinking_model_id": self.thinking_api_config.get("model_id"), - "fast_max_tokens": self.fast_max_tokens, - "thinking_max_tokens": self.thinking_max_tokens, - "fast_extra_params": self.fast_extra_params, - "thinking_extra_params": self.thinking_extra_params, - "default_context_window": self.default_context_window, - }) - except Exception: - pass - - def update_context_budget(self, current_tokens: int, max_tokens: Optional[int]): - """ - 由上层在每次调用前告知当前对话占用的token数和模型最大上下文。 - """ - try: - self.current_context_tokens = max(0, int(current_tokens)) - except (TypeError, ValueError): - self.current_context_tokens = 0 - try: - self.max_context_tokens = int(max_tokens) if max_tokens is not None else None - except (TypeError, ValueError): - self.max_context_tokens = None - - def get_current_thinking_mode(self) -> bool: - """获取当前应该使用的思考模式""" - if self.deep_thinking_session: - return True - if not self.thinking_mode: - return False - if self.force_thinking_next_call: - return True - if self.skip_thinking_next_call: - return False - return self.current_task_first_call - - def _validate_json_string(self, json_str: str) -> tuple: - """ - 验证JSON字符串的完整性 - - Returns: - (is_valid: bool, error_message: str, parsed_data: dict or None) - """ - if not json_str or not json_str.strip(): - return True, "", {} - - # 检查基本的JSON结构标记 - stripped = json_str.strip() - if not stripped.startswith('{') or not stripped.endswith('}'): - return False, "JSON字符串格式不完整(缺少开始或结束大括号)", None - - # 检查引号配对 - in_string = False - escape_next = False - quote_count = 0 - - for char in stripped: - if escape_next: - escape_next = False - continue - - if char == '\\': - escape_next = True - continue - - if char == '"': - quote_count += 1 - in_string = not in_string - - if in_string: - return False, "JSON字符串中存在未闭合的引号", None - - # 尝试解析JSON - try: - parsed_data = json.loads(stripped) - return True, "", parsed_data - except json.JSONDecodeError as e: - return False, f"JSON解析错误: {str(e)}", None - - def _safe_tool_arguments_parse(self, arguments_str: str, tool_name: str) -> tuple: - """ - 安全地解析工具参数,保持失败即时返回 - - Returns: - (success: bool, arguments: dict, error_message: str) - """ - if not arguments_str or not arguments_str.strip(): - return True, {}, "" - - # 长度检查 - max_length = 999999999 # 50KB限制 - if len(arguments_str) > max_length: - return False, {}, f"参数过长({len(arguments_str)}字符),超过{max_length}字符限制" - - # 尝试直接解析JSON - try: - parsed_data = json.loads(arguments_str) - return True, parsed_data, "" - except json.JSONDecodeError as e: - preview_length = 200 - stripped = arguments_str.strip() - preview = stripped[:preview_length] + "..." if len(stripped) > preview_length else stripped - return False, {}, f"JSON解析失败: {str(e)}\n参数预览: {preview}" - async def chat( - self, - messages: List[Dict], - tools: Optional[List[Dict]] = None, - stream: bool = True - ) -> AsyncGenerator[Dict, None]: - """ - 异步调用 OpenAI-compatible API - - Args: - messages: 消息列表 - tools: 工具定义列表 - stream: 是否流式输出 - - Yields: - 响应内容块 - """ - # 检查API密钥 - if not self.api_key or self.api_key.startswith("your-"): - self._print(f"{OUTPUT_FORMATS['error']} API密钥未配置,请检查模型配置") - return - - # 决定是否使用思考模式 - current_thinking_mode = self.get_current_thinking_mode() - api_config = self._select_api_config(current_thinking_mode) - headers = self._build_headers(api_config["api_key"]) - - # 如果当前为快速模式但已有思考内容,提示沿用 - if self.thinking_mode and not current_thinking_mode and self.current_task_thinking: - self._print(f"{OUTPUT_FORMATS['info']} [任务内快速模式] 使用本次任务的思考继续处理...") - - # 记录本次调用的模式 - self.last_call_used_thinking = current_thinking_mode - if current_thinking_mode and self.force_thinking_next_call: - self.force_thinking_next_call = False - if not current_thinking_mode and self.skip_thinking_next_call: - self.skip_thinking_next_call = False - - try: - override_max = self.thinking_max_tokens if current_thinking_mode else self.fast_max_tokens - if override_max is not None: - max_tokens = int(override_max) - else: - max_tokens = int(DEFAULT_RESPONSE_MAX_TOKENS) - if max_tokens <= 0: - raise ValueError("max_tokens must be positive") - except (TypeError, ValueError): - max_tokens = 4096 - - # 动态收缩 max_tokens,避免超过模型上下文窗口 - budget_max_context = self.max_context_tokens or self.default_context_window - if budget_max_context and budget_max_context > 0: - used_tokens = max(0, int(self.current_context_tokens or 0)) - available = budget_max_context - used_tokens - if available <= 0: - # 兜底:让上游错误处理,这里至少给1防止API报参数错误 - max_tokens = 1 - else: - max_tokens = min(max_tokens, available) - - final_messages = self._merge_system_messages(messages) - final_messages = self._sanitize_messages_for_model_capability(final_messages) - final_messages = self._sanitize_message_fields_for_api(final_messages) - - payload = { - "model": api_config["model_id"], - "messages": final_messages, - "stream": stream, - } - payload["max_tokens"] = max_tokens - # 注入模型配置中的额外参数 - extra_params = self.thinking_extra_params if current_thinking_mode else self.fast_extra_params - if extra_params: - payload.update(extra_params) - if tools: - payload["tools"] = tools - payload["tool_choice"] = "auto" - - # 将本次请求落盘,便于出错时快速定位 - try: - self._debug_log({ - "event": "request_prepare", - "model_key": self.model_key, - "thinking_mode_flag": bool(self.thinking_mode), - "deep_thinking_mode": bool(self.deep_thinking_mode), - "deep_thinking_session": bool(self.deep_thinking_session), - "current_call_use_thinking": bool(current_thinking_mode), - "api_base_url": api_config.get("base_url"), - "api_model_id": api_config.get("model_id"), - "payload_model": payload.get("model"), - "payload_has_thinking": "thinking" in payload, - "payload_thinking": payload.get("thinking"), - "payload_enable_thinking": payload.get("enable_thinking"), - "payload_max_tokens": payload.get("max_tokens"), - "payload_max_completion_tokens": payload.get("max_completion_tokens"), - }) - except Exception: - pass - dump_path = self._dump_request_payload(payload, api_config, headers) - - try: - async with httpx.AsyncClient(http2=True, timeout=300) as client: - if stream: - async with client.stream( - "POST", - f"{api_config['base_url']}/chat/completions", - json=payload, - headers=headers - ) as response: - # 检查响应状态 - if response.status_code != 200: - error_bytes = await response.aread() - error_text = error_bytes.decode('utf-8', errors='ignore') if hasattr(error_bytes, 'decode') else str(error_bytes) - self.last_error_info = { - "status_code": response.status_code, - "error_text": error_text, - "error_type": None, - "error_message": None, - "request_dump": (str(dump_path) if dump_path else None), - "base_url": api_config.get("base_url"), - "model_id": api_config.get("model_id"), - "model_key": self.model_key - } - try: - parsed = json.loads(error_text) - err = parsed.get("error") if isinstance(parsed, dict) else {} - if isinstance(err, dict): - self.last_error_info["error_type"] = err.get("type") - self.last_error_info["error_message"] = err.get("message") - except Exception: - pass - self._debug_log({ - "event": "http_error_stream", - "status_code": response.status_code, - "error_text": error_text, - "base_url": api_config.get("base_url"), - "model_id": api_config.get("model_id"), - "model_key": self.model_key, - "request_dump": (str(dump_path) if dump_path else None) - }) - self._print( - f"{OUTPUT_FORMATS['error']} API请求失败 ({response.status_code}): {error_text} " - f"(base_url={api_config.get('base_url')}, model_id={api_config.get('model_id')})" - ) - self._mark_request_error(dump_path, response.status_code, error_text) - yield {"error": self.last_error_info} - return - - async for line in response.aiter_lines(): - if line.startswith("data:"): - json_str = line[5:].strip() - if json_str == "[DONE]": - break - - try: - data = json.loads(json_str) - yield data - except json.JSONDecodeError: - continue - else: - response = await client.post( - f"{api_config['base_url']}/chat/completions", - json=payload, - headers=headers - ) - if response.status_code != 200: - error_text = response.text - self.last_error_info = { - "status_code": response.status_code, - "error_text": error_text, - "error_type": None, - "error_message": None, - "request_dump": (str(dump_path) if dump_path else None), - "base_url": api_config.get("base_url"), - "model_id": api_config.get("model_id"), - "model_key": self.model_key - } - try: - parsed = response.json() - err = parsed.get("error") if isinstance(parsed, dict) else {} - if isinstance(err, dict): - self.last_error_info["error_type"] = err.get("type") - self.last_error_info["error_message"] = err.get("message") - except Exception: - pass - self._debug_log({ - "event": "http_error", - "status_code": response.status_code, - "error_text": error_text, - "base_url": api_config.get("base_url"), - "model_id": api_config.get("model_id"), - "model_key": self.model_key, - "request_dump": (str(dump_path) if dump_path else None) - }) - self._print( - f"{OUTPUT_FORMATS['error']} API请求失败 ({response.status_code}): {error_text} " - f"(base_url={api_config.get('base_url')}, model_id={api_config.get('model_id')})" - ) - self._mark_request_error(dump_path, response.status_code, error_text) - yield {"error": self.last_error_info} - return - # 成功则清空错误状态 - self.last_error_info = None - yield response.json() - - except httpx.ConnectError as e: - connect_detail = str(e).strip() or repr(e) - self._print( - f"{OUTPUT_FORMATS['error']} 无法连接到API服务器,请检查网络连接" - f"({connect_detail})" - ) - self.last_error_info = { - "status_code": None, - "error_text": "connect_error", - "error_type": "connection_error", - "error_message": f"无法连接到API服务器: {connect_detail}", - "error_detail": connect_detail, - "request_dump": (str(dump_path) if dump_path else None), - "base_url": api_config.get("base_url"), - "model_id": api_config.get("model_id"), - "model_key": self.model_key - } - self._debug_log({ - "event": "connect_error", - "status_code": None, - "error_text": "connect_error", - "error_detail": connect_detail, - "base_url": api_config.get("base_url"), - "model_id": api_config.get("model_id"), - "model_key": self.model_key, - "request_dump": (str(dump_path) if dump_path else None) - }) - self._mark_request_error(dump_path, error_text=f"connect_error: {connect_detail}") - yield {"error": self.last_error_info} - except httpx.TimeoutException: - self._print(f"{OUTPUT_FORMATS['error']} API请求超时") - self.last_error_info = { - "status_code": None, - "error_text": "timeout", - "error_type": "timeout", - "error_message": "API请求超时", - "request_dump": (str(dump_path) if dump_path else None), - "base_url": api_config.get("base_url"), - "model_id": api_config.get("model_id"), - "model_key": self.model_key - } - self._debug_log({ - "event": "timeout", - "status_code": None, - "error_text": "timeout", - "base_url": api_config.get("base_url"), - "model_id": api_config.get("model_id"), - "model_key": self.model_key, - "request_dump": (str(dump_path) if dump_path else None) - }) - self._mark_request_error(dump_path, error_text="timeout") - yield {"error": self.last_error_info} - except httpx.RemoteProtocolError as e: - disconnect_detail = str(e).strip() or repr(e) - self._print(f"{OUTPUT_FORMATS['error']} API服务器连接断开({disconnect_detail})") - self.last_error_info = { - "status_code": None, - "error_text": disconnect_detail, - "error_type": "connection_error", - "error_message": f"API服务器连接断开: {disconnect_detail}", - "request_dump": (str(dump_path) if dump_path else None), - "base_url": api_config.get("base_url"), - "model_id": api_config.get("model_id"), - "model_key": self.model_key - } - self._debug_log({ - "event": "server_disconnected", - "status_code": None, - "error_text": disconnect_detail, - "base_url": api_config.get("base_url"), - "model_id": api_config.get("model_id"), - "model_key": self.model_key, - "request_dump": (str(dump_path) if dump_path else None) - }) - self._mark_request_error(dump_path, error_text=f"server_disconnected: {disconnect_detail}") - yield {"error": self.last_error_info} - except Exception as e: - error_text = str(e).strip() or repr(e) - self._print(f"{OUTPUT_FORMATS['error']} API调用异常: {error_text}") - self.last_error_info = { - "status_code": None, - "error_text": error_text, - "error_type": "exception", - "error_message": error_text, - "request_dump": (str(dump_path) if dump_path else None), - "base_url": api_config.get("base_url"), - "model_id": api_config.get("model_id"), - "model_key": self.model_key - } - self._debug_log({ - "event": "exception", - "status_code": None, - "error_text": error_text, - "base_url": api_config.get("base_url"), - "model_id": api_config.get("model_id"), - "model_key": self.model_key, - "request_dump": (str(dump_path) if dump_path else None) - }) - self._mark_request_error(dump_path, error_text=error_text) - yield {"error": self.last_error_info} - - async def chat_with_tools( - self, - messages: List[Dict], - tools: List[Dict], - tool_handler: callable - ) -> str: - """ - 带工具调用的对话(支持多轮) - - Args: - messages: 消息列表 - tools: 工具定义 - tool_handler: 工具处理函数 - - Returns: - 最终回答 - """ - final_response = "" - max_iterations = 200 # 最大迭代次数 - iteration = 0 - all_tool_results = [] # 记录所有工具调用结果 - - while iteration < max_iterations: - iteration += 1 - - # 调用API(始终提供工具定义) - full_response = "" - tool_calls = [] - current_thinking = "" - - # 状态标志 - in_thinking = False - thinking_printed = False - - async for chunk in self.chat(messages, tools, stream=True): - if chunk.get("error"): - # 直接返回错误,让上层处理 - err = chunk["error"] - self.last_error_info = err - err_msg = err.get("error_message") or err.get("error_text") or "API调用失败" - status = err.get("status_code") - self._print(f"{OUTPUT_FORMATS['error']} 模型API错误{f'({status})' if status is not None else ''}: {err_msg}") - return "" - - if "choices" not in chunk: - continue - - delta = chunk["choices"][0].get("delta", {}) - - # 处理思考内容 - reasoning_content = self._extract_reasoning_delta(delta) - if reasoning_content: - if not in_thinking: - self._print("💭 [正在思考]\n", end="", flush=True) - in_thinking = True - thinking_printed = True - current_thinking += reasoning_content - self._print(reasoning_content, end="", flush=True) - - # 处理正常内容 - 独立的if,不是elif - if "content" in delta: - content = delta["content"] - if content: # 只处理非空内容 - # 如果之前在输出思考,先结束思考输出 - if in_thinking: - self._print("\n\n💭 [思考结束]\n\n", end="", flush=True) - in_thinking = False - full_response += content - self._print(content, end="", flush=True) - - # 收集工具调用 - 改进的拼接逻辑 - # 收集工具调用 - 修复JSON分片问题 - if "tool_calls" in delta: - for tool_call in delta["tool_calls"]: - tool_index = tool_call.get("index", 0) - - # 查找或创建对应索引的工具调用 - existing_call = None - for existing in tool_calls: - if existing.get("index") == tool_index: - existing_call = existing - break - - if not existing_call and tool_call.get("id"): - # 创建新的工具调用 - new_call = { - "id": tool_call.get("id"), - "index": tool_index, - "type": tool_call.get("type", "function"), - "function": { - "name": tool_call.get("function", {}).get("name", ""), - "arguments": "" - } - } - tool_calls.append(new_call) - existing_call = new_call - - # 安全地拼接arguments - 简单字符串拼接,不尝试JSON验证 - if existing_call and "function" in tool_call and "arguments" in tool_call["function"]: - new_args = tool_call["function"]["arguments"] - if new_args: # 只拼接非空内容 - existing_call["function"]["arguments"] += new_args - - self._print("") # 最终换行 - - # 如果思考还没结束(只调用工具没有文本),手动结束 - if in_thinking: - self._print("\n💭 [思考结束]\n") - - # 记录思考内容并更新调用状态 - if self.last_call_used_thinking and current_thinking: - self.current_task_thinking = current_thinking - if self.current_task_first_call: - self.current_task_first_call = False # 标记当前任务的第一次调用已完成 - - # 如果没有工具调用,说明完成了 - if not tool_calls: - if full_response: # 有正常回复,任务完成 - final_response = full_response - break - elif iteration == 1: # 第一次就没有工具调用也没有内容,可能有问题 - self._print(f"{OUTPUT_FORMATS['warning']} 模型未返回内容") - break - - # 构建助手消息 - 始终包含所有收集到的内容 - assistant_content_parts = [] - - # 添加正式回复内容(如果有) - if full_response: - assistant_content_parts.append(full_response) - - # 添加工具调用说明 - if tool_calls: - tool_names = [tc['function']['name'] for tc in tool_calls] - assistant_content_parts.append(f"执行工具: {', '.join(tool_names)}") - - # 合并所有内容 - assistant_content = "\n".join(assistant_content_parts) if assistant_content_parts else "执行工具调用" - - assistant_message = { - "role": "assistant", - "content": assistant_content, - "tool_calls": tool_calls - } - if current_thinking: - assistant_message["reasoning_content"] = current_thinking - messages.append(assistant_message) - - # 执行所有工具调用 - 使用鲁棒的参数解析 - for tool_call in tool_calls: - function_name = tool_call["function"]["name"] - arguments_str = tool_call["function"]["arguments"] - - # 使用改进的参数解析方法,增强JSON修复能力 - success, arguments, error_msg = self._safe_tool_arguments_parse(arguments_str, function_name) - - if not success: - self._print(f"{OUTPUT_FORMATS['error']} 工具参数解析失败: {error_msg}") - self._print(f" 工具名称: {function_name}") - self._print(f" 参数长度: {len(arguments_str)} 字符") - - # 返回详细的错误信息给模型 - error_response = { - "success": False, - "error": error_msg, - "tool_name": function_name, - "arguments_length": len(arguments_str), - "suggestion": "请检查参数格式或减少参数长度后重试" - } - - # 如果参数过长,提供分块建议 - if len(arguments_str) > 10000: - error_response["suggestion"] = "参数过长,建议分块处理或使用更简洁的内容" - - messages.append({ - "role": "tool", - "tool_call_id": tool_call["id"], - "name": function_name, - "content": json.dumps(error_response, ensure_ascii=False) - }) - - # 记录失败的调用,防止死循环检测失效 - all_tool_results.append({ - "tool": function_name, - "args": {"parse_error": error_msg, "length": len(arguments_str)}, - "result": f"参数解析失败: {error_msg}" - }) - continue - - self._print(f"\n{OUTPUT_FORMATS['action']} 调用工具: {function_name}") - - tool_result = await tool_handler(function_name, arguments) - - # 解析工具结果,提取关键信息 - result_data = None - try: - result_data = json.loads(tool_result) - if function_name == "read_file": - tool_result_msg = self._format_read_file_result(result_data) - else: - tool_result_msg = tool_result - except Exception: - tool_result_msg = tool_result - - tool_message_content = tool_result_msg - if ( - isinstance(result_data, dict) - and result_data.get("success") is not False - ): - if function_name == "view_image": - img_path = result_data.get("path") - if img_path: - text_part = tool_result_msg if isinstance(tool_result_msg, str) else "" - tool_message_content = self._build_content_with_images(text_part, [img_path]) - elif function_name == "view_video": - video_path = result_data.get("path") - if video_path: - text_part = tool_result_msg if isinstance(tool_result_msg, str) else "" - tool_message_content = self._build_content_with_images(text_part, [], [video_path]) - - messages.append({ - "role": "tool", - "tool_call_id": tool_call["id"], - "name": function_name, - "content": tool_message_content - }) - - # 记录工具结果 - all_tool_results.append({ - "tool": function_name, - "args": arguments, - "result": tool_result_msg - }) - - # 如果连续多次调用同样的工具,可能陷入循环 - if len(all_tool_results) >= 8: - recent_tools = [r["tool"] for r in all_tool_results[-8:]] - if len(set(recent_tools)) == 1: # 最近8次都是同一个工具 - self._print(f"\n{OUTPUT_FORMATS['warning']} 检测到重复操作,停止执行") - break - - if iteration >= max_iterations: - self._print(f"\n{OUTPUT_FORMATS['warning']} 达到最大迭代次数限制") - - return final_response - - async def simple_chat(self, messages: List[Dict]) -> tuple: - """ - 简单对话(无工具调用) - - Args: - messages: 消息列表 - - Returns: - (模型回答, 思考内容) - """ - full_response = "" - thinking_content = "" - in_thinking = False - - # 如果思考模式且已有本任务的思考内容,补充到上下文,确保多次调用时思考不割裂 - if ( - self.thinking_mode - and not self.current_task_first_call - and self.current_task_thinking - ): - thinking_context = ( - "\n=== 📋 本次任务的思考 ===\n" - f"{self.current_task_thinking}\n" - "=== 思考结束 ===\n" - "提示:以上是本轮任务先前的思考,请在此基础上继续。" - ) - messages.append({ - "role": "system", - "content": thinking_context - }) - thinking_context_injected = True - - try: - async for chunk in self.chat(messages, tools=None, stream=True): - if chunk.get("error"): - err = chunk["error"] - self.last_error_info = err - err_msg = err.get("error_message") or err.get("error_text") or "API调用失败" - status = err.get("status_code") - self._print(f"{OUTPUT_FORMATS['error']} 模型API错误{f'({status})' if status is not None else ''}: {err_msg}") - return "", "" - - if "choices" not in chunk: - continue - - delta = chunk["choices"][0].get("delta", {}) - - # 处理思考内容 - reasoning_content = self._extract_reasoning_delta(delta) - if reasoning_content: - if not in_thinking: - self._print("💭 [正在思考]\n", end="", flush=True) - in_thinking = True - thinking_content += reasoning_content - self._print(reasoning_content, end="", flush=True) - - # 处理正常内容 - 独立的if而不是elif - if "content" in delta: - content = delta["content"] - if content: # 只处理非空内容 - if in_thinking: - self._print("\n\n💭 [思考结束]\n\n", end="", flush=True) - in_thinking = False - full_response += content - self._print(content, end="", flush=True) - - self._print("") # 最终换行 - - # 如果思考还没结束(极少情况),手动结束 - if in_thinking: - self._print("\n💭 [思考结束]\n") - - if self.last_call_used_thinking and thinking_content: - self.current_task_thinking = thinking_content - if self.current_task_first_call: - self.current_task_first_call = False - - # 如果没有收到任何响应 - if not full_response and not thinking_content: - self._print(f"{OUTPUT_FORMATS['error']} API未返回任何内容,请检查API密钥和模型ID") - return "", "" - - except Exception as e: - self._print(f"{OUTPUT_FORMATS['error']} API调用失败: {e}") - return "", "" - - return full_response, thinking_content +__all__ = ['DeepSeekClient'] diff --git a/utils/api_client/__init__.py b/utils/api_client/__init__.py new file mode 100644 index 0000000..0507d50 --- /dev/null +++ b/utils/api_client/__init__.py @@ -0,0 +1,20 @@ +from utils.api_client.utils import _api_dump_enabled +from utils.api_client.base_mixin import DeepSeekClientBaseMixin +from utils.api_client.profile_mixin import DeepSeekClientProfileMixin +from utils.api_client.message_mixin import DeepSeekClientMessageMixin +from utils.api_client.tool_mixin import DeepSeekClientToolMixin +from utils.api_client.logging_mixin import DeepSeekClientLoggingMixin +from utils.api_client.formatting_mixin import DeepSeekClientFormattingMixin +from utils.api_client.chat_mixin import DeepSeekClientChatMixin + +class DeepSeekClient( + DeepSeekClientBaseMixin, + DeepSeekClientProfileMixin, + DeepSeekClientMessageMixin, + DeepSeekClientToolMixin, + DeepSeekClientLoggingMixin, + DeepSeekClientFormattingMixin, + DeepSeekClientChatMixin): + pass + +__all__ = ['DeepSeekClient', '_api_dump_enabled'] \ No newline at end of file diff --git a/utils/api_client/base_mixin.py b/utils/api_client/base_mixin.py new file mode 100644 index 0000000..ea82875 --- /dev/null +++ b/utils/api_client/base_mixin.py @@ -0,0 +1,95 @@ +# ========== api_client.py ========== +# utils/api_client.py - OpenAI-compatible API 客户端(支持Web模式) + +import httpx +import json +import asyncio +import base64 +import mimetypes +import os +from typing import List, Dict, Optional, AsyncGenerator, Any +from pathlib import Path +from datetime import datetime +from pathlib import Path +from typing import Tuple +try: + from config import ( + OUTPUT_FORMATS, + DEFAULT_RESPONSE_MAX_TOKENS, + LOGS_DIR, + ) +except ImportError: + import sys + from pathlib import Path + project_root = Path(__file__).resolve().parents[1] + if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) + from config import ( + OUTPUT_FORMATS, + DEFAULT_RESPONSE_MAX_TOKENS, + LOGS_DIR, + ) + +from utils.log_rotation import append_line, prune_dir + + + +from utils.api_client.utils import _api_dump_enabled + +class DeepSeekClientBaseMixin: + def __init__(self, thinking_mode: bool = True, web_mode: bool = False): + # 模型 API 配置(端点 / 密钥 / 模型 ID)由 apply_profile 从 custom_models 注入; + # 这里仅初始化为空,未应用任何模型档案前不可直接发请求。 + self.fast_api_config = { + "base_url": "", + "api_key": "", + "model_id": "" + } + self.thinking_api_config = { + "base_url": "", + "api_key": "", + "model_id": "" + } + self.fast_max_tokens = None + self.thinking_max_tokens = None + self.fast_extra_params: Dict = {} + self.thinking_extra_params: Dict = {} + # 上下文预算(由上层在每次请求前设置) + self.current_context_tokens: int = 0 + self.max_context_tokens: Optional[int] = None + self.default_context_window: Optional[int] = None + self.thinking_mode = thinking_mode # True=智能思考模式, False=快速模式 + self.deep_thinking_mode = False # 深度思考模式:整轮都使用思考模型 + self.deep_thinking_session = False # 当前任务是否处于深度思考会话 + self.web_mode = web_mode # Web模式标志,用于禁用print输出 + # 兼容旧代码路径 + self.api_base_url = self.fast_api_config["base_url"] + self.api_key = self.fast_api_config["api_key"] + self.model_id = self.fast_api_config["model_id"] + self.model_key = None # 由宿主终端注入,便于做模型兼容处理 + self.model_multimodal = "none" # 由模型 profile 注入,model_key 不可用时用于能力兜底 + self.project_path: Optional[str] = None + # 每个任务的独立状态 + self.current_task_first_call = True # 当前任务是否是第一次调用 + self.current_task_thinking = "" # 当前任务的思考内容 + self.force_thinking_next_call = False # 单次强制思考 + self.skip_thinking_next_call = False # 单次强制快速 + self.last_call_used_thinking = False # 最近一次调用是否使用思考模型 + # 最近一次API错误详情 + self.last_error_info: Optional[Dict[str, Any]] = None + # 请求体落盘目录(跟随 LOGS_DIR,默认 ~/.agents//logs/api_requests) + self.request_dump_dir = Path(LOGS_DIR) / "api_requests" + self.debug_log_path = Path(LOGS_DIR) / "api_debug.log" + + def _resolve_env_value(self, name: str) -> Optional[str]: + """从环境变量获取配置(已由 config/__init__.py 统一注入)。""" + value = os.environ.get(name) + if value is None: + return None + value = value.strip() + return value or None + + def _print(self, message: str, end: str = "\n", flush: bool = False): + """安全的打印函数,在Web模式下不输出""" + if not self.web_mode: + print(message, end=end, flush=flush) diff --git a/utils/api_client/chat_mixin.py b/utils/api_client/chat_mixin.py new file mode 100644 index 0000000..b6f2ac6 --- /dev/null +++ b/utils/api_client/chat_mixin.py @@ -0,0 +1,678 @@ +# ========== api_client.py ========== +# utils/api_client.py - OpenAI-compatible API 客户端(支持Web模式) + +import httpx +import json +import asyncio +import base64 +import mimetypes +import os +from typing import List, Dict, Optional, AsyncGenerator, Any +from pathlib import Path +from datetime import datetime +from pathlib import Path +from typing import Tuple +try: + from config import ( + OUTPUT_FORMATS, + DEFAULT_RESPONSE_MAX_TOKENS, + LOGS_DIR, + ) +except ImportError: + import sys + from pathlib import Path + project_root = Path(__file__).resolve().parents[1] + if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) + from config import ( + OUTPUT_FORMATS, + DEFAULT_RESPONSE_MAX_TOKENS, + LOGS_DIR, + ) + +from utils.log_rotation import append_line, prune_dir + + + +from utils.api_client.utils import _api_dump_enabled + +class DeepSeekClientChatMixin: + async def chat( + self, + messages: List[Dict], + tools: Optional[List[Dict]] = None, + stream: bool = True + ) -> AsyncGenerator[Dict, None]: + """ + 异步调用 OpenAI-compatible API + + Args: + messages: 消息列表 + tools: 工具定义列表 + stream: 是否流式输出 + + Yields: + 响应内容块 + """ + # 检查API密钥 + if not self.api_key or self.api_key.startswith("your-"): + self._print(f"{OUTPUT_FORMATS['error']} API密钥未配置,请检查模型配置") + return + + # 决定是否使用思考模式 + current_thinking_mode = self.get_current_thinking_mode() + api_config = self._select_api_config(current_thinking_mode) + headers = self._build_headers(api_config["api_key"]) + + # 如果当前为快速模式但已有思考内容,提示沿用 + if self.thinking_mode and not current_thinking_mode and self.current_task_thinking: + self._print(f"{OUTPUT_FORMATS['info']} [任务内快速模式] 使用本次任务的思考继续处理...") + + # 记录本次调用的模式 + self.last_call_used_thinking = current_thinking_mode + if current_thinking_mode and self.force_thinking_next_call: + self.force_thinking_next_call = False + if not current_thinking_mode and self.skip_thinking_next_call: + self.skip_thinking_next_call = False + + try: + override_max = self.thinking_max_tokens if current_thinking_mode else self.fast_max_tokens + if override_max is not None: + max_tokens = int(override_max) + else: + max_tokens = int(DEFAULT_RESPONSE_MAX_TOKENS) + if max_tokens <= 0: + raise ValueError("max_tokens must be positive") + except (TypeError, ValueError): + max_tokens = 4096 + + # 动态收缩 max_tokens,避免超过模型上下文窗口 + budget_max_context = self.max_context_tokens or self.default_context_window + if budget_max_context and budget_max_context > 0: + used_tokens = max(0, int(self.current_context_tokens or 0)) + available = budget_max_context - used_tokens + if available <= 0: + # 兜底:让上游错误处理,这里至少给1防止API报参数错误 + max_tokens = 1 + else: + max_tokens = min(max_tokens, available) + + final_messages = self._merge_system_messages(messages) + final_messages = self._sanitize_messages_for_model_capability(final_messages) + final_messages = self._sanitize_message_fields_for_api(final_messages) + + payload = { + "model": api_config["model_id"], + "messages": final_messages, + "stream": stream, + } + payload["max_tokens"] = max_tokens + # 注入模型配置中的额外参数 + extra_params = self.thinking_extra_params if current_thinking_mode else self.fast_extra_params + if extra_params: + payload.update(extra_params) + if tools: + payload["tools"] = tools + payload["tool_choice"] = "auto" + + # 将本次请求落盘,便于出错时快速定位 + try: + self._debug_log({ + "event": "request_prepare", + "model_key": self.model_key, + "thinking_mode_flag": bool(self.thinking_mode), + "deep_thinking_mode": bool(self.deep_thinking_mode), + "deep_thinking_session": bool(self.deep_thinking_session), + "current_call_use_thinking": bool(current_thinking_mode), + "api_base_url": api_config.get("base_url"), + "api_model_id": api_config.get("model_id"), + "payload_model": payload.get("model"), + "payload_has_thinking": "thinking" in payload, + "payload_thinking": payload.get("thinking"), + "payload_enable_thinking": payload.get("enable_thinking"), + "payload_max_tokens": payload.get("max_tokens"), + "payload_max_completion_tokens": payload.get("max_completion_tokens"), + }) + except Exception: + pass + dump_path = self._dump_request_payload(payload, api_config, headers) + + try: + async with httpx.AsyncClient(http2=True, timeout=300) as client: + if stream: + async with client.stream( + "POST", + f"{api_config['base_url']}/chat/completions", + json=payload, + headers=headers + ) as response: + # 检查响应状态 + if response.status_code != 200: + error_bytes = await response.aread() + error_text = error_bytes.decode('utf-8', errors='ignore') if hasattr(error_bytes, 'decode') else str(error_bytes) + self.last_error_info = { + "status_code": response.status_code, + "error_text": error_text, + "error_type": None, + "error_message": None, + "request_dump": (str(dump_path) if dump_path else None), + "base_url": api_config.get("base_url"), + "model_id": api_config.get("model_id"), + "model_key": self.model_key + } + try: + parsed = json.loads(error_text) + err = parsed.get("error") if isinstance(parsed, dict) else {} + if isinstance(err, dict): + self.last_error_info["error_type"] = err.get("type") + self.last_error_info["error_message"] = err.get("message") + except Exception: + pass + self._debug_log({ + "event": "http_error_stream", + "status_code": response.status_code, + "error_text": error_text, + "base_url": api_config.get("base_url"), + "model_id": api_config.get("model_id"), + "model_key": self.model_key, + "request_dump": (str(dump_path) if dump_path else None) + }) + self._print( + f"{OUTPUT_FORMATS['error']} API请求失败 ({response.status_code}): {error_text} " + f"(base_url={api_config.get('base_url')}, model_id={api_config.get('model_id')})" + ) + self._mark_request_error(dump_path, response.status_code, error_text) + yield {"error": self.last_error_info} + return + + async for line in response.aiter_lines(): + if line.startswith("data:"): + json_str = line[5:].strip() + if json_str == "[DONE]": + break + + try: + data = json.loads(json_str) + yield data + except json.JSONDecodeError: + continue + else: + response = await client.post( + f"{api_config['base_url']}/chat/completions", + json=payload, + headers=headers + ) + if response.status_code != 200: + error_text = response.text + self.last_error_info = { + "status_code": response.status_code, + "error_text": error_text, + "error_type": None, + "error_message": None, + "request_dump": (str(dump_path) if dump_path else None), + "base_url": api_config.get("base_url"), + "model_id": api_config.get("model_id"), + "model_key": self.model_key + } + try: + parsed = response.json() + err = parsed.get("error") if isinstance(parsed, dict) else {} + if isinstance(err, dict): + self.last_error_info["error_type"] = err.get("type") + self.last_error_info["error_message"] = err.get("message") + except Exception: + pass + self._debug_log({ + "event": "http_error", + "status_code": response.status_code, + "error_text": error_text, + "base_url": api_config.get("base_url"), + "model_id": api_config.get("model_id"), + "model_key": self.model_key, + "request_dump": (str(dump_path) if dump_path else None) + }) + self._print( + f"{OUTPUT_FORMATS['error']} API请求失败 ({response.status_code}): {error_text} " + f"(base_url={api_config.get('base_url')}, model_id={api_config.get('model_id')})" + ) + self._mark_request_error(dump_path, response.status_code, error_text) + yield {"error": self.last_error_info} + return + # 成功则清空错误状态 + self.last_error_info = None + yield response.json() + + except httpx.ConnectError as e: + connect_detail = str(e).strip() or repr(e) + self._print( + f"{OUTPUT_FORMATS['error']} 无法连接到API服务器,请检查网络连接" + f"({connect_detail})" + ) + self.last_error_info = { + "status_code": None, + "error_text": "connect_error", + "error_type": "connection_error", + "error_message": f"无法连接到API服务器: {connect_detail}", + "error_detail": connect_detail, + "request_dump": (str(dump_path) if dump_path else None), + "base_url": api_config.get("base_url"), + "model_id": api_config.get("model_id"), + "model_key": self.model_key + } + self._debug_log({ + "event": "connect_error", + "status_code": None, + "error_text": "connect_error", + "error_detail": connect_detail, + "base_url": api_config.get("base_url"), + "model_id": api_config.get("model_id"), + "model_key": self.model_key, + "request_dump": (str(dump_path) if dump_path else None) + }) + self._mark_request_error(dump_path, error_text=f"connect_error: {connect_detail}") + yield {"error": self.last_error_info} + except httpx.TimeoutException: + self._print(f"{OUTPUT_FORMATS['error']} API请求超时") + self.last_error_info = { + "status_code": None, + "error_text": "timeout", + "error_type": "timeout", + "error_message": "API请求超时", + "request_dump": (str(dump_path) if dump_path else None), + "base_url": api_config.get("base_url"), + "model_id": api_config.get("model_id"), + "model_key": self.model_key + } + self._debug_log({ + "event": "timeout", + "status_code": None, + "error_text": "timeout", + "base_url": api_config.get("base_url"), + "model_id": api_config.get("model_id"), + "model_key": self.model_key, + "request_dump": (str(dump_path) if dump_path else None) + }) + self._mark_request_error(dump_path, error_text="timeout") + yield {"error": self.last_error_info} + except httpx.RemoteProtocolError as e: + disconnect_detail = str(e).strip() or repr(e) + self._print(f"{OUTPUT_FORMATS['error']} API服务器连接断开({disconnect_detail})") + self.last_error_info = { + "status_code": None, + "error_text": disconnect_detail, + "error_type": "connection_error", + "error_message": f"API服务器连接断开: {disconnect_detail}", + "request_dump": (str(dump_path) if dump_path else None), + "base_url": api_config.get("base_url"), + "model_id": api_config.get("model_id"), + "model_key": self.model_key + } + self._debug_log({ + "event": "server_disconnected", + "status_code": None, + "error_text": disconnect_detail, + "base_url": api_config.get("base_url"), + "model_id": api_config.get("model_id"), + "model_key": self.model_key, + "request_dump": (str(dump_path) if dump_path else None) + }) + self._mark_request_error(dump_path, error_text=f"server_disconnected: {disconnect_detail}") + yield {"error": self.last_error_info} + except Exception as e: + error_text = str(e).strip() or repr(e) + self._print(f"{OUTPUT_FORMATS['error']} API调用异常: {error_text}") + self.last_error_info = { + "status_code": None, + "error_text": error_text, + "error_type": "exception", + "error_message": error_text, + "request_dump": (str(dump_path) if dump_path else None), + "base_url": api_config.get("base_url"), + "model_id": api_config.get("model_id"), + "model_key": self.model_key + } + self._debug_log({ + "event": "exception", + "status_code": None, + "error_text": error_text, + "base_url": api_config.get("base_url"), + "model_id": api_config.get("model_id"), + "model_key": self.model_key, + "request_dump": (str(dump_path) if dump_path else None) + }) + self._mark_request_error(dump_path, error_text=error_text) + yield {"error": self.last_error_info} + + async def chat_with_tools( + self, + messages: List[Dict], + tools: List[Dict], + tool_handler: callable + ) -> str: + """ + 带工具调用的对话(支持多轮) + + Args: + messages: 消息列表 + tools: 工具定义 + tool_handler: 工具处理函数 + + Returns: + 最终回答 + """ + final_response = "" + max_iterations = 200 # 最大迭代次数 + iteration = 0 + all_tool_results = [] # 记录所有工具调用结果 + + while iteration < max_iterations: + iteration += 1 + + # 调用API(始终提供工具定义) + full_response = "" + tool_calls = [] + current_thinking = "" + + # 状态标志 + in_thinking = False + thinking_printed = False + + async for chunk in self.chat(messages, tools, stream=True): + if chunk.get("error"): + # 直接返回错误,让上层处理 + err = chunk["error"] + self.last_error_info = err + err_msg = err.get("error_message") or err.get("error_text") or "API调用失败" + status = err.get("status_code") + self._print(f"{OUTPUT_FORMATS['error']} 模型API错误{f'({status})' if status is not None else ''}: {err_msg}") + return "" + + if "choices" not in chunk: + continue + + delta = chunk["choices"][0].get("delta", {}) + + # 处理思考内容 + reasoning_content = self._extract_reasoning_delta(delta) + if reasoning_content: + if not in_thinking: + self._print("💭 [正在思考]\n", end="", flush=True) + in_thinking = True + thinking_printed = True + current_thinking += reasoning_content + self._print(reasoning_content, end="", flush=True) + + # 处理正常内容 - 独立的if,不是elif + if "content" in delta: + content = delta["content"] + if content: # 只处理非空内容 + # 如果之前在输出思考,先结束思考输出 + if in_thinking: + self._print("\n\n💭 [思考结束]\n\n", end="", flush=True) + in_thinking = False + full_response += content + self._print(content, end="", flush=True) + + # 收集工具调用 - 改进的拼接逻辑 + # 收集工具调用 - 修复JSON分片问题 + if "tool_calls" in delta: + for tool_call in delta["tool_calls"]: + tool_index = tool_call.get("index", 0) + + # 查找或创建对应索引的工具调用 + existing_call = None + for existing in tool_calls: + if existing.get("index") == tool_index: + existing_call = existing + break + + if not existing_call and tool_call.get("id"): + # 创建新的工具调用 + new_call = { + "id": tool_call.get("id"), + "index": tool_index, + "type": tool_call.get("type", "function"), + "function": { + "name": tool_call.get("function", {}).get("name", ""), + "arguments": "" + } + } + tool_calls.append(new_call) + existing_call = new_call + + # 安全地拼接arguments - 简单字符串拼接,不尝试JSON验证 + if existing_call and "function" in tool_call and "arguments" in tool_call["function"]: + new_args = tool_call["function"]["arguments"] + if new_args: # 只拼接非空内容 + existing_call["function"]["arguments"] += new_args + + self._print("") # 最终换行 + + # 如果思考还没结束(只调用工具没有文本),手动结束 + if in_thinking: + self._print("\n💭 [思考结束]\n") + + # 记录思考内容并更新调用状态 + if self.last_call_used_thinking and current_thinking: + self.current_task_thinking = current_thinking + if self.current_task_first_call: + self.current_task_first_call = False # 标记当前任务的第一次调用已完成 + + # 如果没有工具调用,说明完成了 + if not tool_calls: + if full_response: # 有正常回复,任务完成 + final_response = full_response + break + elif iteration == 1: # 第一次就没有工具调用也没有内容,可能有问题 + self._print(f"{OUTPUT_FORMATS['warning']} 模型未返回内容") + break + + # 构建助手消息 - 始终包含所有收集到的内容 + assistant_content_parts = [] + + # 添加正式回复内容(如果有) + if full_response: + assistant_content_parts.append(full_response) + + # 添加工具调用说明 + if tool_calls: + tool_names = [tc['function']['name'] for tc in tool_calls] + assistant_content_parts.append(f"执行工具: {', '.join(tool_names)}") + + # 合并所有内容 + assistant_content = "\n".join(assistant_content_parts) if assistant_content_parts else "执行工具调用" + + assistant_message = { + "role": "assistant", + "content": assistant_content, + "tool_calls": tool_calls + } + if current_thinking: + assistant_message["reasoning_content"] = current_thinking + messages.append(assistant_message) + + # 执行所有工具调用 - 使用鲁棒的参数解析 + for tool_call in tool_calls: + function_name = tool_call["function"]["name"] + arguments_str = tool_call["function"]["arguments"] + + # 使用改进的参数解析方法,增强JSON修复能力 + success, arguments, error_msg = self._safe_tool_arguments_parse(arguments_str, function_name) + + if not success: + self._print(f"{OUTPUT_FORMATS['error']} 工具参数解析失败: {error_msg}") + self._print(f" 工具名称: {function_name}") + self._print(f" 参数长度: {len(arguments_str)} 字符") + + # 返回详细的错误信息给模型 + error_response = { + "success": False, + "error": error_msg, + "tool_name": function_name, + "arguments_length": len(arguments_str), + "suggestion": "请检查参数格式或减少参数长度后重试" + } + + # 如果参数过长,提供分块建议 + if len(arguments_str) > 10000: + error_response["suggestion"] = "参数过长,建议分块处理或使用更简洁的内容" + + messages.append({ + "role": "tool", + "tool_call_id": tool_call["id"], + "name": function_name, + "content": json.dumps(error_response, ensure_ascii=False) + }) + + # 记录失败的调用,防止死循环检测失效 + all_tool_results.append({ + "tool": function_name, + "args": {"parse_error": error_msg, "length": len(arguments_str)}, + "result": f"参数解析失败: {error_msg}" + }) + continue + + self._print(f"\n{OUTPUT_FORMATS['action']} 调用工具: {function_name}") + + tool_result = await tool_handler(function_name, arguments) + + # 解析工具结果,提取关键信息 + result_data = None + try: + result_data = json.loads(tool_result) + if function_name == "read_file": + tool_result_msg = self._format_read_file_result(result_data) + else: + tool_result_msg = tool_result + except Exception: + tool_result_msg = tool_result + + tool_message_content = tool_result_msg + if ( + isinstance(result_data, dict) + and result_data.get("success") is not False + ): + if function_name == "view_image": + img_path = result_data.get("path") + if img_path: + text_part = tool_result_msg if isinstance(tool_result_msg, str) else "" + tool_message_content = self._build_content_with_images(text_part, [img_path]) + elif function_name == "view_video": + video_path = result_data.get("path") + if video_path: + text_part = tool_result_msg if isinstance(tool_result_msg, str) else "" + tool_message_content = self._build_content_with_images(text_part, [], [video_path]) + + messages.append({ + "role": "tool", + "tool_call_id": tool_call["id"], + "name": function_name, + "content": tool_message_content + }) + + # 记录工具结果 + all_tool_results.append({ + "tool": function_name, + "args": arguments, + "result": tool_result_msg + }) + + # 如果连续多次调用同样的工具,可能陷入循环 + if len(all_tool_results) >= 8: + recent_tools = [r["tool"] for r in all_tool_results[-8:]] + if len(set(recent_tools)) == 1: # 最近8次都是同一个工具 + self._print(f"\n{OUTPUT_FORMATS['warning']} 检测到重复操作,停止执行") + break + + if iteration >= max_iterations: + self._print(f"\n{OUTPUT_FORMATS['warning']} 达到最大迭代次数限制") + + return final_response + + async def simple_chat(self, messages: List[Dict]) -> tuple: + """ + 简单对话(无工具调用) + + Args: + messages: 消息列表 + + Returns: + (模型回答, 思考内容) + """ + full_response = "" + thinking_content = "" + in_thinking = False + + # 如果思考模式且已有本任务的思考内容,补充到上下文,确保多次调用时思考不割裂 + if ( + self.thinking_mode + and not self.current_task_first_call + and self.current_task_thinking + ): + thinking_context = ( + "\n=== 📋 本次任务的思考 ===\n" + f"{self.current_task_thinking}\n" + "=== 思考结束 ===\n" + "提示:以上是本轮任务先前的思考,请在此基础上继续。" + ) + messages.append({ + "role": "system", + "content": thinking_context + }) + thinking_context_injected = True + + try: + async for chunk in self.chat(messages, tools=None, stream=True): + if chunk.get("error"): + err = chunk["error"] + self.last_error_info = err + err_msg = err.get("error_message") or err.get("error_text") or "API调用失败" + status = err.get("status_code") + self._print(f"{OUTPUT_FORMATS['error']} 模型API错误{f'({status})' if status is not None else ''}: {err_msg}") + return "", "" + + if "choices" not in chunk: + continue + + delta = chunk["choices"][0].get("delta", {}) + + # 处理思考内容 + reasoning_content = self._extract_reasoning_delta(delta) + if reasoning_content: + if not in_thinking: + self._print("💭 [正在思考]\n", end="", flush=True) + in_thinking = True + thinking_content += reasoning_content + self._print(reasoning_content, end="", flush=True) + + # 处理正常内容 - 独立的if而不是elif + if "content" in delta: + content = delta["content"] + if content: # 只处理非空内容 + if in_thinking: + self._print("\n\n💭 [思考结束]\n\n", end="", flush=True) + in_thinking = False + full_response += content + self._print(content, end="", flush=True) + + self._print("") # 最终换行 + + # 如果思考还没结束(极少情况),手动结束 + if in_thinking: + self._print("\n💭 [思考结束]\n") + + if self.last_call_used_thinking and thinking_content: + self.current_task_thinking = thinking_content + if self.current_task_first_call: + self.current_task_first_call = False + + # 如果没有收到任何响应 + if not full_response and not thinking_content: + self._print(f"{OUTPUT_FORMATS['error']} API未返回任何内容,请检查API密钥和模型ID") + return "", "" + + except Exception as e: + self._print(f"{OUTPUT_FORMATS['error']} API调用失败: {e}") + return "", "" + + return full_response, thinking_content diff --git a/utils/api_client/formatting_mixin.py b/utils/api_client/formatting_mixin.py new file mode 100644 index 0000000..8db9b1a --- /dev/null +++ b/utils/api_client/formatting_mixin.py @@ -0,0 +1,82 @@ +# ========== api_client.py ========== +# utils/api_client.py - OpenAI-compatible API 客户端(支持Web模式) + +import httpx +import json +import asyncio +import base64 +import mimetypes +import os +from typing import List, Dict, Optional, AsyncGenerator, Any +from pathlib import Path +from datetime import datetime +from pathlib import Path +from typing import Tuple +try: + from config import ( + OUTPUT_FORMATS, + DEFAULT_RESPONSE_MAX_TOKENS, + LOGS_DIR, + ) +except ImportError: + import sys + from pathlib import Path + project_root = Path(__file__).resolve().parents[1] + if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) + from config import ( + OUTPUT_FORMATS, + DEFAULT_RESPONSE_MAX_TOKENS, + LOGS_DIR, + ) + +from utils.log_rotation import append_line, prune_dir + + + +from utils.api_client.utils import _api_dump_enabled + +class DeepSeekClientFormattingMixin: + def _format_read_file_result(self, data: Dict) -> str: + """根据读取模式格式化 read_file 工具结果。""" + if not isinstance(data, dict): + return json.dumps(data, ensure_ascii=False) + if not data.get("success"): + return json.dumps(data, ensure_ascii=False) + + read_type = data.get("type", "read") + truncated_note = "(内容已截断)" if data.get("truncated") else "" + path = data.get("path", "未知路径") + max_chars = data.get("max_chars") + max_note = f"(max_chars={max_chars})" if max_chars else "" + + if read_type == "read": + line_start = data.get("line_start") + line_end = data.get("line_end") + char_count = data.get("char_count", len(data.get("content", "") or "")) + header = f"读取 {path} 行 {line_start}~{line_end},返回 {char_count} 字符 {max_note}{truncated_note}".strip() + content = data.get("content", "") + return f"{header}\n```\n{content}\n```" + + if read_type == "search": + query = data.get("query", "") + actual = data.get("actual_matches", 0) + returned = data.get("returned_matches", 0) + case_hint = "区分大小写" if data.get("case_sensitive") else "不区分大小写" + header = ( + f"在 {path} 中搜索 \"{query}\",返回 {returned}/{actual} 条结果({case_hint})" + f" {max_note}{truncated_note}" + ).strip() + match_texts = [] + for idx, match in enumerate(data.get("matches", []), 1): + match_note = "(片段截断)" if match.get("truncated") else "" + hits = match.get("hits") or [] + hit_text = ", ".join(str(h) for h in hits) if hits else "无" + label = match.get("id") or f"match_{idx}" + snippet = match.get("snippet", "") + match_texts.append( + f"[{label}] 行 {match.get('line_start')}~{match.get('line_end')} 命中行: {hit_text}{match_note}\n```\n{snippet}\n```" + ) + if not match_texts: + match_texts.append("未找到匹配内容。") + return "\n".join([header] + match_texts) diff --git a/utils/api_client/logging_mixin.py b/utils/api_client/logging_mixin.py new file mode 100644 index 0000000..6e81ad6 --- /dev/null +++ b/utils/api_client/logging_mixin.py @@ -0,0 +1,95 @@ +# ========== api_client.py ========== +# utils/api_client.py - OpenAI-compatible API 客户端(支持Web模式) + +import httpx +import json +import asyncio +import base64 +import mimetypes +import os +from typing import List, Dict, Optional, AsyncGenerator, Any +from pathlib import Path +from datetime import datetime +from pathlib import Path +from typing import Tuple +try: + from config import ( + OUTPUT_FORMATS, + DEFAULT_RESPONSE_MAX_TOKENS, + LOGS_DIR, + ) +except ImportError: + import sys + from pathlib import Path + project_root = Path(__file__).resolve().parents[1] + if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) + from config import ( + OUTPUT_FORMATS, + DEFAULT_RESPONSE_MAX_TOKENS, + LOGS_DIR, + ) + +from utils.log_rotation import append_line, prune_dir + + + +from utils.api_client.utils import _api_dump_enabled + +class DeepSeekClientLoggingMixin: + def _debug_log(self, payload: Dict[str, Any]) -> None: + if not _api_dump_enabled(): + return + try: + entry = { + "ts": datetime.now().isoformat(), + **payload + } + append_line(self.debug_log_path, json.dumps(entry, ensure_ascii=False)) + except Exception: + pass + + def _dump_request_payload(self, payload: Dict, api_config: Dict, headers: Dict) -> Optional[Path]: + """ + 将本次请求的payload、headers、配置落盘,便于排查400等错误。 + 返回写入的文件路径;落盘开关关闭(默认)时返回 None。 + """ + if not _api_dump_enabled(): + return None + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + filename = f"req_{timestamp}.json" + path = self.request_dump_dir / filename + try: + self.request_dump_dir.mkdir(parents=True, exist_ok=True) + headers_sanitized = {} + for k, v in headers.items(): + headers_sanitized[k] = "***" if k.lower() == "authorization" else v + data = { + "timestamp": datetime.now().isoformat(), + "api_config": {k: api_config.get(k) for k in ["base_url", "model_id"]}, + "headers": headers_sanitized, + "payload": payload + } + path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + # 按份保留:只留最近 N 个请求落盘文件 + prune_dir(self.request_dump_dir, pattern="req_*.json") + except Exception as exc: + self._print(f"{OUTPUT_FORMATS['warning']} 请求体落盘失败: {exc}") + return path + + def _mark_request_error(self, dump_path: Path, status_code: int = None, error_text: str = None): + """ + 在已有请求文件中追加错误标记,便于快速定位。 + """ + if not dump_path or not dump_path.exists(): + return + try: + data = json.loads(dump_path.read_text(encoding="utf-8")) + data["error"] = { + "status_code": status_code, + "message": error_text, + "marked_at": datetime.now().isoformat() + } + dump_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + except Exception as exc: + self._print(f"{OUTPUT_FORMATS['warning']} 标记请求错误失败: {exc}") diff --git a/utils/api_client/message_mixin.py b/utils/api_client/message_mixin.py new file mode 100644 index 0000000..4aeee3d --- /dev/null +++ b/utils/api_client/message_mixin.py @@ -0,0 +1,303 @@ +# ========== api_client.py ========== +# utils/api_client.py - OpenAI-compatible API 客户端(支持Web模式) + +import httpx +import json +import asyncio +import base64 +import mimetypes +import os +from typing import List, Dict, Optional, AsyncGenerator, Any +from pathlib import Path +from datetime import datetime +from pathlib import Path +from typing import Tuple +try: + from config import ( + OUTPUT_FORMATS, + DEFAULT_RESPONSE_MAX_TOKENS, + LOGS_DIR, + ) +except ImportError: + import sys + from pathlib import Path + project_root = Path(__file__).resolve().parents[1] + if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) + from config import ( + OUTPUT_FORMATS, + DEFAULT_RESPONSE_MAX_TOKENS, + LOGS_DIR, + ) + +from utils.log_rotation import append_line, prune_dir + + + +from utils.api_client.utils import _api_dump_enabled + +class DeepSeekClientMessageMixin: + def _merge_system_messages(self, messages: List[Dict]) -> List[Dict]: + """ + 仅合并最开头连续的 system 消息(系统提示),后续插入的 system 消息保持原样。 + """ + if not messages: + return messages + + merged_contents: List[str] = [] + idx = 0 + while idx < len(messages) and messages[idx].get("role") == "system": + content = messages[idx].get("content", "") + if isinstance(content, str): + merged_contents.append(content) + else: + merged_contents.append(json.dumps(content, ensure_ascii=False)) + idx += 1 + + if not merged_contents: + return messages + + merged = { + "role": "system", + "content": "\n\n".join(c for c in merged_contents if c) + } + return [merged] + messages[idx:] + + @staticmethod + def _normalize_multimodal_capability(value: Any) -> str: + text = str(value or "none").strip().lower() + if text == "video,image": + return "image,video" + if text in {"none", "image", "video", "image,video"}: + return text + return "none" + + @staticmethod + def _sanitize_content_for_capability( + content: Any, + *, + supports_image: bool, + supports_video: bool, + ) -> Any: + """根据模型多模态能力裁剪 content,避免向纯文本模型发送 image_url/video_url。""" + if not isinstance(content, list): + return content + + kept_parts: List[Dict[str, Any]] = [] + text_segments: List[str] = [] + dropped_media = False + unsupported_media_notice = "当前模型无查看图片/视频能力,无法返回结果" + + for part in content: + if not isinstance(part, dict): + text = str(part).strip() + if text: + text_segments.append(text) + continue + + ctype = str(part.get("type") or "").strip().lower() + if ctype == "text": + text = str(part.get("text") or "") + if text: + text_segments.append(text) + kept_parts.append({"type": "text", "text": text}) + continue + if ctype == "image_url": + if supports_image: + kept_parts.append(part) + else: + dropped_media = True + continue + if ctype == "video_url": + if supports_video: + kept_parts.append(part) + else: + dropped_media = True + continue + + # 其他未知/暂不支持类型统一忽略(纯文本模型下只保留 text) + continue + + # 纯文本模型:统一回退为字符串,规避供应商对 content part 类型的严格校验 + if not supports_image and not supports_video: + text_payload = "\n".join(seg for seg in text_segments if seg).strip() + if dropped_media: + if text_payload: + if unsupported_media_notice in text_payload: + return text_payload + return f"{text_payload}\n\n{unsupported_media_notice}" + return unsupported_media_notice + return text_payload + + # 半多模态模型(例如只支持图片):尽量保留可支持的 part + if kept_parts: + if len(kept_parts) == 1 and kept_parts[0].get("type") == "text": + return kept_parts[0].get("text", "") + return kept_parts + return "\n".join(seg for seg in text_segments if seg).strip() + + def _sanitize_messages_for_model_capability(self, messages: List[Dict]) -> List[Dict]: + model_key = str(self.model_key or "").strip() + supports_image = False + supports_video = False + capability_source = "profile_fallback" + + if model_key: + try: + from config.model_profiles import get_model_capabilities + caps = get_model_capabilities(model_key) + supports_image = bool(caps.get("supports_image")) + supports_video = bool(caps.get("supports_video")) + capability_source = "model_key" + except Exception: + capability_source = "profile_fallback" + + if capability_source != "model_key": + multimodal = self._normalize_multimodal_capability(self.model_multimodal) + supports_image = multimodal in {"image", "image,video"} + supports_video = multimodal in {"video", "image,video"} + + if supports_image and supports_video: + return messages + + changed = 0 + sanitized: List[Dict[str, Any]] = [] + for message in messages or []: + if not isinstance(message, dict): + continue + msg_copy = dict(message) + original_content = msg_copy.get("content") + new_content = self._sanitize_content_for_capability( + original_content, + supports_image=supports_image, + supports_video=supports_video, + ) + if new_content != original_content: + changed += 1 + msg_copy["content"] = new_content + sanitized.append(msg_copy) + + if changed: + self._debug_log( + { + "event": "sanitize_messages_for_model_capability", + "model_key": model_key, + "capability_source": capability_source, + "supports_image": supports_image, + "supports_video": supports_video, + "changed_messages": changed, + } + ) + return sanitized + + def _sanitize_message_fields_for_api(self, messages: List[Dict]) -> List[Dict]: + """移除只供本地 UI/持久化使用、OpenAI-compatible API 不接受的消息字段。""" + sanitized: List[Dict[str, Any]] = [] + stripped_fields: Dict[str, int] = {} + + allowed_common = {"role", "content", "name"} + allowed_by_role = { + "assistant": allowed_common | {"tool_calls", "reasoning_content"}, + "tool": allowed_common | {"tool_call_id"}, + "user": allowed_common, + "system": allowed_common, + } + + for message in messages or []: + if not isinstance(message, dict): + continue + role = str(message.get("role") or "").strip() + allowed = allowed_by_role.get(role, allowed_common) + clean: Dict[str, Any] = {} + for key, value in message.items(): + if key not in allowed: + stripped_fields[key] = stripped_fields.get(key, 0) + 1 + continue + if key == "tool_calls" and not value: + stripped_fields[key] = stripped_fields.get(key, 0) + 1 + continue + clean[key] = value + sanitized.append(clean) + + if stripped_fields: + self._debug_log( + { + "event": "sanitize_message_fields_for_api", + "stripped_fields": stripped_fields, + } + ) + return sanitized + + def _build_content_with_images(self, text: str, images: List[str], videos: Optional[List[Any]] = None) -> Any: + """将文本与图片/视频路径拼成多模态 content(用于 tool 消息)。""" + videos = videos or [] + if not images and not videos: + return text + parts: List[Dict[str, Any]] = [] + extra_videos: List[Any] = [] + if text: + parts.append({"type": "text", "text": text}) + base_path = Path(self.project_path or ".") + for path in images: + try: + abs_path = (base_path / path).resolve() + if not abs_path.exists() or not abs_path.is_file(): + continue + mime, _ = mimetypes.guess_type(abs_path.name) + if mime and mime.startswith("video/"): + extra_videos.append(path) + continue + if mime and not mime.startswith("image/"): + continue + if not mime: + mime = "image/png" + data = abs_path.read_bytes() + b64 = base64.b64encode(data).decode("utf-8") + parts.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}}) + except Exception: + continue + for item in [*videos, *extra_videos]: + try: + if isinstance(item, dict): + path = item.get("path") or "" + else: + path = item + if not path: + continue + abs_path = (base_path / path).resolve() + if not abs_path.exists() or not abs_path.is_file(): + continue + mime, _ = mimetypes.guess_type(abs_path.name) + if not mime: + mime = "video/mp4" + data = abs_path.read_bytes() + b64 = base64.b64encode(data).decode("utf-8") + payload: Dict[str, Any] = { + "type": "video_url", + "video_url": {"url": f"data:{mime};base64,{b64}"} + } + if isinstance(item, dict) and item.get("fps") is not None: + payload["fps"] = item.get("fps") + parts.append(payload) + except Exception: + continue + return parts if parts else text + + if read_type == "extract": + segments = data.get("segments", []) + header = ( + f"从 {path} 抽取 {len(segments)} 个片段 {max_note}{truncated_note}" + ).strip() + seg_texts = [] + for idx, segment in enumerate(segments, 1): + seg_note = "(片段截断)" if segment.get("truncated") else "" + label = segment.get("label") or f"segment_{idx}" + snippet = segment.get("content", "") + seg_texts.append( + f"[{label}] 行 {segment.get('line_start')}~{segment.get('line_end')}{seg_note}\n```\n{snippet}\n```" + ) + if not seg_texts: + seg_texts.append("未提供可抽取的片段。") + return "\n".join([header] + seg_texts) + + return json.dumps(data, ensure_ascii=False) diff --git a/utils/api_client/profile_mixin.py b/utils/api_client/profile_mixin.py new file mode 100644 index 0000000..03f74a2 --- /dev/null +++ b/utils/api_client/profile_mixin.py @@ -0,0 +1,146 @@ +# ========== api_client.py ========== +# utils/api_client.py - OpenAI-compatible API 客户端(支持Web模式) + +import httpx +import json +import asyncio +import base64 +import mimetypes +import os +from typing import List, Dict, Optional, AsyncGenerator, Any +from pathlib import Path +from datetime import datetime +from pathlib import Path +from typing import Tuple +try: + from config import ( + OUTPUT_FORMATS, + DEFAULT_RESPONSE_MAX_TOKENS, + LOGS_DIR, + ) +except ImportError: + import sys + from pathlib import Path + project_root = Path(__file__).resolve().parents[1] + if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) + from config import ( + OUTPUT_FORMATS, + DEFAULT_RESPONSE_MAX_TOKENS, + LOGS_DIR, + ) + +from utils.log_rotation import append_line, prune_dir + + + +from utils.api_client.utils import _api_dump_enabled + +class DeepSeekClientProfileMixin: + def apply_profile(self, profile: Dict): + """ + 动态应用模型配置 + profile 示例: + { + "fast": {"base_url": "...", "api_key": "...", "model_id": "...", "max_tokens": 8192}, + "thinking": {...} 或 None, + "supports_thinking": True/False, + "fast_only": True/False + } + """ + if not profile or "fast" not in profile: + raise ValueError("无效的模型配置") + fast = profile["fast"] or {} + thinking = profile.get("thinking") or fast + self.fast_api_config = { + "base_url": fast.get("base_url") or self.fast_api_config.get("base_url"), + "api_key": fast.get("api_key") or self.fast_api_config.get("api_key"), + "model_id": fast.get("model_id") or self.fast_api_config.get("model_id") + } + self.thinking_api_config = { + "base_url": thinking.get("base_url") or self.thinking_api_config.get("base_url"), + "api_key": thinking.get("api_key") or self.thinking_api_config.get("api_key"), + "model_id": thinking.get("model_id") or self.thinking_api_config.get("model_id") + } + self.fast_max_tokens = fast.get("max_tokens") + self.thinking_max_tokens = thinking.get("max_tokens") + self.fast_extra_params = fast.get("extra_params") or {} + self.thinking_extra_params = thinking.get("extra_params") or {} + self.model_multimodal = self._normalize_multimodal_capability(profile.get("multimodal")) + self.default_context_window = profile.get("context_window") or fast.get("context_window") + # 同步旧字段 + self.api_base_url = self.fast_api_config["base_url"] + self.api_key = self.fast_api_config["api_key"] + self.model_id = self.fast_api_config["model_id"] + try: + self._debug_log({ + "event": "apply_profile", + "model_key": self.model_key, + "fast_model_id": self.fast_api_config.get("model_id"), + "thinking_model_id": self.thinking_api_config.get("model_id"), + "fast_max_tokens": self.fast_max_tokens, + "thinking_max_tokens": self.thinking_max_tokens, + "fast_extra_params": self.fast_extra_params, + "thinking_extra_params": self.thinking_extra_params, + "default_context_window": self.default_context_window, + }) + except Exception: + pass + + def update_context_budget(self, current_tokens: int, max_tokens: Optional[int]): + """ + 由上层在每次调用前告知当前对话占用的token数和模型最大上下文。 + """ + try: + self.current_context_tokens = max(0, int(current_tokens)) + except (TypeError, ValueError): + self.current_context_tokens = 0 + try: + self.max_context_tokens = int(max_tokens) if max_tokens is not None else None + except (TypeError, ValueError): + self.max_context_tokens = None + + def get_current_thinking_mode(self) -> bool: + """获取当前应该使用的思考模式""" + if self.deep_thinking_session: + return True + if not self.thinking_mode: + return False + if self.force_thinking_next_call: + return True + if self.skip_thinking_next_call: + return False + return self.current_task_first_call + + def set_deep_thinking_mode(self, enabled: bool): + """配置深度思考模式(持续使用思考模型)。""" + self.deep_thinking_mode = bool(enabled) + if not enabled: + self.deep_thinking_session = False + + def start_new_task(self, force_deep: bool = False): + """开始新任务(重置任务级别的状态)""" + self.current_task_first_call = True + self.current_task_thinking = "" + self.force_thinking_next_call = False + self.skip_thinking_next_call = False + self.last_call_used_thinking = False + self.deep_thinking_session = bool(force_deep) or bool(self.deep_thinking_mode) + + def _build_headers(self, api_key: str) -> Dict[str, str]: + return { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + } + + def _select_api_config(self, use_thinking: bool) -> Dict[str, str]: + """ + 根据当前模式选择API配置,确保缺失字段回退到默认模型。 + """ + config = self.thinking_api_config if use_thinking else self.fast_api_config + fallback = self.fast_api_config + return { + "base_url": config.get("base_url") or fallback["base_url"], + "api_key": config.get("api_key") or fallback["api_key"], + "model_id": config.get("model_id") or fallback["model_id"] + } diff --git a/utils/api_client/tool_mixin.py b/utils/api_client/tool_mixin.py new file mode 100644 index 0000000..ab3cf20 --- /dev/null +++ b/utils/api_client/tool_mixin.py @@ -0,0 +1,124 @@ +# ========== api_client.py ========== +# utils/api_client.py - OpenAI-compatible API 客户端(支持Web模式) + +import httpx +import json +import asyncio +import base64 +import mimetypes +import os +from typing import List, Dict, Optional, AsyncGenerator, Any +from pathlib import Path +from datetime import datetime +from pathlib import Path +from typing import Tuple +try: + from config import ( + OUTPUT_FORMATS, + DEFAULT_RESPONSE_MAX_TOKENS, + LOGS_DIR, + ) +except ImportError: + import sys + from pathlib import Path + project_root = Path(__file__).resolve().parents[1] + if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) + from config import ( + OUTPUT_FORMATS, + DEFAULT_RESPONSE_MAX_TOKENS, + LOGS_DIR, + ) + +from utils.log_rotation import append_line, prune_dir + + + +from utils.api_client.utils import _api_dump_enabled + +class DeepSeekClientToolMixin: + def _validate_json_string(self, json_str: str) -> tuple: + """ + 验证JSON字符串的完整性 + + Returns: + (is_valid: bool, error_message: str, parsed_data: dict or None) + """ + if not json_str or not json_str.strip(): + return True, "", {} + + # 检查基本的JSON结构标记 + stripped = json_str.strip() + if not stripped.startswith('{') or not stripped.endswith('}'): + return False, "JSON字符串格式不完整(缺少开始或结束大括号)", None + + # 检查引号配对 + in_string = False + escape_next = False + quote_count = 0 + + for char in stripped: + if escape_next: + escape_next = False + continue + + if char == '\\': + escape_next = True + continue + + if char == '"': + quote_count += 1 + in_string = not in_string + + if in_string: + return False, "JSON字符串中存在未闭合的引号", None + + # 尝试解析JSON + try: + parsed_data = json.loads(stripped) + return True, "", parsed_data + except json.JSONDecodeError as e: + return False, f"JSON解析错误: {str(e)}", None + + def _safe_tool_arguments_parse(self, arguments_str: str, tool_name: str) -> tuple: + """ + 安全地解析工具参数,保持失败即时返回 + + Returns: + (success: bool, arguments: dict, error_message: str) + """ + if not arguments_str or not arguments_str.strip(): + return True, {}, "" + + # 长度检查 + max_length = 999999999 # 50KB限制 + if len(arguments_str) > max_length: + return False, {}, f"参数过长({len(arguments_str)}字符),超过{max_length}字符限制" + + # 尝试直接解析JSON + try: + parsed_data = json.loads(arguments_str) + return True, parsed_data, "" + except json.JSONDecodeError as e: + preview_length = 200 + stripped = arguments_str.strip() + preview = stripped[:preview_length] + "..." if len(stripped) > preview_length else stripped + return False, {}, f"JSON解析失败: {str(e)}\n参数预览: {preview}" + + def _extract_reasoning_delta(self, delta: Dict[str, Any]) -> str: + """统一提取思考内容,兼容 reasoning_content / reasoning_details。""" + if not isinstance(delta, dict): + return "" + if "reasoning_content" in delta: + return delta.get("reasoning_content") or "" + details = delta.get("reasoning_details") + if isinstance(details, list): + parts: List[str] = [] + for item in details: + if isinstance(item, dict): + text = item.get("text") + if text: + parts.append(text) + if parts: + return "".join(parts) + return "" diff --git a/utils/api_client/utils.py b/utils/api_client/utils.py new file mode 100644 index 0000000..e515ff0 --- /dev/null +++ b/utils/api_client/utils.py @@ -0,0 +1,84 @@ +# ========== api_client.py ========== +# utils/api_client.py - OpenAI-compatible API 客户端(支持Web模式) + +import httpx +import json +import asyncio +import base64 +import mimetypes +import os +from typing import List, Dict, Optional, AsyncGenerator, Any +from pathlib import Path +from datetime import datetime +from pathlib import Path +from typing import Tuple +try: + from config import ( + OUTPUT_FORMATS, + DEFAULT_RESPONSE_MAX_TOKENS, + LOGS_DIR, + ) +except ImportError: + import sys + from pathlib import Path + project_root = Path(__file__).resolve().parents[1] + if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) + from config import ( + OUTPUT_FORMATS, + DEFAULT_RESPONSE_MAX_TOKENS, + LOGS_DIR, + ) + +from utils.log_rotation import append_line, prune_dir + + +def _api_dump_enabled() -> bool: + """API 请求体落盘开关,默认关闭。""" + return str(os.environ.get("AGENT_API_DUMP_ENABLED", "")).strip().lower() in {"1", "true", "yes", "on"} + +import httpx + +import json + +import asyncio + +import base64 + +import mimetypes + +import os + +from typing import List, Dict, Optional, AsyncGenerator, Any + +from pathlib import Path + +from datetime import datetime + +from pathlib import Path + +from typing import Tuple + +try: + from config import ( + OUTPUT_FORMATS, + DEFAULT_RESPONSE_MAX_TOKENS, + LOGS_DIR, + ) +except ImportError: + import sys + from pathlib import Path + project_root = Path(__file__).resolve().parents[1] + if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) + from config import ( + OUTPUT_FORMATS, + DEFAULT_RESPONSE_MAX_TOKENS, + LOGS_DIR, + ) + +from utils.log_rotation import append_line, prune_dir + +def _api_dump_enabled() -> bool: + """API 请求体落盘开关,默认关闭。""" + return str(os.environ.get("AGENT_API_DUMP_ENABLED", "")).strip().lower() in {"1", "true", "yes", "on"} diff --git a/utils/tool_result_formatter.py b/utils/tool_result_formatter.py index e2c968f..41ca422 100644 --- a/utils/tool_result_formatter.py +++ b/utils/tool_result_formatter.py @@ -1,1198 +1,4 @@ -"""将工具执行结果转换为对话上下文可用的纯文本摘要。""" +# 兼容入口,已迁移到 utils.tool_result_formatter 包 +from utils.tool_result_formatter import format_tool_result_for_context -from __future__ import annotations - -import json -from typing import Any, Dict, List, Optional, Tuple - - -def _looks_like_mcp_payload(function_name: str, result_data: Any) -> bool: - if isinstance(function_name, str) and function_name.startswith("mcp__"): - return True - if not isinstance(result_data, dict): - return False - if result_data.get("tool_alias") or result_data.get("server_id"): - return True - content = result_data.get("content") - if isinstance(content, list) and any(isinstance(item, dict) and item.get("type") for item in content): - return True - return False - - -def _format_mcp_resource_reference(item: Dict[str, Any]) -> str: - uri = str(item.get("uri") or item.get("url") or "").strip() - title = str(item.get("title") or item.get("name") or "").strip() - if uri and title: - return f"[MCP 资源] {title} -> {uri}" - if uri: - return f"[MCP 资源] {uri}" - if title: - return f"[MCP 资源] {title}" - return "" - - -def _sanitize_mcp_content_item(item: Dict[str, Any]) -> Dict[str, Any]: - sanitized = dict(item) - ctype = str(sanitized.get("type") or "").strip().lower() - if ctype in {"image", "audio"}: - data = str(sanitized.pop("data", "") or "") - if data: - sanitized["data_omitted"] = True - sanitized["data_length"] = len(data) - if ctype == "resource": - resource = sanitized.get("resource") - if isinstance(resource, dict) and "blob" in resource: - blob_data = str(resource.get("blob") or "") - resource_copy = dict(resource) - resource_copy.pop("blob", None) - if blob_data: - resource_copy["blob_omitted"] = True - resource_copy["blob_length"] = len(blob_data) - sanitized["resource"] = resource_copy - return sanitized - - -def _is_default_mcp_message(message: str) -> bool: - return message.strip() == "MCP 工具调用完成" - - -def extract_mcp_content_for_context(result_data: Dict[str, Any]) -> Dict[str, Any]: - """提取 MCP content,返回可读文本 + 可落盘媒体列表 + 去二进制后的 payload。""" - if not isinstance(result_data, dict): - return { - "text": "", - "media_items": [], - "sanitized_payload": result_data, - } - - content_items = result_data.get("content") - if not isinstance(content_items, list): - raw_result = result_data.get("raw_result") - if isinstance(raw_result, dict) and isinstance(raw_result.get("content"), list): - content_items = raw_result.get("content") - else: - content_items = [] - - text_lines: List[str] = [] - media_lines: List[str] = [] - media_items: List[Dict[str, Any]] = [] - sanitized_content: List[Dict[str, Any]] = [] - - for index, item in enumerate(content_items, start=1): - if not isinstance(item, dict): - continue - ctype = str(item.get("type") or "").strip().lower() - if ctype == "text": - text_value = str(item.get("text") or "").strip() - if text_value: - text_lines.append(text_value) - sanitized_content.append(_sanitize_mcp_content_item(item)) - continue - - if ctype in {"image", "audio"}: - kind = "image" if ctype == "image" else "audio" - mime_type = str(item.get("mimeType") or item.get("mime_type") or "").strip().lower() - if not mime_type: - mime_type = "image/png" if kind == "image" else "audio/wav" - data_base64 = str(item.get("data") or "") - if data_base64: - media_items.append( - { - "index": index, - "item_type": ctype, - "kind": kind, - "mime_type": mime_type, - "data_base64": data_base64, - "label": item.get("label") or f"{kind}_{index}", - "name": item.get("name"), - "title": item.get("title"), - "uri": item.get("uri"), - "url": item.get("url"), - } - ) - media_lines.append( - f"[MCP {kind} #{index}] {mime_type}({len(data_base64)} base64 chars)" - ) - else: - media_lines.append(f"[MCP {kind} #{index}] 未包含数据") - sanitized_content.append(_sanitize_mcp_content_item(item)) - continue - - if ctype in {"resource_link", "resourcelink"}: - line = _format_mcp_resource_reference(item) - if line: - text_lines.append(line) - sanitized_content.append(_sanitize_mcp_content_item(item)) - continue - - if ctype == "resource": - resource = item.get("resource") - resource_line = _format_mcp_resource_reference(item) - if isinstance(resource, dict): - resource_line = _format_mcp_resource_reference(resource) or resource_line - resource_text = str(resource.get("text") or "").strip() - if resource_text: - text_lines.append(resource_text) - elif resource_line: - text_lines.append(resource_line) - elif resource_line: - text_lines.append(resource_line) - sanitized_content.append(_sanitize_mcp_content_item(item)) - continue - - fallback_line = _format_mcp_resource_reference(item) - if fallback_line: - text_lines.append(fallback_line) - else: - text_lines.append(f"[MCP content:{ctype or 'unknown'}]") - sanitized_content.append(_sanitize_mcp_content_item(item)) - - message = str(result_data.get("message") or "").strip() - if message and not _is_default_mcp_message(message): - if message not in text_lines: - text_lines.insert(0, message) - - if media_lines: - text_lines.extend(media_lines) - - if not text_lines: - structured = result_data.get("structured_content") - if structured is None: - structured = result_data.get("structuredContent") - if structured is not None: - try: - text_lines.append(json.dumps(structured, ensure_ascii=False)) - except Exception: - text_lines.append(str(structured)) - - if not text_lines and message and not _is_default_mcp_message(message): - text_lines.append(message) - - sanitized_payload: Dict[str, Any] = dict(result_data) - if isinstance(result_data.get("content"), list): - sanitized_payload["content"] = sanitized_content - raw_result = result_data.get("raw_result") - if isinstance(raw_result, dict): - raw_sanitized = dict(raw_result) - raw_content = raw_result.get("content") - if isinstance(raw_content, list): - raw_sanitized["content"] = [ - _sanitize_mcp_content_item(item) if isinstance(item, dict) else item - for item in raw_content - ] - sanitized_payload["raw_result"] = raw_sanitized - - return { - "text": "\n".join(line for line in text_lines if line), - "media_items": media_items, - "sanitized_payload": sanitized_payload, - } - - -def format_read_file_result(result_data: Dict[str, Any]) -> str: - """格式化 read_file 工具的输出,兼容读取/搜索/抽取模式。""" - if not isinstance(result_data, dict): - return str(result_data) - if not result_data.get("success"): - return _format_failure("read_file", result_data) - - read_type = result_data.get("type", "read") - truncated_note = "(内容已截断)" if result_data.get("truncated") else "" - path = result_data.get("path", "未知路径") - max_chars = result_data.get("max_chars") - max_note = f"(max_chars={max_chars})" if max_chars else "" - - if read_type == "read": - header = ( - f"读取 {path} 行 {result_data.get('line_start')}~{result_data.get('line_end')} " - f"{max_note}{truncated_note}" - ).strip() - content = result_data.get("content", "") - return f"{header}\n```\n{content}\n```" - - if read_type == "search": - query = result_data.get("query", "") - actual = result_data.get("actual_matches", 0) - returned = result_data.get("returned_matches", 0) - case_hint = "区分大小写" if result_data.get("case_sensitive") else "不区分大小写" - header = ( - f"在 {path} 中搜索 \"{query}\",返回 {returned}/{actual} 条结果({case_hint}) " - f"{max_note}{truncated_note}" - ).strip() - match_texts: List[str] = [] - for idx, match in enumerate(result_data.get("matches", []), 1): - match_note = "(片段截断)" if match.get("truncated") else "" - hits = match.get("hits") or [] - hit_text = ", ".join(str(h) for h in hits) if hits else "无" - label = match.get("id") or f"match_{idx}" - snippet = match.get("snippet", "") - match_texts.append( - f"[{label}] 行 {match.get('line_start')}~{match.get('line_end')} 命中行: {hit_text}{match_note}\n```\n{snippet}\n```" - ) - if not match_texts: - match_texts.append("未找到匹配内容。") - return "\n".join([header] + match_texts) - - if read_type == "extract": - segments = result_data.get("segments", []) - header = f"从 {path} 抽取 {len(segments)} 个片段 {max_note}{truncated_note}".strip() - seg_texts: List[str] = [] - for idx, segment in enumerate(segments, 1): - seg_note = "(片段截断)" if segment.get("truncated") else "" - label = segment.get("label") or f"segment_{idx}" - snippet = segment.get("content", "") - seg_texts.append( - f"[{label}] 行 {segment.get('line_start')}~{segment.get('line_end')}{seg_note}\n```\n{snippet}\n```" - ) - if not seg_texts: - seg_texts.append("未提供可抽取的片段。") - return "\n".join([header] + seg_texts) - - return _format_failure("read_file", {"error": "不支持的读取模式"}) - - -def format_tool_result_for_context(function_name: str, result_data: Any, raw_text: str = "") -> str: - """根据工具名称输出纯文本摘要,必要时附加关键信息。""" - # 自定义工具统一格式 - if isinstance(result_data, dict) and result_data.get("custom_tool"): - tool_id = result_data.get("tool_id") or function_name - success = result_data.get("success") - status = "成功" if success else "失败" - message = result_data.get("message") or result_data.get("summary") or "" - output = result_data.get("output") or "" - parts = [f"[自定义工具] {tool_id} 执行{status}"] - if message: - parts.append(str(message)) - if output: - parts.append(str(output)) - return "\n".join(parts).strip() or raw_text - - if _looks_like_mcp_payload(function_name, result_data): - parsed = extract_mcp_content_for_context(result_data if isinstance(result_data, dict) else {}) - text = str(parsed.get("text") or "").strip() - if text: - return text - return "" - - if function_name in {"read_file", "read_skill", "recall_project_memory"} and isinstance(result_data, dict): - return format_read_file_result(result_data) - - if function_name == "write_file_diff" and isinstance(result_data, dict): - return _format_write_file_diff(result_data, raw_text) - - if function_name == "conversation_search" and isinstance(result_data, dict): - return _format_conversation_search(result_data) - - if function_name == "conversation_review" and isinstance(result_data, dict): - return _format_conversation_review(result_data) - - if not isinstance(result_data, dict): - return raw_text - - handler = TOOL_FORMATTERS.get(function_name) - if handler: - return handler(result_data) - - summary = result_data.get("summary") or result_data.get("message") - error_msg = result_data.get("error") - parts: List[str] = [] - if summary: - parts.append(str(summary)) - if error_msg: - parts.append(f"⚠️ 错误: {error_msg}") - return "\n".join(parts) if parts else raw_text - - -def _format_write_file_diff(result_data: Dict[str, Any], raw_text: str) -> str: - path = result_data.get("path", "目标文件") - summary = result_data.get("summary") or result_data.get("message") - completed = result_data.get("completed") or [] - failed_blocks = result_data.get("failed") or [] - success_blocks = result_data.get("blocks") or [] - lines = [f"[文件补丁] {path}"] - if summary: - lines.append(summary) - if completed: - lines.append(f"✅ 成功块: {', '.join(str(i) for i in completed)}") - if failed_blocks: - fail_descriptions = [] - for item in failed_blocks[:3]: - idx = item.get("index") - reason = item.get("reason") or item.get("error") or "未说明原因" - fail_descriptions.append(f"#{idx}: {reason}") - lines.append("⚠️ 失败块: " + ";".join(fail_descriptions)) - if len(failed_blocks) > 3: - lines.append(f"(其余 {len(failed_blocks) - 3} 个失败块略)") - # 通用排查提示:把最常见的坑点一次性说清楚,减少来回沟通成本 - lines.append("🔎 排查提示(常见易错点):") - lines.append("- 是否把“要新增/要删除/要替换”的每一行都标了 `+` 或 `-`?(漏标会被当成上下文/锚点)") - lines.append("- 空行也要写成单独一行的 `+`(只有 `+` 和换行),否则空行会消失或被当成上下文导致匹配失败。") - lines.append("- 若目标文件是空文件:应使用“仅追加”写法(块内只有 `+` 行,不要混入未加前缀的正文)。") - lines.append("- 若希望在文件中间插入/替换:必须提供足够的上下文行(以空格开头)或删除行(`-`)来锚定位置,不能只贴 `+`。") - lines.append("- 是否存在空格/Tab/缩进差异、全角半角标点差异、大小写差异?上下文与原文必须字节级一致。") - lines.append("- 是否是 CRLF(\\r\\n) 与 LF(\\n) 混用导致原文匹配失败?可先用终端查看/统一换行后再补丁。") - lines.append("- 是否遗漏 `*** Begin Patch`/`*** End Patch` 或在第一个 `@@` 之前写了其它内容?") - detail_sections: List[str] = [] - for item in failed_blocks: - idx = item.get("index") - reason = item.get("reason") or item.get("error") or "未说明原因" - hint = item.get("hint") - block_patch = item.get("block_patch") or item.get("patch") - # 自动判别常见错误形态,便于快速定位问题 - diagnostics = _classify_diff_block_issue(item, result_data) - if not block_patch: - old_text = item.get("old_text") or "" - new_text = item.get("new_text") or "" - synthetic_lines: List[str] = [] - if old_text: - synthetic_lines.extend(f"-{line}" for line in old_text.splitlines()) - if new_text: - synthetic_lines.extend(f"+{line}" for line in new_text.splitlines()) - if synthetic_lines: - block_patch = "\n".join(synthetic_lines) - detail_sections.append(f"- #{idx}: {reason}") - if diagnostics: - detail_sections.append(f" 错误类型: {diagnostics}") - if hint: - detail_sections.append(f" 提示: {hint}") - if block_patch: - detail_sections.append("```diff") - detail_sections.append(block_patch.rstrip("\n")) - detail_sections.append("```") - detail_sections.append("") - if detail_sections and detail_sections[-1] == "": - detail_sections.pop() - if detail_sections: - lines.append("⚠️ 失败块详情:") - lines.extend(detail_sections) - - # 对“成功块”做轻量体检:如果检测到潜在格式风险,给出风险提示(不影响 success 判定) - risk_sections: List[str] = [] - for item in success_blocks: - if not isinstance(item, dict): - continue - status = item.get("status") - idx = item.get("index") - if status != "success": - continue - diag = _classify_diff_block_issue(item, result_data) - if diag: - risk_sections.append(f"- #{idx}: {diag}") - if risk_sections: - lines.append("⚠️ 风险提示(补丁虽成功但格式可能有隐患):") - lines.extend(risk_sections) - - if result_data.get("success") is False and result_data.get("error"): - lines.append(f"⚠️ 错误: {result_data.get('error')}") - formatted = "\n".join(line for line in lines if line) - return formatted or raw_text - - -def _format_conversation_search(result_data: Dict[str, Any]) -> str: - if not result_data.get("success"): - return _format_failure("conversation_search", result_data) - results = result_data.get("results") or [] - header = f"找到 {len(results)} 个当前工作区内的历史对话" - filters: List[str] = [] - keywords = result_data.get("keywords") - if isinstance(keywords, list): - normalized_keywords = [str(item).strip() for item in keywords if str(item or "").strip()] - else: - normalized_keywords = [] - if normalized_keywords: - filters.append(f"关键词:{' / '.join(normalized_keywords)}") - elif result_data.get("query"): - filters.append(f"关键词:{result_data.get('query')}") - if result_data.get("start_date") or result_data.get("end_date"): - filters.append(f"日期:{result_data.get('start_date') or '不限'} ~ {result_data.get('end_date') or '不限'}") - if result_data.get("excluded_conversation_id"): - filters.append("已排除当前对话") - if filters: - header += "(" + ";".join(filters) + ")" - if not results: - return header + "\n未找到匹配对话。" - lines = [header] - for idx, item in enumerate(results, start=1): - lines.append(f"{idx}. {item.get('id')}") - lines.append(f" 标题:{item.get('title') or '未命名对话'}") - if item.get("total_messages") is not None or item.get("total_tools") is not None: - lines.append( - f" 规模:{int(item.get('total_messages') or 0)} 条消息,{int(item.get('total_tools') or 0)} 个工具" - ) - if item.get("first_user_message"): - lines.append(f" 首条用户消息:{item.get('first_user_message')}") - if item.get("created_at"): - lines.append(f" 创建时间:{item.get('created_at')}") - if item.get("updated_at"): - lines.append(f" 更新时间:{item.get('updated_at')}") - return "\n".join(lines) - - -def _format_conversation_review(result_data: Dict[str, Any]) -> str: - if not result_data.get("success"): - return _format_failure("conversation_review", result_data) - if result_data.get("mode") == "read" and result_data.get("content"): - lines = ["对话回顾内容:"] - if result_data.get("title"): - lines.append(f"标题:{result_data.get('title')}") - if result_data.get("char_count") is not None: - lines.append(f"字符数:{result_data.get('char_count')}") - lines.append("") - lines.append(str(result_data.get("content") or "")) - return "\n".join(lines) - path = result_data.get("path") or "" - if result_data.get("too_long"): - lines = [ - f"对话回顾内容太长({result_data.get('char_count')} 字符),已保存到文件:", - str(path), - "请使用 read_file 分段或查找阅读该文件。", - ] - if result_data.get("title"): - lines.insert(1, f"标题:{result_data.get('title')}") - return "\n".join(lines) - lines = [ - "已生成对话回顾文件:", - str(path), - ] - if result_data.get("title"): - lines.append(f"标题:{result_data.get('title')}") - if result_data.get("char_count") is not None: - lines.append(f"字符数:{result_data.get('char_count')}") - lines.append("请使用 read_file 读取该文件。") - return "\n".join(lines) - - -def _format_create_skill(result_data: Dict[str, Any]) -> str: - if not result_data.get("success"): - return _format_failure("create_skill", result_data) - lines = [ - f"已归档 skill:{result_data.get('skill_name') or '未命名'}", - ] - return "\n".join(lines) - - -def _format_create_file(result_data: Dict[str, Any]) -> str: - if not result_data.get("success"): - return _format_failure("create_file", result_data) - return result_data.get("message") or f"已创建空文件: {result_data.get('path', '未知路径')}" - -def _format_write_file(result_data: Dict[str, Any]) -> str: - if not result_data.get("success"): - return _format_failure("write_file", result_data) - path = result_data.get("path") or "未知路径" - mode = str(result_data.get("mode") or "w") - action = "追加" if mode == "a" else "覆盖" - size = result_data.get("size") - message = result_data.get("message") - parts = [f"{action}写入: {path}"] - if size is not None: - parts.append(f"{size} 字节") - if message: - parts.append(str(message)) - return ",".join(parts) - -def _format_edit_file(result_data: Dict[str, Any]) -> str: - if not result_data.get("success"): - base = _format_failure("edit_file", result_data) - failed_details = result_data.get("failed_details") - details = result_data.get("details") - detail_lines: List[str] = [] - if isinstance(failed_details, list) and failed_details: - for item in failed_details[:5]: - if not isinstance(item, dict): - continue - index = item.get("index") - reason = item.get("reason") or item.get("error") or "未知原因" - status = item.get("status") - prefix = f"第 {index} 组" if index is not None else "某一组" - if status: - detail_lines.append(f"{prefix}失败({status}):{reason}") - else: - detail_lines.append(f"{prefix}失败:{reason}") - if isinstance(details, list): - completed = [ - item for item in details - if isinstance(item, dict) and item.get("status") == "success" - ] - if completed: - detail_lines.append( - f"失败前已在内存中完成 {len(completed)} 组预处理,但因后续失败未写入文件。" - ) - if result_data.get("write_performed") is False: - detail_lines.append("文件未写入。") - if detail_lines: - return base + "\n" + "\n".join(detail_lines) - return base - path = result_data.get("path") or "目标文件" - count = result_data.get("replacements") - groups = result_data.get("replacement_groups") - found = result_data.get("found_matches") - msg = result_data.get("message") - replaced_note = f"替换 {count} 处" if isinstance(count, int) else "已完成替换" - if isinstance(groups, int): - replaced_note = f"完成 {groups} 组替换,{replaced_note}" - parts = [f"{replaced_note}: {path}"] - if isinstance(found, int): - parts.append(f"累计找到 {found} 处匹配") - details = result_data.get("details") - if isinstance(details, list) and details: - group_summaries: List[str] = [] - for item in details[:10]: - if not isinstance(item, dict): - continue - index = item.get("index") - item_found = item.get("found_matches") - item_replaced = item.get("replacements") - replace_all = item.get("replace_all") - mode = "全部匹配" if replace_all is True else "首个匹配" - if index is not None and isinstance(item_found, int) and isinstance(item_replaced, int): - group_summaries.append(f"第 {index} 组{mode}:找到 {item_found} 处,替换 {item_replaced} 处") - if group_summaries: - parts.append(";".join(group_summaries)) - if msg: - parts.append(str(msg)) - return ",".join(parts) - - -def _classify_diff_block_issue(block: Dict[str, Any], result_data: Dict[str, Any]) -> str: - """ - 针对 write_file_diff 常见的“离谱/易错”用法做启发式判别,返回简短错误类型说明。 - 不改变后端逻辑,只用于提示。 - """ - patch_text = block.get("block_patch") or block.get("patch") or "" - lines = patch_text.splitlines() - plus = sum(1 for ln in lines if ln.startswith("+")) - minus = sum(1 for ln in lines if ln.startswith("-")) - context = sum(1 for ln in lines if ln.startswith(" ")) - total = len([ln for ln in lines if ln.strip() != ""]) - - reasons: List[str] = [] - # 1) 完全没加 + / - :最常见的“把目标当上下文” - if total > 0 and plus == 0 and minus == 0: - reasons.append("缺少 + / -,整块被当作上下文,无法定位到文件") - # 2) 全是 + 且没有上下文/删除:解析为“纯追加”,若目标非末尾插入会失败 - if plus > 0 and minus == 0 and context == 0: - reasons.append("仅包含 + 行,被视为追加块;若想中间插入/替换需提供上下文或 -") - # 3) 没有上下文或删除行却不是 append_only(多数是漏写空格前缀) - if block.get("append_only") and (context > 0 or minus > 0): - reasons.append("块被解析为追加模式,但混入了上下文/删除行,可能写法不一致") - # 4) 未找到匹配时,提示检查空格/缩进/全角半角/换行差异 - reason_text = (block.get("reason") or "").lower() - if "未找到匹配" in reason_text: - reasons.append("上下文未匹配:检查空格/缩进、全角半角、CRLF/LF、大小写是否与原文完全一致") - # 5) 空行未加 '+' 的典型情形: - # a) 有空白行但整块没有前缀(此前已由 #1 捕获),仍补充提示 - # b) 有空白行且块中存在 + / -,说明空行漏写前缀,易导致上下文匹配失败 - if any(ln == "" or ln.strip() == "" for ln in lines): - if plus == 0 and minus == 0: - reasons.append("空行未写 `+`,被当作上下文,建议空行写成单独一行 `+`") - else: - reasons.append("空行未写 `+`(或空格上下文),混入补丁时会被当作上下文,建议空行单独写成 `+`") - - return ";".join(reasons) - - -def _format_delete_file(result_data: Dict[str, Any]) -> str: - if not result_data.get("success"): - return _format_failure("delete_file", result_data) - path = result_data.get("path") or "未知路径" - action = result_data.get("action") or "deleted" - return f"已{action}文件: {path}" - - -def _format_rename_file(result_data: Dict[str, Any]) -> str: - if not result_data.get("success"): - return _format_failure("rename_file", result_data) - old_path = result_data.get("old_path") or "旧路径未知" - new_path = result_data.get("new_path") or "新路径未知" - return f"已重命名: {old_path} -> {new_path}" - - -def _format_create_folder(result_data: Dict[str, Any]) -> str: - if not result_data.get("success"): - return _format_failure("create_folder", result_data) - return f"已创建文件夹: {result_data.get('path', '未知路径')}" - - - -def _format_terminal_session(result_data: Dict[str, Any]) -> str: - action = result_data.get("action") or result_data.get("terminal_action") or "未知操作" - tag = f"terminal_session[{action}]" - if not result_data.get("success"): - return _format_failure(tag, result_data) - if action == "open": - return ( - f"终端 {result_data.get('session')} 已打开,工作目录 {result_data.get('working_dir')}," - f"当前活动会话: {result_data.get('session')}(共 {result_data.get('total_sessions')} 个)" - ) - if action == "close": - new_active = result_data.get("new_active") or "无" - remaining = result_data.get("remaining_sessions") or [] - return ( - f"终端 {result_data.get('session')} 已关闭,新的活动会话: {new_active}。" - f"剩余会话: {', '.join(remaining) if remaining else '无'}" - ) - if action == "reset": - session = result_data.get("session") or "未知会话" - working_dir = result_data.get("working_dir") or "未知目录" - return f"终端 {session} 已重置,工作目录 {working_dir}。" - if action == "list": - sessions = result_data.get("sessions") or [] - total = result_data.get("total", len(sessions)) - max_allowed = result_data.get("max_allowed") - active = result_data.get("active") or "无" - header = f"共有 {total}/{max_allowed} 个终端会话,活动会话: {active}" - session_lines = [] - for session in sessions: - name = session.get("session_name") or session.get("name") or "未命名" - state = "运行中" if session.get("is_running") else "已停止" - marker = "⭐" if session.get("is_active") else " " - working_dir = session.get("working_dir") or "未知目录" - session_lines.append(f"{marker} {name} | {state} | {working_dir}") - return "\n".join([header] + session_lines) if session_lines else header - return result_data.get("message") or f"{tag} 操作已完成。" - - -def _plain_command_output(result_data: Dict[str, Any]) -> str: - """生成纯文本输出,按需要加状态前缀。""" - output = result_data.get("output") or "" - status = (result_data.get("status") or "").lower() - timeout = result_data.get("timeout") - if timeout is None: - timeout = result_data.get("output_wait") - return_code = result_data.get("return_code") - truncated = result_data.get("truncated") - error = result_data.get("error") - message = result_data.get("message") - - prefixes = [] - partial_output_note = None - partial_no_output_note = None - if status in {"timeout"}: - never_timeout = ( - (isinstance(timeout, str) and timeout.lower() == "never") - or result_data.get("never_timeout") - ) - if never_timeout: - elapsed_ms = result_data.get("elapsed_ms") - secs = None - if isinstance(elapsed_ms, (int, float)) and elapsed_ms > 0: - secs = max(1, int(round(elapsed_ms / 1000))) - if secs: - prefixes.append(f"[partial_output ~{secs}s]") - partial_output_note = f"已返回约{secs}秒内输出,命令可能仍在运行" - partial_no_output_note = f"已等待约{secs}秒未捕获输出,命令可能仍在运行" - else: - prefixes.append("[partial_output]") - partial_output_note = "已返回当前输出,命令可能仍在运行" - partial_no_output_note = "未捕获输出,命令可能仍在运行" - else: - timeout_seconds = None - if isinstance(timeout, (int, float)) and timeout > 0: - timeout_seconds = int(timeout) - elif isinstance(timeout, str): - stripped = timeout.strip() - if stripped.isdigit(): - timeout_seconds = int(stripped) - else: - digits = "".join(ch for ch in stripped if ch.isdigit()) - if digits: - timeout_seconds = int(digits) - - if timeout_seconds and timeout_seconds >= 60: - prefixes.append(f"[partial_output ~{timeout_seconds}s]") - partial_output_note = f"已等待约{timeout_seconds}秒,命令可能仍在运行" - partial_no_output_note = f"已等待约{timeout_seconds}秒未捕获输出,命令可能仍在运行" - elif status in {"killed"}: - prefixes.append("[killed]") - elif status in {"awaiting_input"}: - prefixes.append("[awaiting_input]") - elif status in {"no_output"} and not output: - prefixes.append("[no_output]") - elif status in {"error"} and return_code is not None: - prefixes.append(f"[error rc={return_code}]") - elif status in {"error"}: - prefixes.append("[error]") - - if truncated: - prefixes.append("[truncated]") - - # 如果执行失败且没有输出,优先显示错误信息 - if not result_data.get("success") and not output: - err_text = partial_no_output_note or error or message - if err_text: - prefix_text = "".join(prefixes) if prefixes else "[error]" - return f"{prefix_text} {err_text}" if prefix_text else err_text - # 没有错误文本则仍走后面的输出逻辑(可能显示 no_output) - - prefix_text = "".join(prefixes) - if prefix_text and output: - if partial_output_note: - prefix_text = f"{prefix_text} {partial_output_note}" - return f"{prefix_text}\n{output}" - if prefix_text: - if partial_no_output_note: - prefix_text = f"{prefix_text} {partial_no_output_note}" - return prefix_text - if not output: - return "[no_output]" - return output - - -def _format_terminal_input(result_data: Dict[str, Any]) -> str: - text = _plain_command_output(result_data) - timeout_hint = result_data.get("timeout_hint") - if timeout_hint == "suggest_adjust_timeout": - suggestion = "请根据指令和输出结果判断是否需要提高等待输出时长或修改指令输入" - return f"{text}\n{suggestion}" if text else suggestion - if timeout_hint == "suggest_never_timeout": - suggestion = "请考虑设置 timeout 为 never,让终端持续执行该命令(⚠️注意 在该命令彻底执行完成前该终端会被占用,处于不可输入的状态)" - return f"{text}\n{suggestion}" if text else suggestion - return text - - -def _format_sleep(result_data: Dict[str, Any]) -> str: - if not result_data.get("success"): - return _format_failure("sleep", result_data) - mode = result_data.get("mode") - if mode == "wait_sub_agent_ids": - results = result_data.get("results") or [] - header = result_data.get("message") or f"已等待 {len(results)} 个子智能体结束" - detail_blocks: List[str] = [] - for item in results: - if not isinstance(item, dict): - continue - # 优先复用子智能体原始 system_message,确保与系统通知内容一致 - sys_msg = item.get("system_message") - if isinstance(sys_msg, str) and sys_msg.strip(): - detail_blocks.append(sys_msg.strip()) - else: - detail_blocks.append(_format_wait_sub_agent(item)) - if detail_blocks: - return "\n\n".join([header] + detail_blocks) - return header - if mode == "wait_runcommand_id": - nested = result_data.get("result") - if isinstance(nested, dict): - output = nested.get("output") or "" - if output: - return f"[后台 run_command 完成]\n{output}" - return "[后台 run_command 完成]\n[no_output]" - return "[后台 run_command 完成]" - reason = result_data.get("reason") - timestamp = result_data.get("timestamp") - message = result_data.get("message") or "等待完成" - parts = [message] - if reason: - parts.append(f"原因:{reason}") - if timestamp: - parts.append(f"时间:{timestamp}") - return ";".join(parts) - - -def _format_run_command(result_data: Dict[str, Any]) -> str: - status = str(result_data.get("status") or "").lower() - if result_data.get("background_task_created") is False: - body = _plain_command_output(result_data) - if not body: - body = "[no_output]" - return "[命令在5秒内完成,未创建后台任务]\n" + body - - if status == "running_background": - command_id = result_data.get("command_id") or "-" - message = result_data.get("message") or "后台命令已创建;以下为当前已捕获输出。" - output = result_data.get("output") or "" - lines = [f"[{command_id}]", f"[{message}]"] - if output: - lines.append(output) - else: - lines.append("[no_output]") - return "\n".join(lines) - - text = _plain_command_output(result_data) - if status == "timeout": - suggestion = "建议:在持久终端中直接运行该命令(terminal_session + terminal_input),或缩短命令执行时间。" - text = f"{text}\n{suggestion}" if text else suggestion - return text - - -def _format_todo_create(result_data: Dict[str, Any]) -> str: - if not result_data.get("success"): - return _format_failure("todo_create", result_data) - todo = (result_data.get("todo_list") or {}).copy() - overview = todo.get("overview") or "未命名任务" - total = len(todo.get("tasks") or []) - return f"已创建 TODO:{overview}(共 {total} 项)" - - -def _format_todo_update_task(result_data: Dict[str, Any]) -> str: - if not result_data.get("success"): - return _format_failure("todo_update_task", result_data) - message = result_data.get("message") or "任务状态已更新" - todo = result_data.get("todo_list") or {} - tasks = todo.get("tasks") or [] - total = len(tasks) - done = sum(1 for t in tasks if t.get("status") == "done") - progress_note = f"进度 {done}/{total}" if total else "" - return f"{message};{progress_note}".strip(";") - - -def _format_update_memory(result_data: Dict[str, Any]) -> str: - if not result_data.get("success"): - return _format_failure("update_memory", result_data) - operation = result_data.get("operation") or "write" - idx = result_data.get("index") - count = result_data.get("count") - if operation == "append": - suffix = f"(共 {count} 条)" if count is not None else "" - return f"记忆已追加新条目{suffix}" - if operation == "replace": - return f"记忆第 {idx} 条已替换。" - if operation == "delete": - suffix = f"(剩余 {count} 条)" if count is not None else "" - return f"记忆第 {idx} 条已删除{suffix}" - return f"记忆已更新。" - - -def _format_sub_agent_stats(stats: Optional[Dict[str, Any]]) -> str: - if not isinstance(stats, dict): - return "" - - def _to_int(value: Any) -> int: - try: - return max(0, int(value)) - except (TypeError, ValueError): - return 0 - - api_calls = _to_int(stats.get("api_calls") or stats.get("api_call_count") or stats.get("turn_count")) - files_read = _to_int(stats.get("files_read")) - edit_files = _to_int(stats.get("edit_files")) - searches = _to_int(stats.get("searches")) - web_pages = _to_int(stats.get("web_pages")) - commands = _to_int(stats.get("commands")) - lines = [ - f"调用了{api_calls}次", - f"阅读了{files_read}次文件", - f"编辑了{edit_files}次文件", - f"搜索了{searches}次内容", - f"查看了{web_pages}个网页", - f"运行了{commands}个指令", - ] - return "\n".join(lines) - - -def _format_create_sub_agent(result_data: Dict[str, Any]) -> str: - if not result_data.get("success"): - return _format_failure("create_sub_agent", result_data) - agent_id = result_data.get("agent_id") - task_id = result_data.get("task_id") - status = result_data.get("status") - refs = result_data.get("copied_references") or [] - ref_note = f",附带 {len(refs)} 份参考文件" if refs else "" - deliver_dir = result_data.get("deliverables_dir") - deliver_note = f",交付目录: {deliver_dir}" if deliver_dir else "" - header = f"子智能体 #{agent_id} 已创建(task_id={task_id},状态 {status}{ref_note}{deliver_note})。" - stats_text = _format_sub_agent_stats( - result_data.get("stats") or (result_data.get("final_result") or {}).get("stats") - ) - summary = result_data.get("message") or result_data.get("summary") - elapsed_seconds = result_data.get("runtime_seconds") - if elapsed_seconds is None: - elapsed_seconds = result_data.get("elapsed_seconds") - lines = [header] - if stats_text: - lines.append(stats_text) - if status == "completed" and isinstance(elapsed_seconds, (int, float)): - lines.append(f"运行了{int(round(elapsed_seconds))}秒") - if summary and status in {"completed", "failed", "timeout", "terminated"}: - lines.append(str(summary)) - return "\n".join(lines) - - -def _format_wait_sub_agent(result_data: Dict[str, Any]) -> str: - task_id = result_data.get("task_id") - agent_id = result_data.get("agent_id") - status = result_data.get("status") - stats_value = result_data.get("stats") - if not isinstance(stats_value, dict) and status == "timeout": - stats_value = {} - stats_text = _format_sub_agent_stats(stats_value) - elapsed_seconds = result_data.get("runtime_seconds") - if elapsed_seconds is None: - elapsed_seconds = result_data.get("elapsed_seconds") - if result_data.get("success"): - copied_path = result_data.get("copied_path") or result_data.get("deliverables_path") - message = result_data.get("message") or "子智能体任务已完成。" - deliver_note = f"交付已复制到 {copied_path}" if copied_path else "交付目录已生成" - lines = [f"子智能体 #{agent_id}/{task_id} 完成"] - if stats_text: - lines.append(stats_text) - if isinstance(elapsed_seconds, (int, float)): - lines.append(f"运行了{int(round(elapsed_seconds))}秒") - lines.append(message) - lines.append(deliver_note) - return "\n".join(lines) - message = result_data.get("message") or result_data.get("error") or "子智能体任务失败" - lines = [f"⚠️ 子智能体 #{agent_id}/{task_id} 状态 {status}"] - if stats_text: - lines.append(stats_text) - lines.append(message) - return "\n".join(lines) - - -def _format_get_sub_agent_status(result_data: Dict[str, Any]) -> str: - if not result_data.get("success"): - return _format_failure("get_sub_agent_status", result_data) - results = result_data.get("results") or [] - if not results: - return "未找到子智能体状态。" - blocks = [] - for item in results: - agent_id = item.get("agent_id") - if not item.get("found"): - blocks.append(f"子智能体 #{agent_id} 未找到。") - continue - status = item.get("status") - summary = None - final_result = item.get("final_result") or {} - elapsed_seconds = None - if isinstance(final_result, dict): - summary = final_result.get("message") or final_result.get("summary") - elapsed_seconds = final_result.get("runtime_seconds") - if elapsed_seconds is None: - elapsed_seconds = final_result.get("elapsed_seconds") - if not summary: - summary = item.get("summary") or "" - stats_text = _format_sub_agent_stats(item.get("stats")) - lines = [f"子智能体 #{agent_id} 状态: {status}"] - if stats_text: - lines.append(stats_text) - if status == "completed" and isinstance(elapsed_seconds, (int, float)): - lines.append(f"运行了{int(round(elapsed_seconds))}秒") - if summary: - lines.append(str(summary)) - blocks.append("\n".join(lines)) - return "\n\n".join(blocks) - - -def _format_close_sub_agent(result_data: Dict[str, Any]) -> str: - if not result_data.get("success"): - return _format_failure("close_sub_agent", result_data) - message = result_data.get("message") or "子智能体已关闭。" - task_id = result_data.get("task_id") - status = result_data.get("status") - status_note = f"(状态 {status})" if status else "" - return f"{message}{status_note}(task_id={task_id})" - - -def _format_failure(tag: str, result_data: Dict[str, Any]) -> str: - error = result_data.get("error") or result_data.get("message") or "未知错误" - suggestion = result_data.get("suggestion") - details = result_data.get("details") - parts = [f"⚠️ {tag} 失败: {error}"] - if suggestion: - parts.append(f"建议:{suggestion}") - elif isinstance(details, str) and details: - parts.append(f"详情:{details}") - elif isinstance(details, dict): - detail_msg = details.get("message") or details.get("error") - if detail_msg: - parts.append(f"详情:{detail_msg}") - return ";".join(parts) - - -def _summarize_output_block(output: Optional[str], truncated: Optional[bool]) -> str: - if not output: - return "无可见输出" - lines = output.splitlines() - line_count = len(lines) - char_count = len(output) - meta = f"输出 {line_count} 行 / {char_count} 字符" - if truncated: - meta += "(已截断)" - return f"{meta}\n```\n{output}\n```" - - -def _preview_text(text: str, limit: int) -> Tuple[str, bool]: - """返回截断预览及是否截断标记。""" - if text is None: - return "", False - if len(text) <= limit: - return text, False - return text[:limit], True - - -def _format_terminal_snapshot(result_data: Dict[str, Any]) -> str: - if not result_data.get("success"): - return _format_failure("terminal_snapshot", result_data) - session = result_data.get("session") or "default" - requested = result_data.get("line_limit") - requested_note = f"{requested} 行" if requested is not None else "全部" - actual = result_data.get("lines_returned") - actual_note = f"{actual} 行" if actual is not None else "未知行数" - truncated = result_data.get("truncated") - trunc_note = "已截断" if truncated else "未截断" - output = result_data.get("output") or "" - header = f"会话 {session}:请求 {requested_note},实际返回 {actual_note}({trunc_note})。" - body = _summarize_output_block(output, truncated) - return f"{header}\n{body}" - - -def _format_extract_webpage(result_data: Dict[str, Any]) -> str: - if not result_data.get("success"): - return _format_failure("extract_webpage", result_data) - url = result_data.get("url") or "目标网页" - content = result_data.get("content") or "" - length = len(content) - truncated_flag = result_data.get("truncated") or False - header = f"提取完成:{url},长度 {length} 字符。" - if not content: - return f"{header} 内容为空。" - # 为模型保留完整正文,避免 800 字预览导致上下文缺失 - note_parts = [] - if truncated_flag: - note_parts.append("原始内容已被上游截断") - note = f"({';'.join(note_parts)})" if note_parts else "" - return "\n".join([f"{header}{note}", "```", content, "```"]) - - -def _format_vlm_analyze(result_data: Dict[str, Any]) -> str: - if not result_data.get("success"): - return _format_failure("vlm_analyze", result_data) - content = result_data.get("content") or "" - length = len(content) - preview, truncated = _preview_text(content, 800) - note = "(截断预览)" if truncated else "(未截断)" - header = f"VLM 解析完成,长度 {length} 字符{note}" - if not content: - return f"{header};未返回可识别文本。" - return "\n".join([header, "```", preview, "```"]) - -# 兼容旧名 -def _format_ocr_image(result_data: Dict[str, Any]) -> str: - return _format_vlm_analyze(result_data) - - -def _format_trigger_easter_egg(result_data: Dict[str, Any]) -> str: - if not result_data.get("success"): - return _format_failure("trigger_easter_egg", result_data) - effect = (result_data.get("effect") or "").lower() - duration = result_data.get("duration_seconds") or result_data.get("duration") - if effect == "flood": - return "大水即将淹没屏幕!预计持续 45 秒,不过这期间你和用户还可以继续对话。" - if effect == "snake": - dur_text = f"{duration} 秒" if duration else "约 200 秒" - return f"发光贪吃蛇来访,约 {dur_text} 后或吃满 20 个苹果离场;动画不挡操作。" - message = result_data.get("message") - if message: - return message - return f"已触发彩蛋:{effect or '未知效果'}。" - - -def _format_command_result(label: str, result_data: Dict[str, Any]) -> str: - command = result_data.get("command") or "" - return_code = result_data.get("return_code") - success = result_data.get("success") - status = result_data.get("status") - output = result_data.get("output") - truncated = result_data.get("truncated") - message = result_data.get("message") - - if success: - header = f"{label}: `{command}`" if command else label - if return_code is not None and return_code != "": - header += f" (return_code={return_code})" - lines = [header] - if status and status not in {"completed", "success"}: - lines.append(f"终端状态: {status}") - if message: - lines.append(message) - lines.append(_summarize_output_block(output, truncated)) - return "\n".join(lines) - - error_msg = result_data.get("error") or message or "执行失败" - header = f"⚠️ {label} 失败" - if command: - header += f"(命令 `{command}`)" - lines = [f"{header}: {error_msg}"] - if return_code not in {None, ""}: - lines.append(f"返回码: {return_code}") - if output: - lines.append(_summarize_output_block(output, truncated)) - return "\n".join(lines) - - -def _format_manage_personalization(result_data: Dict[str, Any]) -> str: - action = result_data.get("action") or "read" - if not result_data.get("success"): - validation_errors = result_data.get("validation_errors") or [] - base = _format_failure("manage_personalization", result_data) - if validation_errors: - return base + "\n校验失败: " + ";".join(str(item) for item in validation_errors) - return base - - if action == "update" or result_data.get("updated_field"): - field = result_data.get("updated_field") or "未知字段" - old_value = result_data.get("old_value") - new_value = result_data.get("updated_value") - parts = [f"个性化字段已更新: {field}"] - if old_value is not None: - parts.append(f"旧值: {old_value}") - if new_value is not None: - parts.append(f"新值: {new_value}") - if result_data.get("theme_changed") and result_data.get("new_theme"): - parts.append(f"主题已切换为: {result_data.get('new_theme')}") - message = result_data.get("message") - if message: - parts.append(str(message)) - return "\n".join(parts) - - config = result_data.get("data") or {} - if not isinstance(config, dict): - return result_data.get("message") or "个性化配置读取成功" - - lines = ["当前个性化配置:"] - for key, value in config.items(): - lines.append(f"- {key}: {value}") - message = result_data.get("message") - if message: - lines.append(str(message)) - return "\n".join(lines) - - -def _summarize_todo_tasks(todo: Optional[Dict[str, Any]]) -> str: - if not isinstance(todo, dict): - return "" - tasks = todo.get("tasks") or [] - parts = [] - for task in tasks: - status_icon = "✅" if task.get("status") == "done" else "⬜️" - parts.append(f"{status_icon} task{task.get('index')}: {task.get('title')}") - return ";".join(parts) - - -TOOL_FORMATTERS = { - "create_file": _format_create_file, - "write_file": _format_write_file, - "edit_file": _format_edit_file, - "delete_file": _format_delete_file, - "rename_file": _format_rename_file, - "create_folder": _format_create_folder, - "terminal_snapshot": _format_terminal_snapshot, - "terminal_session": _format_terminal_session, - "terminal_input": _format_terminal_input, - "sleep": _format_sleep, - "run_command": _format_run_command, - "extract_webpage": _format_extract_webpage, - "vlm_analyze": _format_vlm_analyze, - "ocr_image": _format_ocr_image, - "trigger_easter_egg": _format_trigger_easter_egg, - "todo_create": _format_todo_create, - "todo_update_task": _format_todo_update_task, - "update_memory": _format_update_memory, - "create_skill": _format_create_skill, - "manage_personalization": _format_manage_personalization, - "create_sub_agent": _format_create_sub_agent, - "close_sub_agent": _format_close_sub_agent, - "get_sub_agent_status": _format_get_sub_agent_status, -} +__all__ = ['format_tool_result_for_context'] diff --git a/utils/tool_result_formatter/__init__.py b/utils/tool_result_formatter/__init__.py new file mode 100644 index 0000000..69f627d --- /dev/null +++ b/utils/tool_result_formatter/__init__.py @@ -0,0 +1,4 @@ +from utils.tool_result_formatter.dispatch import format_tool_result_for_context +from utils.tool_result_formatter.mcp import extract_mcp_content_for_context + +__all__ = ['format_tool_result_for_context', 'extract_mcp_content_for_context'] diff --git a/utils/tool_result_formatter/agent_context.py b/utils/tool_result_formatter/agent_context.py new file mode 100644 index 0000000..8e54f22 --- /dev/null +++ b/utils/tool_result_formatter/agent_context.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Tuple +from utils.tool_result_formatter.common import ( + _format_failure, _preview_text, _summarize_output_block, _summarize_todo_tasks +) + +def _format_conversation_search(result_data: Dict[str, Any]) -> str: + if not result_data.get("success"): + return _format_failure("conversation_search", result_data) + results = result_data.get("results") or [] + header = f"找到 {len(results)} 个当前工作区内的历史对话" + filters: List[str] = [] + keywords = result_data.get("keywords") + if isinstance(keywords, list): + normalized_keywords = [str(item).strip() for item in keywords if str(item or "").strip()] + else: + normalized_keywords = [] + if normalized_keywords: + filters.append(f"关键词:{' / '.join(normalized_keywords)}") + elif result_data.get("query"): + filters.append(f"关键词:{result_data.get('query')}") + if result_data.get("start_date") or result_data.get("end_date"): + filters.append(f"日期:{result_data.get('start_date') or '不限'} ~ {result_data.get('end_date') or '不限'}") + if result_data.get("excluded_conversation_id"): + filters.append("已排除当前对话") + if filters: + header += "(" + ";".join(filters) + ")" + if not results: + return header + "\n未找到匹配对话。" + lines = [header] + for idx, item in enumerate(results, start=1): + lines.append(f"{idx}. {item.get('id')}") + lines.append(f" 标题:{item.get('title') or '未命名对话'}") + if item.get("total_messages") is not None or item.get("total_tools") is not None: + lines.append( + f" 规模:{int(item.get('total_messages') or 0)} 条消息,{int(item.get('total_tools') or 0)} 个工具" + ) + if item.get("first_user_message"): + lines.append(f" 首条用户消息:{item.get('first_user_message')}") + if item.get("created_at"): + lines.append(f" 创建时间:{item.get('created_at')}") + if item.get("updated_at"): + lines.append(f" 更新时间:{item.get('updated_at')}") + return "\n".join(lines) + +def _format_conversation_review(result_data: Dict[str, Any]) -> str: + if not result_data.get("success"): + return _format_failure("conversation_review", result_data) + if result_data.get("mode") == "read" and result_data.get("content"): + lines = ["对话回顾内容:"] + if result_data.get("title"): + lines.append(f"标题:{result_data.get('title')}") + if result_data.get("char_count") is not None: + lines.append(f"字符数:{result_data.get('char_count')}") + lines.append("") + lines.append(str(result_data.get("content") or "")) + return "\n".join(lines) + path = result_data.get("path") or "" + if result_data.get("too_long"): + lines = [ + f"对话回顾内容太长({result_data.get('char_count')} 字符),已保存到文件:", + str(path), + "请使用 read_file 分段或查找阅读该文件。", + ] + if result_data.get("title"): + lines.insert(1, f"标题:{result_data.get('title')}") + return "\n".join(lines) + lines = [ + "已生成对话回顾文件:", + str(path), + ] + if result_data.get("title"): + lines.append(f"标题:{result_data.get('title')}") + if result_data.get("char_count") is not None: + lines.append(f"字符数:{result_data.get('char_count')}") + lines.append("请使用 read_file 读取该文件。") + return "\n".join(lines) + +def _format_todo_create(result_data: Dict[str, Any]) -> str: + if not result_data.get("success"): + return _format_failure("todo_create", result_data) + todo = (result_data.get("todo_list") or {}).copy() + overview = todo.get("overview") or "未命名任务" + total = len(todo.get("tasks") or []) + return f"已创建 TODO:{overview}(共 {total} 项)" + +def _format_todo_update_task(result_data: Dict[str, Any]) -> str: + if not result_data.get("success"): + return _format_failure("todo_update_task", result_data) + message = result_data.get("message") or "任务状态已更新" + todo = result_data.get("todo_list") or {} + tasks = todo.get("tasks") or [] + total = len(tasks) + done = sum(1 for t in tasks if t.get("status") == "done") + progress_note = f"进度 {done}/{total}" if total else "" + return f"{message};{progress_note}".strip(";") + +def _format_update_memory(result_data: Dict[str, Any]) -> str: + if not result_data.get("success"): + return _format_failure("update_memory", result_data) + operation = result_data.get("operation") or "write" + idx = result_data.get("index") + count = result_data.get("count") + if operation == "append": + suffix = f"(共 {count} 条)" if count is not None else "" + return f"记忆已追加新条目{suffix}" + if operation == "replace": + return f"记忆第 {idx} 条已替换。" + if operation == "delete": + suffix = f"(剩余 {count} 条)" if count is not None else "" + return f"记忆第 {idx} 条已删除{suffix}" + return f"记忆已更新。" + +def _format_sub_agent_stats(stats: Optional[Dict[str, Any]]) -> str: + if not isinstance(stats, dict): + return "" + + def _to_int(value: Any) -> int: + try: + return max(0, int(value)) + except (TypeError, ValueError): + return 0 + + api_calls = _to_int(stats.get("api_calls") or stats.get("api_call_count") or stats.get("turn_count")) + files_read = _to_int(stats.get("files_read")) + edit_files = _to_int(stats.get("edit_files")) + searches = _to_int(stats.get("searches")) + web_pages = _to_int(stats.get("web_pages")) + commands = _to_int(stats.get("commands")) + lines = [ + f"调用了{api_calls}次", + f"阅读了{files_read}次文件", + f"编辑了{edit_files}次文件", + f"搜索了{searches}次内容", + f"查看了{web_pages}个网页", + f"运行了{commands}个指令", + ] + return "\n".join(lines) + +def _format_create_sub_agent(result_data: Dict[str, Any]) -> str: + if not result_data.get("success"): + return _format_failure("create_sub_agent", result_data) + agent_id = result_data.get("agent_id") + task_id = result_data.get("task_id") + status = result_data.get("status") + refs = result_data.get("copied_references") or [] + ref_note = f",附带 {len(refs)} 份参考文件" if refs else "" + deliver_dir = result_data.get("deliverables_dir") + deliver_note = f",交付目录: {deliver_dir}" if deliver_dir else "" + header = f"子智能体 #{agent_id} 已创建(task_id={task_id},状态 {status}{ref_note}{deliver_note})。" + stats_text = _format_sub_agent_stats( + result_data.get("stats") or (result_data.get("final_result") or {}).get("stats") + ) + summary = result_data.get("message") or result_data.get("summary") + elapsed_seconds = result_data.get("runtime_seconds") + if elapsed_seconds is None: + elapsed_seconds = result_data.get("elapsed_seconds") + lines = [header] + if stats_text: + lines.append(stats_text) + if status == "completed" and isinstance(elapsed_seconds, (int, float)): + lines.append(f"运行了{int(round(elapsed_seconds))}秒") + if summary and status in {"completed", "failed", "timeout", "terminated"}: + lines.append(str(summary)) + return "\n".join(lines) + +def _format_wait_sub_agent(result_data: Dict[str, Any]) -> str: + task_id = result_data.get("task_id") + agent_id = result_data.get("agent_id") + status = result_data.get("status") + stats_value = result_data.get("stats") + if not isinstance(stats_value, dict) and status == "timeout": + stats_value = {} + stats_text = _format_sub_agent_stats(stats_value) + elapsed_seconds = result_data.get("runtime_seconds") + if elapsed_seconds is None: + elapsed_seconds = result_data.get("elapsed_seconds") + if result_data.get("success"): + copied_path = result_data.get("copied_path") or result_data.get("deliverables_path") + message = result_data.get("message") or "子智能体任务已完成。" + deliver_note = f"交付已复制到 {copied_path}" if copied_path else "交付目录已生成" + lines = [f"子智能体 #{agent_id}/{task_id} 完成"] + if stats_text: + lines.append(stats_text) + if isinstance(elapsed_seconds, (int, float)): + lines.append(f"运行了{int(round(elapsed_seconds))}秒") + lines.append(message) + lines.append(deliver_note) + return "\n".join(lines) + message = result_data.get("message") or result_data.get("error") or "子智能体任务失败" + lines = [f"⚠️ 子智能体 #{agent_id}/{task_id} 状态 {status}"] + if stats_text: + lines.append(stats_text) + lines.append(message) + return "\n".join(lines) + +def _format_get_sub_agent_status(result_data: Dict[str, Any]) -> str: + if not result_data.get("success"): + return _format_failure("get_sub_agent_status", result_data) + results = result_data.get("results") or [] + if not results: + return "未找到子智能体状态。" + blocks = [] + for item in results: + agent_id = item.get("agent_id") + if not item.get("found"): + blocks.append(f"子智能体 #{agent_id} 未找到。") + continue + status = item.get("status") + summary = None + final_result = item.get("final_result") or {} + elapsed_seconds = None + if isinstance(final_result, dict): + summary = final_result.get("message") or final_result.get("summary") + elapsed_seconds = final_result.get("runtime_seconds") + if elapsed_seconds is None: + elapsed_seconds = final_result.get("elapsed_seconds") + if not summary: + summary = item.get("summary") or "" + stats_text = _format_sub_agent_stats(item.get("stats")) + lines = [f"子智能体 #{agent_id} 状态: {status}"] + if stats_text: + lines.append(stats_text) + if status == "completed" and isinstance(elapsed_seconds, (int, float)): + lines.append(f"运行了{int(round(elapsed_seconds))}秒") + if summary: + lines.append(str(summary)) + blocks.append("\n".join(lines)) + return "\n\n".join(blocks) + +def _format_close_sub_agent(result_data: Dict[str, Any]) -> str: + if not result_data.get("success"): + return _format_failure("close_sub_agent", result_data) + message = result_data.get("message") or "子智能体已关闭。" + task_id = result_data.get("task_id") + status = result_data.get("status") + status_note = f"(状态 {status})" if status else "" + return f"{message}{status_note}(task_id={task_id})" diff --git a/utils/tool_result_formatter/common.py b/utils/tool_result_formatter/common.py new file mode 100644 index 0000000..d31ccfc --- /dev/null +++ b/utils/tool_result_formatter/common.py @@ -0,0 +1,51 @@ +"""将工具执行结果转换为对话上下文可用的纯文本摘要。""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List, Optional, Tuple + + +def _format_failure(tag: str, result_data: Dict[str, Any]) -> str: + error = result_data.get("error") or result_data.get("message") or "未知错误" + suggestion = result_data.get("suggestion") + details = result_data.get("details") + parts = [f"⚠️ {tag} 失败: {error}"] + if suggestion: + parts.append(f"建议:{suggestion}") + elif isinstance(details, str) and details: + parts.append(f"详情:{details}") + elif isinstance(details, dict): + detail_msg = details.get("message") or details.get("error") + if detail_msg: + parts.append(f"详情:{detail_msg}") + return ";".join(parts) + +def _summarize_output_block(output: Optional[str], truncated: Optional[bool]) -> str: + if not output: + return "无可见输出" + lines = output.splitlines() + line_count = len(lines) + char_count = len(output) + meta = f"输出 {line_count} 行 / {char_count} 字符" + if truncated: + meta += "(已截断)" + return f"{meta}\n```\n{output}\n```" + +def _preview_text(text: str, limit: int) -> Tuple[str, bool]: + """返回截断预览及是否截断标记。""" + if text is None: + return "", False + if len(text) <= limit: + return text, False + return text[:limit], True + +def _summarize_todo_tasks(todo: Optional[Dict[str, Any]]) -> str: + if not isinstance(todo, dict): + return "" + tasks = todo.get("tasks") or [] + parts = [] + for task in tasks: + status_icon = "✅" if task.get("status") == "done" else "⬜️" + parts.append(f"{status_icon} task{task.get('index')}: {task.get('title')}") + return ";".join(parts) diff --git a/utils/tool_result_formatter/dispatch.py b/utils/tool_result_formatter/dispatch.py new file mode 100644 index 0000000..21bf177 --- /dev/null +++ b/utils/tool_result_formatter/dispatch.py @@ -0,0 +1,126 @@ +"""将工具执行结果转换为对话上下文可用的纯文本摘要。""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List, Optional, Tuple + +from utils.tool_result_formatter.mcp import ( + _looks_like_mcp_payload, + extract_mcp_content_for_context, +) +from utils.tool_result_formatter.file import ( + format_read_file_result, + _format_write_file_diff, + _format_create_file, + _format_write_file, + _format_edit_file, + _format_delete_file, + _format_rename_file, + _format_create_folder, +) +from utils.tool_result_formatter.terminal import ( + _format_terminal_snapshot, + _format_terminal_session, + _format_terminal_input, + _format_sleep, + _format_run_command, + _format_command_result, +) +from utils.tool_result_formatter.agent_context import ( + _format_conversation_search, + _format_conversation_review, + _format_todo_create, + _format_todo_update_task, + _format_update_memory, + _format_create_sub_agent, + _format_close_sub_agent, + _format_get_sub_agent_status, +) +from utils.tool_result_formatter.web_media import ( + _format_extract_webpage, + _format_vlm_analyze, + _format_ocr_image, + _format_trigger_easter_egg, + _format_create_skill, + _format_manage_personalization, +) +from utils.tool_result_formatter.common import _format_failure + +def format_tool_result_for_context(function_name: str, result_data: Any, raw_text: str = "") -> str: + """根据工具名称输出纯文本摘要,必要时附加关键信息。""" + # 自定义工具统一格式 + if isinstance(result_data, dict) and result_data.get("custom_tool"): + tool_id = result_data.get("tool_id") or function_name + success = result_data.get("success") + status = "成功" if success else "失败" + message = result_data.get("message") or result_data.get("summary") or "" + output = result_data.get("output") or "" + parts = [f"[自定义工具] {tool_id} 执行{status}"] + if message: + parts.append(str(message)) + if output: + parts.append(str(output)) + return "\n".join(parts).strip() or raw_text + + if _looks_like_mcp_payload(function_name, result_data): + parsed = extract_mcp_content_for_context(result_data if isinstance(result_data, dict) else {}) + text = str(parsed.get("text") or "").strip() + if text: + return text + return "" + + if function_name in {"read_file", "read_skill", "recall_project_memory"} and isinstance(result_data, dict): + return format_read_file_result(result_data) + + if function_name == "write_file_diff" and isinstance(result_data, dict): + return _format_write_file_diff(result_data, raw_text) + + if function_name == "conversation_search" and isinstance(result_data, dict): + return _format_conversation_search(result_data) + + if function_name == "conversation_review" and isinstance(result_data, dict): + return _format_conversation_review(result_data) + + if not isinstance(result_data, dict): + return raw_text + + handler = TOOL_FORMATTERS.get(function_name) + if handler: + return handler(result_data) + + summary = result_data.get("summary") or result_data.get("message") + error_msg = result_data.get("error") + parts: List[str] = [] + if summary: + parts.append(str(summary)) + if error_msg: + parts.append(f"⚠️ 错误: {error_msg}") + return "\n".join(parts) if parts else raw_text + + +TOOL_FORMATTERS = { + "create_file": _format_create_file, + "write_file": _format_write_file, + "edit_file": _format_edit_file, + "delete_file": _format_delete_file, + "rename_file": _format_rename_file, + "create_folder": _format_create_folder, + "terminal_snapshot": _format_terminal_snapshot, + "terminal_session": _format_terminal_session, + "terminal_input": _format_terminal_input, + "sleep": _format_sleep, + "run_command": _format_run_command, + "extract_webpage": _format_extract_webpage, + "vlm_analyze": _format_vlm_analyze, + "ocr_image": _format_ocr_image, + "trigger_easter_egg": _format_trigger_easter_egg, + "todo_create": _format_todo_create, + "todo_update_task": _format_todo_update_task, + "update_memory": _format_update_memory, + "create_skill": _format_create_skill, + "manage_personalization": _format_manage_personalization, + "create_sub_agent": _format_create_sub_agent, + "close_sub_agent": _format_close_sub_agent, + "get_sub_agent_status": _format_get_sub_agent_status, +} diff --git a/utils/tool_result_formatter/file.py b/utils/tool_result_formatter/file.py new file mode 100644 index 0000000..a66ca16 --- /dev/null +++ b/utils/tool_result_formatter/file.py @@ -0,0 +1,289 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Tuple +from utils.tool_result_formatter.common import ( + _format_failure, _preview_text, _summarize_output_block, _summarize_todo_tasks +) + +def format_read_file_result(result_data: Dict[str, Any]) -> str: + """格式化 read_file 工具的输出,兼容读取/搜索/抽取模式。""" + if not isinstance(result_data, dict): + return str(result_data) + if not result_data.get("success"): + return _format_failure("read_file", result_data) + + read_type = result_data.get("type", "read") + truncated_note = "(内容已截断)" if result_data.get("truncated") else "" + path = result_data.get("path", "未知路径") + max_chars = result_data.get("max_chars") + max_note = f"(max_chars={max_chars})" if max_chars else "" + + if read_type == "read": + header = ( + f"读取 {path} 行 {result_data.get('line_start')}~{result_data.get('line_end')} " + f"{max_note}{truncated_note}" + ).strip() + content = result_data.get("content", "") + return f"{header}\n```\n{content}\n```" + + if read_type == "search": + query = result_data.get("query", "") + actual = result_data.get("actual_matches", 0) + returned = result_data.get("returned_matches", 0) + case_hint = "区分大小写" if result_data.get("case_sensitive") else "不区分大小写" + header = ( + f"在 {path} 中搜索 \"{query}\",返回 {returned}/{actual} 条结果({case_hint}) " + f"{max_note}{truncated_note}" + ).strip() + match_texts: List[str] = [] + for idx, match in enumerate(result_data.get("matches", []), 1): + match_note = "(片段截断)" if match.get("truncated") else "" + hits = match.get("hits") or [] + hit_text = ", ".join(str(h) for h in hits) if hits else "无" + label = match.get("id") or f"match_{idx}" + snippet = match.get("snippet", "") + match_texts.append( + f"[{label}] 行 {match.get('line_start')}~{match.get('line_end')} 命中行: {hit_text}{match_note}\n```\n{snippet}\n```" + ) + if not match_texts: + match_texts.append("未找到匹配内容。") + return "\n".join([header] + match_texts) + + if read_type == "extract": + segments = result_data.get("segments", []) + header = f"从 {path} 抽取 {len(segments)} 个片段 {max_note}{truncated_note}".strip() + seg_texts: List[str] = [] + for idx, segment in enumerate(segments, 1): + seg_note = "(片段截断)" if segment.get("truncated") else "" + label = segment.get("label") or f"segment_{idx}" + snippet = segment.get("content", "") + seg_texts.append( + f"[{label}] 行 {segment.get('line_start')}~{segment.get('line_end')}{seg_note}\n```\n{snippet}\n```" + ) + if not seg_texts: + seg_texts.append("未提供可抽取的片段。") + return "\n".join([header] + seg_texts) + + return _format_failure("read_file", {"error": "不支持的读取模式"}) + +def _format_write_file_diff(result_data: Dict[str, Any], raw_text: str) -> str: + path = result_data.get("path", "目标文件") + summary = result_data.get("summary") or result_data.get("message") + completed = result_data.get("completed") or [] + failed_blocks = result_data.get("failed") or [] + success_blocks = result_data.get("blocks") or [] + lines = [f"[文件补丁] {path}"] + if summary: + lines.append(summary) + if completed: + lines.append(f"✅ 成功块: {', '.join(str(i) for i in completed)}") + if failed_blocks: + fail_descriptions = [] + for item in failed_blocks[:3]: + idx = item.get("index") + reason = item.get("reason") or item.get("error") or "未说明原因" + fail_descriptions.append(f"#{idx}: {reason}") + lines.append("⚠️ 失败块: " + ";".join(fail_descriptions)) + if len(failed_blocks) > 3: + lines.append(f"(其余 {len(failed_blocks) - 3} 个失败块略)") + # 通用排查提示:把最常见的坑点一次性说清楚,减少来回沟通成本 + lines.append("🔎 排查提示(常见易错点):") + lines.append("- 是否把“要新增/要删除/要替换”的每一行都标了 `+` 或 `-`?(漏标会被当成上下文/锚点)") + lines.append("- 空行也要写成单独一行的 `+`(只有 `+` 和换行),否则空行会消失或被当成上下文导致匹配失败。") + lines.append("- 若目标文件是空文件:应使用“仅追加”写法(块内只有 `+` 行,不要混入未加前缀的正文)。") + lines.append("- 若希望在文件中间插入/替换:必须提供足够的上下文行(以空格开头)或删除行(`-`)来锚定位置,不能只贴 `+`。") + lines.append("- 是否存在空格/Tab/缩进差异、全角半角标点差异、大小写差异?上下文与原文必须字节级一致。") + lines.append("- 是否是 CRLF(\\r\\n) 与 LF(\\n) 混用导致原文匹配失败?可先用终端查看/统一换行后再补丁。") + lines.append("- 是否遗漏 `*** Begin Patch`/`*** End Patch` 或在第一个 `@@` 之前写了其它内容?") + detail_sections: List[str] = [] + for item in failed_blocks: + idx = item.get("index") + reason = item.get("reason") or item.get("error") or "未说明原因" + hint = item.get("hint") + block_patch = item.get("block_patch") or item.get("patch") + # 自动判别常见错误形态,便于快速定位问题 + diagnostics = _classify_diff_block_issue(item, result_data) + if not block_patch: + old_text = item.get("old_text") or "" + new_text = item.get("new_text") or "" + synthetic_lines: List[str] = [] + if old_text: + synthetic_lines.extend(f"-{line}" for line in old_text.splitlines()) + if new_text: + synthetic_lines.extend(f"+{line}" for line in new_text.splitlines()) + if synthetic_lines: + block_patch = "\n".join(synthetic_lines) + detail_sections.append(f"- #{idx}: {reason}") + if diagnostics: + detail_sections.append(f" 错误类型: {diagnostics}") + if hint: + detail_sections.append(f" 提示: {hint}") + if block_patch: + detail_sections.append("```diff") + detail_sections.append(block_patch.rstrip("\n")) + detail_sections.append("```") + detail_sections.append("") + if detail_sections and detail_sections[-1] == "": + detail_sections.pop() + if detail_sections: + lines.append("⚠️ 失败块详情:") + lines.extend(detail_sections) + + # 对“成功块”做轻量体检:如果检测到潜在格式风险,给出风险提示(不影响 success 判定) + risk_sections: List[str] = [] + for item in success_blocks: + if not isinstance(item, dict): + continue + status = item.get("status") + idx = item.get("index") + if status != "success": + continue + diag = _classify_diff_block_issue(item, result_data) + if diag: + risk_sections.append(f"- #{idx}: {diag}") + if risk_sections: + lines.append("⚠️ 风险提示(补丁虽成功但格式可能有隐患):") + lines.extend(risk_sections) + + if result_data.get("success") is False and result_data.get("error"): + lines.append(f"⚠️ 错误: {result_data.get('error')}") + formatted = "\n".join(line for line in lines if line) + return formatted or raw_text + +def _format_create_file(result_data: Dict[str, Any]) -> str: + if not result_data.get("success"): + return _format_failure("create_file", result_data) + return result_data.get("message") or f"已创建空文件: {result_data.get('path', '未知路径')}" + +def _format_write_file(result_data: Dict[str, Any]) -> str: + if not result_data.get("success"): + return _format_failure("write_file", result_data) + path = result_data.get("path") or "未知路径" + mode = str(result_data.get("mode") or "w") + action = "追加" if mode == "a" else "覆盖" + size = result_data.get("size") + message = result_data.get("message") + parts = [f"{action}写入: {path}"] + if size is not None: + parts.append(f"{size} 字节") + if message: + parts.append(str(message)) + return ",".join(parts) + +def _format_edit_file(result_data: Dict[str, Any]) -> str: + if not result_data.get("success"): + base = _format_failure("edit_file", result_data) + failed_details = result_data.get("failed_details") + details = result_data.get("details") + detail_lines: List[str] = [] + if isinstance(failed_details, list) and failed_details: + for item in failed_details[:5]: + if not isinstance(item, dict): + continue + index = item.get("index") + reason = item.get("reason") or item.get("error") or "未知原因" + status = item.get("status") + prefix = f"第 {index} 组" if index is not None else "某一组" + if status: + detail_lines.append(f"{prefix}失败({status}):{reason}") + else: + detail_lines.append(f"{prefix}失败:{reason}") + if isinstance(details, list): + completed = [ + item for item in details + if isinstance(item, dict) and item.get("status") == "success" + ] + if completed: + detail_lines.append( + f"失败前已在内存中完成 {len(completed)} 组预处理,但因后续失败未写入文件。" + ) + if result_data.get("write_performed") is False: + detail_lines.append("文件未写入。") + if detail_lines: + return base + "\n" + "\n".join(detail_lines) + return base + path = result_data.get("path") or "目标文件" + count = result_data.get("replacements") + groups = result_data.get("replacement_groups") + found = result_data.get("found_matches") + msg = result_data.get("message") + replaced_note = f"替换 {count} 处" if isinstance(count, int) else "已完成替换" + if isinstance(groups, int): + replaced_note = f"完成 {groups} 组替换,{replaced_note}" + parts = [f"{replaced_note}: {path}"] + if isinstance(found, int): + parts.append(f"累计找到 {found} 处匹配") + details = result_data.get("details") + if isinstance(details, list) and details: + group_summaries: List[str] = [] + for item in details[:10]: + if not isinstance(item, dict): + continue + index = item.get("index") + item_found = item.get("found_matches") + item_replaced = item.get("replacements") + replace_all = item.get("replace_all") + mode = "全部匹配" if replace_all is True else "首个匹配" + if index is not None and isinstance(item_found, int) and isinstance(item_replaced, int): + group_summaries.append(f"第 {index} 组{mode}:找到 {item_found} 处,替换 {item_replaced} 处") + if group_summaries: + parts.append(";".join(group_summaries)) + if msg: + parts.append(str(msg)) + return ",".join(parts) + +def _classify_diff_block_issue(block: Dict[str, Any], result_data: Dict[str, Any]) -> str: + """ + 针对 write_file_diff 常见的“离谱/易错”用法做启发式判别,返回简短错误类型说明。 + 不改变后端逻辑,只用于提示。 + """ + patch_text = block.get("block_patch") or block.get("patch") or "" + lines = patch_text.splitlines() + plus = sum(1 for ln in lines if ln.startswith("+")) + minus = sum(1 for ln in lines if ln.startswith("-")) + context = sum(1 for ln in lines if ln.startswith(" ")) + total = len([ln for ln in lines if ln.strip() != ""]) + + reasons: List[str] = [] + # 1) 完全没加 + / - :最常见的“把目标当上下文” + if total > 0 and plus == 0 and minus == 0: + reasons.append("缺少 + / -,整块被当作上下文,无法定位到文件") + # 2) 全是 + 且没有上下文/删除:解析为“纯追加”,若目标非末尾插入会失败 + if plus > 0 and minus == 0 and context == 0: + reasons.append("仅包含 + 行,被视为追加块;若想中间插入/替换需提供上下文或 -") + # 3) 没有上下文或删除行却不是 append_only(多数是漏写空格前缀) + if block.get("append_only") and (context > 0 or minus > 0): + reasons.append("块被解析为追加模式,但混入了上下文/删除行,可能写法不一致") + # 4) 未找到匹配时,提示检查空格/缩进/全角半角/换行差异 + reason_text = (block.get("reason") or "").lower() + if "未找到匹配" in reason_text: + reasons.append("上下文未匹配:检查空格/缩进、全角半角、CRLF/LF、大小写是否与原文完全一致") + # 5) 空行未加 '+' 的典型情形: + # a) 有空白行但整块没有前缀(此前已由 #1 捕获),仍补充提示 + # b) 有空白行且块中存在 + / -,说明空行漏写前缀,易导致上下文匹配失败 + if any(ln == "" or ln.strip() == "" for ln in lines): + if plus == 0 and minus == 0: + reasons.append("空行未写 `+`,被当作上下文,建议空行写成单独一行 `+`") + else: + reasons.append("空行未写 `+`(或空格上下文),混入补丁时会被当作上下文,建议空行单独写成 `+`") + + return ";".join(reasons) + +def _format_delete_file(result_data: Dict[str, Any]) -> str: + if not result_data.get("success"): + return _format_failure("delete_file", result_data) + path = result_data.get("path") or "未知路径" + action = result_data.get("action") or "deleted" + return f"已{action}文件: {path}" + +def _format_rename_file(result_data: Dict[str, Any]) -> str: + if not result_data.get("success"): + return _format_failure("rename_file", result_data) + old_path = result_data.get("old_path") or "旧路径未知" + new_path = result_data.get("new_path") or "新路径未知" + return f"已重命名: {old_path} -> {new_path}" + +def _format_create_folder(result_data: Dict[str, Any]) -> str: + if not result_data.get("success"): + return _format_failure("create_folder", result_data) + return f"已创建文件夹: {result_data.get('path', '未知路径')}" diff --git a/utils/tool_result_formatter/mcp.py b/utils/tool_result_formatter/mcp.py new file mode 100644 index 0000000..b252b0d --- /dev/null +++ b/utils/tool_result_formatter/mcp.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Tuple +from utils.tool_result_formatter.common import ( + _format_failure, _preview_text, _summarize_output_block, _summarize_todo_tasks +) + +def _looks_like_mcp_payload(function_name: str, result_data: Any) -> bool: + if isinstance(function_name, str) and function_name.startswith("mcp__"): + return True + if not isinstance(result_data, dict): + return False + if result_data.get("tool_alias") or result_data.get("server_id"): + return True + content = result_data.get("content") + if isinstance(content, list) and any(isinstance(item, dict) and item.get("type") for item in content): + return True + return False + +def _format_mcp_resource_reference(item: Dict[str, Any]) -> str: + uri = str(item.get("uri") or item.get("url") or "").strip() + title = str(item.get("title") or item.get("name") or "").strip() + if uri and title: + return f"[MCP 资源] {title} -> {uri}" + if uri: + return f"[MCP 资源] {uri}" + if title: + return f"[MCP 资源] {title}" + return "" + +def _sanitize_mcp_content_item(item: Dict[str, Any]) -> Dict[str, Any]: + sanitized = dict(item) + ctype = str(sanitized.get("type") or "").strip().lower() + if ctype in {"image", "audio"}: + data = str(sanitized.pop("data", "") or "") + if data: + sanitized["data_omitted"] = True + sanitized["data_length"] = len(data) + if ctype == "resource": + resource = sanitized.get("resource") + if isinstance(resource, dict) and "blob" in resource: + blob_data = str(resource.get("blob") or "") + resource_copy = dict(resource) + resource_copy.pop("blob", None) + if blob_data: + resource_copy["blob_omitted"] = True + resource_copy["blob_length"] = len(blob_data) + sanitized["resource"] = resource_copy + return sanitized + +def _is_default_mcp_message(message: str) -> bool: + return message.strip() == "MCP 工具调用完成" + +def extract_mcp_content_for_context(result_data: Dict[str, Any]) -> Dict[str, Any]: + """提取 MCP content,返回可读文本 + 可落盘媒体列表 + 去二进制后的 payload。""" + if not isinstance(result_data, dict): + return { + "text": "", + "media_items": [], + "sanitized_payload": result_data, + } + + content_items = result_data.get("content") + if not isinstance(content_items, list): + raw_result = result_data.get("raw_result") + if isinstance(raw_result, dict) and isinstance(raw_result.get("content"), list): + content_items = raw_result.get("content") + else: + content_items = [] + + text_lines: List[str] = [] + media_lines: List[str] = [] + media_items: List[Dict[str, Any]] = [] + sanitized_content: List[Dict[str, Any]] = [] + + for index, item in enumerate(content_items, start=1): + if not isinstance(item, dict): + continue + ctype = str(item.get("type") or "").strip().lower() + if ctype == "text": + text_value = str(item.get("text") or "").strip() + if text_value: + text_lines.append(text_value) + sanitized_content.append(_sanitize_mcp_content_item(item)) + continue + + if ctype in {"image", "audio"}: + kind = "image" if ctype == "image" else "audio" + mime_type = str(item.get("mimeType") or item.get("mime_type") or "").strip().lower() + if not mime_type: + mime_type = "image/png" if kind == "image" else "audio/wav" + data_base64 = str(item.get("data") or "") + if data_base64: + media_items.append( + { + "index": index, + "item_type": ctype, + "kind": kind, + "mime_type": mime_type, + "data_base64": data_base64, + "label": item.get("label") or f"{kind}_{index}", + "name": item.get("name"), + "title": item.get("title"), + "uri": item.get("uri"), + "url": item.get("url"), + } + ) + media_lines.append( + f"[MCP {kind} #{index}] {mime_type}({len(data_base64)} base64 chars)" + ) + else: + media_lines.append(f"[MCP {kind} #{index}] 未包含数据") + sanitized_content.append(_sanitize_mcp_content_item(item)) + continue + + if ctype in {"resource_link", "resourcelink"}: + line = _format_mcp_resource_reference(item) + if line: + text_lines.append(line) + sanitized_content.append(_sanitize_mcp_content_item(item)) + continue + + if ctype == "resource": + resource = item.get("resource") + resource_line = _format_mcp_resource_reference(item) + if isinstance(resource, dict): + resource_line = _format_mcp_resource_reference(resource) or resource_line + resource_text = str(resource.get("text") or "").strip() + if resource_text: + text_lines.append(resource_text) + elif resource_line: + text_lines.append(resource_line) + elif resource_line: + text_lines.append(resource_line) + sanitized_content.append(_sanitize_mcp_content_item(item)) + continue + + fallback_line = _format_mcp_resource_reference(item) + if fallback_line: + text_lines.append(fallback_line) + else: + text_lines.append(f"[MCP content:{ctype or 'unknown'}]") + sanitized_content.append(_sanitize_mcp_content_item(item)) + + message = str(result_data.get("message") or "").strip() + if message and not _is_default_mcp_message(message): + if message not in text_lines: + text_lines.insert(0, message) + + if media_lines: + text_lines.extend(media_lines) + + if not text_lines: + structured = result_data.get("structured_content") + if structured is None: + structured = result_data.get("structuredContent") + if structured is not None: + try: + text_lines.append(json.dumps(structured, ensure_ascii=False)) + except Exception: + text_lines.append(str(structured)) + + if not text_lines and message and not _is_default_mcp_message(message): + text_lines.append(message) + + sanitized_payload: Dict[str, Any] = dict(result_data) + if isinstance(result_data.get("content"), list): + sanitized_payload["content"] = sanitized_content + raw_result = result_data.get("raw_result") + if isinstance(raw_result, dict): + raw_sanitized = dict(raw_result) + raw_content = raw_result.get("content") + if isinstance(raw_content, list): + raw_sanitized["content"] = [ + _sanitize_mcp_content_item(item) if isinstance(item, dict) else item + for item in raw_content + ] + sanitized_payload["raw_result"] = raw_sanitized + + return { + "text": "\n".join(line for line in text_lines if line), + "media_items": media_items, + "sanitized_payload": sanitized_payload, + } diff --git a/utils/tool_result_formatter/terminal.py b/utils/tool_result_formatter/terminal.py new file mode 100644 index 0000000..17a0ee8 --- /dev/null +++ b/utils/tool_result_formatter/terminal.py @@ -0,0 +1,249 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Tuple +from utils.tool_result_formatter.common import ( + _format_failure, _preview_text, _summarize_output_block, _summarize_todo_tasks +) + +def _format_terminal_session(result_data: Dict[str, Any]) -> str: + action = result_data.get("action") or result_data.get("terminal_action") or "未知操作" + tag = f"terminal_session[{action}]" + if not result_data.get("success"): + return _format_failure(tag, result_data) + if action == "open": + return ( + f"终端 {result_data.get('session')} 已打开,工作目录 {result_data.get('working_dir')}," + f"当前活动会话: {result_data.get('session')}(共 {result_data.get('total_sessions')} 个)" + ) + if action == "close": + new_active = result_data.get("new_active") or "无" + remaining = result_data.get("remaining_sessions") or [] + return ( + f"终端 {result_data.get('session')} 已关闭,新的活动会话: {new_active}。" + f"剩余会话: {', '.join(remaining) if remaining else '无'}" + ) + if action == "reset": + session = result_data.get("session") or "未知会话" + working_dir = result_data.get("working_dir") or "未知目录" + return f"终端 {session} 已重置,工作目录 {working_dir}。" + if action == "list": + sessions = result_data.get("sessions") or [] + total = result_data.get("total", len(sessions)) + max_allowed = result_data.get("max_allowed") + active = result_data.get("active") or "无" + header = f"共有 {total}/{max_allowed} 个终端会话,活动会话: {active}" + session_lines = [] + for session in sessions: + name = session.get("session_name") or session.get("name") or "未命名" + state = "运行中" if session.get("is_running") else "已停止" + marker = "⭐" if session.get("is_active") else " " + working_dir = session.get("working_dir") or "未知目录" + session_lines.append(f"{marker} {name} | {state} | {working_dir}") + return "\n".join([header] + session_lines) if session_lines else header + return result_data.get("message") or f"{tag} 操作已完成。" + +def _plain_command_output(result_data: Dict[str, Any]) -> str: + """生成纯文本输出,按需要加状态前缀。""" + output = result_data.get("output") or "" + status = (result_data.get("status") or "").lower() + timeout = result_data.get("timeout") + if timeout is None: + timeout = result_data.get("output_wait") + return_code = result_data.get("return_code") + truncated = result_data.get("truncated") + error = result_data.get("error") + message = result_data.get("message") + + prefixes = [] + partial_output_note = None + partial_no_output_note = None + if status in {"timeout"}: + never_timeout = ( + (isinstance(timeout, str) and timeout.lower() == "never") + or result_data.get("never_timeout") + ) + if never_timeout: + elapsed_ms = result_data.get("elapsed_ms") + secs = None + if isinstance(elapsed_ms, (int, float)) and elapsed_ms > 0: + secs = max(1, int(round(elapsed_ms / 1000))) + if secs: + prefixes.append(f"[partial_output ~{secs}s]") + partial_output_note = f"已返回约{secs}秒内输出,命令可能仍在运行" + partial_no_output_note = f"已等待约{secs}秒未捕获输出,命令可能仍在运行" + else: + prefixes.append("[partial_output]") + partial_output_note = "已返回当前输出,命令可能仍在运行" + partial_no_output_note = "未捕获输出,命令可能仍在运行" + else: + timeout_seconds = None + if isinstance(timeout, (int, float)) and timeout > 0: + timeout_seconds = int(timeout) + elif isinstance(timeout, str): + stripped = timeout.strip() + if stripped.isdigit(): + timeout_seconds = int(stripped) + else: + digits = "".join(ch for ch in stripped if ch.isdigit()) + if digits: + timeout_seconds = int(digits) + + if timeout_seconds and timeout_seconds >= 60: + prefixes.append(f"[partial_output ~{timeout_seconds}s]") + partial_output_note = f"已等待约{timeout_seconds}秒,命令可能仍在运行" + partial_no_output_note = f"已等待约{timeout_seconds}秒未捕获输出,命令可能仍在运行" + elif status in {"killed"}: + prefixes.append("[killed]") + elif status in {"awaiting_input"}: + prefixes.append("[awaiting_input]") + elif status in {"no_output"} and not output: + prefixes.append("[no_output]") + elif status in {"error"} and return_code is not None: + prefixes.append(f"[error rc={return_code}]") + elif status in {"error"}: + prefixes.append("[error]") + + if truncated: + prefixes.append("[truncated]") + + # 如果执行失败且没有输出,优先显示错误信息 + if not result_data.get("success") and not output: + err_text = partial_no_output_note or error or message + if err_text: + prefix_text = "".join(prefixes) if prefixes else "[error]" + return f"{prefix_text} {err_text}" if prefix_text else err_text + # 没有错误文本则仍走后面的输出逻辑(可能显示 no_output) + + prefix_text = "".join(prefixes) + if prefix_text and output: + if partial_output_note: + prefix_text = f"{prefix_text} {partial_output_note}" + return f"{prefix_text}\n{output}" + if prefix_text: + if partial_no_output_note: + prefix_text = f"{prefix_text} {partial_no_output_note}" + return prefix_text + if not output: + return "[no_output]" + return output + +def _format_terminal_input(result_data: Dict[str, Any]) -> str: + text = _plain_command_output(result_data) + timeout_hint = result_data.get("timeout_hint") + if timeout_hint == "suggest_adjust_timeout": + suggestion = "请根据指令和输出结果判断是否需要提高等待输出时长或修改指令输入" + return f"{text}\n{suggestion}" if text else suggestion + if timeout_hint == "suggest_never_timeout": + suggestion = "请考虑设置 timeout 为 never,让终端持续执行该命令(⚠️注意 在该命令彻底执行完成前该终端会被占用,处于不可输入的状态)" + return f"{text}\n{suggestion}" if text else suggestion + return text + +def _format_sleep(result_data: Dict[str, Any]) -> str: + if not result_data.get("success"): + return _format_failure("sleep", result_data) + mode = result_data.get("mode") + if mode == "wait_sub_agent_ids": + results = result_data.get("results") or [] + header = result_data.get("message") or f"已等待 {len(results)} 个子智能体结束" + detail_blocks: List[str] = [] + for item in results: + if not isinstance(item, dict): + continue + # 优先复用子智能体原始 system_message,确保与系统通知内容一致 + sys_msg = item.get("system_message") + if isinstance(sys_msg, str) and sys_msg.strip(): + detail_blocks.append(sys_msg.strip()) + else: + detail_blocks.append(_format_wait_sub_agent(item)) + if detail_blocks: + return "\n\n".join([header] + detail_blocks) + return header + if mode == "wait_runcommand_id": + nested = result_data.get("result") + if isinstance(nested, dict): + output = nested.get("output") or "" + if output: + return f"[后台 run_command 完成]\n{output}" + return "[后台 run_command 完成]\n[no_output]" + return "[后台 run_command 完成]" + reason = result_data.get("reason") + timestamp = result_data.get("timestamp") + message = result_data.get("message") or "等待完成" + parts = [message] + if reason: + parts.append(f"原因:{reason}") + if timestamp: + parts.append(f"时间:{timestamp}") + return ";".join(parts) + +def _format_run_command(result_data: Dict[str, Any]) -> str: + status = str(result_data.get("status") or "").lower() + if result_data.get("background_task_created") is False: + body = _plain_command_output(result_data) + if not body: + body = "[no_output]" + return "[命令在5秒内完成,未创建后台任务]\n" + body + + if status == "running_background": + command_id = result_data.get("command_id") or "-" + message = result_data.get("message") or "后台命令已创建;以下为当前已捕获输出。" + output = result_data.get("output") or "" + lines = [f"[{command_id}]", f"[{message}]"] + if output: + lines.append(output) + else: + lines.append("[no_output]") + return "\n".join(lines) + + text = _plain_command_output(result_data) + if status == "timeout": + suggestion = "建议:在持久终端中直接运行该命令(terminal_session + terminal_input),或缩短命令执行时间。" + text = f"{text}\n{suggestion}" if text else suggestion + return text + +def _format_terminal_snapshot(result_data: Dict[str, Any]) -> str: + if not result_data.get("success"): + return _format_failure("terminal_snapshot", result_data) + session = result_data.get("session") or "default" + requested = result_data.get("line_limit") + requested_note = f"{requested} 行" if requested is not None else "全部" + actual = result_data.get("lines_returned") + actual_note = f"{actual} 行" if actual is not None else "未知行数" + truncated = result_data.get("truncated") + trunc_note = "已截断" if truncated else "未截断" + output = result_data.get("output") or "" + header = f"会话 {session}:请求 {requested_note},实际返回 {actual_note}({trunc_note})。" + body = _summarize_output_block(output, truncated) + return f"{header}\n{body}" + +def _format_command_result(label: str, result_data: Dict[str, Any]) -> str: + command = result_data.get("command") or "" + return_code = result_data.get("return_code") + success = result_data.get("success") + status = result_data.get("status") + output = result_data.get("output") + truncated = result_data.get("truncated") + message = result_data.get("message") + + if success: + header = f"{label}: `{command}`" if command else label + if return_code is not None and return_code != "": + header += f" (return_code={return_code})" + lines = [header] + if status and status not in {"completed", "success"}: + lines.append(f"终端状态: {status}") + if message: + lines.append(message) + lines.append(_summarize_output_block(output, truncated)) + return "\n".join(lines) + + error_msg = result_data.get("error") or message or "执行失败" + header = f"⚠️ {label} 失败" + if command: + header += f"(命令 `{command}`)" + lines = [f"{header}: {error_msg}"] + if return_code not in {None, ""}: + lines.append(f"返回码: {return_code}") + if output: + lines.append(_summarize_output_block(output, truncated)) + return "\n".join(lines) diff --git a/utils/tool_result_formatter/web_media.py b/utils/tool_result_formatter/web_media.py new file mode 100644 index 0000000..e1c855f --- /dev/null +++ b/utils/tool_result_formatter/web_media.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Tuple +from utils.tool_result_formatter.common import ( + _format_failure, _preview_text, _summarize_output_block, _summarize_todo_tasks +) + +def _format_create_skill(result_data: Dict[str, Any]) -> str: + if not result_data.get("success"): + return _format_failure("create_skill", result_data) + lines = [ + f"已归档 skill:{result_data.get('skill_name') or '未命名'}", + ] + return "\n".join(lines) + +def _format_extract_webpage(result_data: Dict[str, Any]) -> str: + if not result_data.get("success"): + return _format_failure("extract_webpage", result_data) + url = result_data.get("url") or "目标网页" + content = result_data.get("content") or "" + length = len(content) + truncated_flag = result_data.get("truncated") or False + header = f"提取完成:{url},长度 {length} 字符。" + if not content: + return f"{header} 内容为空。" + # 为模型保留完整正文,避免 800 字预览导致上下文缺失 + note_parts = [] + if truncated_flag: + note_parts.append("原始内容已被上游截断") + note = f"({';'.join(note_parts)})" if note_parts else "" + return "\n".join([f"{header}{note}", "```", content, "```"]) + +def _format_vlm_analyze(result_data: Dict[str, Any]) -> str: + if not result_data.get("success"): + return _format_failure("vlm_analyze", result_data) + content = result_data.get("content") or "" + length = len(content) + preview, truncated = _preview_text(content, 800) + note = "(截断预览)" if truncated else "(未截断)" + header = f"VLM 解析完成,长度 {length} 字符{note}" + if not content: + return f"{header};未返回可识别文本。" + return "\n".join([header, "```", preview, "```"]) + +def _format_ocr_image(result_data: Dict[str, Any]) -> str: + return _format_vlm_analyze(result_data) + +def _format_trigger_easter_egg(result_data: Dict[str, Any]) -> str: + if not result_data.get("success"): + return _format_failure("trigger_easter_egg", result_data) + effect = (result_data.get("effect") or "").lower() + duration = result_data.get("duration_seconds") or result_data.get("duration") + if effect == "flood": + return "大水即将淹没屏幕!预计持续 45 秒,不过这期间你和用户还可以继续对话。" + if effect == "snake": + dur_text = f"{duration} 秒" if duration else "约 200 秒" + return f"发光贪吃蛇来访,约 {dur_text} 后或吃满 20 个苹果离场;动画不挡操作。" + message = result_data.get("message") + if message: + return message + return f"已触发彩蛋:{effect or '未知效果'}。" + +def _format_manage_personalization(result_data: Dict[str, Any]) -> str: + action = result_data.get("action") or "read" + if not result_data.get("success"): + validation_errors = result_data.get("validation_errors") or [] + base = _format_failure("manage_personalization", result_data) + if validation_errors: + return base + "\n校验失败: " + ";".join(str(item) for item in validation_errors) + return base + + if action == "update" or result_data.get("updated_field"): + field = result_data.get("updated_field") or "未知字段" + old_value = result_data.get("old_value") + new_value = result_data.get("updated_value") + parts = [f"个性化字段已更新: {field}"] + if old_value is not None: + parts.append(f"旧值: {old_value}") + if new_value is not None: + parts.append(f"新值: {new_value}") + if result_data.get("theme_changed") and result_data.get("new_theme"): + parts.append(f"主题已切换为: {result_data.get('new_theme')}") + message = result_data.get("message") + if message: + parts.append(str(message)) + return "\n".join(parts) + + config = result_data.get("data") or {} + if not isinstance(config, dict): + return result_data.get("message") or "个性化配置读取成功" + + lines = ["当前个性化配置:"] + for key, value in config.items(): + lines.append(f"- {key}: {value}") + message = result_data.get("message") + if message: + lines.append(str(message)) + return "\n".join(lines)