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.terminal_ops.attach_terminal_manager(self.terminal_manager)
|
||||||
self._apply_container_session(container_session)
|
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.todo_manager = TodoManager(self.context_manager)
|
||||||
self.sub_agent_manager = SubAgentManager(
|
self.sub_agent_manager = SubAgentManager(
|
||||||
project_path=self.project_path,
|
project_path=self.project_path,
|
||||||
|
|||||||
@ -1631,6 +1631,12 @@ class MainTerminalToolsExecutionMixin:
|
|||||||
append_flag = bool(arguments.get("append", False))
|
append_flag = bool(arguments.get("append", False))
|
||||||
if not path:
|
if not path:
|
||||||
result = {"success": False, "error": tr("tools_exec.missing_file_path")}
|
result = {"success": False, "error": tr("tools_exec.missing_file_path")}
|
||||||
|
else:
|
||||||
|
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:
|
else:
|
||||||
# 在写入前先备份当前内容(浅备份)。
|
# 在写入前先备份当前内容(浅备份)。
|
||||||
self._track_shallow_versioning(path)
|
self._track_shallow_versioning(path)
|
||||||
@ -1659,6 +1665,11 @@ class MainTerminalToolsExecutionMixin:
|
|||||||
result = {"success": False, "error": tr("tools_exec.missing_file_path")}
|
result = {"success": False, "error": tr("tools_exec.missing_file_path")}
|
||||||
elif not isinstance(replacements, list) or not replacements:
|
elif not isinstance(replacements, list) or not replacements:
|
||||||
result = {"success": False, "error": tr("tools_exec.missing_replacements")}
|
result = {"success": False, "error": tr("tools_exec.missing_replacements")}
|
||||||
|
else:
|
||||||
|
backend = getattr(self, "execution_backend", None)
|
||||||
|
if backend is not None:
|
||||||
|
# Execution Plane 后端(替身/远端):不触真实磁盘,跳过浅备份
|
||||||
|
result = backend.edit_file(path, replacements)
|
||||||
else:
|
else:
|
||||||
# 在替换前先备份当前内容(浅备份)。
|
# 在替换前先备份当前内容(浅备份)。
|
||||||
self._track_shallow_versioning(path)
|
self._track_shallow_versioning(path)
|
||||||
@ -1909,7 +1920,18 @@ class MainTerminalToolsExecutionMixin:
|
|||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
bg_manager = getattr(self, "background_command_manager", None)
|
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")}
|
result = {"success": False, "error": tr("tools_exec.background_manager_unavailable")}
|
||||||
else:
|
else:
|
||||||
result = bg_manager.create_background_command(
|
result = bg_manager.create_background_command(
|
||||||
@ -1932,6 +1954,16 @@ class MainTerminalToolsExecutionMixin:
|
|||||||
"success": False,
|
"success": False,
|
||||||
"error": tr("tools_exec.fg_timeout_max")
|
"error": tr("tools_exec.fg_timeout_max")
|
||||||
}
|
}
|
||||||
|
else:
|
||||||
|
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:
|
else:
|
||||||
result = await self.terminal_ops.run_command(
|
result = await self.terminal_ops.run_command(
|
||||||
arguments["command"],
|
arguments["command"],
|
||||||
|
|||||||
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