agent-Specialization/core/main_terminal.py

2848 lines
135 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# core/main_terminal.py - 主终端(集成对话持久化)
import asyncio
import json
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, TYPE_CHECKING
from datetime import datetime
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
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, 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_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,
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
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,
)
if TYPE_CHECKING:
from modules.user_container_manager import ContainerHandle
logger = setup_logger(__name__)
# 临时禁用长度检查
DISABLE_LENGTH_CHECK = True
class MainTerminal:
def __init__(
self,
project_path: str,
thinking_mode: bool = False,
run_mode: Optional[str] = None,
data_dir: Optional[str] = None,
container_session: Optional["ContainerHandle"] = None,
usage_tracker: Optional[object] = None,
):
self.project_path = project_path
allowed_modes = {"fast", "thinking", "deep"}
initial_mode = run_mode if run_mode in allowed_modes else ("thinking" if thinking_mode else "fast")
self.run_mode = initial_mode
self.thinking_mode = initial_mode != "fast" # False=快速模式, True=思考模式
self.deep_thinking_mode = initial_mode == "deep"
self.data_dir = Path(data_dir).expanduser().resolve() if data_dir else Path(DATA_DIR).resolve()
self.usage_tracker = usage_tracker
# 初始化组件
self.api_client = DeepSeekClient(thinking_mode=self.thinking_mode)
self.api_client.set_deep_thinking_mode(self.deep_thinking_mode)
self.model_key = "kimi-k2.5"
self.model_profile = get_model_profile(self.model_key)
self.apply_model_profile(self.model_profile)
self.context_manager = ContextManager(project_path, data_dir=str(self.data_dir))
self.context_manager.main_terminal = self
self.container_mount_path = TERMINAL_SANDBOX_MOUNT_PATH or "/workspace"
self.container_cpu_limit = TERMINAL_SANDBOX_CPUS or "未限制"
self.container_memory_limit = TERMINAL_SANDBOX_MEMORY or "未限制"
self.project_storage_limit = f"{PROJECT_MAX_STORAGE_MB}MB" if PROJECT_MAX_STORAGE_MB else "未限制"
self.project_storage_limit_bytes = (
PROJECT_MAX_STORAGE_MB * 1024 * 1024 if PROJECT_MAX_STORAGE_MB else None
)
self.container_session: Optional["ContainerHandle"] = None
self.memory_manager = MemoryManager(data_dir=str(self.data_dir))
self.file_manager = FileManager(project_path, container_session=container_session)
self.search_engine = SearchEngine()
self.terminal_ops = TerminalOperator(project_path, container_session=container_session)
self.ocr_client = OCRClient(project_path, self.file_manager)
self.pending_image_view = None # 供 view_image 工具使用,保存一次性图片插入请求
self.pending_video_view = None # 供 view_video 工具使用,保存一次性视频插入请求
# 新增:终端管理器
self.terminal_manager = TerminalManager(
project_path=project_path,
max_terminals=MAX_TERMINALS,
terminal_buffer_size=TERMINAL_BUFFER_SIZE,
terminal_display_size=TERMINAL_DISPLAY_SIZE,
broadcast_callback=None, # CLI模式不需要广播
container_session=container_session,
)
# 让 run_command/run_python 复用终端容器,保持环境一致
self.terminal_ops.attach_terminal_manager(self.terminal_manager)
self._apply_container_session(container_session)
self.todo_manager = TodoManager(self.context_manager)
self.sub_agent_manager = SubAgentManager(
project_path=self.project_path,
data_dir=str(self.data_dir)
)
self.easter_egg_manager = EasterEggManager()
self._announced_sub_agent_tasks = set()
self.silent_tool_disable = False # 是否静默工具禁用提示
self.current_session_id = 0 # 用于标识不同的任务会话
# 工具类别(可被管理员动态覆盖)
self.tool_categories_map = dict(TOOL_CATEGORIES)
# 自定义工具仅管理员可见/可用
self.user_role: str = "user"
self.custom_tools_enabled = bool(CUSTOM_TOOLS_ENABLED)
self.custom_tool_registry = CustomToolRegistry()
self.custom_tool_executor = CustomToolExecutor(self.custom_tool_registry, self.terminal_ops)
if self.custom_tools_enabled:
default_custom_cat = build_default_tool_category()
# 若未存在 custom 分类则添加,方便前端/策略统一展示
if "custom" not in self.tool_categories_map:
self.tool_categories_map["custom"] = type(next(iter(TOOL_CATEGORIES.values())))(
label=default_custom_cat["label"],
tools=default_custom_cat["tools"],
default_enabled=True,
silent_when_disabled=False,
)
self.admin_forced_category_states: Dict[str, Optional[bool]] = {}
self.admin_disabled_models: List[str] = []
self.admin_policy_ui_blocks: Dict[str, bool] = {}
self.admin_policy_version: Optional[str] = None
# 工具启用状态
self.tool_category_states: Dict[str, bool] = {
key: category.default_enabled
for key, category in self.tool_categories_map.items()
}
self.disabled_tools: Set[str] = set()
self.disabled_notice_tools: Set[str] = set()
self._refresh_disabled_tools()
self.thinking_fast_interval = THINKING_FAST_INTERVAL
self.default_disabled_tool_categories: List[str] = []
# 个性化工具意图开关
self.tool_intent_enabled: bool = False
self.apply_personalization_preferences()
# 新增:自动开始新对话
self._ensure_conversation()
# 命令映射
self.commands = {
"help": self.show_help,
"exit": self.exit_system,
"status": self.show_status,
"memory": self.manage_memory,
"clear": self.clear_conversation,
"history": self.show_history,
"files": self.show_files,
"mode": self.toggle_mode,
"terminals": self.show_terminals,
# 新增:对话管理命令
"conversations": self.show_conversations,
"load": self.load_conversation_command,
"new": self.new_conversation_command,
"save": self.save_conversation_command
}
#self.context_manager._web_terminal_callback = message_callback
def _apply_container_session(self, session: Optional["ContainerHandle"]):
self.container_session = session
if session and session.mode == "docker":
self.container_mount_path = session.mount_path or (TERMINAL_SANDBOX_MOUNT_PATH or "/workspace")
else:
self.container_mount_path = TERMINAL_SANDBOX_MOUNT_PATH or "/workspace"
def _is_host_mode(self) -> bool:
"""判定当前是否运行在宿主机模式,用于豁免配额等限制。"""
if self.container_session and getattr(self.container_session, "mode", None) != "docker":
return True
return (TERMINAL_SANDBOX_MODE or "").lower() == "host"
def record_model_call(self, is_thinking: bool):
if self._is_host_mode():
return True, {}
tracker = getattr(self, "usage_tracker", None)
if not tracker:
return True, {}
mode = "thinking" if is_thinking else "fast"
try:
allowed, info = tracker.check_and_increment(mode)
if allowed:
self._notify_quota_update(mode)
return allowed, info
except Exception:
return True, {}
def record_search_call(self):
if self._is_host_mode():
return True, {}
tracker = getattr(self, "usage_tracker", None)
if not tracker:
return True, {}
try:
allowed, info = tracker.check_and_increment("search")
if allowed:
self._notify_quota_update("search")
return allowed, info
except Exception:
return True, {}
def _notify_quota_update(self, metric: str):
callback = getattr(self, "quota_update_callback", None)
if callable(callback):
try:
callback(metric)
except Exception:
pass
def update_container_session(self, session: Optional["ContainerHandle"]):
self._apply_container_session(session)
if getattr(self, "terminal_manager", None):
self.terminal_manager.update_container_session(session)
if getattr(self, "terminal_ops", None):
self.terminal_ops.set_container_session(session)
if getattr(self, "file_manager", None):
self.file_manager.set_container_session(session)
def _ensure_conversation(self):
"""确保CLI模式下存在可用的对话ID"""
if self.context_manager.current_conversation_id:
return
latest_list = self.context_manager.get_conversation_list(limit=1, offset=0)
conversations = latest_list.get("conversations", []) if latest_list else []
if conversations:
latest = conversations[0]
conv_id = latest.get("id")
if conv_id and self.context_manager.load_conversation_by_id(conv_id):
print(f"{OUTPUT_FORMATS['info']} 已加载最近对话: {conv_id}")
return
conversation_id = self.context_manager.start_new_conversation(
project_path=self.project_path,
thinking_mode=self.thinking_mode,
run_mode=self.run_mode
)
print(f"{OUTPUT_FORMATS['info']} 新建对话: {conversation_id}")
@staticmethod
def _clamp_int(value, default, min_value=None, max_value=None):
"""将输入转换为整数并限制范围。"""
if value is None:
return default
try:
num = int(value)
except (TypeError, ValueError):
return default
if min_value is not None:
num = max(min_value, num)
if max_value is not None:
num = min(max_value, num)
return num
@staticmethod
def _parse_optional_line(value, field_name: str):
"""解析可选的行号参数。"""
if value is None:
return None, None
try:
number = int(value)
except (TypeError, ValueError):
return None, f"{field_name} 必须是整数"
if number < 1:
return None, f"{field_name} 必须大于等于1"
return number, None
@staticmethod
def _truncate_text_block(text: str, max_chars: int):
"""对单段文本应用字符限制。"""
if max_chars and len(text) > max_chars:
return text[:max_chars], True, max_chars
return text, False, len(text)
@staticmethod
def _limit_text_chunks(chunks: List[Dict], text_key: str, max_chars: int):
"""对多个文本片段应用全局字符限制。"""
if max_chars is None or max_chars <= 0:
return chunks, False, sum(len(chunk.get(text_key, "") or "") for chunk in chunks)
remaining = max_chars
limited_chunks: List[Dict] = []
truncated = False
consumed = 0
for chunk in chunks:
snippet = chunk.get(text_key, "") or ""
snippet_len = len(snippet)
chunk_copy = dict(chunk)
if remaining <= 0:
truncated = True
break
if snippet_len > remaining:
chunk_copy[text_key] = snippet[:remaining]
chunk_copy["truncated"] = True
consumed += remaining
limited_chunks.append(chunk_copy)
truncated = True
remaining = 0
break
limited_chunks.append(chunk_copy)
consumed += snippet_len
remaining -= snippet_len
return limited_chunks, truncated, consumed
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}
if task_id:
metadata["task_id"] = task_id
self.context_manager.add_conversation("system", message, metadata=metadata)
print(f"{OUTPUT_FORMATS['info']} {message}")
def apply_personalization_preferences(self, config: Optional[Dict[str, Any]] = None):
"""Apply persisted personalization settings that affect runtime behavior."""
try:
effective_config = config or load_personalization_config(self.data_dir)
except Exception:
effective_config = {}
# 工具意图开关
self.tool_intent_enabled = bool(effective_config.get("tool_intent_enabled"))
interval = effective_config.get("thinking_interval")
if isinstance(interval, int) and interval > 0:
self.thinking_fast_interval = interval
else:
self.thinking_fast_interval = THINKING_FAST_INTERVAL
disabled_categories = []
raw_disabled = effective_config.get("disabled_tool_categories")
if isinstance(raw_disabled, list):
disabled_categories = [
key for key in raw_disabled
if isinstance(key, str) and key in self.tool_categories_map
]
self.default_disabled_tool_categories = disabled_categories
# 图片压缩模式传递给上下文
img_mode = effective_config.get("image_compression")
if isinstance(img_mode, str):
self.context_manager.image_compression_mode = img_mode
# Reset category states to defaults before applying overrides
for key, category in self.tool_categories_map.items():
self.tool_category_states[key] = False if key in disabled_categories else category.default_enabled
self._refresh_disabled_tools()
# 默认模型偏好(优先应用,再处理运行模式)
preferred_model = effective_config.get("default_model")
if isinstance(preferred_model, str) and preferred_model != self.model_key:
try:
self.set_model(preferred_model)
except Exception as exc:
logger.warning("忽略无效默认模型: %s (%s)", preferred_model, exc)
preferred_mode = effective_config.get("default_run_mode")
if isinstance(preferred_mode, str):
normalized_mode = preferred_mode.strip().lower()
if normalized_mode in {"fast", "thinking", "deep"} and normalized_mode != self.run_mode:
try:
self.set_run_mode(normalized_mode)
except ValueError:
logger.warning("忽略无效默认运行模式: %s", preferred_mode)
# 静默禁用工具提示
self.silent_tool_disable = bool(effective_config.get("silent_tool_disable"))
def _handle_read_tool(self, arguments: Dict) -> Dict:
"""集中处理 read_file 工具的三种模式。"""
file_path = arguments.get("path")
if not file_path:
return {"success": False, "error": "缺少文件路径参数"}
read_type = (arguments.get("type") or "read").lower()
if read_type not in {"read", "search", "extract"}:
return {"success": False, "error": f"未知的读取类型: {read_type}"}
max_chars = self._clamp_int(
arguments.get("max_chars"),
READ_TOOL_DEFAULT_MAX_CHARS,
1,
MAX_READ_FILE_CHARS
)
base_result = {
"success": True,
"type": read_type,
"path": None,
"encoding": "utf-8",
"max_chars": max_chars,
"truncated": False
}
if read_type == "read":
start_line, error = self._parse_optional_line(arguments.get("start_line"), "start_line")
if error:
return {"success": False, "error": error}
end_line_val = arguments.get("end_line")
end_line = None
if end_line_val is not None:
end_line, error = self._parse_optional_line(end_line_val, "end_line")
if error:
return {"success": False, "error": error}
if start_line and end_line < start_line:
return {"success": False, "error": "end_line 必须大于等于 start_line"}
read_result = self.file_manager.read_text_segment(
file_path,
start_line=start_line,
end_line=end_line,
size_limit=READ_TOOL_MAX_FILE_SIZE
)
if not read_result.get("success"):
return read_result
content, truncated, char_count = self._truncate_text_block(read_result["content"], max_chars)
base_result.update({
"path": read_result["path"],
"content": content,
"line_start": read_result["line_start"],
"line_end": read_result["line_end"],
"total_lines": read_result["total_lines"],
"file_size": read_result["size"],
"char_count": char_count,
"message": f"已读取 {read_result['path']} 的内容(行 {read_result['line_start']}~{read_result['line_end']}"
})
base_result["truncated"] = truncated
self.context_manager.load_file(read_result["path"])
return base_result
if read_type == "search":
query = arguments.get("query")
if not query:
return {"success": False, "error": "搜索模式需要提供 query 参数"}
max_matches = self._clamp_int(
arguments.get("max_matches"),
READ_TOOL_DEFAULT_MAX_MATCHES,
1,
READ_TOOL_MAX_MATCHES
)
context_before = self._clamp_int(
arguments.get("context_before"),
READ_TOOL_DEFAULT_CONTEXT_BEFORE,
0,
READ_TOOL_MAX_CONTEXT_BEFORE
)
context_after = self._clamp_int(
arguments.get("context_after"),
READ_TOOL_DEFAULT_CONTEXT_AFTER,
0,
READ_TOOL_MAX_CONTEXT_AFTER
)
case_sensitive = bool(arguments.get("case_sensitive"))
search_result = self.file_manager.search_text(
file_path,
query=query,
max_matches=max_matches,
context_before=context_before,
context_after=context_after,
case_sensitive=case_sensitive,
size_limit=READ_TOOL_MAX_FILE_SIZE
)
if not search_result.get("success"):
return search_result
matches = search_result["matches"]
limited_matches, truncated, char_count = self._limit_text_chunks(matches, "snippet", max_chars)
base_result.update({
"path": search_result["path"],
"file_size": search_result["size"],
"query": query,
"max_matches": max_matches,
"actual_matches": len(matches),
"returned_matches": len(limited_matches),
"context_before": context_before,
"context_after": context_after,
"case_sensitive": case_sensitive,
"matches": limited_matches,
"char_count": char_count,
"message": f"{search_result['path']} 中搜索 \"{query}\",返回 {len(limited_matches)} 条结果"
})
base_result["truncated"] = truncated
return base_result
# extract
segments = arguments.get("segments")
if not isinstance(segments, list) or not segments:
return {"success": False, "error": "extract 模式需要提供 segments 数组"}
extract_result = self.file_manager.extract_segments(
file_path,
segments=segments,
size_limit=READ_TOOL_MAX_FILE_SIZE
)
if not extract_result.get("success"):
return extract_result
limited_segments, truncated, char_count = self._limit_text_chunks(
extract_result["segments"],
"content",
max_chars
)
base_result.update({
"path": extract_result["path"],
"segments": limited_segments,
"file_size": extract_result["size"],
"total_lines": extract_result["total_lines"],
"segment_count": len(limited_segments),
"char_count": char_count,
"message": f"已从 {extract_result['path']} 抽取 {len(limited_segments)} 个片段"
})
base_result["truncated"] = truncated
self.context_manager.load_file(extract_result["path"])
return base_result
def set_tool_category_enabled(self, category: str, enabled: bool) -> None:
"""设置工具类别的启用状态 / Toggle tool category enablement."""
categories = self.tool_categories_map
if category not in categories:
raise ValueError(f"未知的工具类别: {category}")
forced = self.admin_forced_category_states.get(category)
if isinstance(forced, bool) and forced != enabled:
raise ValueError("该类别被管理员强制为启用/禁用,无法修改")
self.tool_category_states[category] = bool(enabled)
self._refresh_disabled_tools()
def set_admin_policy(
self,
categories: Optional[Dict[str, "ToolCategory"]] = None,
forced_category_states: Optional[Dict[str, Optional[bool]]] = None,
disabled_models: Optional[List[str]] = None,
) -> None:
"""应用管理员策略(工具分类、强制开关、模型禁用)。"""
if categories:
self.tool_categories_map = dict(categories)
# 保证自定义工具分类存在(仅当功能启用)
if self.custom_tools_enabled and "custom" not in self.tool_categories_map:
self.tool_categories_map["custom"] = type(next(iter(TOOL_CATEGORIES.values())))(
label="自定义工具",
tools=[],
default_enabled=True,
silent_when_disabled=False,
)
# 重新构建启用状态映射,保留已有值
new_states: Dict[str, bool] = {}
for key, cat in self.tool_categories_map.items():
if key in self.tool_category_states:
new_states[key] = self.tool_category_states[key]
else:
new_states[key] = cat.default_enabled
self.tool_category_states = new_states
# 清理已被移除的类别
for removed in list(self.tool_category_states.keys()):
if removed not in self.tool_categories_map:
self.tool_category_states.pop(removed, None)
self.admin_forced_category_states = forced_category_states or {}
self.admin_disabled_models = disabled_models or []
self._refresh_disabled_tools()
def get_tool_settings_snapshot(self) -> List[Dict[str, object]]:
"""获取工具类别状态快照 / Return tool category states snapshot."""
snapshot: List[Dict[str, object]] = []
categories = self.tool_categories_map
for key, category in categories.items():
forced = self.admin_forced_category_states.get(key)
enabled = self.tool_category_states.get(key, category.default_enabled)
if isinstance(forced, bool):
enabled = forced
snapshot.append({
"id": key,
"label": category.label,
"enabled": enabled,
"tools": list(category.tools),
"locked": isinstance(forced, bool),
"locked_state": forced if isinstance(forced, bool) else None,
})
return snapshot
def _refresh_disabled_tools(self) -> None:
"""刷新禁用工具列表 / Refresh disabled tool set."""
disabled: Set[str] = set()
notice: Set[str] = set()
categories = self.tool_categories_map
for key, category in categories.items():
state = self.tool_category_states.get(key, category.default_enabled)
forced = self.admin_forced_category_states.get(key)
if isinstance(forced, bool):
state = forced
if not state:
disabled.update(category.tools)
if not getattr(category, "silent_when_disabled", False):
notice.update(category.tools)
self.disabled_tools = disabled
self.disabled_notice_tools = notice
def _format_disabled_tool_notice(self) -> Optional[str]:
"""生成禁用工具提示信息 / Format disabled tool notice."""
if getattr(self, "silent_tool_disable", False):
return None
if not self.disabled_notice_tools:
return None
lines = ["=== 工具可用性提醒 ==="]
for tool_name in sorted(self.disabled_notice_tools):
lines.append(f"{tool_name}:已被用户禁用")
lines.append("=== 提示结束 ===")
return "\n".join(lines)
async def run(self):
"""运行主终端循环"""
print(f"\n{OUTPUT_FORMATS['info']} 主终端已启动")
print(f"{OUTPUT_FORMATS['info']} 当前对话: {self.context_manager.current_conversation_id}")
while True:
try:
# 获取用户输入(使用人的表情)
user_input = input("\n👤 > ").strip()
if not user_input:
continue
# 处理命令(命令不记录到对话历史)
if user_input.startswith('/'):
await self.handle_command(user_input[1:])
elif user_input.lower() in ['exit', 'quit', 'q']:
# 用户可能忘记加斜杠
print(f"{OUTPUT_FORMATS['info']} 提示: 使用 /exit 退出系统")
continue
elif user_input.lower() == 'help':
print(f"{OUTPUT_FORMATS['info']} 提示: 使用 /help 查看帮助")
continue
else:
# 确保有活动对话
self._ensure_conversation()
# 只有非命令的输入才记录到对话历史
self.context_manager.add_conversation("user", user_input)
# 新增:开始新的任务会话
self.current_session_id += 1
# AI回复前空一行并显示机器人图标
print("\n🤖 >", end=" ")
await self.handle_task(user_input)
# 回复后自动空一行在handle_task完成后
except KeyboardInterrupt:
print(f"\n{OUTPUT_FORMATS['warning']} 使用 /exit 退出系统")
continue
except Exception as e:
logger.error(f"主终端错误: {e}", exc_info=True)
print(f"{OUTPUT_FORMATS['error']} 发生错误: {e}")
# 错误后仍然尝试自动保存
try:
self.context_manager.auto_save_conversation()
except:
pass
async def handle_command(self, command: str):
"""处理系统命令"""
parts = command.split(maxsplit=1)
cmd = parts[0].lower()
args = parts[1] if len(parts) > 1 else ""
if cmd in self.commands:
await self.commands[cmd](args)
else:
print(f"{OUTPUT_FORMATS['error']} 未知命令: {cmd}")
await self.show_help()
async def handle_task(self, user_input: str):
"""处理用户任务(完全修复版:彻底解决对话记录重复问题)"""
try:
# 如果是思考模式,每个新任务重置状态
# 注意:这里重置的是当前任务的第一次调用标志,确保新用户请求重新思考
if self.thinking_mode:
self.api_client.start_new_task(force_deep=self.deep_thinking_mode)
# 新增:开始新的任务会话
self.current_session_id += 1
# === 上下文预算与安全校验 ===
current_tokens = self.context_manager.get_current_context_tokens()
max_context_tokens = get_model_context_window(self.model_key)
if max_context_tokens:
if current_tokens >= max_context_tokens:
msg = (
f"当前对话上下文已达 {current_tokens} tokens"
f"超过模型上限 {max_context_tokens},请先压缩或清理上下文后再试。"
)
print(f"{OUTPUT_FORMATS['error']} {msg}")
# 记录一条系统消息,方便回溯
self.context_manager.add_conversation("system", msg)
return
usage_percent = (current_tokens / max_context_tokens) * 100
warned = self.context_manager.conversation_metadata.get("context_warning_sent", False)
if usage_percent >= 70 and not warned:
warn_msg = (
f"当前上下文约占 {usage_percent:.1f}%{current_tokens}/{max_context_tokens}"
"建议使用压缩功能。"
)
print(f"{OUTPUT_FORMATS['warning']} {warn_msg}")
self.context_manager.conversation_metadata["context_warning_sent"] = True
self.context_manager.auto_save_conversation(force=True)
# 将上下文预算传给API客户端动态调整 max_tokens
self.api_client.update_context_budget(current_tokens, max_context_tokens)
# 构建上下文
context = self.build_context()
# 构建消息
messages = self.build_messages(context, user_input)
# 定义可用工具
tools = self.define_tools()
# 用于收集本次任务的所有信息(关键:不立即保存到对话历史)
collected_tool_calls = []
collected_tool_results = []
final_response = ""
final_thinking = ""
# 工具处理器:只执行工具,收集信息,绝不保存到对话历史
async def tool_handler(tool_name: str, arguments: Dict) -> str:
# 执行工具调用
result = await self.handle_tool_call(tool_name, arguments)
# 生成工具调用ID
tool_call_id = f"call_{datetime.now().timestamp()}_{tool_name}"
# 收集工具调用信息(不保存)
tool_call_info = {
"id": tool_call_id,
"type": "function",
"function": {
"name": tool_name,
"arguments": json.dumps(arguments, ensure_ascii=False)
}
}
collected_tool_calls.append(tool_call_info)
# 处理工具结果用于保存
try:
parsed = json.loads(result)
result_data = parsed if isinstance(parsed, dict) else {}
except Exception:
result_data = {}
tool_result_content = format_tool_result_for_context(tool_name, result_data, result)
# 收集工具结果(不保存)
collected_tool_results.append({
"tool_call_id": tool_call_id,
"name": tool_name,
"content": tool_result_content,
"system_message": result_data.get("system_message") if isinstance(result_data, dict) else None,
"task_id": result_data.get("task_id") if isinstance(result_data, dict) else None,
"raw_result_data": result_data if result_data else None,
})
return result
# 调用带工具的API模型自己决定是否使用工具
response = await self.api_client.chat_with_tools(
messages=messages,
tools=tools,
tool_handler=tool_handler
)
# 保存响应内容
final_response = response
# 获取思考内容(如果有的话)
if self.api_client.current_task_thinking:
final_thinking = self.api_client.current_task_thinking
# ===== 统一保存到对话历史(关键修复) =====
# 1. 构建助手回复内容(思考内容通过 reasoning_content 存储)
assistant_content = final_response or "已完成操作。"
# 2. 保存assistant消息包含tool_calls但不包含结果
self.context_manager.add_conversation(
"assistant",
assistant_content,
collected_tool_calls if collected_tool_calls else None,
reasoning_content=final_thinking or None
)
# 3. 保存独立的tool消息
for tool_result in collected_tool_results:
self.context_manager.add_conversation(
"tool",
tool_result["content"],
tool_call_id=tool_result["tool_call_id"],
name=tool_result["name"]
)
system_message = tool_result.get("system_message")
if system_message:
self._record_sub_agent_message(system_message, tool_result.get("task_id"), inline=False)
# 4. 在终端显示执行信息(不保存到历史)
if collected_tool_calls:
tool_names = [tc['function']['name'] for tc in collected_tool_calls]
for tool_name in tool_names:
if tool_name == "create_file":
print(f"{OUTPUT_FORMATS['file']} 创建文件")
elif tool_name == "read_file":
print(f"{OUTPUT_FORMATS['file']} 读取文件")
elif tool_name in {"vlm_analyze", "ocr_image"}:
print(f"{OUTPUT_FORMATS['file']} VLM 视觉理解")
elif tool_name == "write_file":
print(f"{OUTPUT_FORMATS['file']} 写入文件")
elif tool_name == "edit_file":
print(f"{OUTPUT_FORMATS['file']} 编辑文件")
elif tool_name == "delete_file":
print(f"{OUTPUT_FORMATS['file']} 删除文件")
elif tool_name == "terminal_session":
print(f"{OUTPUT_FORMATS['session']} 终端会话操作")
elif tool_name == "terminal_input":
print(f"{OUTPUT_FORMATS['terminal']} 执行终端命令")
elif tool_name == "web_search":
print(f"{OUTPUT_FORMATS['search']} 网络搜索")
elif tool_name == "run_python":
print(f"{OUTPUT_FORMATS['code']} 执行Python代码")
elif tool_name == "run_command":
print(f"{OUTPUT_FORMATS['terminal']} 执行系统命令")
elif tool_name == "update_memory":
print(f"{OUTPUT_FORMATS['memory']} 更新记忆")
elif tool_name == "sleep":
print(f"{OUTPUT_FORMATS['info']} 等待操作")
else:
print(f"{OUTPUT_FORMATS['action']} 执行: {tool_name}")
if len(tool_names) > 1:
print(f"{OUTPUT_FORMATS['info']} 共执行 {len(tool_names)} 个操作")
except Exception as e:
logger.error(f"任务处理错误: {e}", exc_info=True)
print(f"{OUTPUT_FORMATS['error']} 任务处理失败: {e}")
# 错误时也尝试自动保存
try:
self.context_manager.auto_save_conversation()
except:
pass
async def show_conversations(self, args: str = ""):
"""显示对话列表"""
try:
limit = 10 # 默认显示最近10个对话
if args:
try:
limit = int(args)
limit = max(1, min(limit, 50)) # 限制在1-50之间
except ValueError:
print(f"{OUTPUT_FORMATS['warning']} 无效数量使用默认值10")
limit = 10
conversations = self.context_manager.get_conversation_list(limit=limit)
if not conversations["conversations"]:
print(f"{OUTPUT_FORMATS['info']} 暂无对话记录")
return
print(f"\n📚 最近 {len(conversations['conversations'])} 个对话:")
print("="*70)
for i, conv in enumerate(conversations["conversations"], 1):
# 状态图标
status_icon = "🟢" if conv["status"] == "active" else "📦" if conv["status"] == "archived" else ""
# 当前对话标记
current_mark = " [当前]" if conv["id"] == self.context_manager.current_conversation_id else ""
# 思考模式标记
mode_mark = "💭" if conv["thinking_mode"] else ""
print(f"{i:2d}. {status_icon} {conv['id'][:16]}...{current_mark}")
print(f" {mode_mark} {conv['title'][:50]}{'...' if len(conv['title']) > 50 else ''}")
print(f" 📅 {conv['updated_at'][:19]} | 💬 {conv['total_messages']} 条消息 | 🔧 {conv['total_tools']} 个工具")
print(f" 📁 {conv['project_path']}")
print()
print(f"总计: {conversations['total']} 个对话")
if conversations["has_more"]:
print(f"使用 /conversations {limit + 10} 查看更多")
except Exception as e:
print(f"{OUTPUT_FORMATS['error']} 获取对话列表失败: {e}")
async def load_conversation_command(self, args: str):
"""加载指定对话"""
if not args:
print(f"{OUTPUT_FORMATS['error']} 请指定对话ID")
print("使用方法: /load <对话ID>")
await self.show_conversations("5") # 显示最近5个对话作为提示
return
conversation_id = args.strip()
try:
success = self.context_manager.load_conversation_by_id(conversation_id)
if success:
print(f"{OUTPUT_FORMATS['success']} 对话已加载: {conversation_id}")
print(f"{OUTPUT_FORMATS['info']} 消息数量: {len(self.context_manager.conversation_history)}")
# 如果是思考模式,重置状态(下次任务会重新思考)
if self.thinking_mode:
self.api_client.start_new_task(force_deep=self.deep_thinking_mode)
self.current_session_id += 1
else:
print(f"{OUTPUT_FORMATS['error']} 对话加载失败")
except Exception as e:
print(f"{OUTPUT_FORMATS['error']} 加载对话异常: {e}")
async def new_conversation_command(self, args: str = ""):
"""创建新对话"""
try:
conversation_id = self.context_manager.start_new_conversation(
project_path=self.project_path,
thinking_mode=self.thinking_mode
)
print(f"{OUTPUT_FORMATS['success']} 已创建新对话: {conversation_id}")
# 重置相关状态
if self.thinking_mode:
self.api_client.start_new_task(force_deep=self.deep_thinking_mode)
self.current_session_id += 1
except Exception as e:
print(f"{OUTPUT_FORMATS['error']} 创建新对话失败: {e}")
async def save_conversation_command(self, args: str = ""):
"""手动保存当前对话"""
try:
success = self.context_manager.save_current_conversation()
if success:
print(f"{OUTPUT_FORMATS['success']} 对话已保存")
else:
print(f"{OUTPUT_FORMATS['error']} 对话保存失败")
except Exception as e:
print(f"{OUTPUT_FORMATS['error']} 保存对话异常: {e}")
# ===== 修改现有命令,集成对话管理 =====
async def clear_conversation(self, args: str = ""):
"""清除对话记录(修改版:创建新对话而不是清空)"""
if input("确认创建新对话? 当前对话将被保存 (y/n): ").lower() == 'y':
try:
# 保存当前对话
if self.context_manager.current_conversation_id:
self.context_manager.save_current_conversation()
# 创建新对话
await self.new_conversation_command()
print(f"{OUTPUT_FORMATS['success']} 已开始新对话")
except Exception as e:
print(f"{OUTPUT_FORMATS['error']} 创建新对话失败: {e}")
async def show_status(self, args: str = ""):
"""显示系统状态"""
# 上下文状态
context_status = self.context_manager.check_context_size()
# 记忆状态
memory_stats = self.memory_manager.get_memory_stats()
# 文件结构
structure = self.context_manager.get_project_structure()
# 终端会话状态
terminal_status = self.terminal_manager.list_terminals()
# 思考模式状态
thinking_status = self.get_run_mode_label()
if self.thinking_mode:
thinking_status += f" ({'等待新任务' if self.api_client.current_task_first_call else '任务进行中'})"
# 新增:对话统计
conversation_stats = self.context_manager.get_conversation_statistics()
status_text = f"""
📊 系统状态:
项目路径: {self.project_path}
运行模式: {thinking_status}
当前对话: {self.context_manager.current_conversation_id or ''}
上下文使用: {context_status['usage_percent']:.1f}%
当前消息: {len(self.context_manager.conversation_history)}
终端会话: {terminal_status['total']}/{terminal_status['max_allowed']}
当前会话ID: {self.current_session_id}
项目文件: {structure['total_files']}
项目大小: {structure['total_size'] / 1024 / 1024:.2f} MB
对话总数: {conversation_stats.get('total_conversations', 0)}
历史消息: {conversation_stats.get('total_messages', 0)}
工具调用: {conversation_stats.get('total_tools', 0)}
主记忆: {memory_stats['main_memory']['lines']}
任务记忆: {memory_stats['task_memory']['lines']}
"""
container_report = self._container_status_report()
if container_report:
status_text += container_report
print(status_text)
def _container_status_report(self) -> str:
session = getattr(self, "container_session", None)
if not session or session.mode != "docker":
return ""
stats = collect_stats(session.container_name, session.sandbox_bin)
state = inspect_state(session.container_name, session.sandbox_bin)
lines = [f" 容器: {session.container_name or '未知'}"]
if stats:
cpu = stats.get("cpu_percent")
mem = stats.get("memory", {})
net = stats.get("net_io", {})
block = stats.get("block_io", {})
lines.append(f" CPU: {cpu:.2f}%" if cpu is not None else " CPU: 未知")
if mem:
used = mem.get("used_bytes")
limit = mem.get("limit_bytes")
percent = mem.get("percent")
mem_line = " 内存: "
if used is not None:
mem_line += f"{used / (1024 * 1024):.2f}MB"
if limit:
mem_line += f" / {limit / (1024 * 1024):.2f}MB"
if percent is not None:
mem_line += f" ({percent:.2f}%)"
lines.append(mem_line)
if net:
rx = net.get("rx_bytes") or 0
tx = net.get("tx_bytes") or 0
lines.append(f" 网络: ↓{rx/1024:.1f}KB ↑{tx/1024:.1f}KB")
if block:
read = block.get("read_bytes") or 0
write = block.get("write_bytes") or 0
lines.append(f" 磁盘: 读 {read/1024:.1f}KB / 写 {write/1024:.1f}KB")
else:
lines.append(" 指标: 暂无")
if state:
lines.append(f" 状态: {state.get('status')}")
return "\n".join(lines) + "\n"
async def save_state(self):
"""保存状态"""
try:
# 保存对话历史(使用新的持久化系统)
self.context_manager.save_current_conversation()
# 保存文件备注
self.context_manager.save_annotations()
print(f"{OUTPUT_FORMATS['success']} 状态已保存")
except Exception as e:
print(f"{OUTPUT_FORMATS['error']} 状态保存失败: {e}")
async def show_help(self, args: str = ""):
"""显示帮助信息"""
# 根据当前模式显示不同的帮助信息
mode_info = ""
if self.thinking_mode:
mode_info = "\n💡 思考模式:\n - 每个新任务首次调用深度思考\n - 同一任务后续调用快速响应\n - 每个新任务都会重新思考"
else:
mode_info = "\n⚡ 快速模式:\n - 不进行思考,直接响应\n - 适合简单任务和快速交互"
help_text = f"""
📚 可用命令:
/help - 显示此帮助信息
/exit - 退出系统
/status - 显示系统状态
/memory - 管理记忆
/clear - 创建新对话
/history - 显示对话历史
/files - 显示项目文件
/focused - 显示聚焦文件
/terminals - 显示终端会话
/mode - 切换运行模式
🗂️ 对话管理:
/conversations [数量] - 显示对话列表
/load <对话ID> - 加载指定对话
/new - 创建新对话
/save - 手动保存当前对话
💡 使用提示:
- 直接输入任务描述,系统会自动判断是否需要执行
- 使用 Ctrl+C 可以中断当前操作
- 重要操作会要求确认
- 所有对话都会自动保存,不用担心丢失
🔍 文件聚焦功能:
- 系统可以聚焦最多3个文件实现"边看边改"
- 聚焦的文件内容会持续显示在上下文中
- 适合需要频繁查看和修改的文件
📺 持久化终端:
- 可以打开最多3个终端会话
- 终端保持运行状态,支持交互式程序
- 使用 terminal_session 和 terminal_input 工具控制{mode_info}
"""
print(help_text)
# ===== 保持原有的其他方法不变,只需要小修改 =====
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 {}
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
# 自定义工具只对管理员开放,定义来自文件 registry
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 = []
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
return tools
def define_tools(self) -> List[Dict]:
"""定义可用工具(添加确认工具)"""
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
tools = [
{
"type": "function",
"function": {
"name": "sleep",
"description": "等待指定的秒数,用于短暂延迟/节奏控制(例如让终端产生更多输出、或在两次快照之间留出间隔)。命令是否完成必须用 terminal_snapshot 确认;需要控制命令最长运行时间请使用 run_command/terminal_input 的 timeout。",
"parameters": {
"type": "object",
"properties": self._inject_intent({
"seconds": {
"type": "number",
"description": "等待的秒数可以是小数如0.2秒。建议范围0.1-10秒"
},
"reason": {
"type": "string",
"description": "等待的原因说明(可选)"
}
}),
"required": ["seconds"]
}
}
},
{
"type": "function",
"function": {
"name": "create_file",
"description": "创建新文件(仅创建空文件,正文请使用 write_file 或 edit_file 写入/替换)",
"parameters": {
"type": "object",
"properties": self._inject_intent({
"path": {"type": "string", "description": "文件路径"},
"file_type": {"type": "string", "enum": ["txt", "py", "md"], "description": "文件类型"},
"annotation": {"type": "string", "description": "文件备注"}
}),
"required": ["path", "file_type", "annotation"]
}
}
},
{
"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_file",
"description": "读取/搜索/抽取 UTF-8 文本文件内容。通过 type 参数选择 read阅读、search搜索、extract具体行段支持限制返回字符数。若文件非 UTF-8 或过大,请改用 run_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": "要修改文件的相对路径"
},
"old_string": {
"type": "string",
"description": "要替换的文本(需与文件内容精确匹配,保留缩进)"
},
"new_string": {
"type": "string",
"description": "用于替换的新文本(必须不同于 old_string"
}
}),
"required": ["file_path", "old_string", "new_string"]
}
}
},
{
"type": "function",
"function": {
"name": "vlm_analyze",
"description": "使用大参数视觉语言模型Qwen-VL模型理解图片文字、物体、布局、表格等仅支持本地路径。",
"parameters": {
"type": "object",
"properties": self._inject_intent({
"path": {"type": "string", "description": "项目内的图片相对路径"},
"prompt": {"type": "string", "description": "传递给 VLM 的中文提示词,如“请总结这张图的内容”“表格的总金额是多少”“图中是什么车?”。"}
}),
"required": ["path", "prompt"]
}
}
},
{
"type": "function",
"function": {
"name": "delete_file",
"description": "删除文件",
"parameters": {
"type": "object",
"properties": self._inject_intent({
"path": {"type": "string", "description": "文件路径"}
}),
"required": ["path"]
}
}
},
{
"type": "function",
"function": {
"name": "rename_file",
"description": "重命名文件",
"parameters": {
"type": "object",
"properties": self._inject_intent({
"old_path": {"type": "string", "description": "原文件路径"},
"new_path": {"type": "string", "description": "新文件路径"}
}),
"required": ["old_path", "new_path"]
}
}
},
{
"type": "function",
"function": {
"name": "create_folder",
"description": "创建文件夹",
"parameters": {
"type": "object",
"properties": self._inject_intent({
"path": {"type": "string", "description": "文件夹路径"}
}),
"required": ["path"]
}
}
},
{
"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", "switch"],
"description": "操作类型open-打开新终端close-关闭终端list-列出所有终端switch-切换活动终端"
},
"session_name": {
"type": "string",
"description": "终端会话名称open、close、switch时需要"
},
"working_dir": {
"type": "string",
"description": "工作目录相对于项目路径open时可选"
}
}),
"required": ["action"]
}
}
},
{
"type": "function",
"function": {
"name": "terminal_input",
"description": "向活动终端发送命令或输入。禁止启动会占用终端界面的程序python/node/nano/vim 等);如遇卡死请结合 terminal_snapshot 并使用 terminal_reset 恢复。timeout 必填:\n1) 传入数字最大300会对命令进行硬超时封装。系统终端执行环境若存在 timeout/gtimeout会采用类似 timeout -k 2 {秒}s sh -c '命令; echo __CMD_DONE__...__' 的封装;若没有 timeout/gtimeout少见情况则退化为外层 sh -c 的 sleep/kill 包装例如sh -c '运行的指令 & CMD_PID=$!; (sleep 300 && kill -s INT $CMD_PID >/dev/null 2>&1 && sleep 2 && kill -s KILL $CMD_PID >/dev/null 2>&1) & WAITER=$!; wait $CMD_PID; CMD_STATUS=$?; kill $WAITER >/dev/null 2>&1; echo __CMD_DONE__1770826047975__; exit $CMD_STATUS'。超时后会先 INT 再 KILL进程会被不可恢复地打断可能留下半写文件、锁或残留子进程\n2) 传入 never 表示不封装、不杀进程,命令原样进入终端并维护状态;此时快照可能无法判断完成情况,需要用 curl/ps/ls 等主动验证。\n适合 timeout=never 的场景示例:启动常驻服务/开发服务器npm run dev、python web_server.py、uvicorn ...)、开启后台进程后在另一个终端测试、在后台运行时间极长的任务同时做其他事情、持续输出/长时间任务tail -f 日志、长时间编译/训练/备份/大下载、需要维持会话状态的操作例如登录远程服务器后连续执行多条命令。适合用数字超时的示例ls/rg/pytest/短脚本等快速命令。\n若不确定上一条命令是否结束,先用 terminal_snapshot 确认后再继续输入。",
"parameters": {
"type": "object",
"properties": self._inject_intent({
"command": {
"type": "string",
"description": "要执行的命令或发送的输入"
},
"session_name": {
"type": "string",
"description": "目标终端会话名称(可选,默认使用活动终端)"
},
"timeout": {
"type": ["number", "string"],
"description": "等待输出的最长秒数必填最大300填 never 表示不封装超时且不中断进程(数字超时会触发外层封装)"
}
}),
"required": ["command", "timeout"]
}
}
},
{
"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": "terminal_reset",
"description": "重置指定终端:关闭当前进程并重新创建同名会话,用于从卡死或非法状态中恢复。请在总结中说明重置原因。",
"parameters": {
"type": "object",
"properties": self._inject_intent({
"session_name": {
"type": "string",
"description": "目标终端会话名称(可选,默认活动终端)"
}
})
}
}
},
{
"type": "function",
"function": {
"name": "web_search",
"description": f"当现有资料不足时搜索外部信息(当前时间 {current_time})。调用前说明目的,精准撰写 query并合理设置时间/主题参数;避免重复或无意义的搜索。",
"parameters": {
"type": "object",
"properties": self._inject_intent({
"query": {
"type": "string",
"description": "搜索查询内容(不要包含日期或时间范围)"
},
"max_results": {
"type": "integer",
"description": "最大结果数,可选"
},
"topic": {
"type": "string",
"description": "搜索主题可选值general默认/news/finance"
},
"time_range": {
"type": "string",
"description": "相对时间范围,可选 day/week/month/year支持缩写 d/w/m/y与 days 和 start_date/end_date 互斥"
},
"days": {
"type": "integer",
"description": "最近 N 天,仅当 topic=news 时可用;与 time_range、start_date/end_date 互斥"
},
"start_date": {
"type": "string",
"description": "开始日期YYYY-MM-DD必须与 end_date 同时提供,与 time_range、days 互斥"
},
"end_date": {
"type": "string",
"description": "结束日期YYYY-MM-DD必须与 start_date 同时提供,与 time_range、days 互斥"
},
"country": {
"type": "string",
"description": "国家过滤,仅 topic=general 可用,使用英文小写国名"
}
}),
"required": ["query"]
}
}
},
{
"type": "function",
"function": {
"name": "extract_webpage",
"description": "在 web_search 结果不够详细时提取网页正文。调用前说明用途,注意提取内容会消耗大量 token超过80000字符将被拒绝。",
"parameters": {
"type": "object",
"properties": self._inject_intent({
"url": {"type": "string", "description": "要提取内容的网页URL"}
}),
"required": ["url"]
}
}
},
{
"type": "function",
"function": {
"name": "save_webpage",
"description": "提取网页内容并保存为纯文本文件,适合需要长期留存的长文档。请提供网址与目标路径(含 .txt 后缀),落地后请通过终端命令查看。",
"parameters": {
"type": "object",
"properties": self._inject_intent({
"url": {"type": "string", "description": "要保存的网页URL"},
"target_path": {"type": "string", "description": "保存位置,包含文件名,相对于项目根目录"}
}),
"required": ["url", "target_path"]
}
}
},
{
"type": "function",
"function": {
"name": "run_python",
"description": "执行一次性 Python 脚本,可用于处理二进制或非 UTF-8 文件(如 Excel、Word、PDF、图片或进行数据分析与验证。必须提供 timeout最长60秒一旦超时脚本会被打断且无法继续执行需要重新运行并返回已捕获输出。",
"parameters": {
"type": "object",
"properties": self._inject_intent({
"code": {"type": "string", "description": "Python代码"},
"timeout": {
"type": "number",
"description": "超时时长必填最大60"
}
}),
"required": ["code", "timeout"]
}
}
},
{
"type": "function",
"function": {
"name": "run_command",
"description": "执行一次性终端命令适合查看文件信息file/ls/stat/iconv 等)、转换编码或调用 CLI 工具。禁止启动交互式程序;对已聚焦文件仅允许使用 grep -n 等定位命令。必须提供 timeout最长30秒一旦超时命令**一定会被打断**且无法继续执行需要重新运行并返回已捕获输出输出超过10000字符将被截断或拒绝。",
"parameters": {
"type": "object",
"properties": self._inject_intent({
"command": {"type": "string", "description": "终端命令"},
"timeout": {
"type": "number",
"description": "超时时长必填最大30"
}
}),
"required": ["command", "timeout"]
}
}
},
{
"type": "function",
"function": {
"name": "update_memory",
"description": "按条目更新记忆列表自动编号。append 追加新条目replace 用序号替换delete 用序号删除。",
"parameters": {
"type": "object",
"properties": self._inject_intent({
"memory_type": {"type": "string", "enum": ["main", "task"], "description": "记忆类型"},
"content": {"type": "string", "description": "条目内容。append/replace 时必填"},
"operation": {"type": "string", "enum": ["append", "replace", "delete"], "description": "操作类型"},
"index": {"type": "integer", "description": "要替换/删除的序号从1开始"}
}),
"required": ["memory_type", "operation"]
}
}
},
{
"type": "function",
"function": {
"name": "todo_create",
"description": "创建待办列表,最多 8 条任务;若已有列表将被覆盖。",
"parameters": {
"type": "object",
"properties": self._inject_intent({
"overview": {"type": "string", "description": "一句话概述待办清单要完成的目标50 字以内。"},
"tasks": {
"type": "array",
"description": "任务列表1~8 条,每条写清“动词+对象+目标”。",
"items": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "单个任务描述,写成可执行的步骤"}
},
"required": ["title"]
},
"minItems": 1,
"maxItems": 8
}
}),
"required": ["overview", "tasks"]
}
}
},
{
"type": "function",
"function": {
"name": "todo_update_task",
"description": "批量勾选或取消任务(支持单个或多个任务);全部勾选时提示所有任务已完成。",
"parameters": {
"type": "object",
"properties": self._inject_intent({
"task_index": {"type": "integer", "description": "任务序号1-8兼容旧参数"},
"task_indices": {
"type": "array",
"items": {"type": "integer"},
"minItems": 1,
"maxItems": 8,
"description": "要更新的任务序号列表1-8可一次勾选多个"
},
"completed": {"type": "boolean", "description": "true=打勾false=取消"}
}),
"required": ["completed"]
}
}
},
{
"type": "function",
"function": {
"name": "close_sub_agent",
"description": "强制关闭指定子智能体,适用于长时间无响应、超时或卡死的任务。使用前请确认必要的日志/文件已保留,操作会立即终止该任务。",
"parameters": {
"type": "object",
"properties": self._inject_intent({
"task_id": {"type": "string", "description": "子智能体任务ID"},
"agent_id": {"type": "integer", "description": "子智能体编号1~5若缺少 task_id 可用"}
})
}
}
},
{
"type": "function",
"function": {
"name": "create_sub_agent",
"description": "创建新的子智能体任务。适合大规模信息搜集、网页提取与多文档总结等会占用大量上下文的工作需要提供任务摘要、详细要求、交付目录以及参考文件。注意同一时间最多运行5个子智能体。",
"parameters": {
"type": "object",
"properties": self._inject_intent({
"agent_id": {"type": "integer", "description": "子智能体代号1~5"},
"summary": {"type": "string", "description": "任务摘要,简要说明目标"},
"task": {"type": "string", "description": "任务详细要求"},
"target_dir": {"type": "string", "description": "项目下用于接收交付的相对目录"},
"reference_files": {
"type": "array",
"description": "提供给子智能体的参考文件列表相对路径禁止在summary和task中直接告知子智能体引用图片的路径必须使用本参数提供",
"items": {"type": "string"},
"maxItems": 10
},
"timeout_seconds": {"type": "integer", "description": "子智能体最大运行秒数:单/双次搜索建议180秒多轮搜索整理建议300秒深度调研或长篇分析可设600秒"}
}),
"required": ["agent_id", "summary", "task", "target_dir"]
}
}
},
{
"type": "function",
"function": {
"name": "wait_sub_agent",
"description": "等待指定子智能体任务结束(或超时)。任务完成后会返回交付目录,并将结果复制到指定的项目文件夹。调用时 `timeout_seconds` 应不少于对应子智能体的 `timeout_seconds`,否则可能提前终止等待。",
"parameters": {
"type": "object",
"properties": self._inject_intent({
"task_id": {"type": "string", "description": "子智能体任务ID"},
"agent_id": {"type": "integer", "description": "子智能体代号(可选,用于缺省 task_id 的情况)"},
"timeout_seconds": {"type": "integer", "description": "本次等待的超时时长(秒)"}
}),
"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"]
}
}
}
]
# 视觉模型Qwen-VL / Kimi-k2.5)自带多模态能力,不再暴露 vlm_analyze改为 view_image
if getattr(self, "model_key", None) in {"qwen3-vl-plus", "kimi-k2.5"}:
tools = [
tool for tool in tools
if (tool.get("function") or {}).get("name") != "vlm_analyze"
]
tools.append({
"type": "function",
"function": {
"name": "view_image",
"description": "将指定本地图片插入到对话中(系统代发一条包含图片的消息),便于模型主动查看图片内容。",
"parameters": {
"type": "object",
"properties": self._inject_intent({
"path": {
"type": "string",
"description": "项目内的图片相对路径(不要以 /workspace 开头),支持 png/jpg/webp/gif/bmp/svg。"
}
}),
"required": ["path"]
}
}
})
if getattr(self, "model_key", None) == "kimi-k2.5":
tools.append({
"type": "function",
"function": {
"name": "view_video",
"description": "将指定本地视频插入到对话中(系统代发一条包含视频的消息),便于模型查看视频内容。",
"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)
if self.disabled_tools:
tools = [
tool for tool in tools
if tool.get("function", {}).get("name") not in self.disabled_tools
]
return self._apply_intent_to_tools(tools)
async def handle_tool_call(self, tool_name: str, arguments: Dict) -> str:
"""处理工具调用(添加参数预检查和改进错误处理)"""
# 导入字符限制配置
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)
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)
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)
if path.startswith("/workspace"):
return json.dumps({"success": False, "error": "非法路径,超出项目根目录,请使用不带/workspace的相对路径"}, ensure_ascii=False)
abs_path = (Path(self.context_manager.project_path) / path).resolve()
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": "图片已请求插入到对话中,将在后续消息中呈现。", "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)
if path.startswith("/workspace"):
return json.dumps({"success": False, "error": "非法路径,超出项目根目录,请使用相对路径"}, ensure_ascii=False)
abs_path = (Path(self.context_manager.project_path) / path).resolve()
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": "视频已请求插入到对话中,将在后续消息中呈现。", "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 == "switch":
result = self.terminal_manager.switch_terminal(
session_name=arguments.get("session_name", "default")
)
if result["success"]:
print(f"{OUTPUT_FORMATS['session']} 切换到终端: {arguments.get('session_name', 'default')}")
else:
result = {"success": False, "error": f"未知操作: {action}"}
result["action"] = action
# 终端输入工具
elif tool_name == "terminal_input":
result = self.terminal_manager.send_to_terminal(
command=arguments["command"],
session_name=arguments.get("session_name"),
timeout=arguments.get("timeout")
)
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")
)
elif tool_name == "terminal_reset":
result = self.terminal_manager.reset_terminal(
session_name=arguments.get("session_name")
)
if result["success"]:
print(f"{OUTPUT_FORMATS['session']} 终端会话已重置: {result['session']}")
# sleep工具
elif tool_name == "sleep":
seconds = arguments.get("seconds", 1)
reason = arguments.get("reason", "等待操作完成")
# 限制最大等待时间
max_sleep = 600 # 最多等待60秒
if seconds > max_sleep:
result = {
"success": False,
"error": f"等待时间过长,最多允许 {max_sleep}",
"suggestion": f"建议分多次等待或减少等待时间"
}
else:
# 确保秒数为正数
if seconds <= 0:
result = {
"success": False,
"error": "等待时间必须大于0"
}
else:
print(f"{OUTPUT_FORMATS['info']} 等待 {seconds} 秒: {reason}")
# 执行等待
import asyncio
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 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":
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)
elif tool_name == "edit_file":
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")
)
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":
result = await self.terminal_ops.run_command(
arguments["command"],
timeout=arguments.get("timeout")
)
# 字符数检查
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":
memory_type = arguments["memory_type"]
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:
result = self.memory_manager.update_entries(
memory_type=memory_type,
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", ""),
target_dir=arguments.get("target_dir", ""),
reference_files=arguments.get("reference_files", []),
timeout_seconds=arguments.get("timeout_seconds"),
conversation_id=self.context_manager.current_conversation_id
)
elif tool_name == "wait_sub_agent":
wait_timeout = arguments.get("timeout_seconds")
if not wait_timeout:
task_ref = self.sub_agent_manager.lookup_task(
task_id=arguments.get("task_id"),
agent_id=arguments.get("agent_id")
)
if task_ref:
wait_timeout = task_ref.get("timeout_seconds")
result = self.sub_agent_manager.wait_for_completion(
task_id=arguments.get("task_id"),
agent_id=arguments.get("agent_id"),
timeout_seconds=wait_timeout
)
elif tool_name == "close_sub_agent":
result = self.sub_agent_manager.terminate_sub_agent(
task_id=arguments.get("task_id"),
agent_id=arguments.get("agent_id")
)
elif tool_name == "trigger_easter_egg":
result = self.easter_egg_manager.trigger_effect(arguments.get("effect"))
else:
result = {"success": False, "error": f"未知工具: {tool_name}"}
except Exception as e:
logger.error(f"工具执行失败: {tool_name} - {e}")
result = {"success": False, "error": f"工具执行异常: {str(e)}"}
return json.dumps(result, ensure_ascii=False)
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'
def build_context(self) -> Dict:
"""构建主终端上下文"""
# 读取记忆
memory = self.memory_manager.read_main_memory()
# 构建上下文
return self.context_manager.build_main_context(memory)
def _tool_calls_followed_by_tools(self, conversation: List[Dict], start_idx: int, tool_calls: List[Dict]) -> bool:
"""判断指定助手消息的工具调用是否拥有后续的工具响应。"""
if not tool_calls:
return False
expected_ids = [tc.get("id") for tc in tool_calls if tc.get("id")]
if not expected_ids:
return False
matched: Set[str] = set()
idx = start_idx + 1
total = len(conversation)
while idx < total and len(matched) < len(expected_ids):
next_conv = conversation[idx]
role = next_conv.get("role")
if role == "tool":
call_id = next_conv.get("tool_call_id")
if call_id in expected_ids:
matched.add(call_id)
else:
break
elif role in ("assistant", "user"):
break
idx += 1
return len(matched) == len(expected_ids)
def build_messages(self, context: Dict, user_input: str) -> List[Dict]:
"""构建消息列表(添加终端内容注入)"""
# 加载系统提示Qwen-VL 使用专用提示)
prompt_name = "main_system_qwenvl" if getattr(self, "model_key", "kimi") in {"qwen3-vl-plus", "kimi-k2.5"} else "main_system"
system_prompt = self.load_prompt(prompt_name)
# 格式化系统提示
container_path = self.container_mount_path or "/workspace"
container_cpus = self.container_cpu_limit
container_memory = self.container_memory_limit
project_storage = self.project_storage_limit
model_key = getattr(self, "model_key", "kimi")
prompt_replacements = get_model_prompt_replacements(model_key)
system_prompt = system_prompt.format(
project_path=container_path,
container_path=container_path,
container_cpus=container_cpus,
container_memory=container_memory,
project_storage=project_storage,
file_tree=context["project_info"]["file_tree"],
memory=context["memory"],
current_time=datetime.now().strftime("%Y-%m-%d"),
model_description=prompt_replacements.get("model_description", "")
)
messages = [
{"role": "system", "content": system_prompt}
]
personalization_config = getattr(self.context_manager, "custom_personalization_config", None) or load_personalization_config(self.data_dir)
skills_catalog = get_skills_catalog()
enabled_skills = merge_enabled_skills(
personalization_config.get("enabled_skills") if isinstance(personalization_config, dict) else None,
skills_catalog,
personalization_config.get("skills_catalog_snapshot") if isinstance(personalization_config, dict) else None,
)
skills_template = self.load_prompt("skills_system").strip()
skills_list = build_skills_list(skills_catalog, enabled_skills)
skills_prompt = build_skills_prompt(skills_template, skills_list)
if skills_prompt:
messages.append({"role": "system", "content": skills_prompt})
workspace_system = self.context_manager._build_workspace_system_message(context)
if workspace_system:
messages.append({"role": "system", "content": workspace_system})
if self.tool_category_states.get("todo", True):
todo_prompt = self.load_prompt("todo_guidelines").strip()
if todo_prompt:
messages.append({"role": "system", "content": todo_prompt})
if self.tool_category_states.get("sub_agent", True):
sub_agent_prompt = self.load_prompt("sub_agent_guidelines").strip()
if sub_agent_prompt:
messages.append({"role": "system", "content": sub_agent_prompt})
if self.deep_thinking_mode:
deep_prompt = self.load_prompt("deep_thinking_mode_guidelines").strip()
if deep_prompt:
deep_prompt = deep_prompt.format(
deep_thinking_line=prompt_replacements.get("deep_thinking_line", "")
)
messages.append({"role": "system", "content": deep_prompt})
elif self.thinking_mode:
thinking_prompt = self.load_prompt("thinking_mode_guidelines").strip()
if thinking_prompt:
thinking_prompt = thinking_prompt.format(
thinking_model_line=prompt_replacements.get("thinking_model_line", "")
)
messages.append({"role": "system", "content": thinking_prompt})
# 支持按对话覆盖的个性化配置
personalization_block = build_personalization_prompt(personalization_config, include_header=False)
if personalization_block:
personalization_template = self.load_prompt("personalization").strip()
if personalization_template and "{personalization_block}" in personalization_template:
personalization_text = personalization_template.format(personalization_block=personalization_block)
elif personalization_template:
personalization_text = f"{personalization_template}\n{personalization_block}"
else:
personalization_text = personalization_block
messages.append({"role": "system", "content": personalization_text})
# 支持按对话覆盖的自定义 system promptAPI 用途)。
# 放在最后一个 system 消息位置,确保优先级最高,便于业务场景强约束。
custom_system_prompt = getattr(self.context_manager, "custom_system_prompt", None)
if isinstance(custom_system_prompt, str) and custom_system_prompt.strip():
messages.append({"role": "system", "content": custom_system_prompt.strip()})
# 添加对话历史保留完整结构包括tool_calls和tool消息
conversation = context["conversation"]
for idx, conv in enumerate(conversation):
metadata = conv.get("metadata") or {}
if conv["role"] == "assistant":
# Assistant消息可能包含工具调用
message = {
"role": conv["role"],
"content": conv["content"]
}
reasoning = conv.get("reasoning_content")
if reasoning:
message["reasoning_content"] = reasoning
# 如果有工具调用信息,添加到消息中
tool_calls = conv.get("tool_calls") or []
if tool_calls and self._tool_calls_followed_by_tools(conversation, idx, tool_calls):
message["tool_calls"] = tool_calls
messages.append(message)
elif conv["role"] == "tool":
# Tool消息需要保留完整结构
message = {
"role": "tool",
"content": conv["content"],
"tool_call_id": conv.get("tool_call_id", ""),
"name": conv.get("name", "")
}
messages.append(message)
elif conv["role"] == "system" and metadata.get("sub_agent_notice"):
# 转换为用户消息,让模型能及时响应
messages.append({
"role": "user",
"content": conv["content"]
})
else:
# User 或普通 System 消息
images = conv.get("images") or metadata.get("images") or []
videos = conv.get("videos") or metadata.get("videos") or []
content_payload = (
self.context_manager._build_content_with_images(conv["content"], images, videos)
if (images or videos) else conv["content"]
)
messages.append({
"role": conv["role"],
"content": content_payload
})
# 当前用户输入已经在conversation中了不需要重复添加
todo_message = self.context_manager.render_todo_system_message()
if todo_message:
messages.append({
"role": "system",
"content": todo_message
})
disabled_notice = self._format_disabled_tool_notice()
if disabled_notice:
messages.append({
"role": "system",
"content": disabled_notice
})
return messages
def load_prompt(self, name: str) -> str:
"""加载提示模板"""
prompt_file = Path(PROMPTS_DIR) / f"{name}.txt"
if prompt_file.exists():
with open(prompt_file, 'r', encoding='utf-8') as f:
return f.read()
return "你是一个AI助手。"
async def show_terminals(self, args: str = ""):
"""显示终端会话列表"""
result = self.terminal_manager.list_terminals()
if result["total"] == 0:
print(f"{OUTPUT_FORMATS['info']} 当前没有活动的终端会话")
else:
print(f"\n📺 终端会话列表 ({result['total']}/{result['max_allowed']}):")
print("="*50)
for session in result["sessions"]:
status_icon = "🟢" if session["is_running"] else "🔴"
active_mark = " [活动]" if session["is_active"] else ""
print(f" {status_icon} {session['session_name']}{active_mark}")
print(f" 工作目录: {session['working_dir']}")
print(f" Shell: {session['shell']}")
print(f" 运行时间: {session['uptime_seconds']:.1f}")
if session["is_interactive"]:
print(f" ⚠️ 等待输入")
print("="*50)
async def exit_system(self, args: str = ""):
"""退出系统"""
print(f"{OUTPUT_FORMATS['info']} 正在退出...")
# 关闭所有终端会话
self.terminal_manager.close_all()
# 保存状态
await self.save_state()
exit(0)
async def manage_memory(self, args: str = ""):
"""管理记忆"""
if not args:
print("""
🧠 记忆管理:
/memory show [main|task] - 显示记忆内容
/memory edit [main|task] - 编辑记忆
/memory clear task - 清空任务记忆
/memory merge - 合并任务记忆到主记忆
/memory backup [main|task]- 备份记忆
""")
return
parts = args.split()
action = parts[0] if parts else ""
target = parts[1] if len(parts) > 1 else "main"
if action == "show":
if target == "main":
content = self.memory_manager.read_main_memory()
else:
content = self.memory_manager.read_task_memory()
print(f"\n{'='*50}")
print(content)
print('='*50)
elif action == "clear" and target == "task":
if input("确认清空任务记忆? (y/n): ").lower() == 'y':
self.memory_manager.clear_task_memory()
elif action == "merge":
self.memory_manager.merge_memories()
elif action == "backup":
path = self.memory_manager.backup_memory(target)
if path:
print(f"备份保存到: {path}")
async def show_history(self, args: str = ""):
"""显示对话历史"""
history = self.context_manager.conversation_history[-2000:] # 最近2000条
print("\n📜 对话历史:")
print("="*50)
for conv in history:
timestamp = conv.get("timestamp", "")
if conv["role"] == "user":
role = "👤 用户"
elif conv["role"] == "assistant":
role = "🤖 助手"
elif conv["role"] == "tool":
role = f"🔧 工具[{conv.get('name', 'unknown')}]"
else:
role = conv["role"]
content = conv["content"][:100] + "..." if len(conv["content"]) > 100 else conv["content"]
print(f"\n[{timestamp[:19]}] {role}:")
print(content)
# 如果是助手消息且有工具调用,显示工具信息
if conv["role"] == "assistant" and "tool_calls" in conv and conv["tool_calls"]:
tools = [tc["function"]["name"] for tc in conv["tool_calls"]]
print(f" 🔗 调用工具: {', '.join(tools)}")
print("="*50)
async def show_files(self, args: str = ""):
"""显示项目文件"""
if self.context_manager._is_host_mode_without_safety():
print("\n⚠️ 宿主机模式下文件树不可用")
return
structure = self.context_manager.get_project_structure()
print(f"\n📁 项目文件结构:")
print(self.context_manager._build_file_tree(structure))
print(f"\n总计: {structure['total_files']} 个文件, {structure['total_size'] / 1024 / 1024:.2f} MB")
def set_run_mode(self, mode: str) -> str:
"""统一设置运行模式"""
allowed = ["fast", "thinking", "deep"]
normalized = mode.lower()
if normalized not in allowed:
raise ValueError(f"不支持的模式: {mode}")
# Qwen-VL 官方不支持深度思考模式
if getattr(self, "model_key", None) == "qwen3-vl-plus" and normalized == "deep":
raise ValueError("Qwen-VL 不支持深度思考模式")
# fast-only 模型限制
if getattr(self, "model_profile", {}).get("fast_only") and normalized != "fast":
raise ValueError("当前模型仅支持快速模式")
previous_mode = getattr(self, "run_mode", "fast")
self.run_mode = normalized
self.thinking_mode = normalized != "fast"
self.deep_thinking_mode = normalized == "deep"
self.api_client.thinking_mode = self.thinking_mode
self.api_client.set_deep_thinking_mode(self.deep_thinking_mode)
if self.deep_thinking_mode:
self.api_client.force_thinking_next_call = False
self.api_client.skip_thinking_next_call = False
if not self.thinking_mode:
self.api_client.start_new_task()
elif previous_mode == "deep" and normalized != "deep":
self.api_client.start_new_task()
return self.run_mode
def apply_model_profile(self, profile: dict):
"""将模型配置应用到 API 客户端"""
if not profile:
return
self.api_client.apply_profile(profile)
def set_model(self, model_key: str) -> str:
profile = get_model_profile(model_key)
if getattr(self.context_manager, "has_images", False) and model_key not in {"qwen3-vl-plus", "kimi-k2.5"}:
raise ValueError("当前对话包含图片,仅支持 Qwen-VL 或 Kimi-k2.5")
if getattr(self.context_manager, "has_videos", False) and model_key != "kimi-k2.5":
raise ValueError("当前对话包含视频,仅支持 Kimi-k2.5")
self.model_key = model_key
self.model_profile = profile
# 将模型标识传递给底层 API 客户端,便于按模型做兼容处理
self.api_client.model_key = model_key
# 应用模型配置
self.apply_model_profile(profile)
# fast-only 模型强制快速模式
if profile.get("fast_only") and self.run_mode != "fast":
self.set_run_mode("fast")
# Qwen-VL 不支持深度思考,自动回落到思考模式
if model_key == "qwen3-vl-plus" and self.run_mode == "deep":
self.set_run_mode("thinking")
# 如果模型支持思考,但当前 run_mode 为 thinking/deep则保持否则无需调整
self.api_client.start_new_task(force_deep=self.deep_thinking_mode)
return self.model_key
def get_run_mode_label(self) -> str:
labels = {
"fast": "快速模式(无思考)",
"thinking": "思考模式(首次调用使用思考模型)",
"deep": "深度思考模式(整轮使用思考模型)"
}
return labels.get(self.run_mode, "快速模式(无思考)")
async def toggle_mode(self, args: str = ""):
"""切换运行模式"""
modes = ["fast", "thinking", "deep"]
target_mode = ""
if args:
candidate = args.strip().lower()
if candidate not in modes:
print(f"{OUTPUT_FORMATS['error']} 无效模式: {args}。可选: fast / thinking / deep")
return
target_mode = candidate
else:
current_index = modes.index(self.run_mode) if self.run_mode in modes else 0
target_mode = modes[(current_index + 1) % len(modes)]
if target_mode == self.run_mode:
print(f"{OUTPUT_FORMATS['info']} 当前已是 {self.get_run_mode_label()}")
return
try:
self.set_run_mode(target_mode)
print(f"{OUTPUT_FORMATS['info']} 已切换到: {self.get_run_mode_label()}")
except ValueError as exc:
print(f"{OUTPUT_FORMATS['error']} {exc}")