feat(execution-plane): Execution Contract 与替身执行器(改造第 4 步)
- modules/execution_plane:ExecutionBackend 协议(E1-E4 首版接口面)+ FakeExecutionBackend 内存替身(零真实副作用) - 接入:MainTerminal.execution_backend 属性(默认 None=真实链路), handle_tool_call 的 run_command 前台/后台、write_file、edit_file 四分支注入 - 替身路径自动跳过浅版本备份(不触真实磁盘) - 契约文档 docs/execution_contract.md(本地文档目录,不入库): E1-E10 接口面归纳、权限分工、Host/Docker 后端映射 - 验收:ExecutionPlaneFakeBackendTest 全绿(替身接收 E1-E4 调用、 结果结构同构消费、零磁盘写入、默认路径回归)
This commit is contained in:
parent
d9bd599c1c
commit
27aeed70ca
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
13
modules/execution_plane/__init__.py
Normal file
13
modules/execution_plane/__init__.py
Normal file
@ -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"]
|
||||
63
modules/execution_plane/base.py
Normal file
63
modules/execution_plane/base.py
Normal file
@ -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)。"""
|
||||
...
|
||||
123
modules/execution_plane/fake.py
Normal file
123
modules/execution_plane/fake.py
Normal file
@ -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,
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user