1456 lines
77 KiB
Python
1456 lines
77 KiB
Python
import asyncio
|
||
import json
|
||
import time
|
||
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,
|
||
READONLY_RUN_COMMAND_ALLOWED,
|
||
READONLY_RUN_COMMAND_ALLOWED_GIT_SUBCOMMANDS,
|
||
READONLY_RUN_COMMAND_BLOCKED_TOKENS,
|
||
)
|
||
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,
|
||
READONLY_RUN_COMMAND_ALLOWED,
|
||
READONLY_RUN_COMMAND_ALLOWED_GIT_SUBCOMMANDS,
|
||
READONLY_RUN_COMMAND_BLOCKED_TOKENS,
|
||
)
|
||
|
||
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_manager 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,
|
||
save_personalization_config,
|
||
build_personalization_prompt,
|
||
MAX_SHORT_FIELD_LENGTH,
|
||
MAX_CONSIDERATION_LENGTH,
|
||
MAX_CONSIDERATION_ITEMS,
|
||
ALLOWED_THEMES,
|
||
)
|
||
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
|
||
|
||
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,
|
||
)
|
||
|
||
logger = setup_logger(__name__)
|
||
DISABLE_LENGTH_CHECK = True
|
||
|
||
class MainTerminalToolsExecutionMixin:
|
||
_TERMINAL_SERIES_TOOLS = {
|
||
"terminal_session",
|
||
"terminal_input",
|
||
"terminal_snapshot",
|
||
}
|
||
_SUB_AGENT_SERIES_TOOLS = {
|
||
"create_sub_agent",
|
||
"wait_sub_agent",
|
||
"close_sub_agent",
|
||
"terminate_sub_agent",
|
||
"get_sub_agent_status",
|
||
}
|
||
_READONLY_ALLOWED_TOOLS = {
|
||
"web_search",
|
||
"extract_webpage",
|
||
"read_file",
|
||
"view_image",
|
||
"view_video",
|
||
"vlm_analyze",
|
||
"ocr_image",
|
||
"update_memory",
|
||
"todo_create",
|
||
"todo_update_task",
|
||
"todo_get",
|
||
"sleep",
|
||
}
|
||
_APPROVAL_REQUIRED_TOOLS = {
|
||
"run_command",
|
||
"run_python",
|
||
"terminal_input",
|
||
"write_file",
|
||
"edit_file",
|
||
"create_file",
|
||
"create_folder",
|
||
"delete_file",
|
||
"rename_file",
|
||
"save_webpage",
|
||
"terminal_session",
|
||
}
|
||
|
||
def _record_sub_agent_message(self, message: Optional[str], task_id: Optional[str] = None, inline: bool = False):
|
||
"""以 system 消息记录子智能体状态。"""
|
||
if not message:
|
||
return
|
||
if task_id and task_id in self._announced_sub_agent_tasks:
|
||
return
|
||
if task_id:
|
||
self._announced_sub_agent_tasks.add(task_id)
|
||
logger.info(
|
||
"[SubAgent] record message | task=%s | inline=%s | content=%s",
|
||
task_id,
|
||
inline,
|
||
message.replace("\n", "\\n")[:200],
|
||
)
|
||
metadata = {"sub_agent_notice": True, "inline": inline, "is_auto_generated": True, "auto_message_type": "sub_agent_notice"}
|
||
if task_id:
|
||
metadata["task_id"] = task_id
|
||
self.context_manager.add_conversation("system", message, metadata=metadata)
|
||
print(f"{OUTPUT_FORMATS['info']} {message}")
|
||
|
||
# 扩展的只读命令白名单(包含常用管道命令)
|
||
_READONLY_ALLOWED_EXECUTABLES = {
|
||
"grep", "find", "ls", "pwd", "tree", "cat", "head", "tail",
|
||
"less", "rg", "wc", "du", "stat", "file", "sed", "awk", "git",
|
||
"sort", "uniq", "cut", "tr", "echo", "printf", "date",
|
||
"basename", "dirname", "which", "strings", "ps", "who", "id",
|
||
}
|
||
|
||
# 管道中绝对禁止的目标命令(宁可错杀)
|
||
_DANGEROUS_PIPE_TARGETS = {
|
||
"sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh",
|
||
"rm", "mv", "cp", "dd", "rmdir", "unlink", "mkdir", "touch",
|
||
"chmod", "chown", "chgrp", "chattr", "setfacl",
|
||
"tee", "sponge", "xargs", "parallel",
|
||
"exec", "source", ".", "eval",
|
||
}
|
||
|
||
def _is_readonly_run_command_allowed(self, command: Any) -> bool:
|
||
cmd = str(command or "").strip()
|
||
if not cmd:
|
||
return False
|
||
lowered = cmd.lower()
|
||
|
||
# 1. 绝对禁止的token(子shell、逻辑运算符、重定向)
|
||
# 管道符 | 单独处理,不在此禁止
|
||
absolute_forbidden = ("&&", "||", ";", ">", "<", "$(", "`", "&",)
|
||
if any(token in cmd for token in absolute_forbidden):
|
||
return False
|
||
|
||
# 2. 如果包含管道,使用管道专用检查
|
||
if "|" in cmd:
|
||
return self._is_readonly_pipeline_allowed(cmd)
|
||
|
||
# 3. 普通单命令检查
|
||
return self._is_readonly_single_command_allowed(cmd)
|
||
|
||
def _is_readonly_pipeline_allowed(self, command: str) -> bool:
|
||
"""检查管道链中每个命令是否都是只读的"""
|
||
# 分割管道段(保留空字符串检查)
|
||
segments = [seg.strip() for seg in command.split("|") if seg.strip()]
|
||
if not segments:
|
||
return False
|
||
|
||
for segment in segments:
|
||
# 段内禁止子shell(双重检查)
|
||
if "$(" in segment or "`" in segment:
|
||
return False
|
||
|
||
# 提取命令名
|
||
parts = segment.split()
|
||
if not parts:
|
||
return False
|
||
executable = parts[0].lower()
|
||
|
||
# 检查段内是否有输出重定向(管道内也不允许)
|
||
if ">" in segment:
|
||
return False
|
||
|
||
# 检查是否是安全的只读命令
|
||
if not self._is_safe_readonly_executable(executable, segment):
|
||
return False
|
||
|
||
# 检查是否是危险的管道目标
|
||
if self._is_dangerous_pipe_target(segment):
|
||
return False
|
||
|
||
return True
|
||
|
||
def _is_readonly_single_command_allowed(self, command: str) -> bool:
|
||
"""检查单个命令是否是只读的"""
|
||
lowered = command.lower()
|
||
parts = command.split()
|
||
if not parts:
|
||
return False
|
||
|
||
executable = parts[0].lower()
|
||
|
||
# 使用基础白名单
|
||
basic_allowed = set(READONLY_RUN_COMMAND_ALLOWED or ())
|
||
if executable in basic_allowed and executable not in {"git", "sed", "awk"}:
|
||
return True
|
||
|
||
# 特殊命令检查
|
||
if executable == "git":
|
||
if len(parts) < 2:
|
||
return False
|
||
git_sub = parts[1].lower()
|
||
return git_sub in set(READONLY_RUN_COMMAND_ALLOWED_GIT_SUBCOMMANDS or ())
|
||
|
||
if executable == "sed":
|
||
return len(parts) >= 2 and parts[1] == "-n"
|
||
|
||
if executable == "awk":
|
||
return "system(" not in lowered
|
||
|
||
return False
|
||
|
||
def _is_safe_readonly_executable(self, executable: str, segment: str) -> bool:
|
||
"""检查单个命令是否在扩展白名单中"""
|
||
if executable not in self._READONLY_ALLOWED_EXECUTABLES:
|
||
return False
|
||
|
||
# 特殊命令的安全检查
|
||
lowered = segment.lower()
|
||
|
||
if executable == "sed":
|
||
return "-n" in segment
|
||
|
||
if executable == "awk":
|
||
return "system(" not in lowered
|
||
|
||
if executable == "git":
|
||
parts = segment.split()
|
||
if len(parts) < 2:
|
||
return False
|
||
git_sub = parts[1].lower()
|
||
return git_sub in set(READONLY_RUN_COMMAND_ALLOWED_GIT_SUBCOMMANDS or ())
|
||
|
||
# 检查其他命令是否有危险的参数组合
|
||
# 例如:sort -o(输出到文件)是危险的
|
||
if executable == "sort":
|
||
return "-o" not in segment and "--output" not in lowered
|
||
|
||
return True
|
||
|
||
def _is_dangerous_pipe_target(self, segment: str) -> bool:
|
||
"""检查管道目标是否会执行写入/修改/危险操作"""
|
||
lowered = segment.lower()
|
||
parts = lowered.split()
|
||
if not parts:
|
||
return False
|
||
cmd = parts[0]
|
||
|
||
# 检查是否在危险命令列表中
|
||
if cmd in self._DANGEROUS_PIPE_TARGETS:
|
||
return True
|
||
|
||
# 检查是否有危险的参数(如 -exec, -delete 等)
|
||
dangerous_args = ("-exec", "-delete", "-ok", "-execdir", "-okdir")
|
||
if any(arg in lowered for arg in dangerous_args):
|
||
return True
|
||
|
||
return False
|
||
|
||
def evaluate_tool_permission(self, tool_name: str, arguments: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||
mode = "unrestricted"
|
||
try:
|
||
mode = self.get_permission_mode()
|
||
except Exception:
|
||
mode = str(getattr(self, "current_permission_mode", "unrestricted") or "unrestricted")
|
||
args = arguments or {}
|
||
|
||
if mode == "readonly":
|
||
if tool_name == "run_command":
|
||
if self._is_readonly_run_command_allowed(args.get("command")):
|
||
return {"allowed": True, "mode": mode}
|
||
return {
|
||
"allowed": False,
|
||
"mode": mode,
|
||
"code": "readonly_denied",
|
||
"message": (
|
||
"当前处于只读模式:run_command 仅允许只读命令"
|
||
"(grep/find/ls/cat/rg/git status 等)且禁止多指令拼接。"
|
||
)
|
||
}
|
||
if tool_name not in self._READONLY_ALLOWED_TOOLS:
|
||
return {
|
||
"allowed": False,
|
||
"mode": mode,
|
||
"code": "readonly_denied",
|
||
"message": "当前处于只读模式,已拒绝会修改工作区或执行高风险操作的工具调用。"
|
||
}
|
||
return {"allowed": True, "mode": mode}
|
||
|
||
if mode == "approval":
|
||
if tool_name in {"run_command", "terminal_input"}:
|
||
command_text = args.get("command") or args.get("input")
|
||
if self._is_readonly_run_command_allowed(command_text):
|
||
return {"allowed": True, "mode": mode, "requires_approval": False}
|
||
if tool_name in self._APPROVAL_REQUIRED_TOOLS:
|
||
return {"allowed": True, "mode": mode, "requires_approval": True}
|
||
return {"allowed": True, "mode": mode}
|
||
|
||
return {"allowed": True, "mode": mode}
|
||
|
||
@staticmethod
|
||
def _skill_meta_key(skill_id: str) -> str:
|
||
return f"skill_read::{skill_id}"
|
||
|
||
@staticmethod
|
||
def _file_read_meta_key() -> str:
|
||
return "read_file::visited_paths"
|
||
|
||
def _normalize_tool_path(self, path: Any) -> str:
|
||
raw = str(path or "").strip().replace("\\", "/")
|
||
if not raw:
|
||
return ""
|
||
if raw.startswith("/workspace/"):
|
||
raw = raw.split("/workspace/", 1)[1]
|
||
try:
|
||
raw_path = Path(raw)
|
||
if raw_path.is_absolute():
|
||
abs_path = raw_path.expanduser().resolve()
|
||
else:
|
||
abs_path = (Path(self.context_manager.project_path).resolve() / raw_path).resolve()
|
||
project_root = Path(self.context_manager.project_path).resolve()
|
||
try:
|
||
rel = abs_path.relative_to(project_root)
|
||
return str(rel).replace("\\", "/").lstrip("./").lower()
|
||
except Exception:
|
||
return str(abs_path).replace("\\", "/").lower()
|
||
except Exception:
|
||
return raw.lstrip("./").lower()
|
||
|
||
def _mark_skill_read_from_result(self, tool_name: str, arguments: Dict[str, Any], result: Dict[str, Any]) -> None:
|
||
if tool_name != "read_file":
|
||
return
|
||
if not isinstance(result, dict) or not result.get("success"):
|
||
return
|
||
normalized = self._normalize_tool_path(result.get("path") or arguments.get("path"))
|
||
if not normalized:
|
||
return
|
||
if normalized.endswith("skills/terminal-guide/skill.md"):
|
||
self.context_manager._set_meta_flag(self._skill_meta_key("terminal-guide"), True)
|
||
if normalized.endswith("skills/sub-agent-guide/skill.md"):
|
||
self.context_manager._set_meta_flag(self._skill_meta_key("sub-agent-guide"), True)
|
||
if normalized.endswith("skills/run-command-guide/skill.md"):
|
||
self.context_manager._set_meta_flag(self._skill_meta_key("run-command-guide"), True)
|
||
|
||
def _get_visited_read_files(self) -> Set[str]:
|
||
raw = self.context_manager._get_meta_flag(self._file_read_meta_key(), [])
|
||
if isinstance(raw, set):
|
||
items = raw
|
||
elif isinstance(raw, list):
|
||
items = set(raw)
|
||
elif isinstance(raw, tuple):
|
||
items = set(raw)
|
||
else:
|
||
items = set()
|
||
|
||
visited: Set[str] = set()
|
||
for item in items:
|
||
normalized = self._normalize_tool_path(item)
|
||
if normalized:
|
||
visited.add(normalized)
|
||
return visited
|
||
|
||
def _mark_file_read_from_result(self, tool_name: str, arguments: Dict[str, Any], result: Dict[str, Any]) -> None:
|
||
if tool_name != "read_file":
|
||
return
|
||
if not isinstance(result, dict) or not result.get("success"):
|
||
return
|
||
normalized = self._normalize_tool_path(result.get("path") or arguments.get("path"))
|
||
if not normalized:
|
||
return
|
||
visited = self._get_visited_read_files()
|
||
if normalized in visited:
|
||
return
|
||
visited.add(normalized)
|
||
self.context_manager._set_meta_flag(
|
||
self._file_read_meta_key(),
|
||
sorted(visited),
|
||
)
|
||
|
||
def _has_read_file_in_conversation(self, path: Any) -> bool:
|
||
normalized = self._normalize_tool_path(path)
|
||
if not normalized:
|
||
return False
|
||
return normalized in self._get_visited_read_files()
|
||
|
||
def _mark_file_as_read_visited(self, path: Any) -> None:
|
||
normalized = self._normalize_tool_path(path)
|
||
if not normalized:
|
||
return
|
||
visited = self._get_visited_read_files()
|
||
if normalized in visited:
|
||
return
|
||
visited.add(normalized)
|
||
self.context_manager._set_meta_flag(
|
||
self._file_read_meta_key(),
|
||
sorted(visited),
|
||
)
|
||
|
||
def _target_file_exists(self, path: Any) -> bool:
|
||
try:
|
||
valid, _, full_path = self.file_manager._validate_path(str(path or ""))
|
||
if not valid or full_path is None:
|
||
return False
|
||
return full_path.exists() and full_path.is_file()
|
||
except Exception:
|
||
return False
|
||
|
||
def _check_read_before_edit_prerequisite(self, tool_name: str, arguments: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||
target_path = arguments.get("file_path")
|
||
if not target_path:
|
||
return None
|
||
|
||
if tool_name == "write_file":
|
||
append_mode = bool(arguments.get("append", False))
|
||
if append_mode:
|
||
return None
|
||
# 覆盖模式仅对“已存在文件”执行先读后写约束;
|
||
# 新文件创建仍允许直接 write_file。
|
||
if not self._target_file_exists(target_path):
|
||
return None
|
||
|
||
if self._has_read_file_in_conversation(target_path):
|
||
return None
|
||
|
||
return {
|
||
"success": False,
|
||
"error": f"调用 {tool_name} 前必须先使用 read_file 阅读目标文件",
|
||
"message": (
|
||
f"文件 {target_path} 尚未在当前对话中通过 read_file 阅读。"
|
||
"请先使用 read_file(read/search/extract 任一模式)读取后再编辑。"
|
||
),
|
||
"required_tool": "read_file",
|
||
"required_path": str(target_path),
|
||
"enforcement": "read_before_edit_required",
|
||
}
|
||
|
||
def _is_skill_enabled_for_enforcement(self, skill_id: str) -> bool:
|
||
enabled_skill_ids = getattr(self, "enabled_skill_ids", None)
|
||
if isinstance(enabled_skill_ids, set):
|
||
return skill_id in enabled_skill_ids
|
||
if isinstance(enabled_skill_ids, (list, tuple)):
|
||
return skill_id in set(enabled_skill_ids)
|
||
try:
|
||
config = load_personalization_config(self.data_dir)
|
||
catalog = get_skills_catalog()
|
||
enabled = merge_enabled_skills(
|
||
config.get("enabled_skills") if isinstance(config, dict) else None,
|
||
catalog,
|
||
config.get("skills_catalog_snapshot") if isinstance(config, dict) else None,
|
||
)
|
||
return skill_id in set(enabled or [])
|
||
except Exception:
|
||
return False
|
||
|
||
def _required_skill_for_tool(self, tool_name: str, arguments: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
||
if tool_name in self._TERMINAL_SERIES_TOOLS and bool(getattr(self, "skill_strict_terminal_enabled", False)):
|
||
return "terminal-guide"
|
||
if tool_name in self._SUB_AGENT_SERIES_TOOLS and bool(getattr(self, "skill_strict_sub_agent_enabled", False)):
|
||
return "sub-agent-guide"
|
||
if (
|
||
tool_name == "run_command"
|
||
):
|
||
run_in_background = bool((arguments or {}).get("run_in_background", False))
|
||
if (
|
||
run_in_background
|
||
and bool(getattr(self, "skill_strict_run_command_background_enabled", False))
|
||
):
|
||
return "run-command-guide"
|
||
if (
|
||
(not run_in_background)
|
||
and bool(getattr(self, "skill_strict_run_command_foreground_enabled", False))
|
||
):
|
||
return "run-command-guide"
|
||
return None
|
||
|
||
def _check_skill_strict_prerequisite(self, tool_name: str, arguments: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
|
||
skill_id = self._required_skill_for_tool(tool_name, arguments)
|
||
if not skill_id:
|
||
return None
|
||
# 仅当该 skill 在个人空间中启用时才执行强约束
|
||
if not self._is_skill_enabled_for_enforcement(skill_id):
|
||
return None
|
||
already_read = bool(
|
||
self.context_manager._get_meta_flag(
|
||
self._skill_meta_key(skill_id),
|
||
False
|
||
)
|
||
)
|
||
if already_read:
|
||
return None
|
||
required_path = f"skills/{skill_id}/SKILL.md"
|
||
return {
|
||
"success": False,
|
||
"error": f"调用 {tool_name} 前必须先阅读 {required_path}",
|
||
"message": f"请先使用 read_file 阅读 {required_path},随后再调用该工具。",
|
||
"required_skill": skill_id,
|
||
"required_path": required_path,
|
||
"enforcement": "skill_read_required"
|
||
}
|
||
|
||
async def handle_tool_call(self, tool_name: str, arguments: Dict) -> str:
|
||
"""处理工具调用(添加参数预检查和改进错误处理)"""
|
||
logger.info("[handle_tool_call] 工具调用开始: tool_name=%s, arguments=%s", tool_name, arguments)
|
||
|
||
# 导入字符限制配置
|
||
from config import (
|
||
MAX_READ_FILE_CHARS,
|
||
MAX_RUN_COMMAND_CHARS, MAX_EXTRACT_WEBPAGE_CHARS
|
||
)
|
||
|
||
# 检查是否需要确认
|
||
if tool_name in NEED_CONFIRMATION:
|
||
if not await self.confirm_action(tool_name, arguments):
|
||
return json.dumps({"success": False, "error": "用户取消操作"})
|
||
|
||
# === 新增:预检查参数大小和格式 ===
|
||
try:
|
||
# 检查参数总大小
|
||
arguments_str = json.dumps(arguments, ensure_ascii=False)
|
||
if len(arguments_str) > 200000: # 200KB限制
|
||
return json.dumps({
|
||
"success": False,
|
||
"error": f"参数过大({len(arguments_str)}字符),超过200KB限制",
|
||
"suggestion": "请分块处理或减少参数内容"
|
||
}, ensure_ascii=False)
|
||
|
||
# 针对特定工具的内容检查
|
||
if tool_name == "write_file":
|
||
content = arguments.get("content", "")
|
||
length_limit = 200000
|
||
if not DISABLE_LENGTH_CHECK and len(content) > length_limit:
|
||
return json.dumps({
|
||
"success": False,
|
||
"error": f"文件内容过长({len(content)}字符),超过{length_limit}字符限制",
|
||
"suggestion": "请分块写入,或设置 append=true 多次写入"
|
||
}, ensure_ascii=False)
|
||
if '\\' in content and content.count('\\') > len(content) / 10:
|
||
print(f"{OUTPUT_FORMATS['warning']} 检测到大量转义字符,可能存在格式问题")
|
||
|
||
except Exception as e:
|
||
return json.dumps({
|
||
"success": False,
|
||
"error": f"参数预检查失败: {str(e)}"
|
||
}, ensure_ascii=False)
|
||
|
||
# 自定义工具预解析(仅管理员)
|
||
custom_tool = None
|
||
if self.custom_tools_enabled and getattr(self, "user_role", "user") == "admin":
|
||
try:
|
||
self.custom_tool_registry.reload()
|
||
except Exception:
|
||
pass
|
||
custom_tool = self.custom_tool_registry.get_tool(tool_name)
|
||
|
||
# Skill 强约束:未阅读对应 SKILL.md 时,阻止 terminal/sub-agent 系列工具执行
|
||
strict_error = self._check_skill_strict_prerequisite(tool_name, arguments)
|
||
if strict_error:
|
||
return json.dumps(strict_error, ensure_ascii=False)
|
||
|
||
try:
|
||
if custom_tool:
|
||
result = await self.custom_tool_executor.run(tool_name, arguments)
|
||
elif tool_name == "read_file":
|
||
result = self._handle_read_tool(arguments)
|
||
self._mark_skill_read_from_result(tool_name, arguments, result)
|
||
self._mark_file_read_from_result(tool_name, arguments, result)
|
||
elif tool_name in {"vlm_analyze", "ocr_image"}:
|
||
path = arguments.get("path")
|
||
prompt = arguments.get("prompt")
|
||
if not path:
|
||
return json.dumps({"success": False, "error": "缺少 path 参数", "warnings": []}, ensure_ascii=False)
|
||
result = self.ocr_client.vlm_analyze(path=path, prompt=prompt or "")
|
||
elif tool_name == "view_image":
|
||
path = (arguments.get("path") or "").strip()
|
||
if not path:
|
||
return json.dumps({"success": False, "error": "path 不能为空"}, ensure_ascii=False)
|
||
host_unrestricted = self._is_host_mode()
|
||
if path.startswith("/workspace"):
|
||
if host_unrestricted:
|
||
path = path.split("/workspace", 1)[1].lstrip("/")
|
||
else:
|
||
return json.dumps({"success": False, "error": "非法路径,超出项目根目录,请使用不带/workspace的相对路径"}, ensure_ascii=False)
|
||
if host_unrestricted and (Path(path).is_absolute() or (len(path) > 1 and path[1] == ":")):
|
||
abs_path = Path(path).expanduser().resolve()
|
||
else:
|
||
abs_path = (Path(self.context_manager.project_path) / path).resolve()
|
||
if not host_unrestricted:
|
||
try:
|
||
abs_path.relative_to(Path(self.context_manager.project_path).resolve())
|
||
except Exception:
|
||
return json.dumps({"success": False, "error": "非法路径,超出项目根目录,请使用不带/workspace的相对路径"}, ensure_ascii=False)
|
||
if not abs_path.exists() or not abs_path.is_file():
|
||
return json.dumps({"success": False, "error": f"图片不存在: {path}"}, ensure_ascii=False)
|
||
if abs_path.stat().st_size > 10 * 1024 * 1024:
|
||
return json.dumps({"success": False, "error": "图片过大,需 <= 10MB"}, ensure_ascii=False)
|
||
allowed_ext = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".svg"}
|
||
if abs_path.suffix.lower() not in allowed_ext:
|
||
return json.dumps({"success": False, "error": f"不支持的图片格式: {abs_path.suffix}"}, ensure_ascii=False)
|
||
# 记录待附加图片,供上层将图片附加到工具结果
|
||
self.pending_image_view = {
|
||
"path": str(path)
|
||
}
|
||
result = {"success": True, "message": "图片已附加到工具结果中,将随 tool 返回。", "path": path}
|
||
elif tool_name == "view_video":
|
||
path = (arguments.get("path") or "").strip()
|
||
if not path:
|
||
return json.dumps({"success": False, "error": "path 不能为空"}, ensure_ascii=False)
|
||
host_unrestricted = self._is_host_mode()
|
||
if path.startswith("/workspace"):
|
||
if host_unrestricted:
|
||
path = path.split("/workspace", 1)[1].lstrip("/")
|
||
else:
|
||
return json.dumps({"success": False, "error": "非法路径,超出项目根目录,请使用相对路径"}, ensure_ascii=False)
|
||
if host_unrestricted and (Path(path).is_absolute() or (len(path) > 1 and path[1] == ":")):
|
||
abs_path = Path(path).expanduser().resolve()
|
||
else:
|
||
abs_path = (Path(self.context_manager.project_path) / path).resolve()
|
||
if not host_unrestricted:
|
||
try:
|
||
abs_path.relative_to(Path(self.context_manager.project_path).resolve())
|
||
except Exception:
|
||
return json.dumps({"success": False, "error": "非法路径,超出项目根目录,请使用相对路径"}, ensure_ascii=False)
|
||
if not abs_path.exists() or not abs_path.is_file():
|
||
return json.dumps({"success": False, "error": f"视频不存在: {path}"}, ensure_ascii=False)
|
||
allowed_ext = {".mp4", ".mov", ".mkv", ".avi", ".webm"}
|
||
if abs_path.suffix.lower() not in allowed_ext:
|
||
return json.dumps({"success": False, "error": f"不支持的视频格式: {abs_path.suffix}"}, ensure_ascii=False)
|
||
if abs_path.stat().st_size > 50 * 1024 * 1024:
|
||
return json.dumps({"success": False, "error": "视频过大,需 <= 50MB"}, ensure_ascii=False)
|
||
self.pending_video_view = {"path": str(path)}
|
||
result = {
|
||
"success": True,
|
||
"message": "视频已附加到工具结果中,将随 tool 返回。",
|
||
"path": path
|
||
}
|
||
|
||
# 终端会话管理工具
|
||
elif tool_name == "terminal_session":
|
||
action = arguments["action"]
|
||
|
||
if action == "open":
|
||
result = self.terminal_manager.open_terminal(
|
||
session_name=arguments.get("session_name", "default"),
|
||
working_dir=arguments.get("working_dir"),
|
||
make_active=True
|
||
)
|
||
if result["success"]:
|
||
print(f"{OUTPUT_FORMATS['session']} 终端会话已打开: {arguments.get('session_name', 'default')}")
|
||
|
||
elif action == "close":
|
||
result = self.terminal_manager.close_terminal(
|
||
session_name=arguments.get("session_name", "default")
|
||
)
|
||
if result["success"]:
|
||
print(f"{OUTPUT_FORMATS['session']} 终端会话已关闭: {arguments.get('session_name', 'default')}")
|
||
|
||
elif action == "list":
|
||
result = self.terminal_manager.list_terminals()
|
||
|
||
elif action == "reset":
|
||
result = self.terminal_manager.reset_terminal(
|
||
session_name=arguments.get("session_name")
|
||
)
|
||
if result["success"]:
|
||
print(f"{OUTPUT_FORMATS['session']} 终端会话已重置: {result['session']}")
|
||
|
||
else:
|
||
result = {"success": False, "error": f"未知操作: {action}"}
|
||
result["action"] = action
|
||
|
||
# 终端输入工具
|
||
elif tool_name == "terminal_input":
|
||
output_wait = arguments.get("output_wait")
|
||
if output_wait is None:
|
||
output_wait = arguments.get("timeout")
|
||
result = self.terminal_manager.send_to_terminal(
|
||
command=arguments["command"],
|
||
session_name=arguments.get("session_name"),
|
||
output_wait=output_wait
|
||
)
|
||
if result["success"]:
|
||
print(f"{OUTPUT_FORMATS['terminal']} 执行命令: {arguments['command']}")
|
||
|
||
elif tool_name == "terminal_snapshot":
|
||
result = self.terminal_manager.get_terminal_snapshot(
|
||
session_name=arguments.get("session_name"),
|
||
lines=arguments.get("lines"),
|
||
max_chars=arguments.get("max_chars")
|
||
)
|
||
|
||
# sleep工具
|
||
elif tool_name == "sleep":
|
||
seconds = arguments.get("seconds")
|
||
wait_sub_agent_ids = arguments.get("wait_sub_agent_ids")
|
||
wait_runcommand_id = arguments.get("wait_runcommand_id")
|
||
reason = arguments.get("reason", "等待操作完成")
|
||
|
||
provided = 0
|
||
if seconds is not None:
|
||
provided += 1
|
||
if wait_sub_agent_ids:
|
||
provided += 1
|
||
if wait_runcommand_id:
|
||
provided += 1
|
||
if provided == 0:
|
||
result = {
|
||
"success": False,
|
||
"error": "sleep 至少需要提供一个参数:seconds / wait_sub_agent_ids / wait_runcommand_id"
|
||
}
|
||
elif provided > 1:
|
||
result = {
|
||
"success": False,
|
||
"error": "sleep 的等待参数互斥:seconds / wait_sub_agent_ids / wait_runcommand_id 只能提供一个"
|
||
}
|
||
elif wait_sub_agent_ids:
|
||
if not isinstance(wait_sub_agent_ids, list) or not wait_sub_agent_ids:
|
||
result = {"success": False, "error": "wait_sub_agent_ids 必须是非空数组"}
|
||
else:
|
||
normalized_ids = []
|
||
invalid = []
|
||
for item in wait_sub_agent_ids:
|
||
try:
|
||
agent_id = int(item)
|
||
if agent_id <= 0:
|
||
raise ValueError()
|
||
normalized_ids.append(agent_id)
|
||
except Exception:
|
||
invalid.append(item)
|
||
if invalid:
|
||
result = {"success": False, "error": f"wait_sub_agent_ids 含非法值: {invalid}"}
|
||
else:
|
||
manager = getattr(self, "sub_agent_manager", None)
|
||
if not manager:
|
||
result = {"success": False, "error": "子智能体管理器不可用"}
|
||
else:
|
||
task_ids = []
|
||
missing = []
|
||
for aid in normalized_ids:
|
||
task = manager.lookup_task(agent_id=aid)
|
||
if not task:
|
||
missing.append(aid)
|
||
else:
|
||
task_ids.append(task.get("task_id"))
|
||
if missing:
|
||
result = {"success": False, "error": f"未找到对应子智能体: {missing}"}
|
||
else:
|
||
wait_results = []
|
||
waited_task_ids = []
|
||
for tid in task_ids:
|
||
wait_result = await asyncio.to_thread(
|
||
manager.wait_for_completion,
|
||
task_id=tid,
|
||
timeout_seconds=None,
|
||
)
|
||
wait_results.append(wait_result)
|
||
waited_task_ids.append(tid)
|
||
try:
|
||
task = manager.tasks.get(tid)
|
||
if isinstance(task, dict):
|
||
task["notified"] = True
|
||
task["updated_at"] = time.time()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
manager._save_state()
|
||
except Exception:
|
||
pass
|
||
result = {
|
||
"success": True,
|
||
"mode": "wait_sub_agent_ids",
|
||
"agent_ids": normalized_ids,
|
||
"waited_task_ids": waited_task_ids,
|
||
"results": wait_results,
|
||
"message": f"已等待 {len(normalized_ids)} 个子智能体结束"
|
||
}
|
||
try:
|
||
if not hasattr(self, "_announced_sub_agent_tasks"):
|
||
self._announced_sub_agent_tasks = set()
|
||
for tid in waited_task_ids:
|
||
if tid:
|
||
self._announced_sub_agent_tasks.add(tid)
|
||
except Exception:
|
||
pass
|
||
elif wait_runcommand_id:
|
||
bg_manager = getattr(self, "background_command_manager", None)
|
||
if not bg_manager:
|
||
result = {"success": False, "error": "后台命令管理器不可用"}
|
||
else:
|
||
rec = bg_manager.get_record(str(wait_runcommand_id))
|
||
current_conv = getattr(self.context_manager, "current_conversation_id", None)
|
||
if not rec:
|
||
result = {"success": False, "error": f"未找到后台命令: {wait_runcommand_id}"}
|
||
elif rec.get("conversation_id") and current_conv and rec.get("conversation_id") != current_conv:
|
||
result = {"success": False, "error": "该后台命令不属于当前对话"}
|
||
else:
|
||
wait_result = await asyncio.to_thread(
|
||
bg_manager.wait_for_completion,
|
||
str(wait_runcommand_id),
|
||
None,
|
||
True,
|
||
)
|
||
bg_manager.mark_claimed(str(wait_runcommand_id))
|
||
result = {
|
||
"success": bool(wait_result.get("success", False)),
|
||
"mode": "wait_runcommand_id",
|
||
"command_id": str(wait_runcommand_id),
|
||
"result": wait_result,
|
||
"message": "后台 run_command 等待完成"
|
||
}
|
||
else:
|
||
max_sleep = 3600 # 最多等待3600秒(1小时)
|
||
seconds_parse_ok = True
|
||
try:
|
||
seconds = float(seconds)
|
||
except Exception:
|
||
result = {"success": False, "error": "seconds 必须是数字"}
|
||
seconds_parse_ok = False
|
||
seconds = 0.0
|
||
if seconds_parse_ok and seconds > max_sleep:
|
||
result = {
|
||
"success": False,
|
||
"error": f"等待时间过长,最多允许 {max_sleep} 秒",
|
||
"suggestion": f"建议分多次等待或减少等待时间"
|
||
}
|
||
elif seconds_parse_ok:
|
||
# 确保秒数为正数
|
||
if seconds <= 0:
|
||
result = {
|
||
"success": False,
|
||
"error": "等待时间必须大于0"
|
||
}
|
||
else:
|
||
print(f"{OUTPUT_FORMATS['info']} 等待 {seconds} 秒: {reason}")
|
||
await asyncio.sleep(seconds)
|
||
result = {
|
||
"success": True,
|
||
"message": f"已等待 {seconds} 秒",
|
||
"reason": reason,
|
||
"timestamp": datetime.now().isoformat()
|
||
}
|
||
print(f"{OUTPUT_FORMATS['success']} 等待完成")
|
||
|
||
elif tool_name == "create_file":
|
||
result = self.file_manager.create_file(
|
||
path=arguments["path"],
|
||
file_type=arguments["file_type"]
|
||
)
|
||
if isinstance(result, dict) and result.get("success"):
|
||
# create_file 新建文件后,视为当前会话已“接触”该文件,
|
||
# 避免后续 write_file/edit_file 被“先 read_file 再编辑”误拦截。
|
||
self._mark_file_as_read_visited(result.get("path") or arguments.get("path"))
|
||
# 添加备注
|
||
if result["success"] and arguments.get("annotation"):
|
||
self.context_manager.update_annotation(
|
||
result["path"],
|
||
arguments["annotation"]
|
||
)
|
||
if result.get("success"):
|
||
result["message"] = (
|
||
f"已创建空文件: {result['path']}。请使用 write_file 写入内容,或使用 edit_file 进行替换。"
|
||
)
|
||
|
||
elif tool_name == "delete_file":
|
||
result = self.file_manager.delete_file(arguments["path"])
|
||
# 如果删除成功,同时删除备注
|
||
if result.get("success") and result.get("action") == "deleted":
|
||
deleted_path = result.get("path")
|
||
# 删除备注
|
||
if deleted_path in self.context_manager.file_annotations:
|
||
del self.context_manager.file_annotations[deleted_path]
|
||
self.context_manager.save_annotations()
|
||
print(f"🧹 已删除文件备注: {deleted_path}")
|
||
|
||
elif tool_name == "rename_file":
|
||
result = self.file_manager.rename_file(
|
||
arguments["old_path"],
|
||
arguments["new_path"]
|
||
)
|
||
# 如果重命名成功,更新备注和聚焦的key
|
||
# 如果重命名成功,更新备注
|
||
if result.get("success") and result.get("action") == "renamed":
|
||
old_path = result.get("old_path")
|
||
new_path = result.get("new_path")
|
||
# 更新备注
|
||
if old_path in self.context_manager.file_annotations:
|
||
annotation = self.context_manager.file_annotations[old_path]
|
||
del self.context_manager.file_annotations[old_path]
|
||
self.context_manager.file_annotations[new_path] = annotation
|
||
self.context_manager.save_annotations()
|
||
print(f"📝 已更新文件备注: {old_path} -> {new_path}")
|
||
|
||
elif tool_name == "write_file":
|
||
read_guard_error = self._check_read_before_edit_prerequisite(tool_name, arguments)
|
||
if read_guard_error:
|
||
return json.dumps(read_guard_error, ensure_ascii=False)
|
||
path = arguments.get("file_path")
|
||
content = arguments.get("content", "")
|
||
append_flag = bool(arguments.get("append", False))
|
||
if not path:
|
||
result = {"success": False, "error": "缺少必要参数: file_path"}
|
||
else:
|
||
mode = "a" if append_flag else "w"
|
||
result = self.file_manager.write_file(path, content, mode=mode)
|
||
if isinstance(result, dict) and result.get("success"):
|
||
# write_file 成功后,该文件视作当前会话已“接触”,
|
||
# 允许后续继续 write/edit 而不再触发先读拦截。
|
||
self._mark_file_as_read_visited(result.get("path") or path)
|
||
|
||
elif tool_name == "edit_file":
|
||
read_guard_error = self._check_read_before_edit_prerequisite(tool_name, arguments)
|
||
if read_guard_error:
|
||
return json.dumps(read_guard_error, ensure_ascii=False)
|
||
path = arguments.get("file_path")
|
||
old_text = arguments.get("old_string")
|
||
new_text = arguments.get("new_string")
|
||
if not path:
|
||
result = {"success": False, "error": "缺少必要参数: file_path"}
|
||
elif old_text is None or new_text is None:
|
||
result = {"success": False, "error": "缺少必要参数: old_string/new_string"}
|
||
elif old_text == new_text:
|
||
result = {"success": False, "error": "old_string 与 new_string 相同,无法执行替换"}
|
||
elif not old_text:
|
||
result = {"success": False, "error": "old_string 不能为空,请从 read_file 内容中精确复制"}
|
||
else:
|
||
result = self.file_manager.replace_in_file(path, old_text, new_text)
|
||
elif tool_name == "create_folder":
|
||
result = self.file_manager.create_folder(arguments["path"])
|
||
|
||
elif tool_name == "web_search":
|
||
allowed, quota_info = self.record_search_call()
|
||
if not allowed:
|
||
return json.dumps({
|
||
"success": False,
|
||
"error": f"搜索配额已用尽,将在 {quota_info.get('reset_at')} 重置。请向用户说明情况并提供替代方案。",
|
||
"quota": quota_info
|
||
}, ensure_ascii=False)
|
||
search_response = await self.search_engine.search_with_summary(
|
||
query=arguments["query"],
|
||
max_results=arguments.get("max_results"),
|
||
topic=arguments.get("topic"),
|
||
time_range=arguments.get("time_range"),
|
||
days=arguments.get("days"),
|
||
start_date=arguments.get("start_date"),
|
||
end_date=arguments.get("end_date"),
|
||
country=arguments.get("country"),
|
||
include_domains=arguments.get("include_domains")
|
||
)
|
||
|
||
if search_response["success"]:
|
||
result = {
|
||
"success": True,
|
||
"summary": search_response["summary"],
|
||
"filters": search_response.get("filters", {}),
|
||
"query": search_response.get("query"),
|
||
"results": search_response.get("results", []),
|
||
"total_results": search_response.get("total_results", 0)
|
||
}
|
||
else:
|
||
result = {
|
||
"success": False,
|
||
"error": search_response.get("error", "搜索失败"),
|
||
"filters": search_response.get("filters", {}),
|
||
"query": search_response.get("query"),
|
||
"results": search_response.get("results", []),
|
||
"total_results": search_response.get("total_results", 0)
|
||
}
|
||
|
||
elif tool_name == "extract_webpage":
|
||
url = arguments["url"]
|
||
try:
|
||
# 从config获取API密钥
|
||
from config import TAVILY_API_KEY
|
||
full_content, _ = await extract_webpage_content(
|
||
urls=url,
|
||
api_key=TAVILY_API_KEY,
|
||
extract_depth="basic",
|
||
max_urls=1
|
||
)
|
||
|
||
# 字符数检查
|
||
char_count = len(full_content)
|
||
if char_count > MAX_EXTRACT_WEBPAGE_CHARS:
|
||
result = {
|
||
"success": False,
|
||
"error": f"网页提取返回了过长的{char_count}字符,请不要提取这个网页,可以使用网页保存功能,然后使用read工具查找或查看网页",
|
||
"char_count": char_count,
|
||
"limit": MAX_EXTRACT_WEBPAGE_CHARS,
|
||
"url": url
|
||
}
|
||
else:
|
||
result = {
|
||
"success": True,
|
||
"url": url,
|
||
"content": full_content
|
||
}
|
||
except Exception as e:
|
||
result = {
|
||
"success": False,
|
||
"error": f"网页提取失败: {str(e)}",
|
||
"url": url
|
||
}
|
||
|
||
elif tool_name == "save_webpage":
|
||
url = arguments["url"]
|
||
target_path = arguments["target_path"]
|
||
try:
|
||
from config import TAVILY_API_KEY
|
||
except ImportError:
|
||
TAVILY_API_KEY = None
|
||
|
||
if not TAVILY_API_KEY or TAVILY_API_KEY == "your-tavily-api-key":
|
||
result = {
|
||
"success": False,
|
||
"error": "Tavily API密钥未配置,无法保存网页",
|
||
"url": url,
|
||
"path": target_path
|
||
}
|
||
else:
|
||
try:
|
||
extract_result = await tavily_extract(
|
||
urls=url,
|
||
api_key=TAVILY_API_KEY,
|
||
extract_depth="basic",
|
||
max_urls=1
|
||
)
|
||
|
||
if not extract_result or "error" in extract_result:
|
||
error_message = extract_result.get("error", "提取失败,未返回任何内容") if isinstance(extract_result, dict) else "提取失败"
|
||
result = {
|
||
"success": False,
|
||
"error": error_message,
|
||
"url": url,
|
||
"path": target_path
|
||
}
|
||
else:
|
||
results_list = extract_result.get("results", []) if isinstance(extract_result, dict) else []
|
||
|
||
primary_result = None
|
||
for item in results_list:
|
||
if item.get("raw_content"):
|
||
primary_result = item
|
||
break
|
||
if primary_result is None and results_list:
|
||
primary_result = results_list[0]
|
||
|
||
if not primary_result:
|
||
failed_list = extract_result.get("failed_results", []) if isinstance(extract_result, dict) else []
|
||
result = {
|
||
"success": False,
|
||
"error": "提取成功结果为空,无法保存",
|
||
"url": url,
|
||
"path": target_path,
|
||
"failed": failed_list
|
||
}
|
||
else:
|
||
content_to_save = primary_result.get("raw_content") or primary_result.get("content") or ""
|
||
|
||
if not content_to_save:
|
||
result = {
|
||
"success": False,
|
||
"error": "网页内容为空,未写入文件",
|
||
"url": url,
|
||
"path": target_path
|
||
}
|
||
else:
|
||
write_result = self.file_manager.write_file(target_path, content_to_save, mode="w")
|
||
|
||
if not write_result.get("success"):
|
||
result = {
|
||
"success": False,
|
||
"error": write_result.get("error", "写入文件失败"),
|
||
"url": url,
|
||
"path": target_path
|
||
}
|
||
else:
|
||
char_count = len(content_to_save)
|
||
byte_size = len(content_to_save.encode("utf-8"))
|
||
result = {
|
||
"success": True,
|
||
"url": url,
|
||
"path": write_result.get("path", target_path),
|
||
"char_count": char_count,
|
||
"byte_size": byte_size,
|
||
"message": f"网页内容已以纯文本保存到 {write_result.get('path', target_path)},可用 read_file 的 search/extract 查看,必要时再用终端命令。"
|
||
}
|
||
|
||
if isinstance(extract_result, dict) and extract_result.get("failed_results"):
|
||
result["warnings"] = extract_result["failed_results"]
|
||
|
||
except Exception as e:
|
||
result = {
|
||
"success": False,
|
||
"error": f"网页保存失败: {str(e)}",
|
||
"url": url,
|
||
"path": target_path
|
||
}
|
||
|
||
elif tool_name == "run_python":
|
||
result = await self.terminal_ops.run_python_code(
|
||
arguments["code"],
|
||
timeout=arguments.get("timeout")
|
||
)
|
||
|
||
elif tool_name == "run_command":
|
||
run_in_background = bool(arguments.get("run_in_background", False))
|
||
timeout_value = arguments.get("timeout")
|
||
if run_in_background:
|
||
if timeout_value is None or float(timeout_value) <= 0:
|
||
result = {
|
||
"success": False,
|
||
"error": "后台模式下 timeout 参数必填且需大于0"
|
||
}
|
||
elif float(timeout_value) > 3600:
|
||
result = {
|
||
"success": False,
|
||
"error": "后台模式下 timeout 最大为 3600 秒"
|
||
}
|
||
else:
|
||
bg_manager = getattr(self, "background_command_manager", None)
|
||
if not bg_manager:
|
||
result = {"success": False, "error": "后台命令管理器不可用"}
|
||
else:
|
||
result = bg_manager.create_background_command(
|
||
terminal_ops=self.terminal_ops,
|
||
command=arguments["command"],
|
||
timeout=timeout_value,
|
||
conversation_id=getattr(self.context_manager, "current_conversation_id", None),
|
||
wait_seconds=5.0,
|
||
)
|
||
else:
|
||
if timeout_value is None or float(timeout_value) <= 0:
|
||
result = {
|
||
"success": False,
|
||
"error": "timeout 参数必填且需大于0"
|
||
}
|
||
elif float(timeout_value) > 60:
|
||
result = {
|
||
"success": False,
|
||
"error": "前台模式下 timeout 最大为 60 秒"
|
||
}
|
||
else:
|
||
result = await self.terminal_ops.run_command(
|
||
arguments["command"],
|
||
timeout=timeout_value
|
||
)
|
||
|
||
# 字符数检查
|
||
if result.get("success") and "output" in result:
|
||
char_count = len(result["output"])
|
||
if char_count > MAX_RUN_COMMAND_CHARS:
|
||
result = {
|
||
"success": False,
|
||
"error": f"结果内容过大,有{char_count}字符,请使用限制字符数的获取内容方式,根据程度选择10k以内的数",
|
||
"char_count": char_count,
|
||
"limit": MAX_RUN_COMMAND_CHARS,
|
||
"command": arguments["command"]
|
||
}
|
||
|
||
elif tool_name == "update_memory":
|
||
operation = arguments["operation"]
|
||
content = arguments.get("content")
|
||
index = arguments.get("index")
|
||
|
||
# 参数校验
|
||
if operation == "append" and (not content or not str(content).strip()):
|
||
result = {"success": False, "error": "append 操作需要 content"}
|
||
elif operation == "replace" and (index is None or index <= 0 or not content or not str(content).strip()):
|
||
result = {"success": False, "error": "replace 操作需要有效的 index 和 content"}
|
||
elif operation == "delete" and (index is None or index <= 0):
|
||
result = {"success": False, "error": "delete 操作需要有效的 index"}
|
||
else:
|
||
# 统一使用 main 记忆类型
|
||
result = self.memory_manager.update_entries(
|
||
memory_type="main",
|
||
operation=operation,
|
||
content=content,
|
||
index=index
|
||
)
|
||
|
||
elif tool_name == "todo_create":
|
||
result = self.todo_manager.create_todo_list(
|
||
overview=arguments.get("overview", ""),
|
||
tasks=arguments.get("tasks", [])
|
||
)
|
||
|
||
elif tool_name == "todo_update_task":
|
||
task_indices = arguments.get("task_indices")
|
||
if task_indices is None:
|
||
task_indices = arguments.get("task_index")
|
||
result = self.todo_manager.update_task_status(
|
||
task_indices=task_indices,
|
||
completed=arguments.get("completed", True)
|
||
)
|
||
|
||
elif tool_name == "create_sub_agent":
|
||
result = self.sub_agent_manager.create_sub_agent(
|
||
agent_id=arguments.get("agent_id"),
|
||
summary=arguments.get("summary", ""),
|
||
task=arguments.get("task", ""),
|
||
deliverables_dir=arguments.get("deliverables_dir", ""),
|
||
run_in_background=arguments.get("run_in_background", False),
|
||
timeout_seconds=arguments.get("timeout_seconds"),
|
||
thinking_mode=arguments.get("thinking_mode"),
|
||
conversation_id=self.context_manager.current_conversation_id
|
||
)
|
||
|
||
# 如果不是后台运行,阻塞等待完成
|
||
if not arguments.get("run_in_background", False) and result.get("success"):
|
||
task_id = result.get("task_id")
|
||
wait_result = self.sub_agent_manager.wait_for_completion(
|
||
task_id=task_id,
|
||
timeout_seconds=arguments.get("timeout_seconds")
|
||
)
|
||
# 合并结果
|
||
result.update(wait_result)
|
||
# 阻塞式执行不需要额外插入 system 消息
|
||
result.pop("system_message", None)
|
||
# 标记已通知,避免后续轮询再插入 system 消息
|
||
try:
|
||
task = self.sub_agent_manager.tasks.get(task_id)
|
||
if isinstance(task, dict):
|
||
task["notified"] = True
|
||
task["updated_at"] = time.time()
|
||
self.sub_agent_manager._save_state()
|
||
except Exception:
|
||
pass
|
||
|
||
elif tool_name == "terminate_sub_agent":
|
||
result = self.sub_agent_manager.terminate_sub_agent(
|
||
agent_id=arguments.get("agent_id")
|
||
)
|
||
|
||
elif tool_name == "get_sub_agent_status":
|
||
result = self.sub_agent_manager.get_sub_agent_status(
|
||
agent_ids=arguments.get("agent_ids", [])
|
||
)
|
||
|
||
elif tool_name == "trigger_easter_egg":
|
||
result = self.easter_egg_manager.trigger_effect(arguments.get("effect"))
|
||
|
||
elif tool_name == "manage_personalization":
|
||
logger.info("[handle_tool_call] 进入manage_personalization分支")
|
||
result = await self._execute_manage_personalization(arguments)
|
||
logger.info("[handle_tool_call] manage_personalization执行完成: result=%s", result)
|
||
|
||
else:
|
||
result = {"success": False, "error": f"未知工具: {tool_name}"}
|
||
|
||
except Exception as e:
|
||
logger.error(f"工具执行失败: {tool_name} - {e}")
|
||
logger.exception("[handle_tool_call] 工具执行异常详情")
|
||
result = {"success": False, "error": f"工具执行异常: {str(e)}"}
|
||
|
||
logger.info("[handle_tool_call] 工具调用结束: tool_name=%s, result=%s", tool_name, result)
|
||
return json.dumps(result, ensure_ascii=False)
|
||
|
||
async def _execute_manage_personalization(self, arguments: Dict) -> Dict:
|
||
"""执行个性化管理操作"""
|
||
print(f"[DEBUG] _execute_manage_personalization 被调用: {arguments}")
|
||
logger.info("[_execute_manage_personalization] 方法被调用: arguments=%s", arguments)
|
||
action = arguments.get("action")
|
||
logger.info("[_execute_manage_personalization] action=%s", action)
|
||
|
||
if action == "read":
|
||
logger.info("[_execute_manage_personalization] 进入read分支")
|
||
# 读取所有配置
|
||
try:
|
||
config = load_personalization_config(self.data_dir)
|
||
# 只返回可修改的字段
|
||
readable_fields = ["self_identify", "user_name", "profession", "tone", "considerations", "theme", "communication_style", "enabled"]
|
||
result = {k: v for k, v in config.items() if k in readable_fields}
|
||
# 构建详细返回信息
|
||
field_descriptions = {
|
||
"enabled": "个性化功能总开关",
|
||
"self_identify": f"AI自称: {result.get('self_identify') or '(未设置)'}",
|
||
"user_name": f"AI如何称呼用户: {result.get('user_name') or '(未设置)'}",
|
||
"profession": f"用户职业: {result.get('profession') or '(未设置)'}",
|
||
"tone": f"交流语气: {result.get('tone') or '(未设置)'}",
|
||
"considerations": f"注意事项: {len(result.get('considerations') or [])} 条",
|
||
"theme": f"主题配色: {result.get('theme', 'classic')}",
|
||
"communication_style": f"交流风格: {'拟人聊天风格' if result.get('communication_style') == 'human_like' else '标准AI风格'}"
|
||
}
|
||
details = "\n".join([f"- {field_descriptions.get(k, k)}: {v}" for k, v in result.items()])
|
||
logger.info("[_execute_manage_personalization] read成功")
|
||
return {
|
||
"success": True,
|
||
"data": result,
|
||
"message": f"个性化配置读取成功:\n{details}"
|
||
}
|
||
except Exception as e:
|
||
logger.error("[_execute_manage_personalization] read失败: %s", e)
|
||
return {"success": False, "error": f"读取配置失败: {str(e)}"}
|
||
|
||
elif action == "update":
|
||
logger.info("[_execute_manage_personalization] 进入update分支")
|
||
field = arguments.get("field")
|
||
value = arguments.get("value")
|
||
logger.info("[_execute_manage_personalization] field=%s, value=%s", field, value)
|
||
|
||
if not field:
|
||
logger.warning("[_execute_manage_personalization] field未指定")
|
||
return {"success": False, "error": "更新操作需要指定 field 参数"}
|
||
|
||
# 验证字段是否允许修改
|
||
logger.info("[_execute_manage_personalization] 验证字段: field=%s", field)
|
||
allowed_fields = ["self_identify", "user_name", "profession", "tone", "considerations", "theme", "communication_style"]
|
||
if field not in allowed_fields:
|
||
logger.warning("[_execute_manage_personalization] 字段不允许修改: %s not in %s", field, allowed_fields)
|
||
return {"success": False, "error": f"字段 '{field}' 不允许修改,可修改字段: {allowed_fields}"}
|
||
|
||
# 验证value
|
||
validation_errors = []
|
||
|
||
if field in ["self_identify", "user_name", "profession", "tone"]:
|
||
# 字符串字段验证
|
||
if not isinstance(value, str):
|
||
validation_errors.append(f"{field} 必须是字符串")
|
||
elif len(value) > MAX_SHORT_FIELD_LENGTH:
|
||
validation_errors.append(f"{field} 不能超过 {MAX_SHORT_FIELD_LENGTH} 个字符")
|
||
|
||
elif field == "considerations":
|
||
# 注意事项数组验证
|
||
if not isinstance(value, list):
|
||
validation_errors.append("considerations 必须是字符串数组")
|
||
else:
|
||
if len(value) > MAX_CONSIDERATION_ITEMS:
|
||
validation_errors.append(f"considerations 不能超过 {MAX_CONSIDERATION_ITEMS} 项")
|
||
for i, item in enumerate(value):
|
||
if not isinstance(item, str):
|
||
validation_errors.append(f"considerations[{i}] 必须是字符串")
|
||
elif len(item) > MAX_CONSIDERATION_LENGTH:
|
||
validation_errors.append(f"considerations[{i}] 不能超过 {MAX_CONSIDERATION_LENGTH} 个字符")
|
||
|
||
elif field == "theme":
|
||
# 主题验证
|
||
if not isinstance(value, str):
|
||
validation_errors.append("theme 必须是字符串")
|
||
elif value not in ALLOWED_THEMES:
|
||
validation_errors.append(f"theme 必须是以下之一: {list(ALLOWED_THEMES)}")
|
||
|
||
elif field == "communication_style":
|
||
# 交流风格验证
|
||
if not isinstance(value, str):
|
||
validation_errors.append("communication_style 必须是字符串")
|
||
elif value not in ["default", "human_like"]:
|
||
validation_errors.append("communication_style 必须是 'default' 或 'human_like'")
|
||
|
||
if validation_errors:
|
||
logger.warning("[_execute_manage_personalization] 验证失败: %s", validation_errors)
|
||
return {
|
||
"success": False,
|
||
"error": "验证失败",
|
||
"validation_errors": validation_errors
|
||
}
|
||
|
||
# 加载当前配置并更新
|
||
try:
|
||
logger.info("[_execute_manage_personalization] 开始加载配置")
|
||
config = load_personalization_config(self.data_dir)
|
||
old_value = config.get(field) # 记录旧值
|
||
config[field] = value
|
||
logger.info("[_execute_manage_personalization] 配置已修改: field=%s, old=%s, new=%s", field, old_value, value)
|
||
|
||
# 保存配置
|
||
save_personalization_config(self.data_dir, config)
|
||
print(f"[DEBUG] 配置已保存: field={field}, value={value}")
|
||
logger.info("[_execute_manage_personalization] 配置已保存到文件")
|
||
|
||
# 重新加载配置到当前终端
|
||
self.apply_personalization_preferences(config)
|
||
logger.info("[_execute_manage_personalization] 配置已应用到终端")
|
||
|
||
# 调试日志:记录主题变更
|
||
logger.info("[manage_personalization] 配置已更新并应用: field=%s, value=%s", field, value)
|
||
if field == "theme":
|
||
logger.info("[manage_personalization] 主题已变更: old=%s, new=%s", old_value, value)
|
||
|
||
# 构建返回结果
|
||
result = {
|
||
"success": True,
|
||
"message": f"字段 '{field}' 更新成功",
|
||
"updated_field": field,
|
||
"old_value": old_value,
|
||
"updated_value": value
|
||
}
|
||
|
||
# 如果是主题更新,添加主题变更标记
|
||
if field == "theme":
|
||
result["theme_changed"] = True
|
||
result["new_theme"] = value
|
||
logger.info("[manage_personalization] 返回结果包含主题变更标记: theme_changed=true, new_theme=%s", value)
|
||
|
||
return result
|
||
|
||
except Exception as e:
|
||
logger.error("[_execute_manage_personalization] 保存失败: %s", e)
|
||
logger.exception("[_execute_manage_personalization] 保存异常详情")
|
||
return {"success": False, "error": f"保存配置失败: {str(e)}"}
|
||
|
||
else:
|
||
logger.warning("[_execute_manage_personalization] 未知的action: %s", action)
|
||
return {"success": False, "error": f"未知的 action: {action},可选: read, update"}
|
||
|
||
async def confirm_action(self, action: str, arguments: Dict) -> bool:
|
||
"""确认危险操作"""
|
||
print(f"\n{OUTPUT_FORMATS['confirm']} 需要确认的操作:")
|
||
print(f" 操作: {action}")
|
||
print(f" 参数: {json.dumps(arguments, ensure_ascii=False, indent=2)}")
|
||
|
||
response = input("\n是否继续? (y/n): ").strip().lower()
|
||
return response == 'y'
|