mirror of
https://github.com/zhenxun-org/zhenxun_bot.git
synced 2026-10-23 05:00:02 +08:00
♻️ refactor(tools): 重构工具装饰器系统并优化沙箱与文档注释 (#2147)
* ♻️ refactor(ai): 重构 AI 服务模块并完善文档注释 - 【重构】统一清理并优化所有 AI 服务模块文件的导入语句,将其移至文件顶部 - 【重构】重构 `hooks.py` 中的 `Hooks` 派发逻辑,使用通用管道函数消除重复代码,并引入 `HookPoint` 描述符 - 【重构】重构工具装饰器实现,新增 `toolkit` 类装饰器,优化 `BaseToolkit` 配置合并与前缀处理 - 【功能】Docker 沙箱容器创建时支持自动注入系统代理环境变量并配置 `ExtraHosts` - 【功能】Jupyter 服务启动前自动清理旧进程并初始化临时目录权限 - 【修复】优化 Pydantic 结构化输出校验失败时的错误信息提取,提供更详细的字段级错误反馈 - 【修复】在 `api.py` 中避免将 `ModelRetry` 和 `ControlFlowExit` 异常错误地包装为 `LLMException` - 【文档】为 AI 服务、沙箱、工具链、工作流等核心模块补充完整的 Docstring 和类型注释 * 📝 docs(ai): 补全核心模块文档注释并清理冗余代码 - 补全 `run/context`、`run/hooks` 和 `tools/engine/registry` 中类与方法的中文文档注释 - 清理 `tools/providers/builtin/sandbox` 中未使用的 `PythonPluginProtocol` 协议及相关导入 - 规范化部分代码的格式与尾随逗号 * ♻️ refactor!(flow): 重构 Task 为 AgentTask 并优化工作流元数据定义 - 【Breaking Change】将 `Task` 重命名为 `AgentTask` 以避免命名冲突 - 更新 Agent、Team、Workflow 等模块中的类型声明与相关逻辑 - 引入 `AutoNodeMeta` 强类型元数据,替换工作流装饰器中的裸字典定义 - 将 `StepMeta`、`ConditionMeta` 和 `RouterMeta` 统一移动至 `types.py` - 优化 `RunnableNode` 对上游 `AgentTask` 的处理与拼接逻辑 - 调整团队协作策略中 `FinishAction` 的返回值为完整结果对象 * ♻️ refactor(workflow): 移除人工确认机制并重构错误策略 - 移除工作流节点的人工确认(HITL)与挂起继续机制 - 删除 `auto` 自动化工作流及相关装饰器文件 - 将错误处理策略类从 `types.py` 拆分并移动到新文件 `policies.py` - 优化节点执行失败时的异常信息格式化输出 - 移除 `WorkflowRunResult` 和 `StepOutput` 中与挂起相关的状态字段 * 🚨 auto fix by pre-commit hooks --------- Co-authored-by: webjoin111 <455457521@qq.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
webjoin111
pre-commit-ci[bot]
parent
80fc5b86a7
commit
0b32d69c9c
@@ -10,22 +10,30 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
class BaseSandboxExtension(ABC):
|
||||
"""沙箱功能能力扩展基类"""
|
||||
|
||||
def __init__(self, session: "BaseSandboxSession"):
|
||||
"""初始化沙箱扩展实例,绑定当前沙箱会话"""
|
||||
self.session = session
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def extension_name(self) -> str:
|
||||
"""获取扩展能力的唯一名称"""
|
||||
pass
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
"""在扩展挂载到会话时触发的钩子函数"""
|
||||
logger.debug(f"[SandboxExtension] 扩展 '{self.extension_name}' 已挂载。")
|
||||
|
||||
async def on_unmount(self) -> None:
|
||||
"""在扩展从会话卸载时触发的钩子函数"""
|
||||
logger.debug(f"[SandboxExtension] 扩展 '{self.extension_name}' 已卸载。")
|
||||
|
||||
|
||||
class BaseMcpProxyExtension(BaseSandboxExtension):
|
||||
"""沙箱 MCP 代理扩展基类"""
|
||||
|
||||
@abstractmethod
|
||||
@asynccontextmanager
|
||||
async def connect_mcp(
|
||||
@@ -34,4 +42,5 @@ class BaseMcpProxyExtension(BaseSandboxExtension):
|
||||
args: list[str],
|
||||
env: dict[str, str] | None = None,
|
||||
) -> AsyncGenerator[tuple[Any, Any], None]:
|
||||
"""建立与沙箱内部 MCP 服务的代理连接并返回输入输出管道"""
|
||||
yield None, None
|
||||
|
||||
@@ -15,14 +15,18 @@ from zhenxun.utils.pydantic_compat import model_dump_json, model_validate
|
||||
|
||||
|
||||
class UniversalMcpExtension(BaseMcpProxyExtension):
|
||||
"""通用 MCP 代理扩展类,用于在沙箱内连接 MCP 服务"""
|
||||
|
||||
@property
|
||||
def extension_name(self) -> str:
|
||||
"""获取 MCP 代理扩展的唯一名称"""
|
||||
return "universal_mcp"
|
||||
|
||||
@asynccontextmanager
|
||||
async def connect_mcp(
|
||||
self, command: str, args: list[str], env: dict[str, str] | None = None
|
||||
) -> AsyncGenerator[tuple[Any, Any], None]:
|
||||
"""启动沙箱内的 MCP 服务器,并建立与之进行 JSON-RPC 通信的双向内存流管道"""
|
||||
if not isinstance(self.session, SupportsStreamExecution):
|
||||
raise RuntimeError(
|
||||
"当前沙箱驱动不支持流式后台进程执行 (SupportsStreamExecution),"
|
||||
|
||||
@@ -17,6 +17,7 @@ class BaseSandboxSession(SandboxChannel):
|
||||
"""沙箱会话接口,持有 Client 分配的具体资源,提供统一的标准操作"""
|
||||
|
||||
def __init__(self, state: SandboxSessionState):
|
||||
"""初始化沙箱会话实例并设置工作空间"""
|
||||
self.state = state
|
||||
self.last_active_time: float = time.time()
|
||||
self.loaded_skills: set[str] = set()
|
||||
@@ -27,6 +28,7 @@ class BaseSandboxSession(SandboxChannel):
|
||||
|
||||
@property
|
||||
def session_id(self) -> str:
|
||||
"""获取当前会话的 ID"""
|
||||
return self.state.session_id
|
||||
|
||||
def get_meta(self, key: str, default: Any = None) -> Any:
|
||||
@@ -103,6 +105,7 @@ class BaseSandboxSession(SandboxChannel):
|
||||
|
||||
@abstractmethod
|
||||
async def close(self) -> None:
|
||||
"""关闭当前沙箱会话并释放关联资源"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
@@ -114,37 +117,46 @@ class BaseSandboxSession(SandboxChannel):
|
||||
env: dict[str, str] | None = None,
|
||||
on_output: Any = None,
|
||||
) -> Any:
|
||||
"""在沙箱内异步执行子进程"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def read(self, path: str) -> bytes:
|
||||
"""读取沙箱内指定路径的文件内容"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def write(self, path: str, data: bytes) -> bool:
|
||||
"""向沙箱内指定路径写入文件数据"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def rm(self, path: str, recursive: bool = False) -> bool:
|
||||
"""在沙箱内删除指定路径的文件或目录"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def mkdir(self, path: str, parents: bool = False) -> bool:
|
||||
"""在沙箱内创建目录"""
|
||||
pass
|
||||
|
||||
async def write_raw_file(self, path: str, content: str) -> bool:
|
||||
"""向沙箱中写入纯文本文件内容"""
|
||||
return await self.write(path, content.encode("utf-8"))
|
||||
|
||||
async def read_raw_file(self, path: str) -> str:
|
||||
"""读取并以 utf-8 解码沙箱中的文本文件内容"""
|
||||
data = await self.read(path)
|
||||
return data.decode("utf-8", errors="replace")
|
||||
|
||||
async def delete_raw_file(self, path: str) -> bool:
|
||||
"""删除沙箱中的纯文本文件"""
|
||||
return await self.rm(path)
|
||||
|
||||
async def upload_raw_dir(
|
||||
self, local_dir_path: str, sandbox_target_path: str
|
||||
) -> bool:
|
||||
"""将本地文件夹上传并映射至沙箱内指定位置"""
|
||||
return True
|
||||
|
||||
|
||||
@@ -159,14 +171,17 @@ class BaseSandboxClient(ABC):
|
||||
session_id: str,
|
||||
blueprint: SandboxBlueprint | None = None,
|
||||
) -> BaseSandboxSession:
|
||||
"""创建并启动一个全新的沙箱会话环境"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def resume(self, state: SandboxSessionState) -> BaseSandboxSession:
|
||||
"""恢复一个已有的沙箱会话环境"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def delete(self, session: BaseSandboxSession) -> None:
|
||||
"""销毁指定的沙箱环境并清理容器或实例"""
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from typing import Any, ClassVar
|
||||
import aiodocker
|
||||
import anyio
|
||||
|
||||
from zhenxun.configs.config import BotConfig
|
||||
from zhenxun.services.ai.config import get_llm_config
|
||||
from zhenxun.services.ai.core.exceptions import SandboxPathEscapeError, WorkspaceIOError
|
||||
from zhenxun.services.ai.sandbox.models import (
|
||||
@@ -32,6 +33,7 @@ class DockerInteractiveTerminalSession(InteractiveTerminalSession):
|
||||
"""PTY 交互式会话:接管 Docker Stream,带有防死循环 Token 截断机制"""
|
||||
|
||||
def __init__(self, session: "DockerSandboxSession"):
|
||||
"""初始化 Docker PTY 交互式会话实例"""
|
||||
self.session = session
|
||||
self.exec_stream = None
|
||||
self.buffer = ""
|
||||
@@ -39,6 +41,7 @@ class DockerInteractiveTerminalSession(InteractiveTerminalSession):
|
||||
self.ansi_escape = re.compile(r"(?:\x1B[@-_]|[\x80-\x9F])[0-?]*[ -/]*[@-~]")
|
||||
|
||||
async def start(self, cmd: str, env: dict[str, str] | None = None) -> None:
|
||||
"""在容器中异步开启 PTY 终端执行指定命令"""
|
||||
if not self.session.container:
|
||||
raise RuntimeError("沙箱容器未启动")
|
||||
|
||||
@@ -62,6 +65,7 @@ class DockerInteractiveTerminalSession(InteractiveTerminalSession):
|
||||
self._read_task = asyncio.create_task(self._read_loop())
|
||||
|
||||
async def _read_loop(self):
|
||||
"""异步循环读取容器执行输出流,并写入本地缓冲区(包含防超长截断)"""
|
||||
try:
|
||||
while True:
|
||||
if not self.exec_stream:
|
||||
@@ -82,18 +86,22 @@ class DockerInteractiveTerminalSession(InteractiveTerminalSession):
|
||||
logger.debug(f"[PTY] 流读取结束: {e}")
|
||||
|
||||
async def send_input(self, text: str) -> None:
|
||||
"""向容器的 PTY 终端输入标准输入数据"""
|
||||
if self.exec_stream:
|
||||
await self.exec_stream.write_in(text.encode("utf-8"))
|
||||
|
||||
async def read_output(self, timeout: int = 5) -> str: # noqa: ASYNC109
|
||||
"""获取缓冲区最新的 50 行终端输出内容"""
|
||||
lines = self.buffer.split("\n")
|
||||
return "\n".join(lines[-50:]).strip()
|
||||
|
||||
async def interrupt(self) -> None:
|
||||
"""发送 Ctrl+C 中断信号以打断当前执行进程"""
|
||||
if self.exec_stream:
|
||||
await self.exec_stream.write_in(b"\x03")
|
||||
|
||||
async def close(self) -> None:
|
||||
"""关闭 PTY 读取循环任务和 exec 流资源"""
|
||||
if self._read_task:
|
||||
self._read_task.cancel()
|
||||
if self.exec_stream:
|
||||
@@ -105,33 +113,41 @@ class DockerSandboxProcessStream(SandboxProcessStream):
|
||||
"""包装 aiodocker 的流,使其符合 SandboxProcessStream 协议"""
|
||||
|
||||
def __init__(self, docker_stream):
|
||||
"""初始化封装的 Docker 流程管道流"""
|
||||
self.stream = docker_stream
|
||||
|
||||
async def read(self) -> ProcessStreamMessage | None:
|
||||
"""异步读取管道流中的数据块并转换为 ProcessStreamMessage"""
|
||||
msg = await self.stream.read_out()
|
||||
if msg is None:
|
||||
return None
|
||||
return ProcessStreamMessage(stream_type=msg.stream, data=msg.data)
|
||||
|
||||
async def write(self, data: bytes) -> None:
|
||||
"""向管道中异步写入数据字节"""
|
||||
await self.stream.write_in(data)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""关闭 Docker 管道流资源"""
|
||||
await self.stream.close()
|
||||
|
||||
|
||||
class DockerSandboxSession(BaseSandboxSession):
|
||||
"""Docker 驱动底层的具体沙箱会话通道实现"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
state: SandboxSessionState,
|
||||
container: Any,
|
||||
):
|
||||
"""初始化 Docker 沙箱会话并传入 Docker 容器句柄"""
|
||||
super().__init__(state)
|
||||
self.container = container
|
||||
self._vfs_helper_installed = False
|
||||
self._workspace_created = False
|
||||
|
||||
async def is_alive(self) -> bool:
|
||||
"""查询底层 Docker 容器是否正处于 Running 状态"""
|
||||
if not self.container:
|
||||
return False
|
||||
try:
|
||||
@@ -141,9 +157,11 @@ class DockerSandboxSession(BaseSandboxSession):
|
||||
return False
|
||||
|
||||
async def create_pty_session(self) -> InteractiveTerminalSession:
|
||||
"""创建交互式 Docker 终端会话实例"""
|
||||
return DockerInteractiveTerminalSession(self)
|
||||
|
||||
async def _ensure_workspace(self):
|
||||
"""确保在容器内成功创建该会话的工作空间目录"""
|
||||
if not self._workspace_created and self.container:
|
||||
exec_inst = await self.container.exec(
|
||||
cmd=["/bin/sh", "-c", f"mkdir -p '{self.workspace_path}'"]
|
||||
@@ -162,6 +180,7 @@ class DockerSandboxSession(BaseSandboxSession):
|
||||
cwd: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> AsyncGenerator[SandboxProcessStream, None]:
|
||||
"""在指定工作目录下创建一个流式交互的进程通道"""
|
||||
cmd_list = ["/bin/sh", "-c", command] if isinstance(command, str) else command
|
||||
env_list = [f"{k}={v}" for k, v in env.items()] if env else None
|
||||
|
||||
@@ -178,7 +197,7 @@ class DockerSandboxSession(BaseSandboxSession):
|
||||
yield DockerSandboxProcessStream(raw_stream)
|
||||
|
||||
async def _ensure_vfs_helper(self):
|
||||
"""预置路径安全探针"""
|
||||
"""确保在沙箱容器内装有路径安全分析二进制文件"""
|
||||
if self._vfs_helper_installed:
|
||||
return
|
||||
check = await self.run_process(f"test -x {RESOLVE_PATH_HELPER.install_path}")
|
||||
@@ -191,7 +210,7 @@ class DockerSandboxSession(BaseSandboxSession):
|
||||
async def _validate_remote_path(
|
||||
self, path: str | Path, for_write: bool = False, base_dir: str | None = None
|
||||
) -> Path:
|
||||
"""基于沙箱内真实环境的防软链接逃逸解析"""
|
||||
"""使用 VFS 探针对给定的沙箱路径进行安全性防逃逸校验"""
|
||||
base_dir = base_dir or self.workspace_path
|
||||
target_posix = coerce_posix_path(path).as_posix()
|
||||
is_write = "1" if for_write else "0"
|
||||
@@ -227,6 +246,7 @@ class DockerSandboxSession(BaseSandboxSession):
|
||||
env: dict[str, str] | None = None,
|
||||
on_output: Any = None,
|
||||
) -> SandboxExecutionResult:
|
||||
"""在容器中指定目录下执行非交互式进程并等待其运行结果"""
|
||||
from zhenxun.services.ai.core.exceptions import SandboxFatalError
|
||||
|
||||
self.touch()
|
||||
@@ -288,6 +308,7 @@ class DockerSandboxSession(BaseSandboxSession):
|
||||
return SandboxExecutionResult(exit_code=-1, error=str(e))
|
||||
|
||||
async def read(self, path: str | Path) -> bytes:
|
||||
"""通过 Docker Tar 归档接口读取容器内的指定文件内容"""
|
||||
self.touch()
|
||||
secure_path = await self._validate_remote_path(path, for_write=False)
|
||||
try:
|
||||
@@ -305,6 +326,7 @@ class DockerSandboxSession(BaseSandboxSession):
|
||||
raise WorkspaceIOError(str(path), f"读取文件异常: {e}")
|
||||
|
||||
async def write(self, path: str | Path, data: bytes) -> bool:
|
||||
"""通过 Docker put_archive 接口将文件写入容器的指定路径"""
|
||||
self.touch()
|
||||
secure_path = await self._validate_remote_path(path, for_write=True)
|
||||
|
||||
@@ -328,12 +350,14 @@ class DockerSandboxSession(BaseSandboxSession):
|
||||
return False
|
||||
|
||||
async def rm(self, path: str | Path, recursive: bool = False) -> bool:
|
||||
"""在容器内执行 rm 命令移除指定文件或目录"""
|
||||
secure_path = await self._validate_remote_path(path, for_write=True)
|
||||
flag = "-rf" if recursive else "-f"
|
||||
res = await self.run_process(f"rm {flag} '{secure_path.as_posix()}'")
|
||||
return res.exit_code == 0
|
||||
|
||||
async def mkdir(self, path: str | Path, parents: bool = False) -> bool:
|
||||
"""在容器内执行 mkdir 命令创建目录"""
|
||||
secure_path = await self._validate_remote_path(path, for_write=True)
|
||||
flag = "-p" if parents else ""
|
||||
res = await self.run_process(f"mkdir {flag} '{secure_path.as_posix()}'")
|
||||
@@ -342,6 +366,7 @@ class DockerSandboxSession(BaseSandboxSession):
|
||||
async def upload_raw_dir(
|
||||
self, local_dir_path: str, sandbox_target_path: str
|
||||
) -> bool:
|
||||
"""通过打包 tar 归档将宿主机本地目录上传至容器内指定路径"""
|
||||
aio_path = anyio.Path(local_dir_path)
|
||||
if not await aio_path.exists() or not await aio_path.is_dir():
|
||||
return False
|
||||
@@ -369,7 +394,7 @@ class DockerSandboxSession(BaseSandboxSession):
|
||||
return False
|
||||
|
||||
async def close(self) -> None:
|
||||
"""关闭会话:仅清理工作区,不销毁共享容器"""
|
||||
"""关闭会话并在容器内清理本会话对应的工作空间目录"""
|
||||
try:
|
||||
if await self.is_alive():
|
||||
await self.rm(self.workspace_path, recursive=True)
|
||||
@@ -378,6 +403,8 @@ class DockerSandboxSession(BaseSandboxSession):
|
||||
|
||||
|
||||
class DockerSandboxClient(BaseSandboxClient):
|
||||
"""基于 Docker 实现的沙箱物理资源管理器类"""
|
||||
|
||||
backend_id = "docker"
|
||||
_global_docker_client: ClassVar[Any] = None
|
||||
_containers: ClassVar[dict[str, Any]] = {}
|
||||
@@ -390,10 +417,25 @@ class DockerSandboxClient(BaseSandboxClient):
|
||||
session_id: str,
|
||||
blueprint: SandboxBlueprint | None = None,
|
||||
) -> BaseSandboxSession:
|
||||
"""建立或复用物理容器,并为该会话初始化专属的工作目录和 Python 虚拟环境"""
|
||||
bp = blueprint or SandboxBlueprint()
|
||||
eff_image = bp.image or get_llm_config().sandbox.docker_image
|
||||
eff_cname = bp.container_name
|
||||
|
||||
proxy_envs = []
|
||||
if BotConfig.system_proxy:
|
||||
sandbox_proxy = BotConfig.system_proxy.replace(
|
||||
"127.0.0.1", "host.docker.internal"
|
||||
).replace("localhost", "host.docker.internal")
|
||||
|
||||
proxy_envs = [
|
||||
f"HTTP_PROXY={sandbox_proxy}",
|
||||
f"HTTPS_PROXY={sandbox_proxy}",
|
||||
f"http_proxy={sandbox_proxy}",
|
||||
f"https_proxy={sandbox_proxy}",
|
||||
f"ALL_PROXY={sandbox_proxy}",
|
||||
]
|
||||
|
||||
async with self._init_lock:
|
||||
if DockerSandboxClient._global_docker_client is None:
|
||||
import aiodocker
|
||||
@@ -465,6 +507,7 @@ class DockerSandboxClient(BaseSandboxClient):
|
||||
"HF_HOME=/tmp/hf_cache",
|
||||
"JUPYTER_RUNTIME_DIR=/tmp/jupyter_runtime",
|
||||
"JUPYTER_DATA_DIR=/tmp/jupyter_data",
|
||||
*proxy_envs,
|
||||
],
|
||||
"Cmd": [
|
||||
"/bin/sh",
|
||||
@@ -476,6 +519,7 @@ class DockerSandboxClient(BaseSandboxClient):
|
||||
"HostConfig": {
|
||||
"PortBindings": port_bindings,
|
||||
"Binds": binds,
|
||||
"ExtraHosts": ["host.docker.internal:host-gateway"],
|
||||
},
|
||||
"Labels": {
|
||||
"zhenxun_component": "sandbox",
|
||||
@@ -549,10 +593,11 @@ class DockerSandboxClient(BaseSandboxClient):
|
||||
return session
|
||||
|
||||
async def resume(self, state: SandboxSessionState) -> BaseSandboxSession:
|
||||
"""暂不支持通过还原状态重建 Docker 沙箱会话"""
|
||||
raise NotImplementedError("Docker Driver 不支持无状态重建恢复。")
|
||||
|
||||
async def delete(self, session: BaseSandboxSession) -> None:
|
||||
"""触发会话销毁及引用计数物理回收"""
|
||||
"""清理会话工作区,并当物理容器处于长闲置时触发物理销毁回收"""
|
||||
await session.close()
|
||||
|
||||
from zhenxun.services.ai.sandbox.manager import sandbox_manager
|
||||
@@ -584,6 +629,7 @@ class DockerSandboxClient(BaseSandboxClient):
|
||||
|
||||
@classmethod
|
||||
async def close_env(cls):
|
||||
"""清理释放全部管理的 Docker 容器并关闭 Docker 客户端连接"""
|
||||
for cname, container in cls._containers.items():
|
||||
try:
|
||||
await container.delete(force=True)
|
||||
@@ -602,7 +648,7 @@ class DockerSandboxClient(BaseSandboxClient):
|
||||
|
||||
@classmethod
|
||||
async def silent_prune_orphans(cls):
|
||||
"""供 manager 后台延迟调用的静默清理函数"""
|
||||
"""在系统启动时静默搜寻并强力删除带有残留标记的孤儿容器"""
|
||||
try:
|
||||
async with aiodocker.Docker() as docker:
|
||||
await asyncio.wait_for(docker.system.info(), timeout=2.0)
|
||||
|
||||
@@ -37,6 +37,7 @@ class ProvisionerRegistry:
|
||||
|
||||
@classmethod
|
||||
def register(cls, provisioner: BaseProvisioner) -> None:
|
||||
"""向注册中心注册一个环境配置器"""
|
||||
if provisioner.name in cls._provisioners:
|
||||
logger.warning(
|
||||
f"[ProvisionerRegistry] 覆盖已存在的配置器: {provisioner.name}"
|
||||
@@ -46,10 +47,12 @@ class ProvisionerRegistry:
|
||||
|
||||
@classmethod
|
||||
def get(cls, name: str) -> BaseProvisioner | None:
|
||||
"""获取指定名称的环境配置器"""
|
||||
return cls._provisioners.get(name)
|
||||
|
||||
@classmethod
|
||||
def get_all(cls) -> dict[str, BaseProvisioner]:
|
||||
"""获取所有已注册的环境配置器列表"""
|
||||
return cls._provisioners.copy()
|
||||
|
||||
|
||||
@@ -61,11 +64,13 @@ class UnifiedManifestProvisioner(BaseProvisioner):
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""返回统一环境清单装配器的名称"""
|
||||
return "unified_manifest"
|
||||
|
||||
async def install(
|
||||
self, session: "BaseSandboxSession", blueprint: "SandboxBlueprint"
|
||||
) -> bool:
|
||||
"""根据 SandboxBlueprint 环境清单指纹装配沙箱依赖环境"""
|
||||
target_hash = blueprint.calculate_hash()
|
||||
|
||||
if session.get_meta("env_hash") == target_hash:
|
||||
@@ -96,6 +101,7 @@ class UnifiedManifestProvisioner(BaseProvisioner):
|
||||
async def scan_and_setup_workspace(
|
||||
self, session: "BaseSandboxSession", workspace_dir: str
|
||||
) -> bool:
|
||||
"""扫描项目工作区,根据 requirements.txt 或 package.json 自动安装所需依赖"""
|
||||
check = await session.run_process(f"test -f {workspace_dir}/requirements.txt")
|
||||
if check.exit_code == 0:
|
||||
check_uv = await session.run_process("command -v uv")
|
||||
|
||||
@@ -4,9 +4,7 @@ from typing import Any, cast
|
||||
import nonebot
|
||||
|
||||
from zhenxun.services.ai.config import get_llm_config
|
||||
from zhenxun.services.ai.sandbox.models import (
|
||||
SandboxBlueprint,
|
||||
)
|
||||
from zhenxun.services.ai.sandbox.models import SandboxBlueprint
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.utils.lifespan import LifespanManager
|
||||
|
||||
@@ -23,11 +21,13 @@ class SandboxManager:
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化沙箱环境管理器,配置活跃会话、会话锁及生存期管理器"""
|
||||
self._active_sessions: dict[str, BaseSandboxSession] = {}
|
||||
self._session_locks: dict[str, asyncio.Lock] = {}
|
||||
self.lifespan_manager = LifespanManager()
|
||||
|
||||
def _get_lock(self, session_id: str) -> asyncio.Lock:
|
||||
"""获取或创建指定会话的 asyncio 互斥锁,确保并发操作的安全性"""
|
||||
if session_id not in self._session_locks:
|
||||
self._session_locks[session_id] = asyncio.Lock()
|
||||
return self._session_locks[session_id]
|
||||
@@ -36,6 +36,7 @@ class SandboxManager:
|
||||
self,
|
||||
blueprint: SandboxBlueprint,
|
||||
) -> BaseSandboxClient:
|
||||
"""根据全局配置和蓝图参数决定并获取具体的沙箱驱动客户端实例"""
|
||||
global_type = get_llm_config().sandbox.sandbox_type
|
||||
|
||||
effective_type = (
|
||||
@@ -148,6 +149,7 @@ class SandboxManager:
|
||||
return True
|
||||
|
||||
async def release_resource(self, resource_id: str):
|
||||
"""释放指定会话的沙箱资源并销毁底层物理容器"""
|
||||
if resource_id in self._active_sessions:
|
||||
session = self._active_sessions.pop(resource_id)
|
||||
try:
|
||||
@@ -161,10 +163,12 @@ class SandboxManager:
|
||||
)
|
||||
|
||||
async def close_session(self, session_id: str) -> None:
|
||||
"""从生存期管理器中注销并销毁指定会话的沙箱环境"""
|
||||
await self.lifespan_manager.unregister(session_id)
|
||||
await self.release_resource(session_id)
|
||||
|
||||
async def shutdown_all(self) -> None:
|
||||
"""关闭所有当前活跃的沙箱环境并停止生存期监测"""
|
||||
keys = list(self._active_sessions.keys())
|
||||
for sid in keys:
|
||||
await self.close_session(sid)
|
||||
@@ -179,6 +183,7 @@ driver = nonebot.get_driver()
|
||||
|
||||
@driver.on_startup
|
||||
async def _startup_sandboxes():
|
||||
"""异步初始化沙箱,触发后台自动清理孤儿容器"""
|
||||
if not get_llm_config().sandbox.enable_sandbox:
|
||||
return
|
||||
clients = SandboxRegistry.get_all_clients()
|
||||
@@ -199,6 +204,7 @@ async def _startup_sandboxes():
|
||||
|
||||
@driver.on_shutdown
|
||||
async def _shutdown_sandboxes():
|
||||
"""在系统关闭时优雅关闭所有沙箱并清理 Docker 环境"""
|
||||
if not get_llm_config().sandbox.enable_sandbox:
|
||||
return
|
||||
await sandbox_manager.shutdown_all()
|
||||
|
||||
@@ -8,11 +8,14 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
class SandboxRegistry:
|
||||
"""沙箱组件与能力扩展注册中心"""
|
||||
|
||||
_clients: ClassVar[dict[str, type["BaseSandboxClient"]]] = {}
|
||||
_extensions: ClassVar[dict[str, type["BaseSandboxExtension"]]] = {}
|
||||
|
||||
@classmethod
|
||||
def register_client(cls, name: str, client_cls: type["BaseSandboxClient"]) -> None:
|
||||
"""注册一个沙箱驱动客户端类"""
|
||||
if name in cls._clients:
|
||||
logger.warning(f"[SandboxRegistry] 覆盖已存在的沙箱客户端: {name}")
|
||||
cls._clients[name] = client_cls
|
||||
@@ -20,16 +23,19 @@ class SandboxRegistry:
|
||||
|
||||
@classmethod
|
||||
def get_client_cls(cls, name: str) -> type["BaseSandboxClient"]:
|
||||
"""获取指定名称的沙箱驱动客户端类"""
|
||||
if name not in cls._clients:
|
||||
raise ValueError(f"未找到名为 '{name}' 的沙箱客户端。")
|
||||
return cls._clients[name]
|
||||
|
||||
@classmethod
|
||||
def get_all_clients(cls) -> dict[str, type["BaseSandboxClient"]]:
|
||||
"""获取所有已注册的沙箱驱动客户端类"""
|
||||
return cls._clients.copy()
|
||||
|
||||
@classmethod
|
||||
def register_extension(cls, extension_cls: type["BaseSandboxExtension"]) -> None:
|
||||
"""注册一个沙箱功能能力扩展类"""
|
||||
if (
|
||||
isinstance(getattr(extension_cls, "extension_name", None), property)
|
||||
and extension_cls.extension_name.fget
|
||||
@@ -42,4 +48,5 @@ class SandboxRegistry:
|
||||
|
||||
@classmethod
|
||||
def get_extension_cls(cls, name: str) -> type["BaseSandboxExtension"] | None:
|
||||
"""获取指定名称的沙箱能力扩展类"""
|
||||
return cls._extensions.get(name)
|
||||
|
||||
@@ -17,6 +17,7 @@ from zhenxun.utils.utils import infer_plugin_namespace
|
||||
|
||||
|
||||
def parse_shebang(script_path: str | Path) -> str | None:
|
||||
"""解析脚本首行的 Shebang,获取解释器名称"""
|
||||
path = Path(script_path)
|
||||
if not path.is_file():
|
||||
return None
|
||||
@@ -46,6 +47,7 @@ def parse_shebang(script_path: str | Path) -> str | None:
|
||||
def get_execution_command(
|
||||
script_path: str | Path, args: list[str] | None = None
|
||||
) -> str:
|
||||
"""根据 Shebang 或文件后缀生成对应的脚本执行命令"""
|
||||
path = Path(script_path)
|
||||
interpreter = parse_shebang(path)
|
||||
if not interpreter:
|
||||
@@ -66,6 +68,8 @@ def get_execution_command(
|
||||
|
||||
|
||||
class JupyterWSClient:
|
||||
"""管理与单个 Jupyter Kernel WebSocket 通道通信的客户端"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
http_session: aiohttp.ClientSession,
|
||||
@@ -73,6 +77,7 @@ class JupyterWSClient:
|
||||
ws_url: str,
|
||||
kernel_id: str,
|
||||
):
|
||||
"""初始化 Jupyter WebSocket 客户端并绑定内核ID"""
|
||||
self.http_session = http_session
|
||||
self.base_url = base_url
|
||||
self.ws_url = ws_url
|
||||
@@ -80,6 +85,7 @@ class JupyterWSClient:
|
||||
self.ws: aiohttp.ClientWebSocketResponse | None = None
|
||||
|
||||
async def _connect_ws(self) -> aiohttp.ClientWebSocketResponse:
|
||||
"""建立并返回与 Jupyter Kernel 活跃的 WebSocket 连接"""
|
||||
if self.ws is None or self.ws.closed:
|
||||
self.ws = await self.http_session.ws_connect(
|
||||
f"{self.ws_url}/api/kernels/{self.kernel_id}/channels"
|
||||
@@ -88,6 +94,7 @@ class JupyterWSClient:
|
||||
return self.ws
|
||||
|
||||
async def interrupt(self):
|
||||
"""向 Jupyter Kernel 发送 HTTP POST 中断正在执行的进程"""
|
||||
try:
|
||||
async with self.http_session.post(
|
||||
f"{self.base_url}/api/kernels/{self.kernel_id}/interrupt"
|
||||
@@ -100,6 +107,7 @@ class JupyterWSClient:
|
||||
logger.warning(f"[JupyterWSClient] 中断 Kernel 失败: {e}")
|
||||
|
||||
async def execute(self, code: str, timeout: int = 30, on_output=None):
|
||||
"""在 Jupyter 核心中执行代码,并通过 WebSocket 接收标准输出、标准错误和图像"""
|
||||
background_tasks: set[asyncio.Task[None]] = set()
|
||||
|
||||
try:
|
||||
@@ -226,6 +234,7 @@ class JupyterWSClient:
|
||||
)
|
||||
|
||||
async def close(self):
|
||||
"""关闭与 Jupyter Kernel 的 WebSocket 通道连接"""
|
||||
if self.ws and not self.ws.closed:
|
||||
await self.ws.close()
|
||||
self.ws = None
|
||||
@@ -235,6 +244,7 @@ class JupyterServerManager:
|
||||
"""管理沙箱内的 Jupyter 引擎生命周期及长连接"""
|
||||
|
||||
def __init__(self, session: BaseSandboxSession):
|
||||
"""初始化 Jupyter 服务生命周期及会话连接管理器"""
|
||||
self.session = session
|
||||
self._http_session: aiohttp.ClientSession | None = None
|
||||
self.base_url = ""
|
||||
@@ -243,6 +253,7 @@ class JupyterServerManager:
|
||||
self._clients: dict[str, JupyterWSClient] = {}
|
||||
|
||||
async def ensure_started(self, env_vars: dict[str, str] | None = None):
|
||||
"""确保沙箱环境内部已拉起并运行着 Jupyter Server"""
|
||||
if self._is_started:
|
||||
return
|
||||
|
||||
@@ -258,6 +269,14 @@ class JupyterServerManager:
|
||||
if check_jupyter.exit_code != 0:
|
||||
raise RuntimeError("沙箱内未安装 jupyter-server,请检查 Blueprint")
|
||||
|
||||
await self.session.run_process(
|
||||
"mkdir -p /tmp/jupyter_runtime /tmp/jupyter_data && "
|
||||
"chmod 777 /tmp/jupyter_runtime /tmp/jupyter_data"
|
||||
)
|
||||
|
||||
await self.session.run_process("pkill -9 -f jupyter-server || true")
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
env_str = " ".join([f"{k}={v}" for k, v in (env_vars or {}).items()])
|
||||
start_cmd = (
|
||||
f"nohup env {env_str} jupyter-server "
|
||||
@@ -300,6 +319,7 @@ class JupyterServerManager:
|
||||
)
|
||||
|
||||
async def get_client(self, kernel_name: str) -> JupyterWSClient:
|
||||
"""获取并连接到指定内核的 Jupyter WebSocket 客户端"""
|
||||
await self.ensure_started()
|
||||
if kernel_name in self._clients:
|
||||
return self._clients[kernel_name]
|
||||
@@ -321,6 +341,7 @@ class JupyterServerManager:
|
||||
return client
|
||||
|
||||
async def close(self):
|
||||
"""关闭并清理所有 Jupyter WS 连接及 HTTP 会话资源"""
|
||||
for client in self._clients.values():
|
||||
await client.close()
|
||||
self._clients.clear()
|
||||
@@ -334,6 +355,7 @@ class BaseCodeExecutor(ABC):
|
||||
"""代码执行器抽象基类"""
|
||||
|
||||
def __init__(self, session: BaseSandboxSession):
|
||||
"""初始化代码执行器,绑定沙箱会话"""
|
||||
self.session = session
|
||||
|
||||
@abstractmethod
|
||||
@@ -344,6 +366,7 @@ class BaseCodeExecutor(ABC):
|
||||
injected_code: str | None = None,
|
||||
on_output: Callable[[str, bytes], Awaitable[None]] | None = None,
|
||||
) -> SandboxExecutionResult:
|
||||
"""在沙箱会话中执行指定代码并返回执行结果"""
|
||||
pass
|
||||
|
||||
|
||||
@@ -351,6 +374,7 @@ class GenericCLIExecutor(BaseCodeExecutor):
|
||||
"""模板驱动的通用代码执行器"""
|
||||
|
||||
def __init__(self, session, profile: "LanguageProfile"):
|
||||
"""初始化通用命令行代码执行器,传入语言配置模板"""
|
||||
super().__init__(session)
|
||||
self.profile = profile
|
||||
|
||||
@@ -361,6 +385,7 @@ class GenericCLIExecutor(BaseCodeExecutor):
|
||||
injected_code: str | None = None,
|
||||
on_output: Callable[[str, bytes], Awaitable[None]] | None = None,
|
||||
) -> SandboxExecutionResult:
|
||||
"""将代码写入临时文件,必要时编译并执行,返回执行结果"""
|
||||
env = None
|
||||
if injected_code:
|
||||
await self.session.write(
|
||||
@@ -403,6 +428,7 @@ class CodeExecutorRegistry:
|
||||
|
||||
@classmethod
|
||||
def _normalize_lang(cls, language: str) -> str:
|
||||
"""规范化语言名称,转换为统一小写格式并应用别名"""
|
||||
lang_lower = language.lower().strip()
|
||||
return cls._aliases.get(lang_lower, lang_lower)
|
||||
|
||||
@@ -414,6 +440,7 @@ class CodeExecutorRegistry:
|
||||
is_stateful: bool = False,
|
||||
scope: str | None = None,
|
||||
) -> None:
|
||||
"""注册一个语言对应的代码执行器构造工厂"""
|
||||
ns = scope if scope is not None else infer_plugin_namespace()
|
||||
lang_norm = cls._normalize_lang(language)
|
||||
|
||||
@@ -435,7 +462,7 @@ class CodeExecutorRegistry:
|
||||
kernel_name: str,
|
||||
scope: str | None = None,
|
||||
) -> None:
|
||||
"""语法糖:快速注册一门基于 Jupyter Kernel 的有状态语言"""
|
||||
"""快速注册一个基于 Jupyter 内核的有状态运行语言"""
|
||||
cls.register(
|
||||
language,
|
||||
lambda session: GenericJupyterExecutor(session, kernel_name=kernel_name),
|
||||
@@ -445,6 +472,7 @@ class CodeExecutorRegistry:
|
||||
|
||||
@classmethod
|
||||
def register_profile(cls, profile: "LanguageProfile") -> None:
|
||||
"""注册一个基于命令行的无状态运行语言配置模板"""
|
||||
cls._profiles[profile.language.lower()] = profile
|
||||
for alias in profile.aliases:
|
||||
cls._profiles[alias.lower()] = profile
|
||||
@@ -454,6 +482,7 @@ class CodeExecutorRegistry:
|
||||
def create_executor(
|
||||
cls, language: str, needs_state: bool, session: Any, namespace: str = "global"
|
||||
) -> BaseCodeExecutor:
|
||||
"""根据语言和有无状态需求为指定会话创建具体的执行器实例"""
|
||||
lang_norm = cls._normalize_lang(language)
|
||||
|
||||
for target_ns in [namespace, "global"]:
|
||||
@@ -482,6 +511,7 @@ class CodeExecutorRegistry:
|
||||
|
||||
@classmethod
|
||||
def get_supported_languages(cls, namespace: str = "global") -> list[str]:
|
||||
"""获取所有目前已注册支持的编程语言列表"""
|
||||
langs = set(cls._executors.get("global", {}).keys())
|
||||
if namespace in cls._executors:
|
||||
langs.update(cls._executors[namespace].keys())
|
||||
@@ -493,6 +523,7 @@ class GenericJupyterExecutor(BaseCodeExecutor):
|
||||
"""基于 Jupyter 协议的泛化有状态执行器。支持多语言 REPL。"""
|
||||
|
||||
def __init__(self, session, kernel_name: str = "python3"):
|
||||
"""初始化泛用 Jupyter 有状态执行器,设置默认内核"""
|
||||
super().__init__(session)
|
||||
self.kernel_name = kernel_name
|
||||
self.manager = JupyterServerManager(session)
|
||||
@@ -504,6 +535,7 @@ class GenericJupyterExecutor(BaseCodeExecutor):
|
||||
injected_code: str | None = None,
|
||||
on_output: Callable[[str, bytes], Awaitable[None]] | None = None,
|
||||
) -> SandboxExecutionResult:
|
||||
"""利用 Jupyter 内核长连接异步执行代码并返回运行结果"""
|
||||
try:
|
||||
await self.manager.ensure_started()
|
||||
except Exception as e:
|
||||
@@ -524,6 +556,7 @@ class GenericJupyterExecutor(BaseCodeExecutor):
|
||||
return result
|
||||
|
||||
async def close(self):
|
||||
"""关闭 Jupyter 客户端及后台运行环境"""
|
||||
await self.manager.close()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user