diff --git a/core/main_terminal.py b/core/main_terminal.py index d7f25208..6c017653 100644 --- a/core/main_terminal.py +++ b/core/main_terminal.py @@ -142,6 +142,12 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi self.terminal_ops.attach_terminal_manager(self.terminal_manager) self._apply_container_session(container_session) + # Execution Plane 后端(docs/execution_contract.md):None = 现有 + # Host/Docker 真实链路;注入 ExecutionBackend 实现(如替身执行器)后, + # handle_tool_call 的 E1-E4 分支(run_command/后台/write_file/edit_file) + # 改走该后端。测试或未来远端执行装配点注入。 + self.execution_backend = None + self.todo_manager = TodoManager(self.context_manager) self.sub_agent_manager = SubAgentManager( project_path=self.project_path, diff --git a/core/main_terminal_parts/tools_execution.py b/core/main_terminal_parts/tools_execution.py index b253e2ab..87ed2c02 100644 --- a/core/main_terminal_parts/tools_execution.py +++ b/core/main_terminal_parts/tools_execution.py @@ -1632,10 +1632,16 @@ class MainTerminalToolsExecutionMixin: if not path: result = {"success": False, "error": tr("tools_exec.missing_file_path")} else: - # 在写入前先备份当前内容(浅备份)。 - self._track_shallow_versioning(path) - mode = "a" if append_flag else "w" - result = self.file_manager.write_file(path, content, mode=mode) + backend = getattr(self, "execution_backend", None) + if backend is not None: + # Execution Plane 后端(替身/远端):不触真实磁盘,跳过浅备份 + mode = "a" if append_flag else "w" + result = backend.write_file(path, content, mode=mode) + else: + # 在写入前先备份当前内容(浅备份)。 + self._track_shallow_versioning(path) + mode = "a" if append_flag else "w" + result = self.file_manager.write_file(path, content, mode=mode) if isinstance(result, dict) and result.get("success"): # write_file 成功后,该文件视作当前会话已“接触”, # 允许后续继续 write/edit 而不再触发先读拦截。 @@ -1660,9 +1666,14 @@ class MainTerminalToolsExecutionMixin: elif not isinstance(replacements, list) or not replacements: result = {"success": False, "error": tr("tools_exec.missing_replacements")} else: - # 在替换前先备份当前内容(浅备份)。 - self._track_shallow_versioning(path) - result = self.file_manager.replace_many_in_file(path, replacements) + backend = getattr(self, "execution_backend", None) + if backend is not None: + # Execution Plane 后端(替身/远端):不触真实磁盘,跳过浅备份 + result = backend.edit_file(path, replacements) + else: + # 在替换前先备份当前内容(浅备份)。 + self._track_shallow_versioning(path) + result = self.file_manager.replace_many_in_file(path, replacements) if isinstance(result, dict) and result.get("success"): self._mark_file_as_read_visited(result.get("path") or path) # 快捷窗口:记录本次对话编辑过的文件 @@ -1909,7 +1920,18 @@ class MainTerminalToolsExecutionMixin: } else: bg_manager = getattr(self, "background_command_manager", None) - if not bg_manager: + backend = getattr(self, "execution_backend", None) + if backend is not None: + # Execution Plane 后端(替身/远端):不走真实后台命令线程 + result = backend.run_command_background( + arguments["command"], + timeout=float(timeout_value), + conversation_id=getattr(self.context_manager, "current_conversation_id", None), + wait_seconds=5.0, + network_permission=network_permission, + sandbox_write_access=sandbox_write_access, + ) + elif not bg_manager: result = {"success": False, "error": tr("tools_exec.background_manager_unavailable")} else: result = bg_manager.create_background_command( @@ -1933,12 +1955,22 @@ class MainTerminalToolsExecutionMixin: "error": tr("tools_exec.fg_timeout_max") } else: - result = await self.terminal_ops.run_command( - arguments["command"], - timeout=timeout_value, - sandbox_write_access=sandbox_write_access, - network_permission=network_permission, - ) + backend = getattr(self, "execution_backend", None) + if backend is not None: + # Execution Plane 后端(替身/远端):不起真实子进程 + result = await backend.run_command( + arguments["command"], + timeout=float(timeout_value), + sandbox_write_access=sandbox_write_access, + network_permission=network_permission, + ) + else: + result = await self.terminal_ops.run_command( + arguments["command"], + timeout=timeout_value, + sandbox_write_access=sandbox_write_access, + network_permission=network_permission, + ) # 字符数检查 if result.get("success") and "output" in result: diff --git a/modules/execution_plane/__init__.py b/modules/execution_plane/__init__.py new file mode 100644 index 00000000..d008a3cb --- /dev/null +++ b/modules/execution_plane/__init__.py @@ -0,0 +1,13 @@ +"""Execution Plane:Runtime ↔ 执行环境分层(docs/execution_contract.md)。 + +- base.ExecutionBackend:执行环境后端协议(E1-E4 首版接口面) +- fake.FakeExecutionBackend:内存替身执行器(Runtime 独立测试用) + +接入方式:MainTerminal/WebTerminal 实例的 ``execution_backend`` 属性 +(默认 None = 现有 Host/Docker 真实链路;注入实现后工具编排层 +handle_tool_call 的 E1-E4 分支改走该后端)。 +""" +from modules.execution_plane.base import ExecutionBackend +from modules.execution_plane.fake import FakeExecutionBackend + +__all__ = ["ExecutionBackend", "FakeExecutionBackend"] diff --git a/modules/execution_plane/base.py b/modules/execution_plane/base.py new file mode 100644 index 00000000..d812c69a --- /dev/null +++ b/modules/execution_plane/base.py @@ -0,0 +1,63 @@ +"""Execution Plane 契约:Runtime ↔ 执行环境分层接口(docs/execution_contract.md §3)。 + +定位:工具编排层(core/main_terminal_parts/tools_execution.py)通过本接口触达 +执行环境;现有 Host/Docker 实现仍由 terminal_ops/file_manager 等承载(默认路径), +本接口是「替身执行器 / 未来远端执行后端」的统一接入点。 + +首版接口面(E1-E4,归纳自现状调用点,未发明新能力): +- E1 run_command(前台命令) +- E2 run_command_background(后台命令登记) +- E3 write_file / E4 edit_file(文件写) + +结果结构与现有真实后端保持一致(调用方按同一结构消费): +- run_command: {success, status, output, return_code, truncated, elapsed_ms} + status ∈ {"completed", "timeout", "error", "cancelled"} +- run_command_background: {success, command_id, status}(status="running_background") +- write_file/edit_file: {success, path, original_file, new_file} + (original_file/new_file 为写前/后全文,编辑摘要链路依赖) +""" +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Protocol, runtime_checkable + + +@runtime_checkable +class ExecutionBackend(Protocol): + """执行环境后端协议。实现方:FakeExecutionBackend(替身)、未来 Host/Docker 适配器。 + + 注意:本协议只承诺「执行语义」,不承诺安全边界——权限裁决(permission mode)、 + 路径授权(_validate_path)、命令校验(_validate_command)在 Runtime 编排层完成, + 与本接口正交(契约 §4)。 + """ + + async def run_command( + self, + command: str, + *, + timeout: float, + sandbox_write_access: bool, + network_permission: str, + ) -> Dict[str, Any]: + """E1 前台命令执行。""" + ... + + def run_command_background( + self, + command: str, + *, + timeout: float, + conversation_id: Optional[str], + wait_seconds: float, + network_permission: str, + sandbox_write_access: bool, + ) -> Dict[str, Any]: + """E2 后台命令登记(立即返回 command_id,执行异步进行)。""" + ... + + def write_file(self, path: str, content: str, *, mode: str = "w") -> Dict[str, Any]: + """E3 文件写入(mode "w" 覆盖 / "a" 追加)。""" + ... + + def edit_file(self, path: str, replacements: List[Dict[str, Any]]) -> Dict[str, Any]: + """E4 文件精确替换(replacements 元素含 old_string/new_string/replace_all)。""" + ... diff --git a/modules/execution_plane/fake.py b/modules/execution_plane/fake.py new file mode 100644 index 00000000..9fe45b7d --- /dev/null +++ b/modules/execution_plane/fake.py @@ -0,0 +1,123 @@ +"""FakeExecutionBackend:内存替身执行器(Execution Plane 的首个非真实实现)。 + +用途:Runtime + 替身执行器的独立测试(docs/execution_contract.md §6 验收)。 +不产生任何真实副作用:不起子进程、不写磁盘、不开终端。 +文件写落内存文件系统;命令执行只记录调用并返回固定结构。 + +非目标:不模拟命令真实输出语义、不模拟审批/权限裁决(那是 Runtime 编排层职责)。 +""" +from __future__ import annotations + +import time +import uuid +from typing import Any, Dict, List, Optional + + +class FakeExecutionBackend: + """内存替身执行器。calls 记录全部调用(供测试断言)。""" + + def __init__(self) -> None: + self.calls: List[Dict[str, Any]] = [] + self.files: Dict[str, str] = {} # path -> content(内存文件系统) + self.background_commands: Dict[str, Dict[str, Any]] = {} + + async def run_command( + self, + command: str, + *, + timeout: float, + sandbox_write_access: bool, + network_permission: str, + ) -> Dict[str, Any]: + self.calls.append({ + "op": "run_command", + "command": command, + "timeout": timeout, + "sandbox_write_access": sandbox_write_access, + "network_permission": network_permission, + }) + return { + "success": True, + "status": "completed", + "output": f"[fake-exec] {command}", + "return_code": 0, + "truncated": False, + "elapsed_ms": 0, + } + + def run_command_background( + self, + command: str, + *, + timeout: float, + conversation_id: Optional[str], + wait_seconds: float, + network_permission: str, + sandbox_write_access: bool, + ) -> Dict[str, Any]: + command_id = f"fake_bg_{uuid.uuid4().hex[:8]}" + self.calls.append({ + "op": "run_command_background", + "command": command, + "timeout": timeout, + "conversation_id": conversation_id, + "sandbox_write_access": sandbox_write_access, + "network_permission": network_permission, + }) + self.background_commands[command_id] = { + "command": command, + "status": "completed", + "output": f"[fake-exec-bg] {command}", + "return_code": 0, + "created_at": time.time(), + } + return { + "success": True, + "command_id": command_id, + "status": "running_background", + } + + def write_file(self, path: str, content: str, *, mode: str = "w") -> Dict[str, Any]: + original = self.files.get(path) + if mode == "a" and original is not None: + new_content = original + content + else: + new_content = content + self.files[path] = new_content + self.calls.append({"op": "write_file", "path": path, "mode": mode, "bytes": len(content)}) + return { + "success": True, + "path": path, + "original_file": original, + "new_file": new_content, + } + + def edit_file(self, path: str, replacements: List[Dict[str, Any]]) -> Dict[str, Any]: + original = self.files.get(path) + if original is None: + self.calls.append({"op": "edit_file", "path": path, "error": "not_found"}) + return {"success": False, "error": f"文件不存在: {path}"} + new_content = original + applied = 0 + for rep in replacements: + old = rep.get("old_string", "") + new = rep.get("new_string", "") + if not old or old not in new_content: + continue + if rep.get("replace_all"): + applied += new_content.count(old) + new_content = new_content.replace(old, new) + else: + new_content = new_content.replace(old, new, 1) + applied += 1 + if applied == 0: + return {"success": False, "error": "未找到任何匹配内容"} + self.files[path] = new_content + self.calls.append({"op": "edit_file", "path": path, "replacements_applied": applied}) + return { + "success": True, + "path": path, + "original_file": original, + "new_file": new_content, + "replacements_applied": applied, + }