agent-Specialization/core/main_terminal.py

616 lines
29 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 - 主终端(集成对话持久化)
from pathlib import Path
import time
from typing import Any, Dict, List, Optional, Set, TYPE_CHECKING
try:
from config import (
OUTPUT_FORMATS,
DATA_DIR,
MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE,
TERMINAL_SANDBOX_MOUNT_PATH,
TERMINAL_SANDBOX_MODE,
TERMINAL_SANDBOX_CPUS,
TERMINAL_SANDBOX_MEMORY,
PROJECT_MAX_STORAGE_MB,
CUSTOM_TOOLS_ENABLED,
MCP_TOOLS_ENABLED,
HOST_EXECUTION_MODE_DEFAULT,
HOST_EXECUTION_DIRECT_TTL_SECONDS,
)
except ImportError:
import sys
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,
MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE,
TERMINAL_SANDBOX_MOUNT_PATH,
TERMINAL_SANDBOX_MODE,
TERMINAL_SANDBOX_CPUS,
TERMINAL_SANDBOX_MEMORY,
PROJECT_MAX_STORAGE_MB,
CUSTOM_TOOLS_ENABLED,
MCP_TOOLS_ENABLED,
HOST_EXECUTION_MODE_DEFAULT,
HOST_EXECUTION_DIRECT_TTL_SECONDS,
)
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.background_command_manager import BackgroundCommandManager
from modules.ocr_client import OCRClient
from modules.easter_egg_manager import EasterEggManager
from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category
from modules.custom_tool_executor import CustomToolExecutor
from modules.mcp_server_registry import MCPServerRegistry, build_default_mcp_category
from modules.mcp_client_manager import MCPClientManager
try:
from config.limits import THINKING_FAST_INTERVAL
except ImportError:
THINKING_FAST_INTERVAL = 10
from core.tool_config import TOOL_CATEGORIES
from utils.api_client import DeepSeekClient
from utils.context_manager import ContextManager
from utils.conversation_manager import ConversationManager
from utils.host_workspace_debug import write_host_workspace_debug
from config.model_profiles import get_default_model_key, get_model_profile
from core.main_terminal_parts import (
MainTerminalCommandMixin,
MainTerminalContextMixin,
MainTerminalToolsMixin,
)
if TYPE_CHECKING:
from modules.user_container_manager import ContainerHandle
class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTerminalToolsMixin):
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"
# 记录因 fast-only 模型被强制切到 fast 前的模式,便于切回可思考模型时恢复
self._mode_before_fast_only: Optional[str] = None
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.project_path = project_path
self.api_client.set_deep_thinking_mode(self.deep_thinking_mode)
self.model_key = get_default_model_key()
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, data_dir=str(self.data_dir))
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 复用终端容器,保持环境一致
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),
container_session=container_session,
)
self.background_command_manager = BackgroundCommandManager(
project_path=self.project_path,
)
self.easter_egg_manager = EasterEggManager()
self._announced_sub_agent_tasks = set()
self.silent_tool_disable = True # 是否静默工具禁用提示(默认开启)
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.mcp_tools_enabled = bool(MCP_TOOLS_ENABLED)
self.mcp_server_registry = MCPServerRegistry()
self.mcp_client_manager = MCPClientManager(
self.mcp_server_registry,
container_session=container_session,
)
self.mcp_tool_alias_map: Dict[str, object] = {}
if self.mcp_tools_enabled:
default_mcp_cat = build_default_mcp_category()
if "mcp" not in self.tool_categories_map:
self.tool_categories_map["mcp"] = type(next(iter(TOOL_CATEGORIES.values())))(
label=default_mcp_cat["label"],
tools=default_mcp_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
# Skill 强约束开关(按个人空间配置)
self.skill_strict_terminal_enabled: bool = False
self.skill_strict_sub_agent_enabled: bool = False
self.skill_strict_run_command_foreground_enabled: bool = False
self.skill_strict_run_command_background_enabled: bool = False
self.default_permission_mode: str = "unrestricted"
self.current_permission_mode: str = "unrestricted"
self.host_execution_mode: str = "sandbox"
self.host_execution_direct_until: Optional[float] = None
self.pending_permission_mode: Optional[str] = None
self.pending_execution_mode: Optional[str] = None
# 当前生效的启用 skills用于强约束判定
self.enabled_skill_ids: Set[str] = set()
self.apply_personalization_preferences()
self._init_host_execution_mode()
# 新增:自动开始新对话
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
}
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 _init_host_execution_mode(self):
default_mode = str(HOST_EXECUTION_MODE_DEFAULT or "sandbox").strip().lower()
self.host_execution_mode = "direct" if default_mode == "direct" else "sandbox"
self.host_execution_direct_until = None
self._apply_execution_mode_to_runtime()
def _apply_execution_mode_to_runtime(self):
mode = self.get_execution_mode()
if getattr(self, "terminal_ops", None):
self.terminal_ops.set_host_execution_mode(mode)
if getattr(self, "terminal_manager", None):
self.terminal_manager.set_host_execution_mode(mode)
if getattr(self, "sub_agent_manager", None):
self.sub_agent_manager.set_host_execution_mode(mode)
def get_pending_runtime_modes(self) -> Dict[str, Optional[str]]:
return {
"permission_mode": self.pending_permission_mode,
"execution_mode": self.pending_execution_mode,
}
def queue_permission_mode_change(self, mode: str) -> str:
normalized = str(mode or "").strip().lower()
if normalized not in {"readonly", "approval", "auto_approval", "unrestricted"}:
raise ValueError("无效权限模式,仅支持 readonly / approval / auto_approval / unrestricted")
self.pending_permission_mode = normalized
self._persist_runtime_mode_metadata({
"pending_permission_mode": normalized,
})
return normalized
def queue_execution_mode_change(self, mode: str) -> str:
normalized = str(mode or "").strip().lower()
if normalized not in {"sandbox", "direct"}:
raise ValueError("无效执行环境,仅支持 sandbox / direct")
self.pending_execution_mode = normalized
self._persist_runtime_mode_metadata({
"pending_execution_mode": normalized,
})
return normalized
def apply_pending_runtime_mode_changes(self) -> List[Dict[str, str]]:
notices: List[Dict[str, str]] = []
updates: Dict[str, Any] = {}
pending_permission = self.pending_permission_mode
if pending_permission:
current = self.get_permission_mode() if hasattr(self, "get_permission_mode") else "unrestricted"
if pending_permission != current:
self.set_permission_mode(pending_permission, persist=False)
label = {
"readonly": "只读",
"approval": "审批",
"auto_approval": "自动审核",
"unrestricted": "无限制",
}.get(pending_permission, pending_permission)
notices.append({"text": f"权限模式被用户修改为 {label}", "source": "权限变更"})
updates["permission_mode"] = pending_permission
updates["pending_permission_mode"] = None
self.pending_permission_mode = None
pending_execution = self.pending_execution_mode
if pending_execution:
current_exec = self.get_execution_mode()
if pending_execution != current_exec:
self.set_execution_mode(pending_execution)
label = {"sandbox": "沙箱", "direct": "完全访问权限"}.get(pending_execution, pending_execution)
notices.append({"text": f"执行环境被用户修改为 {label}", "source": "执行环境变更"})
updates["execution_mode"] = pending_execution
updates["pending_execution_mode"] = None
self.pending_execution_mode = None
if updates:
self._persist_runtime_mode_metadata(updates)
return notices
def _persist_runtime_mode_metadata(self, updates: Dict[str, Any]) -> None:
if not isinstance(updates, dict) or not updates:
return
cm = getattr(self, "context_manager", None)
conv_id = getattr(cm, "current_conversation_id", None) if cm else None
if not cm or not conv_id:
return
try:
cm.conversation_manager.update_conversation_metadata(conv_id, updates)
if isinstance(getattr(cm, "conversation_metadata", None), dict):
cm.conversation_metadata.update(updates)
except Exception:
pass
def _refresh_execution_mode_by_ttl(self):
if self.host_execution_mode != "direct":
return
until = self.host_execution_direct_until
if not until:
return
if time.time() >= float(until):
self.host_execution_mode = "sandbox"
self.host_execution_direct_until = None
self._apply_execution_mode_to_runtime()
def get_execution_mode(self) -> str:
self._refresh_execution_mode_by_ttl()
return "direct" if self.host_execution_mode == "direct" else "sandbox"
def get_execution_mode_state(self) -> Dict:
mode = self.get_execution_mode()
ttl_seconds = int(HOST_EXECUTION_DIRECT_TTL_SECONDS)
return {
"mode": mode,
"default_mode": "direct" if str(HOST_EXECUTION_MODE_DEFAULT).lower() == "direct" else "sandbox",
"ttl_seconds": ttl_seconds,
"direct_until": self.host_execution_direct_until,
}
def set_execution_mode(self, mode: str) -> Dict:
normalized = str(mode or "").strip().lower()
if normalized not in {"sandbox", "direct"}:
raise ValueError("无效执行环境,仅支持 sandbox / direct")
self.host_execution_mode = normalized
ttl_seconds = int(HOST_EXECUTION_DIRECT_TTL_SECONDS)
if normalized == "direct":
self.host_execution_direct_until = None if ttl_seconds in (0, -1) else (time.time() + max(1, ttl_seconds))
else:
self.host_execution_direct_until = None
self._apply_execution_mode_to_runtime()
return self.get_execution_mode_state()
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)
if getattr(self, "mcp_client_manager", None):
self.mcp_client_manager.set_container_session(session)
if getattr(self, "sub_agent_manager", None):
try:
self.sub_agent_manager.set_container_session(session)
except Exception:
pass
def update_project_path(self, project_path: str):
"""更新当前终端绑定的工作目录(宿主机工作区切换场景)。"""
try:
resolved_path = str(Path(project_path).expanduser().resolve())
except Exception:
resolved_path = str(project_path)
try:
current_resolved = str(Path(self.project_path).expanduser().resolve())
except Exception:
current_resolved = str(self.project_path)
try:
current_context_resolved = str(
Path(getattr(getattr(self, "context_manager", None), "project_path", "")).expanduser().resolve()
)
except Exception:
current_context_resolved = str(
getattr(getattr(self, "context_manager", None), "project_path", "")
)
try:
current_conversation_scope = str(
Path(
getattr(
getattr(getattr(self, "context_manager", None), "conversation_manager", None),
"current_project_path",
"",
)
).expanduser().resolve()
)
except Exception:
current_conversation_scope = str(
getattr(
getattr(getattr(self, "context_manager", None), "conversation_manager", None),
"current_project_path",
"",
)
)
write_host_workspace_debug(
"terminal.update_project_path.begin",
terminal_id=id(self),
current_project_path=current_resolved,
target_project_path=resolved_path,
)
if (
resolved_path == current_resolved
and current_context_resolved == resolved_path
and current_conversation_scope == resolved_path
):
write_host_workspace_debug(
"terminal.update_project_path.skip_same_path",
terminal_id=id(self),
project_path=resolved_path,
)
return
self.project_path = resolved_path
# API 客户端
if getattr(self, "api_client", None):
try:
self.api_client.project_path = resolved_path
except Exception:
pass
# 上下文管理器(影响 file tree / AGENTS.md 注入 / prompt
if getattr(self, "context_manager", None):
try:
new_path = Path(resolved_path).resolve()
self.context_manager.project_path = new_path
self.context_manager.initial_project_path = new_path
self.context_manager.project_snapshot = None
self.context_manager._host_runtime_cache = None
self.context_manager.conversation_manager = ConversationManager(
base_dir=str(getattr(self.context_manager, "data_dir", DATA_DIR)),
project_path=str(new_path),
)
self.context_manager.current_conversation_id = None
self.context_manager.conversation_history = []
self.context_manager.todo_list = None
metadata = getattr(self.context_manager, "conversation_metadata", None)
if isinstance(metadata, dict):
metadata["project_path"] = str(new_path)
try:
workspace_root = Path(
getattr(self.context_manager.conversation_manager, "workspace_root", "")
).resolve()
metadata["project_relative_path"] = new_path.relative_to(workspace_root).as_posix()
except Exception:
metadata["project_relative_path"] = None
# 清理旧工作区文件树快照,避免 prompt 继续展示切换前内容
metadata.pop("project_file_tree", None)
metadata.pop("project_statistics", None)
metadata.pop("project_snapshot_at", None)
self._ensure_conversation()
except Exception:
pass
# 文件/终端操作器
if getattr(self, "file_manager", None):
try:
self.file_manager.project_path = Path(resolved_path).resolve()
except Exception:
pass
if getattr(self, "terminal_ops", None):
try:
self.terminal_ops.project_path = Path(resolved_path).resolve()
except Exception:
pass
# 关闭已有实时终端会话,确保后续 pwd/cwd 以新工作区启动
if getattr(self, "terminal_manager", None):
try:
self.terminal_manager.project_path = Path(resolved_path).resolve()
self.terminal_manager.close_all()
except Exception:
pass
# 其它依赖 project_path 的模块
if getattr(self, "ocr_client", None):
try:
self.ocr_client.project_path = Path(resolved_path).resolve()
except Exception:
pass
if getattr(self, "sub_agent_manager", None):
try:
self.sub_agent_manager.project_path = Path(resolved_path).resolve()
except Exception:
pass
if getattr(self, "background_command_manager", None):
try:
self.background_command_manager.project_path = Path(resolved_path).resolve()
except Exception:
pass
# MCP stdio 客户端是长连接子进程,切换工作区后需重建,确保后续 cwd 与路径映射一致。
if getattr(self, "mcp_client_manager", None):
try:
self.mcp_client_manager.close_all_clients()
except Exception:
pass
# 强制下次请求重新同步 skills含 AGENTS.md 场景)
self._skills_synced_project_path = None
write_host_workspace_debug(
"terminal.update_project_path.done",
terminal_id=id(self),
project_path=self.project_path,
context_project_path=str(getattr(getattr(self, "context_manager", None), "project_path", "")),
current_conversation_id=getattr(getattr(self, "context_manager", None), "current_conversation_id", None),
)
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,
metadata_overrides={
"permission_mode": self.default_permission_mode or "unrestricted",
"execution_mode": self.get_execution_mode(),
"pending_permission_mode": None,
"pending_execution_mode": None,
"frozen_permission_prompt": self._build_permission_mode_message() or "",
"frozen_execution_prompt": self._build_execution_mode_message() or "",
},
)
print(f"{OUTPUT_FORMATS['info']} 新建对话: {conversation_id}")