Compare commits

...

2 Commits

Author SHA1 Message Date
2bfbddb0dd fix(server/tasks): 修复 skill 创建任务失败并补充调试日志
- 补全从 api.py 拆到 skills.py 时遗漏的 _build_skill_context_messages 导入
- 捕获 skill 文件读取时的 UnicodeDecodeError/OSError,转为 400 错误
- 在 skill 路径解析与读取流程中增加详细 debug_log
2026-06-21 01:45:45 +08:00
8e1a339102 refactor: split api_client, tool_result_formatter, tools_definition into sub-packages
- utils/api_client.py -> utils/api_client/ (mixin-based)
- utils/tool_result_formatter.py -> utils/tool_result_formatter/ (by tool category)
- core/main_terminal_parts/tools_definition.py -> core/main_terminal_parts/tools_definition/ (by tool group)
- Update AGENTS.md, CLAUDE.md, and split memory index
- Keep original files as compatibility re-exports
- Includes TerminalPanel display adjustment and ToolAction.vue diff line fix
2026-06-20 21:51:45 +08:00
38 changed files with 5061 additions and 3737 deletions

View File

@ -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中优先级

View File

@ -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` 文件。
- Python4 空格、`snake_case` 函数、`PascalCase` 类,新增公共函数尽量补 type hints。
- Vue/TS保持现有代码风格不做无关风格清洗。
- CLI React/TS保留现有 Ink 渲染方式与光标修正逻辑,不要轻易重写输入框定位策略。

View File

@ -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/**

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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"]
}
}
},
]

View File

@ -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

View File

@ -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_identifyAI自称最多20字、user_nameAI如何称呼用户最多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"]
}
}
}
]

View File

@ -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"]
}
}
},
]

View File

@ -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

View File

@ -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/<name>/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_allreplace_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"]
}
}
},
]

View File

@ -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)

View File

@ -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"]
}
}
},
]

View File

@ -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"]
}
}
},
]

View File

@ -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"]
}
}
},
]

View File

@ -6,7 +6,7 @@ import json
import time
import threading
import uuid
import re
import traceback
from collections import deque
from pathlib import Path
from typing import Dict, Any, Optional, List
@ -23,14 +23,11 @@ from utils.host_workspace_debug import write_host_workspace_debug
from config import DATA_DIR, WORKSPACE_SKILLS_DIRNAME
from modules.goal_state_manager import GoalStateManager, REASON_USER_CANCEL
from server.tasks import task_manager
from server.tasks.skills import _build_skill_context_messages
from server.tasks.helpers import _task_public_payload
from server.tasks.media import _normalize_media_payload
SKILL_FRONTMATTER_RE = re.compile(r"^---\s*\n(?P<body>.*?)\n---\s*\n?", re.S)
SKILL_FIELD_RE = re.compile(r"^(?P<key>name|description)\s*:\s*(?P<value>.*)$")
@tasks_bp.route("/api/tasks", methods=["GET"])
@api_login_required
@ -87,14 +84,27 @@ def create_task_api():
raw_skill_refs = payload.get("skill_refs")
if raw_skill_refs:
try:
debug_log(
f"[SkillsAPI] create_task skill_refs type={type(raw_skill_refs).__name__} "
f"count={len(raw_skill_refs) if isinstance(raw_skill_refs, list) else 'N/A'} "
f"refs={raw_skill_refs!r}"
)
_terminal, workspace = get_user_resources(username, workspace_id)
if not workspace:
debug_log(f"[SkillsAPI] create_task workspace unavailable user={username} ws={workspace_id}")
return jsonify({"success": False, "error": "工作区不可用"}), 400
debug_log(
f"[SkillsAPI] create_task workspace OK project_path={getattr(workspace, 'project_path', None)!r} "
f"data_dir={getattr(workspace, 'data_dir', None)!r}"
)
skill_context_messages = _build_skill_context_messages(workspace, raw_skill_refs)
debug_log(f"[SkillsAPI] create_task built {len(skill_context_messages)} skill context messages")
except ValueError as exc:
debug_log(f"[SkillsAPI] create_task ValueError: {exc}")
return jsonify({"success": False, "error": str(exc)}), 400
except Exception as exc:
debug_log(f"[SkillsAPI] 读取技能上下文失败: {exc}")
debug_log(f"[SkillsAPI] 读取技能上下文失败 traceback:\n{traceback.format_exc()}")
return jsonify({"success": False, "error": "读取 skill 失败"}), 500
try:
debug_log(

View File

@ -1,27 +1,17 @@
"""简单任务 API将聊天任务与 WebSocket 解耦,支持后台运行与轮询"""
"""Workspace skill 读取与 /api/skills 接口"""
from __future__ import annotations
from server.tasks import tasks_bp
import mimetypes
import json
import time
import threading
import uuid
import re
from collections import deque
from pathlib import Path
from typing import Dict, Any, Optional, List
from typing import Dict, Any, List
from flask import Blueprint, request, jsonify
from flask import current_app, session
from flask import request, jsonify
from flask import session
from server.auth_helpers import api_login_required, get_current_username
from server.context import get_user_resources, ensure_conversation_loaded
from server.chat_flow import run_chat_task_sync
from server.state import stop_flags
from server.utils_common import debug_log, log_conn_diag
from utils.host_workspace_debug import write_host_workspace_debug
from config import DATA_DIR, WORKSPACE_SKILLS_DIRNAME
from modules.goal_state_manager import GoalStateManager, REASON_USER_CANCEL
from server.context import get_user_resources
from server.utils_common import debug_log
from config import WORKSPACE_SKILLS_DIRNAME
SKILL_FRONTMATTER_RE = re.compile(r"^---\s*\n(?P<body>.*?)\n---\s*\n?", re.S)
@ -48,18 +38,30 @@ def _workspace_skills_dir(workspace) -> Path:
return (Path(workspace.project_path).expanduser().resolve() / WORKSPACE_SKILLS_DIRNAME).resolve()
def _resolve_workspace_skill_path(workspace, raw_path: str) -> Path:
skills_dir = _workspace_skills_dir(workspace)
target = Path(str(raw_path or "")).expanduser()
if not target.is_absolute():
target = Path(workspace.project_path).expanduser().resolve() / target
target = target.resolve()
try:
skills_dir = _workspace_skills_dir(workspace)
except Exception as exc:
debug_log(f"[SkillsAPI] resolve skill cannot compute skills_dir: {exc}")
raise ValueError(f"无法定位工作区 skills 目录: {exc}") from exc
debug_log(f"[SkillsAPI] resolve skill skills_dir={skills_dir!r} raw_path={raw_path!r}")
try:
target = Path(str(raw_path or "")).expanduser()
if not target.is_absolute():
target = Path(workspace.project_path).expanduser().resolve() / target
target = target.resolve()
except Exception as exc:
debug_log(f"[SkillsAPI] resolve skill cannot resolve path: {exc}")
raise ValueError(f"skill 路径解析失败: {exc}") from exc
debug_log(f"[SkillsAPI] resolve skill resolved_target={target!r} name={target.name!r}")
if target.name != "SKILL.md":
raise ValueError("skill 路径必须指向 SKILL.md")
raise ValueError(f"skill 路径必须指向 SKILL.md: {target.name}")
try:
target.relative_to(skills_dir)
except ValueError as exc:
debug_log(f"[SkillsAPI] resolve skill path outside skills_dir: target={target!r} skills_dir={skills_dir!r}")
raise ValueError("skill 路径必须位于当前工作区 .agents/skills/ 内") from exc
if not target.is_file():
debug_log(f"[SkillsAPI] resolve skill file not found: {target!r}")
raise ValueError("skill 文件不存在")
return target
@ -83,21 +85,30 @@ def _list_workspace_skills(workspace) -> List[Dict[str, str]]:
def _build_skill_context_messages(workspace, raw_refs: Any) -> List[Dict[str, str]]:
if not isinstance(raw_refs, list):
debug_log(f"[SkillsAPI] build_skill_context raw_refs is not list: {type(raw_refs).__name__}")
return []
messages: List[Dict[str, str]] = []
seen_paths = set()
for item in raw_refs[:10]:
for idx, item in enumerate(raw_refs[:10]):
if not isinstance(item, dict):
debug_log(f"[SkillsAPI] build_skill_context item[{idx}] not dict: {item!r}")
continue
raw_path = str(item.get("path") or "").strip()
debug_log(f"[SkillsAPI] build_skill_context item[{idx}] raw_path={raw_path!r} name={item.get('name')!r}")
if not raw_path:
continue
skill_file = _resolve_workspace_skill_path(workspace, raw_path)
path_key = str(skill_file)
if path_key in seen_paths:
debug_log(f"[SkillsAPI] build_skill_context duplicate path skipped: {path_key}")
continue
seen_paths.add(path_key)
content = skill_file.read_text(encoding="utf-8")
try:
content = skill_file.read_text(encoding="utf-8")
except UnicodeDecodeError as exc:
raise ValueError(f"skill 文件编码错误,无法读取: {skill_file.name}") from exc
except OSError as exc:
raise ValueError(f"读取 skill 文件失败: {exc}") from exc
metadata = _parse_skill_metadata(content, skill_file.parent.name)
messages.append({
"name": metadata.get("name") or skill_file.parent.name,

View File

@ -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<string, number> = { ' ': 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<number>();
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;

View File

@ -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;
}
//

File diff suppressed because it is too large Load Diff

View File

@ -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']

View File

@ -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/<mode>/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)

View File

@ -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

View File

@ -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)

View File

@ -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]:
"""
将本次请求的payloadheaders配置落盘便于排查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}")

View File

@ -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)

View File

@ -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"]
}

View File

@ -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 ""

84
utils/api_client/utils.py Normal file
View File

@ -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"}

File diff suppressed because it is too large Load Diff

View File

@ -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']

View File

@ -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}"

View File

@ -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)

View File

@ -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,
}

View File

@ -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', '未知路径')}"

View File

@ -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,
}

View File

@ -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)

View File

@ -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)