agent-Specialization/core/main_terminal.py

441 lines
20 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
from typing import 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,
)
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,
)
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.host_workspace_debug import write_host_workspace_debug
from config.model_profiles import 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 = "kimi-k2.5"
self.model_profile = get_model_profile(self.model_key)
self.apply_model_profile(self.model_profile)
self.context_manager = ContextManager(project_path, data_dir=str(self.data_dir))
self.context_manager.main_terminal = self
self.container_mount_path = TERMINAL_SANDBOX_MOUNT_PATH or "/workspace"
self.container_cpu_limit = TERMINAL_SANDBOX_CPUS or "未限制"
self.container_memory_limit = TERMINAL_SANDBOX_MEMORY or "未限制"
self.project_storage_limit = f"{PROJECT_MAX_STORAGE_MB}MB" if PROJECT_MAX_STORAGE_MB else "未限制"
self.project_storage_limit_bytes = (
PROJECT_MAX_STORAGE_MB * 1024 * 1024 if PROJECT_MAX_STORAGE_MB else None
)
self.container_session: Optional["ContainerHandle"] = None
self.memory_manager = MemoryManager(data_dir=str(self.data_dir))
self.file_manager = FileManager(project_path, container_session=container_session, 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/run_python 复用终端容器,保持环境一致
self.terminal_ops.attach_terminal_manager(self.terminal_manager)
self._apply_container_session(container_session)
self.todo_manager = TodoManager(self.context_manager)
self.sub_agent_manager = SubAgentManager(
project_path=self.project_path,
data_dir=str(self.data_dir),
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"
# 当前生效的启用 skills用于强约束判定
self.enabled_skill_ids: Set[str] = set()
self.apply_personalization_preferences()
# 新增:自动开始新对话
self._ensure_conversation()
# 命令映射
self.commands = {
"help": self.show_help,
"exit": self.exit_system,
"status": self.show_status,
"memory": self.manage_memory,
"clear": self.clear_conversation,
"history": self.show_history,
"files": self.show_files,
"mode": self.toggle_mode,
"terminals": self.show_terminals,
# 新增:对话管理命令
"conversations": self.show_conversations,
"load": self.load_conversation_command,
"new": self.new_conversation_command,
"save": self.save_conversation_command
}
def _apply_container_session(self, session: Optional["ContainerHandle"]):
self.container_session = session
if session and session.mode == "docker":
self.container_mount_path = session.mount_path or (TERMINAL_SANDBOX_MOUNT_PATH or "/workspace")
else:
self.container_mount_path = TERMINAL_SANDBOX_MOUNT_PATH or "/workspace"
def _is_host_mode(self) -> bool:
"""判定当前是否运行在宿主机模式,用于豁免配额等限制。"""
if self.container_session and getattr(self.container_session, "mode", None) != "docker":
return True
return (TERMINAL_SANDBOX_MODE or "").lower() == "host"
def record_model_call(self, is_thinking: bool):
if self._is_host_mode():
return True, {}
tracker = getattr(self, "usage_tracker", None)
if not tracker:
return True, {}
mode = "thinking" if is_thinking else "fast"
try:
allowed, info = tracker.check_and_increment(mode)
if allowed:
self._notify_quota_update(mode)
return allowed, info
except Exception:
return True, {}
def record_search_call(self):
if self._is_host_mode():
return True, {}
tracker = getattr(self, "usage_tracker", None)
if not tracker:
return True, {}
try:
allowed, info = tracker.check_and_increment("search")
if allowed:
self._notify_quota_update("search")
return allowed, info
except Exception:
return True, {}
def _notify_quota_update(self, metric: str):
callback = getattr(self, "quota_update_callback", None)
if callable(callback):
try:
callback(metric)
except Exception:
pass
def update_container_session(self, session: Optional["ContainerHandle"]):
self._apply_container_session(session)
if getattr(self, "terminal_manager", None):
self.terminal_manager.update_container_session(session)
if getattr(self, "terminal_ops", None):
self.terminal_ops.set_container_session(session)
if getattr(self, "file_manager", None):
self.file_manager.set_container_session(session)
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", "")
)
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:
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
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)
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"
},
)
print(f"{OUTPUT_FORMATS['info']} 新建对话: {conversation_id}")