Files
zhenxun_bot/zhenxun/services/ai/sandbox/models.py
T
922d092650 ♻️ refactor(core): 重构 AI 能力与定时任务调度系统 (#2148)
* ♻️ refactor(core): 重构 AI 能力与定时任务调度系统

- 【AI 能力与工具】重构 Capability 注册与管理机制,引入 CapabilityManager 统一管理
- 移除全局能力注册表,改用声明式装饰器 `@capability` 进行解耦注册
- 重构工具解析器链,使用统一的 BaseToolResolver 代替原有的多个特定解析器
- 增强工具查询过滤,支持通配符匹配、工具箱过滤和排除标签
- 【定时任务调度】重构定时任务管理器,引入 SchedulerRegistry 统一管理任务元数据
- 引入 JobConfig 聚合定时任务配置,支持用户维度的定时任务调度
- 重构执行分发器,支持并发限制、串行间隔和随机延迟打散
- 【运行上下文】引入 ScheduledDeps 以支持后台和定时任务环境下的依赖注入
- 优化 RunContext,支持从定时任务上下文快速构造,并提供 emit 辅助方法
- 【日志与监控】引入 AILoggerProxy,实现 AI 各模块的专属日志输出
- 将各模块的全局 logger 替换为对应的模块专属日志代理
- 【其他优化】修复 Pydantic V1 兼容层中 model_validator 的装饰器兼容性问题
- 在非交互式环境(如定时任务)中自动隐藏 HITL 交互工具以节省 Token

* ♻️ refactor(core): 优化内部导入路径并提升 Pydantic 兼容性

- 【重构】将 `services/ai` 模块内的绝对导入重构为相对导入,优化包结构
- 【重构】移除不必要的 `if TYPE_CHECKING` 保护,通过 `from __future__ import annotations` 直接导入类型
- 【清理】清理 `core/messages/types.py` 中未使用的 `AssistantContentUnion` 等联合类型定义
- 【优化】在 `utils/pydantic_compat.py` 中新增 `model_rebuild` 兼容函数,统一 Pydantic V1/V2 的模型重建逻辑
- 【优化】将部分函数内部的延迟导入提升至模块顶部,规范代码结构

* ♻️ refactor(imports): 优化导入路径为相对导入并清理冗余导入

- 【重构】将 AI 服务相关模块中的绝对导入路径修改为相对导入,提升模块内聚性与可移植性
- 【清理】移除多处函数内部或类方法中未使用的冗余导入,避免循环引用和资源浪费
- 【格式化】微调部分工具装饰器和返回语句的格式与尾随逗号

* 🚨 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-10 09:14:06 +08:00

465 lines
16 KiB
Python

"""
沙箱相关核心类型定义
"""
from abc import ABC, abstractmethod
import asyncio
import hashlib
import json
from pathlib import Path
import re
from typing import TYPE_CHECKING, Annotated, Any, Literal
from pydantic import BaseModel, ConfigDict, Field
from zhenxun.services.ai.utils.logger import log_sandbox as logger
from zhenxun.utils.pydantic_compat import model_dump
if TYPE_CHECKING:
from .drivers.base import BaseSandboxSession
class LanguageProfile(BaseModel):
"""语言执行配置模板"""
language: str
"""语言名称"""
aliases: list[str] = Field(default_factory=list)
"""语言别名列表"""
source_ext: str
"""源文件后缀名"""
compile_cmd: str | None = None
"""编译命令"""
run_cmd: str
"""运行命令"""
deps_install_cmd: str | None = None
"""依赖安装命令"""
class BaseEntry(BaseModel, ABC):
"""沙箱物化项(Artifacts)抽象基类"""
@abstractmethod
async def apply(self, session: "BaseSandboxSession", dest: str) -> None:
"""将当前项物化(应用)到沙箱中的指定目标路径"""
pass
class MemoryFile(BaseEntry):
"""基于内存字符串或二进制的数据物化"""
type: Literal["memory_file"] = "memory_file"
content: str | bytes
async def apply(self, session: "BaseSandboxSession", dest: str) -> None:
data = (
self.content.encode("utf-8")
if isinstance(self.content, str)
else self.content
)
await session.write(dest, data)
class LocalFile(BaseEntry):
"""基于宿主机本地单文件的物化"""
type: Literal["local_file"] = "local_file"
src: str
async def apply(self, session: "BaseSandboxSession", dest: str) -> None:
local_path = Path(self.src)
if await asyncio.to_thread(local_path.is_file):
content = await asyncio.to_thread(local_path.read_bytes)
await session.write(dest, content)
else:
logger.warning(f"[Sandbox] 本地文件 {self.src} 不存在,已跳过物化。")
class LocalDir(BaseEntry):
"""基于宿主机本地目录的物化 (会自动打包上传)"""
type: Literal["local_dir"] = "local_dir"
src: str
async def apply(self, session: "BaseSandboxSession", dest: str) -> None:
success = await session.upload_raw_dir(self.src, dest)
if not success:
logger.warning(f"[Sandbox] 本地目录 {self.src} 上传失败,已跳过物化。")
EntryUnion = Annotated[MemoryFile | LocalFile | LocalDir, Field(discriminator="type")]
class BaseSetupStep(BaseModel, ABC):
"""环境装配步骤基类,多态支持任何第三方语言的安装"""
model_config = ConfigDict(arbitrary_types_allowed=True)
@abstractmethod
async def apply(self, session: Any) -> None:
"""在沙箱会话中执行该装配步骤"""
pass
class AptSetup(BaseSetupStep):
type: Literal["apt"] = "apt"
packages: list[str]
async def apply(self, session: Any) -> None:
if not self.packages:
return
res = await session.run_process("dpkg -l", timeout=10)
installed_pkgs = set()
if res.exit_code == 0:
for line in res.stdout.splitlines():
if line.startswith("ii "):
parts = line.split()
if len(parts) >= 2:
pkg_name = parts[1].split(":")[0].strip().lower()
installed_pkgs.add(pkg_name)
missing_packages = [
pkg for pkg in self.packages if pkg.strip().lower() not in installed_pkgs
]
if not missing_packages:
logger.debug(f"[Sandbox] Apt 依赖已全部满足,跳过安装: {self.packages}")
return
pkg_str = " ".join(missing_packages)
logger.info(f"[Sandbox] 正在安装缺失的系统级依赖 (Apt): {pkg_str}")
await session.run_process(
"sudo DEBIAN_FRONTEND=noninteractive apt-get update -qq && "
f"sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq {pkg_str}",
timeout=300,
)
class PythonSetup(BaseSetupStep):
type: Literal["python"] = "python"
packages: list[str]
async def apply(self, session: Any) -> None:
if not self.packages:
return
res = await session.run_process("python3 -m pip freeze", timeout=10)
installed_pkgs = set()
if res.exit_code == 0:
for line in res.stdout.splitlines():
if "==" not in line and "@" not in line:
continue
base_name = re.split(r"[=><~!@\[]", line)[0].strip().lower()
installed_pkgs.add(base_name)
missing_packages = [
pkg
for pkg in self.packages
if re.split(r"[=><~!@\[]", pkg)[0].strip().lower() not in installed_pkgs
]
if not missing_packages:
logger.debug(f"[Sandbox] Python 依赖已全部满足,跳过安装: {self.packages}")
return
pkg_str = " ".join(missing_packages)
check_uv = await session.run_process("command -v uv")
if check_uv.exit_code != 0:
logger.info("[Sandbox] 未检测到 uv,正在极速下载 uv...")
await session.run_process(
"pip install uv --disable-pip-version-check -q", timeout=60
)
logger.info(f"[Sandbox] 正在安装缺失的 Python 依赖: {pkg_str}")
res = await session.run_process(f"uv pip install {pkg_str}", timeout=120)
if res.exit_code != 0:
logger.warning(f"[Sandbox] uv 安装报错,尝试降级使用 pip: {res.stderr}")
await session.run_process(f"pip install {pkg_str} -q", timeout=180)
class NodeSetup(BaseSetupStep):
type: Literal["node"] = "node"
packages: list[str]
async def apply(self, session: Any) -> None:
if not self.packages:
return
res = await session.run_process("npm list -g --depth=0", timeout=15)
installed_pkgs = set()
if res.exit_code == 0:
for line in res.stdout.splitlines():
match = re.search(r"[├└]──\s+((?:@[^/]+/)?[^@\s]+)", line)
if match:
installed_pkgs.add(match.group(1).strip().lower())
missing_packages = []
for pkg in self.packages:
base_name = (
("@" + pkg[1:].split("@")[0])
if pkg.startswith("@")
else pkg.split("@")[0]
)
if base_name.strip().lower() not in installed_pkgs:
missing_packages.append(pkg)
if not missing_packages:
logger.debug(f"[Sandbox] Node 依赖已全部满足,跳过安装: {self.packages}")
return
pkg_str = " ".join(missing_packages)
logger.info(f"[Sandbox] 正在安装缺失的 Node 依赖: {pkg_str}")
res = await session.run_process(
f"npm install -g {pkg_str} --no-fund --no-audit --cache /tmp/npm-cache",
timeout=180,
)
if res.exit_code != 0:
from zhenxun.services.ai.core.exceptions import SandboxFatalError
raise SandboxFatalError(
f"Node 依赖安装失败 (Exit Code: {res.exit_code}): "
f"{res.stderr or res.error}"
)
class ShellSetup(BaseSetupStep):
type: Literal["shell"] = "shell"
scripts: list[str]
async def apply(self, session: Any) -> None:
for script in self.scripts:
logger.info(f"[Sandbox] 正在执行自定义装配脚本: {script}")
res = await session.run_process(script, timeout=300)
if res.exit_code != 0:
from zhenxun.services.ai.core.exceptions import SandboxFatalError
raise SandboxFatalError(
f"自定义装配脚本执行失败: {res.stderr or res.error}"
)
class BindMount(BaseModel):
"""宿主机物理目录绑定映射配置"""
host_path: str
"""宿主机绝对路径"""
sandbox_path: str = "/workspace"
"""沙箱内目标路径,默认覆盖 /workspace"""
read_only: bool = False
"""是否以只读模式挂载"""
class SandboxBlueprint(BaseModel):
"""沙箱大一统声明式蓝图配置"""
sandbox_type: str = Field(default="auto")
"""强制驱动类型,如 docker 或 local"""
enable_network: bool = Field(default=False)
"""是否允许访问外网"""
needs_state: bool = Field(default=False)
"""是否需要持久化状态"""
image: str | None = Field(default=None)
"""自定义镜像名称。为空则使用全局默认配置"""
container_name: str = Field(default="global")
"""容器/命名空间名称。相同名称将共享同一个物理容器和依赖环境"""
setup_steps: list[BaseSetupStep] = Field(default_factory=list)
"""多态环境装配图元管线"""
entries: dict[str, EntryUnion] = Field(default_factory=dict)
"""沙箱物化节点树(Artifacts),键为沙箱内的相对目标路径"""
env: dict[str, str] = Field(default_factory=dict)
"""环境变量"""
required_extensions: list[str] = Field(default_factory=list)
"""需要自动挂载的扩展"""
bind_mounts: list[BindMount] = Field(default_factory=list)
"""宿主机目录物理挂载映射列表 (Bind Mounts)"""
def with_sandbox_type(self, sandbox_type: str) -> "SandboxBlueprint":
"""设置沙箱驱动类型"""
self.sandbox_type = sandbox_type
return self
def with_network(self, enable: bool = True) -> "SandboxBlueprint":
"""设置是否启用网络访问"""
self.enable_network = enable
return self
def with_state(self, enable: bool = True) -> "SandboxBlueprint":
"""设置是否启用状态持久化"""
self.needs_state = enable
return self
def with_image(self, image: str) -> "SandboxBlueprint":
"""声明使用的自定义 Docker 镜像"""
self.image = image
return self
def with_container_name(self, name: str) -> "SandboxBlueprint":
"""声明沙箱使用的物理容器命名空间(实现环境隔离)"""
self.container_name = name
return self
def with_setup_step(self, step: BaseSetupStep) -> "SandboxBlueprint":
"""声明一个多态环境装配步骤"""
self.setup_steps.append(step)
return self
def with_python_packages(self, packages: list[str]) -> "SandboxBlueprint":
"""追加预置 Python 依赖包"""
self.setup_steps.append(PythonSetup(packages=packages))
return self
def with_system_packages(self, packages: list[str]) -> "SandboxBlueprint":
"""追加预置系统包依赖"""
self.setup_steps.append(AptSetup(packages=packages))
return self
def with_node_packages(self, packages: list[str]) -> "SandboxBlueprint":
"""追加预置 Node 依赖包"""
self.setup_steps.append(NodeSetup(packages=packages))
return self
def with_install_scripts(self, scripts: list[str]) -> "SandboxBlueprint":
"""追加自定义安装脚本"""
self.setup_steps.append(ShellSetup(scripts=scripts))
return self
def with_file(self, path: str, content: str | bytes) -> "SandboxBlueprint":
"""声明预置内存字符串或二进制文件"""
self.entries[path] = MemoryFile(content=content)
return self
def with_local_file(self, path: str, local_path: str) -> "SandboxBlueprint":
"""声明预置本地宿主机单文件"""
self.entries[path] = LocalFile(src=local_path)
return self
def with_local_dir(self, path: str, local_dir: str) -> "SandboxBlueprint":
"""声明预置本地宿主机完整目录"""
for bm in self.bind_mounts:
if path.startswith(bm.sandbox_path) or bm.sandbox_path.startswith(path):
logger.warning(
f"⚠️ [Sandbox] 路径 '{path}' 和物理挂载映射 "
f"'{bm.sandbox_path}' 存在重叠!\n"
"继续使用 with_local_dir 打包上传可能会引发冗余上传"
"或双向覆盖冲突。建议直接使用物理挂载。"
)
self.entries[path] = LocalDir(src=local_dir)
return self
def with_env(self, key: str, value: str) -> "SandboxBlueprint":
"""设置环境变量"""
self.env[key] = value
return self
def with_extension(self, extension: str) -> "SandboxBlueprint":
"""声明需要挂载的扩展"""
if extension not in self.required_extensions:
self.required_extensions.append(extension)
return self
def with_workspace(
self,
local_path: str | Path,
remote_path: str = "/workspace",
read_only: bool = False,
) -> "SandboxBlueprint":
"""声明宿主机物理目录双向挂载 (Bind Mount)"""
from pathlib import Path
if remote_path in self.entries:
logger.warning(
f"⚠️ [Sandbox] 目标沙箱路径 '{remote_path}' "
"已包含普通实体映射(如 LocalDir/File)。\n"
"强制绑定物理挂载 (Bind Mount) 将会遮蔽原有实体。"
)
abs_path = Path(local_path).resolve().as_posix()
self.bind_mounts.append(
BindMount(host_path=abs_path, sandbox_path=remote_path, read_only=read_only)
)
return self
def calculate_hash(self) -> str:
"""计算当前环境配置的 MD5 指纹,用于缓存命中"""
entries_dict = {
k: model_dump(self.entries[k]) for k in sorted(self.entries.keys())
}
data_to_hash = {
"image": self.image,
"container_name": self.container_name,
"steps": [model_dump(s) for s in self.setup_steps],
"entries": entries_dict,
"bind_mounts": [model_dump(m) for m in self.bind_mounts],
}
json_str = json.dumps(data_to_hash, separators=(",", ":"))
return hashlib.md5(json_str.encode("utf-8")).hexdigest()
def merge(self, other: "SandboxBlueprint") -> "SandboxBlueprint":
"""合并另一个蓝图配置"""
if not other:
return self
self.enable_network = self.enable_network or other.enable_network
self.needs_state = self.needs_state or other.needs_state
self.sandbox_type = (
other.sandbox_type if other.sandbox_type != "auto" else self.sandbox_type
)
self.image = other.image or self.image
self.container_name = (
other.container_name
if other.container_name != "global"
else self.container_name
)
self.setup_steps.extend(other.setup_steps)
self.entries.update(other.entries)
self.env.update(other.env)
self.required_extensions = list(
dict.fromkeys(self.required_extensions + other.required_extensions)
)
self.bind_mounts.extend(other.bind_mounts)
return self
class SandboxExecutionResult(BaseModel):
"""沙箱统一的执行结果数据结构"""
stdout: str = Field(default="")
"""标准输出流"""
stderr: str = Field(default="")
"""标准错误流"""
exit_code: int = Field(default=0)
"""进程退出码"""
error: str | None = Field(default=None)
"""框架执行错误信息"""
is_timeout: bool = Field(default=False)
"""进程是否因超时被挂起(仍在运行)"""
images: list[str] = Field(default_factory=list)
"""Base64 图片列表"""
artifacts: dict[str, bytes] = Field(default_factory=dict)
"""生成的文件工件"""
@property
def is_success(self) -> bool:
return self.exit_code == 0 and self.error is None
class SandboxSessionState(BaseModel):
"""沙箱会话的序列化状态,用于无状态恢复"""
session_id: str
"""会话唯一标识符"""
backend_id: str
"""后端驱动分配的容器/沙箱 ID"""
container_name: str = "global"
"""底层所关联的物理容器名称"""
sandbox_type: str = "docker"
"""沙箱驱动类型"""
workspace_root_ready: bool = False
"""工作区目录是否已初始化完成"""
extra_data: dict[str, Any] = Field(default_factory=dict)
"""会话的其它序列化元数据"""