Files
zhenxun_bot/zhenxun/services/ai/sandbox/runtimes.py
T
0b32d69c9c ♻️ 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>
2026-07-05 11:47:21 +08:00

596 lines
21 KiB
Python

from abc import ABC, abstractmethod
import asyncio
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Any, ClassVar
import uuid
import aiohttp
from zhenxun.services.ai.sandbox.drivers.base import BaseSandboxSession
from zhenxun.services.ai.sandbox.models import (
LanguageProfile,
SandboxExecutionResult,
)
from zhenxun.services.log import logger
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
try:
with open(path, encoding="utf-8") as f:
first_line = f.readline().strip()
if not first_line.startswith("#!"):
return None
shebang = first_line[2:].strip()
if not shebang:
return None
parts = shebang.split()
if Path(parts[0]).name == "env":
for part in parts[1:]:
if not part.startswith("-"):
return part
return None
return Path(parts[0]).name
except Exception:
return 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:
ext = path.suffix.lower()
if ext == ".py":
interpreter = "python3"
elif ext in (".js", ".ts"):
interpreter = "node"
elif ext in (".sh", ".bash"):
interpreter = "bash"
else:
interpreter = "sh"
cmd_parts = [interpreter, path.as_posix()]
if args:
cmd_parts.extend(args)
return " ".join(cmd_parts)
class JupyterWSClient:
"""管理与单个 Jupyter Kernel WebSocket 通道通信的客户端"""
def __init__(
self,
http_session: aiohttp.ClientSession,
base_url: str,
ws_url: str,
kernel_id: str,
):
"""初始化 Jupyter WebSocket 客户端并绑定内核ID"""
self.http_session = http_session
self.base_url = base_url
self.ws_url = ws_url
self.kernel_id = kernel_id
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"
)
assert self.ws is not None
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"
):
pass
logger.debug(
f"[JupyterWSClient] 成功向 Kernel {self.kernel_id} 发送中断信号"
)
except Exception as e:
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:
ws = await self._connect_ws()
except Exception as e:
return SandboxExecutionResult(
exit_code=-1, error=f"网络连接 Jupyter Kernel 失败: {e}"
)
msg_id = uuid.uuid4().hex
req = {
"header": {
"msg_id": msg_id,
"msg_type": "execute_request",
"version": "5.0",
},
"parent_header": {},
"metadata": {},
"channel": "shell",
"content": {"code": code, "silent": False, "store_history": False},
}
try:
if ws.closed:
ws = await self._connect_ws()
await ws.send_json(req)
except Exception as e:
logger.warning(f"[JupyterWSClient] WebSocket 发送失败,尝试重连: {e}")
self.ws = None
ws = await self._connect_ws()
await ws.send_json(req)
stdout_parts = []
stderr_parts = []
images = []
exit_code = 0
current_out_len = 0
MAX_OUT_LEN = 50000
async def _receive_loop():
nonlocal exit_code, current_out_len
while True:
msg = await ws.receive_json()
if msg.get("parent_header", {}).get("msg_id") != msg_id:
continue
msg_type = msg["msg_type"]
content = msg["content"]
def _append_text(text: str, is_stdout: bool = True) -> bool:
nonlocal current_out_len
if current_out_len + len(text) > MAX_OUT_LEN:
allowed_len = max(0, MAX_OUT_LEN - current_out_len)
truncated = (
text[:allowed_len]
+ "\n...[系统警告:输出超长已被强行截断]..."
)
(stdout_parts if is_stdout else stderr_parts).append(truncated)
current_out_len += len(truncated)
return False
current_out_len += len(text)
(stdout_parts if is_stdout else stderr_parts).append(text)
if on_output:
t = asyncio.create_task(
on_output(
"stdout" if is_stdout else "stderr",
text.encode("utf-8"),
)
)
background_tasks.add(t)
t.add_done_callback(background_tasks.discard)
return True
if msg_type == "stream":
if not _append_text(
content["text"], is_stdout=(content["name"] == "stdout")
):
task = asyncio.create_task(self.interrupt())
background_tasks.add(task)
task.add_done_callback(background_tasks.discard)
exit_code = -1
break
elif msg_type == "error":
if not _append_text(
"\n".join(content["traceback"]), is_stdout=False
):
break
exit_code = 1
elif msg_type in ["display_data", "execute_result"]:
data = content.get("data", {})
if "text/plain" in data:
if not _append_text(data["text/plain"] + "\n", is_stdout=True):
break
if "image/png" in data:
images.append(data["image/png"])
elif msg_type == "execute_reply":
if content.get("status") == "error":
exit_code = 1
break
try:
await asyncio.wait_for(_receive_loop(), timeout=timeout)
except (asyncio.TimeoutError, TimeoutError):
await self.interrupt()
return SandboxExecutionResult(
stdout="".join(stdout_parts),
stderr="".join(stderr_parts),
exit_code=-1,
error=f"执行超时 ({timeout}s)",
)
except Exception as e:
return SandboxExecutionResult(
stdout="".join(stdout_parts),
stderr="".join(stderr_parts),
exit_code=-1,
error=str(e),
)
return SandboxExecutionResult(
stdout="".join(stdout_parts),
stderr="".join(stderr_parts),
exit_code=exit_code,
images=images,
)
async def close(self):
"""关闭与 Jupyter Kernel 的 WebSocket 通道连接"""
if self.ws and not self.ws.closed:
await self.ws.close()
self.ws = None
class JupyterServerManager:
"""管理沙箱内的 Jupyter 引擎生命周期及长连接"""
def __init__(self, session: BaseSandboxSession):
"""初始化 Jupyter 服务生命周期及会话连接管理器"""
self.session = session
self._http_session: aiohttp.ClientSession | None = None
self.base_url = ""
self.ws_url = ""
self._is_started = False
self._clients: dict[str, JupyterWSClient] = {}
async def ensure_started(self, env_vars: dict[str, str] | None = None):
"""确保沙箱环境内部已拉起并运行着 Jupyter Server"""
if self._is_started:
return
jupyter_port = self.session.get_meta("jupyter_port")
if not jupyter_port:
raise RuntimeError("沙箱未分配或映射 Jupyter 端口,无法建立服务")
check_jupyter = await self.session.run_process("command -v jupyter-server")
if check_jupyter.error:
raise RuntimeError(
f"检查 jupyter-server 失败 (系统错误): {check_jupyter.error}"
)
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 "
"--ServerApp.ip=0.0.0.0 --ServerApp.port=8888 "
"--ServerApp.token='' --ServerApp.password='' "
"--ServerApp.disable_check_xsrf=True "
"--ServerApp.allow_origin='*' --ServerApp.allow_root=True "
f"> {self.session.workspace_path}/jupyter.log 2>&1 &"
)
start_res = await self.session.run_process(start_cmd)
if start_res.error:
raise RuntimeError(f"执行启动命令失败 (系统错误): {start_res.error}")
self.base_url = f"http://127.0.0.1:{jupyter_port}"
self.ws_url = f"ws://127.0.0.1:{jupyter_port}"
self._http_session = aiohttp.ClientSession()
for _ in range(15):
try:
async with self._http_session.get(
f"{self.base_url}/api/kernels"
) as resp:
if resp.status == 200:
self._is_started = True
logger.info(
"[JupyterManager] Jupyter 引擎拉起成功 "
f"(Port: {jupyter_port})"
)
return
except Exception:
pass
await asyncio.sleep(1)
log_res = await self.session.run_process(
f"cat {self.session.workspace_path}/jupyter.log"
)
error_details = log_res.stdout if log_res.exit_code == 0 else "无法读取日志"
raise RuntimeError(
f"Jupyter 服务动态拉起并等待 API 响应超时。日志内容: {error_details}"
)
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]
if not self._http_session:
raise RuntimeError("HTTP 会话未建立")
async with self._http_session.post(
f"{self.base_url}/api/kernels", json={"name": kernel_name}
) as resp:
kernel_id = (await resp.json()).get("id")
if not kernel_id:
raise RuntimeError(f"分配 Kernel {kernel_name} 失败")
client = JupyterWSClient(
self._http_session, self.base_url, self.ws_url, kernel_id
)
self._clients[kernel_name] = client
return client
async def close(self):
"""关闭并清理所有 Jupyter WS 连接及 HTTP 会话资源"""
for client in self._clients.values():
await client.close()
self._clients.clear()
if self._http_session:
await self._http_session.close()
self._http_session = None
self._is_started = False
class BaseCodeExecutor(ABC):
"""代码执行器抽象基类"""
def __init__(self, session: BaseSandboxSession):
"""初始化代码执行器,绑定沙箱会话"""
self.session = session
@abstractmethod
async def execute_code(
self,
code: str,
timeout: int = 30,
injected_code: str | None = None,
on_output: Callable[[str, bytes], Awaitable[None]] | None = None,
) -> SandboxExecutionResult:
"""在沙箱会话中执行指定代码并返回执行结果"""
pass
class GenericCLIExecutor(BaseCodeExecutor):
"""模板驱动的通用代码执行器"""
def __init__(self, session, profile: "LanguageProfile"):
"""初始化通用命令行代码执行器,传入语言配置模板"""
super().__init__(session)
self.profile = profile
async def execute_code(
self,
code: str,
timeout: int = 30,
injected_code: str | None = None,
on_output: Callable[[str, bytes], Awaitable[None]] | None = None,
) -> SandboxExecutionResult:
"""将代码写入临时文件,必要时编译并执行,返回执行结果"""
env = None
if injected_code:
await self.session.write(
f"{self.session.workspace_path}/zhenxun_host.py",
injected_code.encode("utf-8"),
)
script_path = f"{self.session.workspace_path}/main{self.profile.source_ext}"
await self.session.write(script_path, code.encode("utf-8"))
if self.profile.compile_cmd:
compile_cmd = self.profile.compile_cmd.format(source_file=script_path)
res = await self.session.run_process(
compile_cmd, timeout=timeout, env=env, on_output=on_output
)
if res.exit_code != 0:
return res
run_cmd = self.profile.run_cmd.format(source_file=script_path)
return await self.session.run_process(
run_cmd, timeout=timeout, env=env, on_output=on_output
)
class CodeExecutorRegistry:
"""多语言代码执行器动态注册中心"""
_executors: ClassVar[
dict[str, dict[str, dict[bool, Callable[[Any], BaseCodeExecutor]]]]
] = {}
_profiles: ClassVar[dict[str, "LanguageProfile"]] = {}
_aliases: ClassVar[dict[str, str]] = {
"py": "python",
"js": "javascript",
"sh": "bash",
"shell": "bash",
"ts": "typescript",
}
@classmethod
def _normalize_lang(cls, language: str) -> str:
"""规范化语言名称,转换为统一小写格式并应用别名"""
lang_lower = language.lower().strip()
return cls._aliases.get(lang_lower, lang_lower)
@classmethod
def register(
cls,
language: str,
executor_cls: Callable[[Any], BaseCodeExecutor],
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)
if ns not in cls._executors:
cls._executors[ns] = {}
if lang_norm not in cls._executors[ns]:
cls._executors[ns][lang_norm] = {}
cls._executors[ns][lang_norm][is_stateful] = executor_cls
logger.debug(
f"[CodeExecutorRegistry] 成功注册执行器: {ns} -> {lang_norm} "
f"(Stateful: {is_stateful})"
)
@classmethod
def register_jupyter_language(
cls,
language: str,
kernel_name: str,
scope: str | None = None,
) -> None:
"""快速注册一个基于 Jupyter 内核的有状态运行语言"""
cls.register(
language,
lambda session: GenericJupyterExecutor(session, kernel_name=kernel_name),
is_stateful=True,
scope=scope,
)
@classmethod
def register_profile(cls, profile: "LanguageProfile") -> None:
"""注册一个基于命令行的无状态运行语言配置模板"""
cls._profiles[profile.language.lower()] = profile
for alias in profile.aliases:
cls._profiles[alias.lower()] = profile
logger.debug(f"[CodeExecutorRegistry] 成功注册语言配置模板: {profile.language}")
@classmethod
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"]:
if target_ns in cls._executors and lang_norm in cls._executors[target_ns]:
lang_executors = cls._executors[target_ns][lang_norm]
if needs_state and True in lang_executors:
return lang_executors[True](session)
if False in lang_executors:
return lang_executors[False](session)
for ns_dict in cls._executors.values():
if lang_norm in ns_dict:
lang_executors = ns_dict[lang_norm]
if needs_state and True in lang_executors:
return lang_executors[True](session)
if False in lang_executors:
return lang_executors[False](session)
if lang_norm in cls._profiles:
return GenericCLIExecutor(session, cls._profiles[lang_norm])
raise ValueError(
f"当前沙箱生态未提供针对语言 '{language}' 的代码执行器。"
f"支持的语言有: {cls.get_supported_languages()}"
)
@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())
langs.update(cls._profiles.keys())
return list(langs)
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)
async def execute_code(
self,
code: str,
timeout: int = 30,
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:
return SandboxExecutionResult(exit_code=-1, error=str(e))
if injected_code:
await self.session.write(
f"{self.session.workspace_path}/zhenxun_host.py",
injected_code.encode("utf-8"),
)
try:
client = await self.manager.get_client(self.kernel_name)
except Exception as e:
return SandboxExecutionResult(exit_code=-1, error=str(e))
result = await client.execute(code, timeout=timeout, on_output=on_output)
return result
async def close(self):
"""关闭 Jupyter 客户端及后台运行环境"""
await self.manager.close()
CodeExecutorRegistry.register_profile(
LanguageProfile(
language="python",
aliases=["py"],
source_ext=".py",
run_cmd="python3 {source_file}",
)
)
CodeExecutorRegistry.register_profile(
LanguageProfile(
language="bash",
aliases=["sh", "shell"],
source_ext=".sh",
run_cmd="bash {source_file}",
)
)
CodeExecutorRegistry.register_profile(
LanguageProfile(
language="javascript",
aliases=["js", "node"],
source_ext=".js",
run_cmd="node {source_file}",
)
)
CodeExecutorRegistry.register_jupyter_language("python", "python3", scope="global")
__all__ = [
"BaseCodeExecutor",
"CodeExecutorRegistry",
"GenericCLIExecutor",
"GenericJupyterExecutor",
"get_execution_command",
]