mirror of
https://github.com/zhenxun-org/zhenxun_bot.git
synced 2026-10-19 03:00:09 +08:00
✨ feat!(llm): 重构并升级大语言模型服务为全新 AI 智能体框架 (#2146)
* ✨ feat!(llm): 重构并升级大语言模型服务为全新 AI 智能体框架 - 【重构】将原 services/llm 重构并迁移至全新的 services/ai 架构,提供向下兼容垫片 - 【新增】引入 Agent、Team、Workflow 三大智能体与工作流编排范式 - 【新增】引入基于 RAG 的长期向量记忆与中期槽位记忆系统 - 【新增】引入基于 Docker 的安全代码执行沙箱环境 - 【新增】支持 MCP 协议,允许动态管理和调用 MCP 服务 - 【新增】引入输入输出安全合规护栏与自愈反思机制 - 【优化】重构并优化多厂商 API 适配器 (Gemini, OpenAI, DeepSeek, GLM 等) - 【优化】优化日志脱敏与 Token 预估机制 - 【移除】移除旧版 llm default 和 llm reset-key 命令,新增 llm mcp 管理命令 * 🔧 chore(deps): 更新项目依赖与配置 - 添加 mcp、jieba 和 aiodocker 依赖到配置文件及 requirements.txt - 在 pyright 配置中设置 reportMissingImports 为 none - 调整 .gitignore 中 resources 目录的忽略规则 * ♻️ refactor(tools): 重构工具终止机制并清理知识库日志输出 - 统一使用 `context.state["__end_run__"]` 替代 `EndRunResult` 控制任务结束 - 移除文件系统和向量知识库检索工具中 `ToolResult` 的 `.with_log` 调用 - 调整指令处理器(Directive)的返回值为 `tool_res.output` - 修复部分类型检查警告并优化联合类型判断语法 * ♻️ refactor(tools): 重构工具副作用指令与控制流熔断机制 - 引入 `DirectivePayload` 及 `ToolResult` 的子类以结构化表达工具副作用 - 移除通过 `context.state` 传递魔术变量的隐式控制流设计 - 重构 `DirectiveManager` 处理器接口,直接在处理器中修改 `AgentState` 并构建 `AgentRunResult` - 在 `StandardAgentExecutor` 中统一通过 `directive_manager` 调度工具返回的副作用指令 - 补全 `MessageBuilder` 中部分核心方法的文档注释 * 🐛 fix(sandbox): 修复 Docker 沙箱容器状态检测与会话清理逻辑 -【修复】修正 `is_alive` 中直接读取私有属性的问题,改用 `show()` 返回值 -【修复】解决 `execute_code` 中缓存的执行器与当前会话不一致的问题 -【优化】在清理工作区前增加容器存活检测,避免向已死容器发送请求 -【优化】创建容器时增加运行状态校验,若已停止则自动从缓存中移除并重建 -【优化】优化容器销毁和清理逻辑,静默处理容器不存在 (404) 的异常 * 📝 docs(core): 补充核心模块初始化方法的文档注释 * 🚨 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
bdc1374848
commit
80fc5b86a7
@@ -0,0 +1,173 @@
|
||||
from abc import ABC, abstractmethod
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from zhenxun.services.ai.sandbox.addons.base import BaseSandboxExtension
|
||||
from zhenxun.services.ai.sandbox.models import (
|
||||
SandboxBlueprint,
|
||||
SandboxSessionState,
|
||||
)
|
||||
from zhenxun.services.ai.sandbox.protocols import (
|
||||
InteractiveTerminalSession,
|
||||
SandboxChannel,
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
self.installed_packages: set[str] = set()
|
||||
self._extensions: dict[str, BaseSandboxExtension] = {}
|
||||
self._meta: dict[str, Any] = {}
|
||||
self.workspace_path: str = f"/workspace/{state.session_id}"
|
||||
|
||||
@property
|
||||
def session_id(self) -> str:
|
||||
return self.state.session_id
|
||||
|
||||
def get_meta(self, key: str, default: Any = None) -> Any:
|
||||
"""获取底层驱动的元数据(如映射端口、Base_URL等)"""
|
||||
return self._meta.get(key, default)
|
||||
|
||||
async def mount_extension(self, extension_name: str) -> None:
|
||||
"""动态挂载一个高级能力扩展到当前通道"""
|
||||
from zhenxun.services.ai.sandbox.registry import SandboxRegistry
|
||||
|
||||
extension_cls = SandboxRegistry.get_extension_cls(extension_name)
|
||||
if not extension_cls:
|
||||
raise ValueError(
|
||||
f"Extension '{extension_name}' 未在 SandboxRegistry 注册。"
|
||||
)
|
||||
extension_instance = extension_cls(self)
|
||||
await extension_instance.on_mount()
|
||||
self._extensions[extension_name] = extension_instance
|
||||
|
||||
def get_extension(self, extension_name: str) -> BaseSandboxExtension:
|
||||
"""获取已挂载的高级能力扩展"""
|
||||
if extension_name not in self._extensions:
|
||||
raise RuntimeError(f"Extension '{extension_name}' 尚未被挂载到此沙箱环境。")
|
||||
return self._extensions[extension_name]
|
||||
|
||||
async def apply_blueprint(
|
||||
self, blueprint: SandboxBlueprint, base_path: str | None = None
|
||||
) -> None:
|
||||
"""声明式应用初始化清单"""
|
||||
if blueprint.env:
|
||||
current_env = self.get_meta("env", {})
|
||||
current_env.update(blueprint.env)
|
||||
self._meta["env"] = current_env
|
||||
|
||||
actual_base_path = base_path or self.workspace_path
|
||||
|
||||
if blueprint.entries:
|
||||
for rel_path, entry in blueprint.entries.items():
|
||||
target_path = f"{actual_base_path}/{rel_path}".replace("//", "/")
|
||||
await entry.apply(self, target_path)
|
||||
|
||||
def touch(self) -> None:
|
||||
"""更新最后活跃时间,防止被 GC 清理"""
|
||||
self.last_active_time = time.time()
|
||||
|
||||
async def is_alive(self) -> bool:
|
||||
"""
|
||||
检测当前沙箱驱动是否仍然存活且可用。
|
||||
默认返回 True,要求各底层驱动根据自身特性实现具体探活逻辑。
|
||||
"""
|
||||
return True
|
||||
|
||||
async def install_dependencies(self, blueprint: SandboxBlueprint) -> bool:
|
||||
"""通过注册的环境配置器(Provisioners)热安装依赖包"""
|
||||
from zhenxun.services.ai.sandbox.environments import ProvisionerRegistry
|
||||
|
||||
self.touch()
|
||||
success = True
|
||||
|
||||
provisioner = ProvisionerRegistry.get("unified_manifest")
|
||||
if provisioner:
|
||||
res = await provisioner.install(self, blueprint)
|
||||
if res:
|
||||
self.installed_packages.add("unified_env_installed")
|
||||
else:
|
||||
success = False
|
||||
|
||||
return success
|
||||
|
||||
@abstractmethod
|
||||
async def create_pty_session(self) -> InteractiveTerminalSession:
|
||||
"""创建一个交互式 PTY 终端会话"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def close(self) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def run_process(
|
||||
self,
|
||||
command: str | list[str],
|
||||
cwd: str | None = None,
|
||||
timeout: float | None = None,
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
class BaseSandboxClient(ABC):
|
||||
"""沙箱客户端接口,仅负责与底座通信及生命周期管理"""
|
||||
|
||||
backend_id: str
|
||||
|
||||
@abstractmethod
|
||||
async def create(
|
||||
self,
|
||||
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
|
||||
|
||||
|
||||
BaseSandboxDriver = BaseSandboxSession
|
||||
@@ -0,0 +1,632 @@
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
import io
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tarfile
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import aiodocker
|
||||
import anyio
|
||||
|
||||
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 (
|
||||
SandboxBlueprint,
|
||||
SandboxExecutionResult,
|
||||
SandboxSessionState,
|
||||
)
|
||||
from zhenxun.services.ai.sandbox.protocols import (
|
||||
InteractiveTerminalSession,
|
||||
ProcessStreamMessage,
|
||||
SandboxProcessStream,
|
||||
)
|
||||
from zhenxun.services.ai.sandbox.storage import RESOLVE_PATH_HELPER, coerce_posix_path
|
||||
from zhenxun.services.log import logger
|
||||
|
||||
from .base import BaseSandboxClient, BaseSandboxSession
|
||||
|
||||
|
||||
class DockerInteractiveTerminalSession(InteractiveTerminalSession):
|
||||
"""PTY 交互式会话:接管 Docker Stream,带有防死循环 Token 截断机制"""
|
||||
|
||||
def __init__(self, session: "DockerSandboxSession"):
|
||||
self.session = session
|
||||
self.exec_stream = None
|
||||
self.buffer = ""
|
||||
self._read_task = None
|
||||
self.ansi_escape = re.compile(r"(?:\x1B[@-_]|[\x80-\x9F])[0-?]*[ -/]*[@-~]")
|
||||
|
||||
async def start(self, cmd: str, env: dict[str, str] | None = None) -> None:
|
||||
if not self.session.container:
|
||||
raise RuntimeError("沙箱容器未启动")
|
||||
|
||||
cmd_list = ["/bin/sh", "-c", cmd] if isinstance(cmd, str) else cmd
|
||||
env_list = [f"{k}={v}" for k, v in env.items()] if env else None
|
||||
|
||||
await self.session._ensure_workspace()
|
||||
exec_inst = await self.session.container.exec(
|
||||
cmd=cmd_list,
|
||||
tty=True,
|
||||
stdin=True,
|
||||
stdout=True,
|
||||
stderr=True,
|
||||
workdir=self.session.workspace_path,
|
||||
environment=env_list,
|
||||
)
|
||||
|
||||
self.exec_stream = exec_inst.start(detach=False)
|
||||
await self.exec_stream.__aenter__()
|
||||
|
||||
self._read_task = asyncio.create_task(self._read_loop())
|
||||
|
||||
async def _read_loop(self):
|
||||
try:
|
||||
while True:
|
||||
if not self.exec_stream:
|
||||
break
|
||||
msg = await self.exec_stream.read_out()
|
||||
if msg is None:
|
||||
break
|
||||
text = msg.data.decode("utf-8", errors="replace")
|
||||
clean_text = self.ansi_escape.sub("", text)
|
||||
self.buffer += clean_text
|
||||
|
||||
if len(self.buffer) > 20000:
|
||||
self.buffer = (
|
||||
"...\n[系统保护:已强行丢弃早期超长输出]\n"
|
||||
+ self.buffer[-19000:]
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"[PTY] 流读取结束: {e}")
|
||||
|
||||
async def send_input(self, text: str) -> None:
|
||||
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
|
||||
lines = self.buffer.split("\n")
|
||||
return "\n".join(lines[-50:]).strip()
|
||||
|
||||
async def interrupt(self) -> None:
|
||||
if self.exec_stream:
|
||||
await self.exec_stream.write_in(b"\x03")
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._read_task:
|
||||
self._read_task.cancel()
|
||||
if self.exec_stream:
|
||||
await self.exec_stream.close()
|
||||
self.exec_stream = None
|
||||
|
||||
|
||||
class DockerSandboxProcessStream(SandboxProcessStream):
|
||||
"""包装 aiodocker 的流,使其符合 SandboxProcessStream 协议"""
|
||||
|
||||
def __init__(self, docker_stream):
|
||||
self.stream = docker_stream
|
||||
|
||||
async def read(self) -> ProcessStreamMessage | None:
|
||||
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:
|
||||
await self.stream.close()
|
||||
|
||||
|
||||
class DockerSandboxSession(BaseSandboxSession):
|
||||
def __init__(
|
||||
self,
|
||||
state: SandboxSessionState,
|
||||
container: Any,
|
||||
):
|
||||
super().__init__(state)
|
||||
self.container = container
|
||||
self._vfs_helper_installed = False
|
||||
self._workspace_created = False
|
||||
|
||||
async def is_alive(self) -> bool:
|
||||
if not self.container:
|
||||
return False
|
||||
try:
|
||||
info = await self.container.show()
|
||||
return info.get("State", {}).get("Running", False)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def create_pty_session(self) -> InteractiveTerminalSession:
|
||||
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}'"]
|
||||
)
|
||||
async with exec_inst.start(detach=False) as stream:
|
||||
while True:
|
||||
msg = await stream.read_out()
|
||||
if msg is None:
|
||||
break
|
||||
self._workspace_created = True
|
||||
|
||||
@asynccontextmanager
|
||||
async def create_stream_process(
|
||||
self,
|
||||
command: str | list[str],
|
||||
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
|
||||
|
||||
await self._ensure_workspace()
|
||||
exec_inst = await self.container.exec(
|
||||
cmd=cmd_list,
|
||||
stdin=True,
|
||||
stdout=True,
|
||||
stderr=True,
|
||||
environment=env_list,
|
||||
workdir=cwd or self.workspace_path,
|
||||
)
|
||||
async with exec_inst.start(detach=False) as raw_stream:
|
||||
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}")
|
||||
if check.exit_code != 0:
|
||||
res = await self.run_process(RESOLVE_PATH_HELPER.install_command())
|
||||
if res.exit_code != 0 or res.error:
|
||||
raise RuntimeError(f"安装沙箱 VFS 探针失败: {res.stderr or res.error}")
|
||||
self._vfs_helper_installed = True
|
||||
|
||||
async def _validate_remote_path(
|
||||
self, path: str | Path, for_write: bool = False, base_dir: str | None = None
|
||||
) -> Path:
|
||||
"""基于沙箱内真实环境的防软链接逃逸解析"""
|
||||
base_dir = base_dir or self.workspace_path
|
||||
target_posix = coerce_posix_path(path).as_posix()
|
||||
is_write = "1" if for_write else "0"
|
||||
|
||||
if not target_posix.startswith("/"):
|
||||
import posixpath
|
||||
|
||||
target_posix = posixpath.normpath(f"{base_dir}/{target_posix}")
|
||||
|
||||
if not get_llm_config().sandbox.enable_vfs_helper:
|
||||
return Path(target_posix)
|
||||
|
||||
await self._ensure_vfs_helper()
|
||||
|
||||
cmd = [str(RESOLVE_PATH_HELPER.install_path), base_dir, target_posix, is_write]
|
||||
res = await self.run_process(cmd)
|
||||
|
||||
if res.exit_code == 0:
|
||||
resolved = res.stdout.strip()
|
||||
if not resolved:
|
||||
raise WorkspaceIOError(str(path), "路径解析返回为空")
|
||||
return Path(resolved)
|
||||
if res.exit_code == 111:
|
||||
resolved_path = res.stderr.replace("workspace escape: ", "").strip()
|
||||
raise SandboxPathEscapeError(path=str(path), resolved_path=resolved_path)
|
||||
raise WorkspaceIOError(str(path), f"探针解析路径异常: {res.stderr}")
|
||||
|
||||
async def run_process(
|
||||
self,
|
||||
command: str | list[str],
|
||||
cwd: str | None = None,
|
||||
timeout: float | None = 30.0, # noqa: ASYNC109
|
||||
env: dict[str, str] | None = None,
|
||||
on_output: Any = None,
|
||||
) -> SandboxExecutionResult:
|
||||
from zhenxun.services.ai.core.exceptions import SandboxFatalError
|
||||
|
||||
self.touch()
|
||||
if not self.container:
|
||||
raise SandboxFatalError("沙箱容器未启动或句柄已丢失。")
|
||||
|
||||
if not await self.is_alive():
|
||||
raise SandboxFatalError(
|
||||
f"沙箱容器 '{self.state.container_name}' 已意外死亡 "
|
||||
"(可能遭遇 WSL OOMKiller 或被宿主机强杀)。"
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
try:
|
||||
await self._ensure_workspace()
|
||||
exec_inst = await self.container.exec(
|
||||
cmd=cmd_list,
|
||||
stdout=True,
|
||||
stderr=True,
|
||||
workdir=cwd or self.workspace_path,
|
||||
environment=env_list,
|
||||
)
|
||||
stdout_buf = bytearray()
|
||||
stderr_buf = bytearray()
|
||||
is_timeout = False
|
||||
|
||||
async def _read_stream():
|
||||
async with exec_inst.start(detach=False) as stream:
|
||||
while True:
|
||||
msg = await stream.read_out()
|
||||
if msg is None:
|
||||
break
|
||||
if msg.stream == 1:
|
||||
stdout_buf.extend(msg.data)
|
||||
if on_output:
|
||||
await on_output("stdout", msg.data)
|
||||
elif msg.stream == 2:
|
||||
stderr_buf.extend(msg.data)
|
||||
if on_output:
|
||||
await on_output("stderr", msg.data)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(_read_stream(), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
is_timeout = True
|
||||
|
||||
info = await exec_inst.inspect()
|
||||
exit_code = info.get("ExitCode", -1)
|
||||
|
||||
return SandboxExecutionResult(
|
||||
stdout=stdout_buf.decode("utf-8", errors="replace").strip(),
|
||||
stderr=stderr_buf.decode("utf-8", errors="replace").strip(),
|
||||
exit_code=exit_code,
|
||||
is_timeout=is_timeout,
|
||||
)
|
||||
except Exception as e:
|
||||
return SandboxExecutionResult(exit_code=-1, error=str(e))
|
||||
|
||||
async def read(self, path: str | Path) -> bytes:
|
||||
self.touch()
|
||||
secure_path = await self._validate_remote_path(path, for_write=False)
|
||||
try:
|
||||
tar_obj: tarfile.TarFile = await self.container.get_archive(
|
||||
secure_path.as_posix()
|
||||
)
|
||||
|
||||
members = tar_obj.getmembers()
|
||||
if not members:
|
||||
return b""
|
||||
|
||||
f = tar_obj.extractfile(members[0])
|
||||
return f.read() if f else b""
|
||||
except Exception as e:
|
||||
raise WorkspaceIOError(str(path), f"读取文件异常: {e}")
|
||||
|
||||
async def write(self, path: str | Path, data: bytes) -> bool:
|
||||
self.touch()
|
||||
secure_path = await self._validate_remote_path(path, for_write=True)
|
||||
|
||||
def _create_tar():
|
||||
buf = io.BytesIO()
|
||||
with tarfile.open(fileobj=buf, mode="w") as tar:
|
||||
tarinfo = tarfile.TarInfo(name=secure_path.name)
|
||||
tarinfo.size = len(data)
|
||||
tar.addfile(tarinfo, io.BytesIO(data))
|
||||
return buf.getvalue()
|
||||
|
||||
try:
|
||||
await self.run_process(f"rm -f '{secure_path.as_posix()}'")
|
||||
|
||||
await self.mkdir(secure_path.parent, parents=True)
|
||||
tar_bytes = await asyncio.to_thread(_create_tar)
|
||||
await self.container.put_archive(secure_path.parent.as_posix(), tar_bytes)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[Docker I/O] 写入失败: {e}")
|
||||
return False
|
||||
|
||||
async def rm(self, path: str | Path, recursive: bool = False) -> bool:
|
||||
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:
|
||||
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()}'")
|
||||
return res.exit_code == 0
|
||||
|
||||
async def upload_raw_dir(
|
||||
self, local_dir_path: str, sandbox_target_path: str
|
||||
) -> bool:
|
||||
aio_path = anyio.Path(local_dir_path)
|
||||
if not await aio_path.exists() or not await aio_path.is_dir():
|
||||
return False
|
||||
|
||||
local_path = Path(local_dir_path)
|
||||
|
||||
def _create_tar():
|
||||
buf = io.BytesIO()
|
||||
with tarfile.open(fileobj=buf, mode="w") as tar:
|
||||
for item in local_path.rglob("*"):
|
||||
if item.is_file():
|
||||
arcname = item.relative_to(local_path).as_posix()
|
||||
tar.add(item, arcname=arcname)
|
||||
return buf.getvalue()
|
||||
|
||||
try:
|
||||
await self.mkdir(sandbox_target_path, parents=True)
|
||||
tar_bytes = await asyncio.to_thread(_create_tar)
|
||||
await self.container.put_archive(sandbox_target_path, tar_bytes)
|
||||
return True
|
||||
except Exception as e:
|
||||
from zhenxun.services.log import logger
|
||||
|
||||
logger.error(f"[Docker I/O] 上传目录失败: {e}")
|
||||
return False
|
||||
|
||||
async def close(self) -> None:
|
||||
"""关闭会话:仅清理工作区,不销毁共享容器"""
|
||||
try:
|
||||
if await self.is_alive():
|
||||
await self.rm(self.workspace_path, recursive=True)
|
||||
except Exception as e:
|
||||
logger.error(f"清理沙箱会话工作区 {self.session_id} 异常: {e}")
|
||||
|
||||
|
||||
class DockerSandboxClient(BaseSandboxClient):
|
||||
backend_id = "docker"
|
||||
_global_docker_client: ClassVar[Any] = None
|
||||
_containers: ClassVar[dict[str, Any]] = {}
|
||||
_jupyter_ports: ClassVar[dict[str, int]] = {}
|
||||
_init_lock = asyncio.Lock()
|
||||
_engine_available = False
|
||||
|
||||
async def create(
|
||||
self,
|
||||
session_id: str,
|
||||
blueprint: SandboxBlueprint | None = None,
|
||||
) -> BaseSandboxSession:
|
||||
bp = blueprint or SandboxBlueprint()
|
||||
eff_image = bp.image or get_llm_config().sandbox.docker_image
|
||||
eff_cname = bp.container_name
|
||||
|
||||
async with self._init_lock:
|
||||
if DockerSandboxClient._global_docker_client is None:
|
||||
import aiodocker
|
||||
|
||||
temp_client = aiodocker.Docker()
|
||||
try:
|
||||
await asyncio.wait_for(temp_client.system.info(), timeout=5.0)
|
||||
DockerSandboxClient._global_docker_client = temp_client
|
||||
DockerSandboxClient._engine_available = True
|
||||
except Exception as e:
|
||||
await temp_client.close()
|
||||
from zhenxun.services.ai.core.exceptions import SandboxFatalError
|
||||
|
||||
raise SandboxFatalError(
|
||||
f"无法连接到本地 Docker 引擎 (引擎未启动或无权限): {e}"
|
||||
)
|
||||
|
||||
if eff_cname in DockerSandboxClient._containers:
|
||||
try:
|
||||
c = DockerSandboxClient._containers[eff_cname]
|
||||
info = await c.show()
|
||||
if not info.get("State", {}).get("Running", False):
|
||||
raise RuntimeError("Container is not running")
|
||||
except Exception:
|
||||
DockerSandboxClient._containers.pop(eff_cname, None)
|
||||
DockerSandboxClient._jupyter_ports.pop(eff_cname, None)
|
||||
|
||||
if eff_cname not in DockerSandboxClient._containers:
|
||||
port_bindings = {}
|
||||
import socket
|
||||
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("", 0))
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
jupyter_port = s.getsockname()[1]
|
||||
port_bindings = {"8888/tcp": [{"HostPort": str(jupyter_port)}]}
|
||||
|
||||
from zhenxun.configs.path_config import DATA_PATH
|
||||
|
||||
safe_image_name = eff_image.replace(":", ".").replace("/", "_")
|
||||
|
||||
global_env_dir = (
|
||||
DATA_PATH / "ai" / "sandbox" / safe_image_name / eff_cname / "env"
|
||||
)
|
||||
global_env_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
global_home_dir = (
|
||||
DATA_PATH / "ai" / "sandbox" / safe_image_name / eff_cname / "home"
|
||||
)
|
||||
global_home_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
binds = [f"{global_env_dir.resolve().as_posix()}:/global_env:rw"]
|
||||
binds.append(f"{global_home_dir.resolve().as_posix()}:/root:rw")
|
||||
if bp.bind_mounts:
|
||||
for mount in bp.bind_mounts:
|
||||
mode = "ro" if mount.read_only else "rw"
|
||||
binds.append(f"{mount.host_path}:{mount.sandbox_path}:{mode}")
|
||||
|
||||
container_config = {
|
||||
"Image": eff_image,
|
||||
"Env": [
|
||||
"npm_config_prefix=/global_env/npm",
|
||||
"VIRTUAL_ENV=/global_env/python_venv",
|
||||
"PATH=/global_env/python_venv/bin:/global_env/npm/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
"NODE_PATH=/global_env/npm/lib/node_modules",
|
||||
"npm_config_cache=/tmp/npm_cache",
|
||||
"PIP_CACHE_DIR=/tmp/pip_cache",
|
||||
"YARN_CACHE_FOLDER=/tmp/yarn_cache",
|
||||
"HF_HOME=/tmp/hf_cache",
|
||||
"JUPYTER_RUNTIME_DIR=/tmp/jupyter_runtime",
|
||||
"JUPYTER_DATA_DIR=/tmp/jupyter_data",
|
||||
],
|
||||
"Cmd": [
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"mkdir -p /workspace /tmp/jupyter_runtime /tmp/jupyter_data "
|
||||
"&& chmod 700 /tmp/jupyter_runtime "
|
||||
"&& tail -f /dev/null",
|
||||
],
|
||||
"HostConfig": {
|
||||
"PortBindings": port_bindings,
|
||||
"Binds": binds,
|
||||
},
|
||||
"Labels": {
|
||||
"zhenxun_component": "sandbox",
|
||||
"zhenxun_container_name": eff_cname,
|
||||
},
|
||||
}
|
||||
|
||||
import uuid
|
||||
|
||||
name = f"zx_sandbox_{eff_cname}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
try:
|
||||
container = (
|
||||
await DockerSandboxClient._global_docker_client.containers.run(
|
||||
config=container_config, name=name
|
||||
)
|
||||
)
|
||||
except Exception as ex:
|
||||
from zhenxun.services.ai.core.exceptions import SandboxFatalError
|
||||
|
||||
err_msg = str(ex)
|
||||
if isinstance(ex, AssertionError):
|
||||
err_msg = (
|
||||
"aiodocker AssertionError (可能因宿主机/WSL不支持"
|
||||
"某些 Docker 挂载特性或端口冲突导致被内核驳回)"
|
||||
)
|
||||
raise SandboxFatalError(
|
||||
f"Docker API 拒绝了容器创建请求。底层原因: {err_msg}"
|
||||
)
|
||||
|
||||
DockerSandboxClient._containers[eff_cname] = container
|
||||
DockerSandboxClient._jupyter_ports[eff_cname] = jupyter_port
|
||||
logger.info(
|
||||
f"[DockerSandbox] 已启动物理隔离容器: {eff_cname} "
|
||||
f"(镜像: {eff_image})"
|
||||
)
|
||||
|
||||
state = SandboxSessionState(
|
||||
session_id=session_id,
|
||||
backend_id=self.backend_id,
|
||||
container_name=eff_cname,
|
||||
sandbox_type=self.backend_id,
|
||||
)
|
||||
session = DockerSandboxSession(
|
||||
state, DockerSandboxClient._containers[eff_cname]
|
||||
)
|
||||
if eff_cname in DockerSandboxClient._jupyter_ports:
|
||||
session._meta["jupyter_port"] = DockerSandboxClient._jupyter_ports[
|
||||
eff_cname
|
||||
]
|
||||
|
||||
check_venv = await session.run_process(
|
||||
"test -x /global_env/python_venv/bin/pip"
|
||||
)
|
||||
if check_venv.exit_code != 0:
|
||||
logger.info(
|
||||
f"正在初始化/修复容器 [{eff_cname}] 的共享 Python 虚拟环境...",
|
||||
command="SandboxManager",
|
||||
)
|
||||
init_res = await session.run_process(
|
||||
"rm -rf /global_env/python_venv && "
|
||||
"uv venv --seed --system-site-packages /global_env/python_venv || "
|
||||
"python3 -m venv --system-site-packages /global_env/python_venv"
|
||||
)
|
||||
if init_res.exit_code != 0:
|
||||
logger.error(
|
||||
f"初始化虚拟环境失败: {init_res.stderr or init_res.stdout}",
|
||||
command="SandboxManager",
|
||||
)
|
||||
|
||||
return session
|
||||
|
||||
async def resume(self, state: SandboxSessionState) -> BaseSandboxSession:
|
||||
raise NotImplementedError("Docker Driver 不支持无状态重建恢复。")
|
||||
|
||||
async def delete(self, session: BaseSandboxSession) -> None:
|
||||
"""触发会话销毁及引用计数物理回收"""
|
||||
await session.close()
|
||||
|
||||
from zhenxun.services.ai.sandbox.manager import sandbox_manager
|
||||
|
||||
cname = session.state.container_name
|
||||
|
||||
in_use = any(
|
||||
s.state.container_name == cname
|
||||
for sid, s in sandbox_manager._active_sessions.items()
|
||||
if sid != session.session_id
|
||||
)
|
||||
|
||||
if not in_use and cname in self._containers:
|
||||
try:
|
||||
await self._containers[cname].delete(force=True)
|
||||
self._containers.pop(cname, None)
|
||||
self._jupyter_ports.pop(cname, None)
|
||||
logger.info(
|
||||
f"[DockerSandbox] 物理容器 {cname} "
|
||||
"已长时间闲置,已触发彻底销毁释放内存。"
|
||||
)
|
||||
except Exception as e:
|
||||
self._containers.pop(cname, None)
|
||||
self._jupyter_ports.pop(cname, None)
|
||||
if getattr(e, "status", None) == 404 or "No such container" in str(e):
|
||||
logger.debug(f"[DockerSandbox] 物理容器 {cname} 已不存在。")
|
||||
else:
|
||||
logger.error(f"[DockerSandbox] 闲置销毁物理容器 {cname} 失败: {e}")
|
||||
|
||||
@classmethod
|
||||
async def close_env(cls):
|
||||
for cname, container in cls._containers.items():
|
||||
try:
|
||||
await container.delete(force=True)
|
||||
logger.info(f"已清理物理容器: {cname}")
|
||||
except Exception as e:
|
||||
if getattr(e, "status", None) == 404 or "No such container" in str(e):
|
||||
logger.debug(f"物理容器 {cname} 已不存在,无需清理。")
|
||||
else:
|
||||
logger.error(f"清理物理容器 {cname} 失败: {e}")
|
||||
cls._containers.clear()
|
||||
cls._jupyter_ports.clear()
|
||||
|
||||
if cls._global_docker_client:
|
||||
await cls._global_docker_client.close()
|
||||
cls._global_docker_client = None
|
||||
|
||||
@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)
|
||||
containers = await docker.containers.list(
|
||||
filters={"label": ["zhenxun_component=sandbox"]}, all=True
|
||||
)
|
||||
count = 0
|
||||
for c in containers:
|
||||
try:
|
||||
await c.delete(force=True)
|
||||
count += 1
|
||||
except Exception:
|
||||
pass
|
||||
if count > 0:
|
||||
from zhenxun.services.log import logger
|
||||
|
||||
logger.info(
|
||||
f"静默清理:已成功回收 {count} 个上次异常退出遗留的沙箱容器。",
|
||||
command="SandboxManager",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
from zhenxun.services.ai.sandbox.registry import SandboxRegistry
|
||||
|
||||
SandboxRegistry.register_client("docker", DockerSandboxClient)
|
||||
Reference in New Issue
Block a user