agent-Specialization/core/main_terminal.py

2705 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, MAX_FOCUS_FILE_CHARS,
TERMINAL_SANDBOX_MOUNT_PATH,
TERMINAL_SANDBOX_CPUS,
TERMINAL_SANDBOX_MEMORY,
PROJECT_MAX_STORAGE_MB,
)
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, MAX_FOCUS_FILE_CHARS,
TERMINAL_SANDBOX_MOUNT_PATH,
TERMINAL_SANDBOX_CPUS,
TERMINAL_SANDBOX_MEMORY,
PROJECT_MAX_STORAGE_MB,
)
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,
)
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
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"
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.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,
)
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.focused_files = {} # {path: content} 存储聚焦的文件内容
self.current_session_id = 0 # 用于标识不同的任务会话
# 工具启用状态
self.tool_category_states = {
key: category.default_enabled
for key, category in TOOL_CATEGORIES.items()
}
self.disabled_tools = set()
self.disabled_notice_tools = 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,
"focused": self.show_focused_files,
"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
#self.context_manager._focused_files = self.focused_files # 引用传递
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 record_model_call(self, is_thinking: bool):
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):
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 TOOL_CATEGORIES
]
self.default_disabled_tool_categories = disabled_categories
# Reset category states to defaults before applying overrides
for key, category in TOOL_CATEGORIES.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)
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."""
if category not in TOOL_CATEGORIES:
raise ValueError(f"未知的工具类别: {category}")
self.tool_category_states[category] = bool(enabled)
self._refresh_disabled_tools()
def get_tool_settings_snapshot(self) -> List[Dict[str, object]]:
"""获取工具类别状态快照 / Return tool category states snapshot."""
snapshot: List[Dict[str, object]] = []
for key, category in TOOL_CATEGORIES.items():
snapshot.append({
"id": key,
"label": category.label,
"enabled": self.tool_category_states.get(key, category.default_enabled),
"tools": list(category.tools),
})
return snapshot
def _refresh_disabled_tools(self) -> None:
"""刷新禁用工具列表 / Refresh disabled tool set."""
disabled = set()
notice = set()
for key, enabled in self.tool_category_states.items():
if not enabled:
category = TOOL_CATEGORIES[key]
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 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
# 构建上下文
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)
# 补充TODO完成提示放在tool消息之后保证格式正确
raw_payload = tool_result.get("raw_result_data") or {}
todo_note = raw_payload.get("system_note")
if todo_note:
self.context_manager.add_conversation("system", todo_note)
# 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_diff":
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 == "focus_file":
print(f"🔍 聚焦文件")
elif tool_name == "unfocus_file":
print(f"❌ 取消聚焦")
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()
# 聚焦文件状态
focused_size = sum(len(content) for content in self.focused_files.values())
# 终端会话状态
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)}
聚焦文件: {len(self.focused_files)}/3 个 ({focused_size/1024:.1f}KB)
终端会话: {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
def define_tools(self) -> List[Dict]:
"""定义可用工具(添加确认工具)"""
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
tools = [
{
"type": "function",
"function": {
"name": "sleep",
"description": "等待指定的秒数。用于等待长时间操作完成,如安装包、编译、服务启动等。当终端或进程需要时间完成操作时使用。",
"parameters": {
"type": "object",
"properties": self._inject_intent({
"seconds": {
"type": "number",
"description": "等待的秒数可以是小数如2.5秒。建议范围0.5-30秒"
},
"reason": {
"type": "string",
"description": "等待的原因说明(可选)"
}
}),
"required": ["seconds"]
}
}
},
{
"type": "function",
"function": {
"name": "create_file",
"description": "创建新文件(仅创建空文件,正文请使用 write_file_diff 提交补丁)",
"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": "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": "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": "write_file_diff",
"description": "使用统一 diff`@@` 块、`-`/`+`/空格行)对单个文件做精确编辑:追加、插入、替换、删除都可以在一次调用里完成。\\n硬性规则\\n\\n1) 补丁必须被 `*** Begin Patch` 与 `*** End Patch` 包裹。\\n2) 每个修改块必须以 `@@ [id:数字]` 开头。\\n3) 块内每一行只能是三类之一:\\n - 上下文行:以空格开头(` ␠`),表示“文件里必须原样存在”的锚点;\\n - 删除行:以 `-` 开头,表示要从文件中移除的原文;\\n - 新增行:以 `+` 开头,表示要写入的新内容。\\n4) 任何“想新增/想删除/想替换”的内容都必须逐行写 `+` 或 `-`;空行也必须写成单独一行的 `+`(这一行只有 `+` 和换行);如果你把多行新内容直接贴上去却不加 `+`,它会被当成上下文锚点去匹配原文件,极易导致“未找到匹配的原文”。\\n5) 重要语义:一个块里如果完全没有上下文行(空格开头)也没有删除行(`-`那么它会被视为“仅追加append-only也就是把所有 `+` 行追加到文件末尾——这对“给空文件写正文”很合适,但对“插入到中间”是错误的。\\n\\n正面案例至少 5 个,且都包含多行原文/多处修改)\\n\\n1) 给空文件写完整正文(追加到末尾;空文件=正确)\\n目标新建 README.md 后一次性写入标题、安装、用法、FAQ多段落、多行\\n要点没有上下文/删除行 → 追加模式;空文件时最常用。\\n\\n*** Begin Patch\\n@@ [id:1]\\n+# 项目名称\\n+\\n+一个简短说明:这个项目用于……\\n+\\n+## 安装\\n+\\n+```bash\\n+pip install -r requirements.txt\\n+```\\n+\\n+## 快速开始\\n+\\n+```bash\\n+python main.py\\n+```\\n+\\n+## 常见问题\\n+\\n+- Q: 为什么会报 xxx\\n+ A: 先检查 yyy再确认 zzz。\\n+\\n*** End Patch\\n\\n2) “不删除,直接插入内容”到函数内部(必须用上下文锚定插入位置)\\n目标在 def build_prompt(...): 里插入日志与参数归一化,但不改动其它行。\\n要点插入发生在“两个上下文行之间”上下文必须精确包含缩进\\n\\n*** Begin Patch\\n@@ [id:1]\\n def build_prompt(user_text: str, system_text: str, tags: list):\\n prompt_parts = []\\n+ # 参数归一化:去掉首尾空白,避免模型误判\\n+ user_text = (user_text or \\\"\\\").strip()\\n+ system_text = (system_text or \\\"\\\").strip()\\n+\\n+ logger.debug(\\\"build_prompt: tags=%s, user_len=%d\\\", tags, len(user_text))\\n prompt_parts.append(system_text)\\n prompt_parts.append(user_text)\\n if tags:\\n prompt_parts.append(\\\"TAGS: \\\" + \\\",\\\".join(tags))\\n*** End Patch\\n\\n3) 复杂替换:整段函数重构(多行 old/new + 保留稳定上下文)\\n目标把旧的 apply_patch()(弱校验)替换成新实现(多分支、异常信息更清晰)。\\n要点替换不是“改一行”而是“删一段、加一段”并用函数签名/相邻代码作锚点。\\n\\n*** Begin Patch\\n@@ [id:1]\\n class FilePatcher:\\n def __init__(self, root: Path):\\n self.root = root\\n\\n def apply_patch(self, path: str, patch_text: str) -> dict:\\n- # old: naive replace\\n- content = (self.root / path).read_text(encoding=\\\"utf-8\\\")\\n- content = content.replace(\\\"foo\\\", \\\"bar\\\")\\n- (self.root / path).write_text(content, encoding=\\\"utf-8\\\")\\n- return {\\\"success\\\": True}\\n+ full_path = (self.root / path).resolve()\\n+ if self.root not in full_path.parents and full_path != self.root:\\n+ return {\\\"success\\\": False, \\\"error\\\": \\\"非法路径:越界访问\\\"}\\n+\\n+ if \\\"*** Begin Patch\\\" not in patch_text or \\\"*** End Patch\\\" not in patch_text:\\n+ return {\\\"success\\\": False, \\\"error\\\": \\\"补丁格式错误:缺少 Begin/End 标记\\\"}\\n+\\n+ try:\\n+ original = full_path.read_text(encoding=\\\"utf-8\\\")\\n+ except Exception as e:\\n+ return {\\\"success\\\": False, \\\"error\\\": f\\\"读取失败: {e}\\\"}\\n+\\n+ # 这里省略:解析 blocks、逐块应用、失败回滚等\\n+ updated = original\\n+ try:\\n+ full_path.write_text(updated, encoding=\\\"utf-8\\\")\\n+ except Exception as e:\\n+ return {\\\"success\\\": False, \\\"error\\\": f\\\"写入失败: {e}\\\"}\\n+\\n+ return {\\\"success\\\": True, \\\"message\\\": \\\"已应用补丁\\\"}\\n*** End Patch\\n\\n4) 复杂多块:同一文件里同时“加 import + 替换逻辑 + 插入新 helper + 删除旧函数”\\n目标一次调用完成 4 种操作,且每块都有足够上下文,避免误匹配。\\n要点不同区域用不同 @@ [id:n] 分块,互不干扰。\\n\\n*** Begin Patch\\n@@ [id:1]\\n-import json\\n+import json\\n+import re\\n from pathlib import Path\\n\\n@@ [id:2]\\n def normalize_user_input(text: str) -> str:\\n- return text\\n+ text = (text or \\\"\\\").strip()\\n+ # 压缩多余空白,减少提示词抖动\\n+ text = re.sub(r\\\"\\\\\\\\s+\\\", \\\" \\\", text)\\n+ return text\\n\\n@@ [id:3]\\n def load_config(path: str) -> dict:\\n cfg_path = Path(path)\\n if not cfg_path.exists():\\n return {}\\n data = cfg_path.read_text(encoding=\\\"utf-8\\\")\\n return json.loads(data)\\n+\\n+def safe_get(cfg: dict, key: str, default=None):\\n+ if not isinstance(cfg, dict):\\n+ return default\\n+ return cfg.get(key, default)\\n\\n@@ [id:4]\\n-def legacy_parse_flags(argv):\\n- # deprecated, kept for compatibility\\n- flags = {}\\n- for item in argv:\\n- if item.startswith(\\\"--\\\"):\\n- k, _, v = item[2:].partition(\\\"=\\\")\\n- flags[k] = v or True\\n- return flags\\n-\\n def main():\\n cfg = load_config(\\\"config.json\\\")\\n # ...\\n*** End Patch\\n\\n5) 删除示例:删除一整段“废弃配置块”,并顺手修正周围空行(多行删除 + 上下文)\\n目标删掉 DEPRECATED_* 配置和旧注释,确保删除位置精确。\\n要点删除行必须逐行 `-`;保留上下文行确保定位。\\n\\n*** Begin Patch\\n@@ [id:1]\\n # ==============================\\n # Runtime Config\\n # ==============================\\n-DEPRECATED_TIMEOUT = 5\\n-DEPRECATED_RETRIES = 1\\n-# 注意:这些字段将在下个版本移除\\n-# 请迁移到 NEW_TIMEOUT / NEW_RETRIES\\n NEW_TIMEOUT = 30\\n NEW_RETRIES = 3\\n*** End Patch\\n\\n如何写“带上下文”的正确姿势要点\\n\\n- 上下文要选“稳定锚点”:函数签名、类名、关键注释、紧邻的两三行缩进代码。\\n- 不要用“容易变的行”当唯一锚点:时间戳、日志序号、随机 id、生成内容片段。\\n- 上下文必须字节级一致(空格/Tab/大小写/标点都算),否则会匹配失败。\\n\\n反面案例至少 3 个,且都是“真实会踩坑”的类型)\\n\\n反例 A来自一次常见错误空文件时只有第一行加了 `+`,后面直接贴正文\\n这会让后面的正文变成“上下文锚点”工具会去空文件里找这些原文必然失败常见报错未找到匹配的原文\\n\\n*** Begin Patch\\n@@ [id:1]\\n+\\n仰望U9X·电驭苍穹\\n银箭破空电光闪\\n三千马力云中藏\\n*** End Patch\\n\\n正确做法正文每一行都要写 `+`(包括空行也写 `+`),空行写法是一行单独的 `+`。\\n\\n反例空行没加 `+`,会被当成上下文,空文件/定位修改时容易失败)\\n\\n*** Begin Patch\\n@@ [id:1]\\n+title = \\\"示例\\\"\\n\\n+[db]\\n+enabled = true\\n*** End Patch\\n\\n正确空行也要用 `+` 表示)\\n\\n*** Begin Patch\\n@@ [id:1]\\n+title = \\\"示例\\\"\\n+\\n+[db]\\n+enabled = true\\n*** End Patch\\n\\n对应的正确 patch 示例:向空文件追加多行)\\n\\n*** Begin Patch\\n@@ [id:1]\\n+\\n+仰望U9X·电驭苍穹\\n+银箭破空电光闪\\n+三千马力云中藏\\n*** End Patch\\n\\n反例 B想“插入到中间”却只写 `+`(没有任何上下文/删除行)\\n这种块会被当成“追加到文件末尾”结果内容跑到文件最后不会插入到你以为的位置。\\n\\n*** Begin Patch\\n@@ [id:1]\\n+# 我以为会插到某个函数上面\\n+print(\\\"hello\\\")\\n*** End Patch\\n\\n正确做法用上下文锚定插入点见正面案例 2\\n\\n对应的正确 patch 示例:用上下文把内容插入到函数内部,而不是追加到文件末尾)\\n\\n*** Begin Patch\\n@@ [id:1]\\n def main():\\n config = load_config(\\\"config.json\\\")\\n+ # 这里插入:启动提示(不会移动到文件末尾)\\n+ print(\\\"hello\\\")\\n run(config)\\n*** End Patch\\n\\n反例 C补丁在第一个 `@@` 之前出现内容 / 或漏掉 Begin/End 标记\\n解析会直接报格式错误例如“在检测到第一个 @@ 块之前出现内容”、“缺少 Begin/End 标记”)。\\n\\n错误形态示意\\n这里先写了一段说明文字没有 @@\\n@@ [id:1]\\n+...\\n\\n正确做法确保第一段非空内容必须从 `@@ [id:n]` 开始,并且整体有 Begin/End。\\n\\n对应的正确 patch 示例:完整结构、第一段内容从 @@ 块开始)\\n\\n*** Begin Patch\\n@@ [id:1]\\n # ==============================\\n # Runtime Config\\n # ==============================\\n+# 说明:此处新增一行注释作为示例\\n NEW_TIMEOUT = 30\\n*** End Patch\\n",
"parameters": {
"type": "object",
"properties": self._inject_intent({
"path": {"type": "string", "description": "目标文件路径(相对项目根目录)。"},
"patch": {
"type": "string",
"description": "完整补丁文本,格式类似 unified diff必须用 *** Begin Patch / *** End Patch 包裹,并用 @@ [id:数字] 划分多个修改块。示例:*** Begin Patch\\n@@ [id:1]\\n def main():\\n- def greet(self):\\n- return \"hi\"\\n+ def greet(self, name: str) -> str:\\n+ message = f\"Hello, {name}!\"\\n+ return message\\n@@ [id:2]\\n+\\n+if __name__ == \"__main__\":\\n+ print(\"hello world\")\\n*** End Patch"
}
}),
"required": ["path", "patch"]
}
}
},
{
"type": "function",
"function": {
"name": "create_folder",
"description": "创建文件夹",
"parameters": {
"type": "object",
"properties": self._inject_intent({
"path": {"type": "string", "description": "文件夹路径"}
}),
"required": ["path"]
}
}
},
{
"type": "function",
"function": {
"name": "focus_file",
"description": "聚焦 UTF-8 文本文件,将完整内容持续注入上下文。适合频繁查看/修改的核心文件;超过字符限制或非 UTF-8 时会拒绝。",
"parameters": {
"type": "object",
"properties": self._inject_intent({
"path": {"type": "string", "description": "文件路径"}
}),
"required": ["path"]
}
}
},
{
"type": "function",
"function": {
"name": "unfocus_file",
"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超时仅终止当前命令终端保持可用。",
"parameters": {
"type": "object",
"properties": self._inject_intent({
"command": {
"type": "string",
"description": "要执行的命令或发送的输入"
},
"session_name": {
"type": "string",
"description": "目标终端会话名称(可选,默认使用活动终端)"
},
"timeout": {
"type": "number",
"description": "等待输出的最长秒数必填最大300"
}
}),
"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 条可执行项。概述请控制在 50 字以内,直接说明清单目标;任务列表只写 2~4 条明确步骤。",
"parameters": {
"type": "object",
"properties": self._inject_intent({
"overview": {"type": "string", "description": "一句话概述待办清单要完成的目标50 字以内。"},
"tasks": {
"type": "array",
"description": "任务列表,建议 2~4 条,每条写清“动词+对象+目标”。",
"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"},
"completed": {"type": "boolean", "description": "true=打勾false=取消"}
}),
"required": ["task_index", "completed"]
}
}
},
{
"type": "function",
"function": {
"name": "todo_finish",
"description": "尝试结束待办列表,需同步汇报每项任务结果。若仍有未完事项,请注明原因与后续建议。",
"parameters": {
"type": "object",
"properties": self._inject_intent({
"reason": {"type": "string", "description": "可选说明"}
})
}
}
},
{
"type": "function",
"function": {
"name": "todo_finish_confirm",
"description": "在任务未完成时确认是否提前结束。若确认结束,请说明后续交付建议或遗留风险。",
"parameters": {
"type": "object",
"properties": self._inject_intent({
"confirm": {"type": "boolean", "description": "true=确认结束false=继续执行"},
"reason": {"type": "string", "description": "确认结束时的说明"}
}),
"required": ["confirm"]
}
}
},
{
"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 自带多模态能力,不向其暴露额外的 vlm_analyze 工具,避免重复与误导
if getattr(self, "model_key", None) == "qwen3-vl-plus":
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 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_FOCUS_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) > 50000: # 50KB限制
return json.dumps({
"success": False,
"error": f"参数过大({len(arguments_str)}字符)超过50KB限制",
"suggestion": "请分块处理或减少参数内容"
}, ensure_ascii=False)
# 针对特定工具的内容检查
if tool_name == "create_file" and "content" in arguments:
content = arguments.get("content", "")
if not DISABLE_LENGTH_CHECK and len(content) > 9999999999: # 30KB内容限制
return json.dumps({
"success": False,
"error": f"文件内容过长({len(content)}字符),建议分块处理",
"suggestion": "请拆分内容或使用 write_file_diff 工具输出结构化补丁"
}, 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)
try:
if 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 == "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_diff 提交补丁写入正文。"
)
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}")
# 删除聚焦
if deleted_path in self.focused_files:
del self.focused_files[deleted_path]
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}")
# 更新聚焦
if old_path in self.focused_files:
content = self.focused_files[old_path]
del self.focused_files[old_path]
self.focused_files[new_path] = content
print(f"🔍 已更新文件聚焦: {old_path} -> {new_path}")
elif tool_name == "write_file_diff":
path = arguments.get("path")
patch_text = arguments.get("patch")
if not path or not patch_text:
result = {"success": False, "error": "缺少必要参数: path/patch"}
else:
length_limit = 30000
if not DISABLE_LENGTH_CHECK and len(patch_text) > length_limit:
result = {
"success": False,
"error": f"补丁内容过长({len(patch_text)}字符),超过{length_limit}字符上限",
"suggestion": "请拆分补丁后多次调用 write_file_diff。"
}
else:
diff_result = self.file_manager.apply_diff_patch(path, patch_text)
result = diff_result
elif tool_name == "create_folder":
result = self.file_manager.create_folder(arguments["path"])
elif tool_name == "focus_file":
path = arguments["path"]
# 检查是否已经聚焦
if path in self.focused_files:
result = {"success": False, "error": f"文件已经处于聚焦状态: {path}"}
else:
# 检查聚焦文件数量限制
if len(self.focused_files) >= 3:
result = {
"success": False,
"error": f"已达到最大聚焦文件数量(3个),当前聚焦: {list(self.focused_files.keys())}",
"suggestion": "请先使用 unfocus_file 取消部分文件的聚焦"
}
else:
# 读取文件内容
read_result = self.file_manager.read_file(path)
if read_result["success"]:
# 字符数检查
char_count = len(read_result["content"])
if char_count > MAX_FOCUS_FILE_CHARS:
result = {
"success": False,
"error": f"文件过大,有{char_count}字符请使用run_command限制字符数返回",
"char_count": char_count,
"limit": MAX_FOCUS_FILE_CHARS
}
else:
self.focused_files[path] = read_result["content"]
result = {
"success": True,
"message": f"文件已聚焦: {path}",
"focused_files": list(self.focused_files.keys()),
"file_size": len(read_result["content"])
}
print(f"🔍 文件已聚焦: {path} ({len(read_result['content'])} 字节)")
else:
result = read_result
elif tool_name == "unfocus_file":
path = arguments["path"]
if path in self.focused_files:
del self.focused_files[path]
result = {
"success": True,
"message": f"已取消文件聚焦: {path}",
"remaining_focused": list(self.focused_files.keys())
}
print(f"✖️ 已取消文件聚焦: {path}")
else:
result = {"success": False, "error": f"文件未处于聚焦状态: {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":
result = self.todo_manager.update_task_status(
task_index=arguments.get("task_index"),
completed=arguments.get("completed", True)
)
elif tool_name == "todo_finish":
result = self.todo_manager.finish_todo(
reason=arguments.get("reason")
)
elif tool_name == "todo_finish_confirm":
result = self.todo_manager.confirm_finish(
confirm=arguments.get("confirm", False),
reason=arguments.get("reason")
)
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") == "qwen3-vl-plus" 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 %H:%M:%S"),
model_description=prompt_replacements.get("model_description", "")
)
messages = [
{"role": "system", "content": system_prompt}
]
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_config = load_personalization_config(self.data_dir)
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})
# 添加对话历史保留完整结构包括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 []
content_payload = (
self.context_manager._build_content_with_images(conv["content"], images)
if images 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
})
# 在最后注入聚焦文件内容作为系统消息
if self.focused_files:
focused_content = "\n\n=== 🔍 正在聚焦的文件 ===\n"
focused_content += f"(共 {len(self.focused_files)} 个文件处于聚焦状态)\n"
for path, content in self.focused_files.items():
size_kb = len(content) / 1024
focused_content += f"\n--- 文件: {path} ({size_kb:.1f}KB) ---\n"
focused_content += f"```\n{content}\n```\n"
focused_content += "\n=== 聚焦文件结束 ===\n"
focused_content += "提示:以上文件正在被聚焦,你可以直接看到完整内容并进行修改,禁止再次读取。"
messages.append({
"role": "system",
"content": focused_content
})
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_focused_files(self, args: str = ""):
"""显示当前聚焦的文件"""
if not self.focused_files:
print(f"{OUTPUT_FORMATS['info']} 当前没有聚焦的文件")
else:
print(f"\n🔍 聚焦文件列表 ({len(self.focused_files)}/3):")
print("="*50)
for path, content in self.focused_files.items():
size_kb = len(content) / 1024
lines = content.count('\n') + 1
print(f" 📄 {path}")
print(f" 大小: {size_kb:.1f}KB | 行数: {lines}")
print("="*50)
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 = ""):
"""显示项目文件"""
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 != "qwen3-vl-plus":
raise ValueError("当前对话包含图片,仅支持 Qwen-VL")
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")
# 如果模型支持思考,但当前 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}")