✨ 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:
Rumio
2026-07-03 08:53:56 +08:00
committed by GitHub
co-authored by webjoin111 pre-commit-ci[bot]
parent bdc1374848
commit 80fc5b86a7
223 changed files with 41885 additions and 10006 deletions
@@ -0,0 +1,21 @@
from .manager import skill_manager
from .models import (
INSTRUCTIONS,
METADATA,
RESOURCES,
Skill,
SkillFrontmatter,
SkillSource,
)
from .toolkit import SkillMetaToolkit
__all__ = [
"INSTRUCTIONS",
"METADATA",
"RESOURCES",
"Skill",
"SkillFrontmatter",
"SkillMetaToolkit",
"SkillSource",
"skill_manager",
]
@@ -0,0 +1,33 @@
from collections.abc import Sequence
from pathlib import Path
from typing import Any
from zhenxun.services.ai.capabilities import AbstractCapability
from zhenxun.services.ai.run import RunContext
from zhenxun.services.ai.tools.providers.skills.manager import skill_manager
from zhenxun.services.ai.tools.providers.skills.models import Skill, SkillSource
from zhenxun.services.ai.tools.providers.skills.toolkit import SkillMetaToolkit
from zhenxun.utils.utils import infer_plugin_namespace
class SkillCapability(AbstractCapability):
"""技能库挂载能力组件"""
def __init__(
self,
skills: Sequence[str | Path | Skill | SkillSource] | None = None,
namespace: str | None = None,
):
self.skills = skills or []
self.namespace = namespace or infer_plugin_namespace()
async def get_tools(self, context: RunContext) -> list[Any]:
tools = []
if self.skills:
resolved_skills = await skill_manager.resolve_mixed_skills(
self.skills, namespace=self.namespace
)
tools.append(SkillMetaToolkit(allowed_skills=resolved_skills))
return tools
@@ -0,0 +1,401 @@
from abc import ABC, abstractmethod
import asyncio
from collections.abc import Sequence
import json
from pathlib import Path
import re
from typing import Any, cast
from typing_extensions import Self
import aiofiles
import yaml
from zhenxun.configs.path_config import DATA_PATH
from zhenxun.services.ai.tools.providers.skills.models import (
INSTRUCTIONS,
METADATA,
RESOURCES,
Skill,
SkillBlueprintBuilder,
SkillEnvConfig,
SkillFrontmatter,
)
from zhenxun.services.log import logger
from zhenxun.utils.pydantic_compat import model_dump, model_validate
from zhenxun.utils.utils import infer_plugin_namespace
class SkillConfigManager:
"""基于 Skill ID 隔离的持久化配置金库管理器"""
def __init__(self):
self.config_path = DATA_PATH / "ai" / "skill_envs.json"
self.config = SkillEnvConfig()
self._load_sync()
def _load_sync(self):
if self.config_path.exists():
try:
with open(self.config_path, encoding="utf-8") as f:
self.config = model_validate(SkillEnvConfig, json.load(f))
except Exception as e:
logger.error(f"加载 skill_envs.json 失败: {e}")
async def save(self):
try:
self.config_path.parent.mkdir(parents=True, exist_ok=True)
async with aiofiles.open(self.config_path, "w", encoding="utf-8") as f:
content = json.dumps(
model_dump(self.config), ensure_ascii=False, indent=4
)
await f.write(content)
except Exception as e:
logger.error(f"保存 skill_envs.json 失败: {e}")
def get_envs_for_skill(self, namespace: str, skill_id: str) -> dict[str, str]:
return self.config.envs.get(namespace, {}).get(skill_id, {})
async def ensure_template(
self, namespace: str, skill_id: str, required_envs: list[str]
):
"""确保配置文件中存在所需的模板,缺失则补充为空字符串"""
if not required_envs:
return
changed = False
skill_envs = self.config.envs.setdefault(namespace, {}).setdefault(skill_id, {})
for env_key in required_envs:
if env_key not in skill_envs:
skill_envs[env_key] = ""
changed = True
if changed:
await self.save()
skill_env_manager = SkillConfigManager()
class SkillManager:
"""全局技能注册与发现中心 (本地文件系统模式)"""
_instance: "SkillManager | None" = None
def __new__(cls) -> Self:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cast(Self, cls._instance)
def __init__(self):
if getattr(self, "_initialized", False):
return
self._skills: dict[str, Skill] = {}
self._scan_dirs: dict[Path, str] = {}
self._initialized = True
self._discovery_lock = asyncio.Lock()
default_skill_dir = DATA_PATH / "ai" / "skills"
default_skill_dir.mkdir(parents=True, exist_ok=True)
self._scan_dirs[default_skill_dir] = "global"
def add_scan_dir(self, path: str | Path, namespace: str | None = None):
p = Path(path).resolve()
if namespace is None:
namespace = infer_plugin_namespace()
if p not in self._scan_dirs:
self._scan_dirs[p] = namespace
def _get_valid_skill_dir(self, path: str | Path) -> Path | None:
"""统一目录校验:判断是否为合法的技能目录"""
p = Path(path).resolve()
if p.exists() and p.is_dir() and not p.name.startswith("."):
return p
return None
def _iter_valid_skill_dirs(self, target_dir: str | Path):
"""统一扫描器:迭代目标父目录下的所有合法技能子目录"""
p = Path(target_dir).resolve()
if not p.exists() or not p.is_dir():
return
for child in p.iterdir():
if valid_child := self._get_valid_skill_dir(child):
yield valid_child
async def load_local_skill(
self, path: str | Path, namespace: str | None = None
) -> Skill:
"""
[无痕加载] 读取指定路径的技能并返回独立的 Skill 实例。
该技能不会被注册到全局 manager 中,专门用于局部按需挂载。
"""
if namespace is None:
namespace = infer_plugin_namespace()
skill = await self._parse_skill_from_path(path, namespace)
if not skill:
raise ValueError(
f"无法从路径加载技能,请检查目录与 SKILL.md 是否合法: {path}"
)
return skill
async def discover_skills(self) -> dict[str, Skill]:
"""遍历所有扫描目录,发现并合并所有合法技能"""
async with self._discovery_lock:
if self._skills:
return self._skills
for scan_dir, namespace in self._scan_dirs.items():
for child in self._iter_valid_skill_dirs(scan_dir):
try:
skill = await self._parse_skill_metadata(child, namespace)
if skill:
self._skills[f"{namespace}::{skill.id}"] = skill
except Exception as e:
logger.error(f"解析技能 {child.name} 失败: {e}", e=e)
logger.info(f"技能扫描完成,共加载 {len(self._skills)} 个技能。")
return self._skills
async def get_skill_details(
self, name: str, namespace: str = "global"
) -> Skill | None:
"""获取技能详细信息,并在内存中缓存补全后的资源"""
skills = await self.discover_skills()
skill = skills.get(f"{namespace}::{name}") or skills.get(f"global::{name}")
if not skill:
return None
if skill.disclosure_level < RESOURCES:
skill = self._load_skill_resources(skill)
async with self._discovery_lock:
self._skills[f"{skill.namespace}::{skill.id}"] = skill
return skill
async def resolve_mixed_skills(
self, skills: Sequence[Any], namespace: str | None = None
) -> list[Skill]:
"""统一解析混合类型的技能列表(str, Path, Skill, SkillSource)"""
caller_namespace = namespace or infer_plugin_namespace()
resolved = []
resolvers = [self._normalize_to_resolver(s) for s in skills]
for r in resolvers:
resolved.extend(await r.resolve(self, caller_namespace))
return resolved
def _normalize_to_resolver(self, item: Any) -> "BaseSkillResolver":
if isinstance(item, Skill):
return SkillInstanceResolver(item)
elif isinstance(item, str):
return StringSkillResolver(item)
elif isinstance(item, Path):
return PathSkillResolver(item)
elif type(item).__name__ == "SkillSource":
return SourceSkillResolver(item)
raise TypeError(f"无法将对象 {type(item)} 解析为技能源。")
async def read_skill_resource(self, skill: Skill, file_path: str) -> str | None:
"""提供统一的资源文件(如 markdown 参考、脚本等)物理读取接口"""
target_file = (skill.path / file_path).resolve()
if not target_file.is_file():
fallback_file = (skill.path / "scripts" / file_path).resolve()
if fallback_file.is_file():
target_file = fallback_file
if target_file.is_file() and target_file.is_relative_to(skill.path.resolve()):
return target_file.read_text(encoding="utf-8")
return None
async def clear_cache(self):
"""清空缓存以重新扫描(方便测试热更)"""
async with self._discovery_lock:
self._skills.clear()
async def _parse_skill_from_path(
self, path: str | Path, namespace: str
) -> Skill | None:
"""按径解析技能,返回提权到最高级别的孤立技能实例,不污染全局状态"""
p = self._get_valid_skill_dir(path)
if not p:
logger.warning(
f"[SkillManager] 忽略无效技能目录 (不存在、非目录或为隐藏目录): {path}"
)
return None
try:
skill = await self._parse_skill_metadata(p, namespace)
if skill:
skill = self._load_skill_resources(skill)
return skill
except Exception as e:
logger.error(f"解析孤立技能 {p.name} 失败: {e}", e=e)
return None
async def _parse_frontmatter_and_body(
self, skill_dir: Path
) -> tuple[dict | None, str]:
skill_md_path = skill_dir / "SKILL.md"
if not skill_md_path.exists():
return None, ""
async with aiofiles.open(skill_md_path, encoding="utf-8") as f:
content = await f.read()
if not content.startswith("---"):
return None, ""
match = re.search(r"^---\s*\n(.*?)\n---\s*\n?(.*)$", content, re.DOTALL)
if not match:
return None, ""
yaml_content, body = match.groups()
try:
return yaml.safe_load(yaml_content), body.strip()
except yaml.YAMLError as e:
logger.warning(f"Skill {skill_dir.name} YAML 解析失败: {e}")
return None, ""
async def _parse_skill_metadata(
self, skill_dir: Path, namespace: str
) -> Skill | None:
fm_dict, _ = await self._parse_frontmatter_and_body(skill_dir)
if (
not isinstance(fm_dict, dict)
or "name" not in fm_dict
or "description" not in fm_dict
):
return None
blueprint, required_envs = SkillBlueprintBuilder.build(fm_dict)
fm_dict["blueprint"] = blueprint
fm_dict["required_envs"] = required_envs
skill = Skill(
frontmatter=SkillFrontmatter(**fm_dict),
path=skill_dir,
disclosure_level=METADATA,
source="local",
namespace=namespace,
)
if skill.frontmatter.required_envs:
await skill_env_manager.ensure_template(
namespace, skill.id, skill.frontmatter.required_envs
)
return skill
def _load_skill_instructions(self, skill: Skill) -> Skill:
if skill.disclosure_level >= INSTRUCTIONS:
return skill
_, body = skill_manager._parse_frontmatter_and_body_sync(skill.path)
return skill.with_disclosure_level(level=INSTRUCTIONS, instructions=body)
def _parse_frontmatter_and_body_sync(
self, skill_dir: Path
) -> tuple[dict | None, str]:
"""同步版本的回退方法"""
skill_md_path = skill_dir / "SKILL.md"
if not skill_md_path.exists():
return None, ""
content = skill_md_path.read_text(encoding="utf-8")
if not content.startswith("---"):
return None, ""
match = re.search(r"^---\s*\n(.*?)\n---\s*\n?(.*)$", content, re.DOTALL)
if not match:
return None, ""
try:
return yaml.safe_load(match.group(1)), match.group(2).strip()
except yaml.YAMLError:
return None, ""
def _load_skill_resources(self, skill: Skill) -> Skill:
if skill.disclosure_level >= RESOURCES:
return skill
if skill.disclosure_level < INSTRUCTIONS:
skill = self._load_skill_instructions(skill)
scripts = (
[
f.name
for f in (skill.path / "scripts").iterdir()
if f.is_file() and not f.name.startswith(".")
]
if (skill.path / "scripts").is_dir()
else []
)
refs = (
[
f.name
for f in (skill.path / "references").iterdir()
if f.is_file() and not f.name.startswith(".")
]
if (skill.path / "references").is_dir()
else []
)
return skill.with_disclosure_level(
level=RESOURCES, scripts=scripts, references=refs
)
skill_manager = SkillManager()
class BaseSkillResolver(ABC):
"""技能解析器基类,用于统一不同类型的技能加载逻辑"""
@abstractmethod
async def resolve(self, manager: SkillManager, namespace: str) -> list[Skill]: ...
class SkillInstanceResolver(BaseSkillResolver):
"""直接解析已有的 Skill 实例解析器"""
def __init__(self, skill: Skill):
self.skill = skill
async def resolve(self, manager: SkillManager, namespace: str) -> list[Skill]:
return [self.skill]
class StringSkillResolver(BaseSkillResolver):
"""根据技能 ID 解析技能的解析器"""
def __init__(self, skill_id: str):
self.skill_id = skill_id
async def resolve(self, manager: SkillManager, namespace: str) -> list[Skill]:
skill = await manager.get_skill_details(self.skill_id, namespace)
return [skill] if skill else []
class PathSkillResolver(BaseSkillResolver):
"""根据本地路径解析技能的解析器"""
def __init__(self, path: Path):
self.path = path
async def resolve(self, manager: SkillManager, namespace: str) -> list[Skill]:
skill = await manager.load_local_skill(self.path, namespace)
return [skill] if skill else []
class SourceSkillResolver(BaseSkillResolver):
"""根据 SkillSource 描述符动态扫描解析技能的解析器"""
def __init__(self, source: Any):
self.source = source
async def resolve(self, manager: SkillManager, namespace: str) -> list[Skill]:
candidates = []
if self.source.fetch_all:
candidates = list((await manager.discover_skills()).values())
elif self.source.scan_dir:
for child in manager._iter_valid_skill_dirs(self.source.scan_dir):
sk = await manager._parse_skill_from_path(child, namespace)
if sk:
candidates.append(sk)
resolved = []
for cand in candidates:
if (
not self.source.exclude_skills
or cand.id not in self.source.exclude_skills
):
resolved.append(cand)
return resolved
@@ -0,0 +1,260 @@
from pathlib import Path
from typing import Annotated, Any, Literal
from pydantic import BaseModel, ConfigDict, Field
from zhenxun.services.ai.sandbox.models import SandboxBlueprint
from zhenxun.utils.pydantic_compat import model_validator
DisclosureLevel = Annotated[
Literal[1, 2, 3], "Progressive disclosure levels for skill loading."
]
METADATA: DisclosureLevel = 1
INSTRUCTIONS: DisclosureLevel = 2
RESOURCES: DisclosureLevel = 3
class SkillEnvConfig(BaseModel):
"""全量技能环境变量配置根节点"""
envs: dict[str, dict[str, dict[str, str]]] = Field(default_factory=dict)
class SkillFrontmatter(BaseModel):
model_config = ConfigDict(populate_by_name=True) # type: ignore
name: str = Field(...)
"""技能名称"""
description: str = Field(...)
"""技能描述"""
compatibility: str | None = Field(default=None)
"""环境要求"""
metadata: dict[str, Any] | None = Field(default=None)
"""自定义元数据"""
allowed_tools: list[str] | None = Field(default=None, alias="allowed-tools")
"""允许的工具"""
permissions: dict[str, Any] | None = Field(default=None)
"""沙箱权限声明 (如 network: true/false)"""
blueprint: SandboxBlueprint = Field(default_factory=SandboxBlueprint)
"""统一环境装配蓝图声明 (根据 metadata 等自动推导)"""
required_envs: list[str] = Field(default_factory=list)
"""声明该技能必需的全局环境变量 Key"""
@model_validator(mode="before")
@classmethod
def parse_allowed_tools(cls, values: dict[str, Any]) -> dict[str, Any]:
"""将字符串格式的 allowed-tools 按空格切分为列表"""
key = "allowed-tools"
alt_key = "allowed_tools"
raw = values.get(key) or values.get(alt_key)
if isinstance(raw, str):
values[key] = raw.split()
return values
@property
def enable_network(self) -> bool:
"""网络权限:默认开启(高信任静态资产),
允许在 YAML 中通过 permissions.network 显式关闭"""
if self.permissions and isinstance(self.permissions, dict):
return self.permissions.get("network", True)
return True
class Skill(BaseModel):
frontmatter: SkillFrontmatter
"""解析后的YAML元数据"""
instructions: str | None = Field(default=None)
"""SKILL.md正文指令,在INSTRUCTIONS级别填充"""
path: Path
"""技能所在目录"""
disclosure_level: DisclosureLevel = Field(default=METADATA)
"""当前披露等级"""
scripts: list[str] = Field(default_factory=list)
"""脚本文件列表"""
references: list[str] = Field(default_factory=list)
"""参考文档列表"""
source: str = Field(default="local")
"""技能来源提供者标识"""
namespace: str = Field(default="global")
"""隔离所属的插件命名空间"""
def with_disclosure_level(
self,
level: DisclosureLevel,
instructions: str | None = None,
scripts: list[str] | None = None,
references: list[str] | None = None,
) -> "Skill":
"""创建一个提升了披露等级的全新 Skill 实例"""
return Skill(
frontmatter=self.frontmatter,
instructions=instructions
if instructions is not None
else self.instructions,
path=self.path,
disclosure_level=level,
scripts=scripts if scripts is not None else self.scripts,
references=references if references is not None else self.references,
source=self.source,
namespace=self.namespace,
)
@property
def id(self) -> str:
"""技能的唯一标识符(强制使用所在文件夹的名称)"""
return self.path.name
def to_xml(self) -> str:
"""将技能转化为结构化的 XML 格式,供大模型友好读取"""
xml = f"<skill>\n <name>{self.id}</name>\n <description>{self.description}</description>\n" # noqa: E501
if self.instructions:
xml += f" <instructions>\n{self.instructions}\n </instructions>\n"
if self.scripts:
xml += (
" <available_scripts>\n"
+ "\n".join(f" <script>{s}</script>" for s in self.scripts)
+ "\n </available_scripts>\n"
)
if self.references:
xml += (
" <available_references>\n"
+ "\n".join(f" <reference>{r}</reference>" for r in self.references)
+ "\n </available_references>\n"
)
xml += "</skill>"
return xml
@property
def name(self) -> str:
"""技能的显示名称(来自 YAML,可能不符合规范)"""
return self.frontmatter.name
@property
def description(self) -> str:
return self.frontmatter.description
class SkillSource(BaseModel):
"""显式定义的技能源 (动态解析器)"""
fetch_all: bool = Field(default=False)
"""是否拉取全局所有已注册的技能"""
scan_dir: Path | None = Field(default=None)
"""扫描特定物理目录下的所有技能"""
exclude_skills: list[str] | None = Field(default=None)
"""需要排除的技能 ID 列表"""
@classmethod
def all(cls, exclude: list[str] | None = None) -> "SkillSource":
"""声明式:获取系统全局挂载目录下的所有技能"""
return cls(fetch_all=True, exclude_skills=exclude)
@classmethod
def from_dir(
cls, path: str | Path, exclude: list[str] | None = None
) -> "SkillSource":
"""声明式:获取指定物理目录下的所有技能(支持私有独立技能库)"""
from pathlib import Path
return cls(scan_dir=Path(path), exclude_skills=exclude)
class SkillBlueprintBuilder:
"""环境蓝图组装器:将 YAML 字典剥离解析为沙箱 Blueprint 和环境变量要求"""
@classmethod
def build(cls, values: dict[str, Any]) -> tuple[SandboxBlueprint, list[str]]:
env_setup_data = values.get("env_setup", {})
python_packages = env_setup_data.get("python_packages", [])
system_packages = env_setup_data.get("system_packages", [])
node_packages = env_setup_data.get("node_packages", [])
bins = env_setup_data.get("bins", [])
install_scripts = env_setup_data.get("install_scripts", [])
required_envs = values.get("required_envs", [])
metadata = values.get("metadata", {})
if isinstance(metadata, str):
import json
try:
metadata = json.loads(metadata)
except Exception:
metadata = {}
if isinstance(metadata, dict):
for data in metadata.values():
if not isinstance(data, dict):
continue
requires = data.get("requires", {})
if isinstance(requires, dict):
for bin_key in ("bins", "anyBins"):
b = requires.get(bin_key, [])
if isinstance(b, list):
bins.extend(b)
envs = requires.get("env", [])
if isinstance(envs, list):
required_envs.extend(envs)
installs = data.get("install", [])
if isinstance(installs, list):
for inst in installs:
if not isinstance(inst, dict):
continue
kind = inst.get("kind", "").lower()
if kind in ("python", "pip", "uv"):
pkg = inst.get("package", "")
if isinstance(pkg, str) and pkg:
python_packages.extend(pkg.split())
elif kind in ("node", "npm"):
pkg = inst.get("package", "")
if isinstance(pkg, str) and pkg:
node_packages.append(pkg)
elif kind in ("apt"):
pkg = inst.get("formula") or inst.get("package") or ""
if isinstance(pkg, str) and pkg:
pkg_name = pkg.split("/")[-1]
system_packages.append(pkg_name)
elif kind == "go":
mod = inst.get("module", "")
if isinstance(mod, str) and mod:
install_scripts.append(f"go install {mod}")
key = "allowed-tools"
alt_key = "allowed_tools"
raw_tools = values.get(key) or values.get(alt_key)
tools_list = (
raw_tools.split()
if isinstance(raw_tools, str)
else (raw_tools if isinstance(raw_tools, list) else [])
)
import re
for tool in tools_list:
if isinstance(tool, str) and tool.startswith("Bash("):
match = re.search(r"Bash\((.*?)\)", tool)
if match:
inner = match.group(1)
cmd = inner.split(":")[0]
if cmd not in bins:
bins.append(cmd)
from zhenxun.services.ai.sandbox.models import (
AptSetup,
NodeSetup,
PythonSetup,
ShellSetup,
)
steps = []
if system_packages:
steps.append(AptSetup(packages=list(dict.fromkeys(system_packages))))
if python_packages:
steps.append(PythonSetup(packages=list(dict.fromkeys(python_packages))))
if node_packages:
steps.append(NodeSetup(packages=list(dict.fromkeys(node_packages))))
if install_scripts:
steps.append(ShellSetup(scripts=install_scripts))
blueprint = SandboxBlueprint(setup_steps=steps)
return blueprint, list(dict.fromkeys(required_envs))
@@ -0,0 +1,320 @@
from typing import Any, cast
from zhenxun.services.ai.run import Inject, RunContext
from zhenxun.services.ai.sandbox.models import SandboxBlueprint
from zhenxun.services.ai.sandbox.protocols import (
SupportsCommandExecution,
SupportsFileSystem,
)
from zhenxun.services.ai.tools.core.decorators import silent, tool
from zhenxun.services.ai.tools.core.toolkit import BaseToolkit
from zhenxun.services.ai.tools.models import ResolvedToolPayload, ToolResult
from zhenxun.services.ai.tools.providers.skills.manager import (
skill_env_manager,
skill_manager,
)
from zhenxun.services.ai.tools.providers.skills.models import Skill
from zhenxun.services.log import logger
from zhenxun.utils.pydantic_compat import model_copy
from zhenxun.utils.utils import infer_plugin_namespace
class SkillSandboxExecutionMixin:
"""技能沙箱执行与自愈逻辑混入类"""
async def _ensure_skill_workspace(
self, skill: Skill, session_id: str, sandbox: Any
) -> tuple[Any, str]:
"""负责环境隔离与沙箱启动装配,返回底层执行器和目标工作区路径"""
bp = model_copy(skill.frontmatter.blueprint, deep=True)
bp.enable_network = skill.frontmatter.enable_network
executor = await sandbox.get_or_create_session(session_id, blueprint=bp)
fs_executor = cast(SupportsFileSystem, executor)
target_workspace = f"{executor.workspace_path}/{skill.id}"
if skill.id not in executor.loaded_skills:
await fs_executor.upload_raw_dir(str(skill.path), target_workspace)
try:
await sandbox.setup_workspace_environment(session_id, target_workspace)
except Exception as e:
logger.warning(f"设置沙箱工作区环境失败: {e}")
executor.loaded_skills.add(skill.id)
return executor, target_workspace
def _prepare_skill_env_vars(
self, skill: Skill, context: RunContext | None, **kwargs
) -> dict[str, str] | ToolResult:
"""处理环境变量注入与缺失拦截"""
configured_envs = skill_env_manager.get_envs_for_skill(
skill.namespace, skill.id
)
missing_keys = [
k for k in skill.frontmatter.required_envs if not configured_envs.get(k)
]
if missing_keys:
logger.warning(f"技能 {skill.id} 因缺少环境变量 {missing_keys} 被拦截。")
return ToolResult(
output=(
f"❌ 技能执行被系统拦截:缺少必需的全局环境变量 "
f"{missing_keys}。\n"
"💡 [智能体自愈引导]:当前技能的底层配置缺失,无法正常运行。"
"请你立即停止尝试,并向用户抱歉,提示用户(或 Bot 管理员)"
"在机器人后端的 `data/ai/skill_envs.json` 文件中为该技能配置"
"相应的环境变量(API Key 等),配置完成后方可使用。"
),
).as_error()
env_vars = {}
for k, v in configured_envs.items():
if v:
env_vars[k] = str(v)
for k, v in kwargs.items():
env_vars[k] = str(v)
if context:
for k, v in context.state.items():
if isinstance(v, str | int | float | bool):
env_vars[k] = str(v)
final_env = {}
for k, v in env_vars.items():
final_env[k] = str(v)
final_env[k.upper()] = str(v)
return final_env
def _format_execution_result(self, result: Any, command: str) -> ToolResult:
"""统一处理沙箱输出并转换为 ToolResult"""
output = (
result.stdout + ("\nSTDERR:\n" + result.stderr if result.stderr else "")
).strip()
if getattr(result, "is_timeout", False) or result.exit_code == -1:
final_output = (
f"🚨 终端命令执行发生严重系统异常或超时被强杀\n"
f"(Exit Code: {result.exit_code})!\n"
"这通常意味着网络不通、下载数据过大耗时太长,"
"或沙箱环境崩溃。输出为空。"
)
elif result.exit_code != 0:
final_output = (
f"❌ 终端命令执行失败 (Exit Code: {result.exit_code})。\n"
f"输出日志:\n{output or '无日志输出'}"
)
else:
final_output = output or "✅ 执行成功 (无控制台输出)。"
logger.info(f"终端命令 {command} 执行完毕, Exit Code: {result.exit_code}")
tool_result = ToolResult(output=final_output)
if result.exit_code != 0:
tool_result = tool_result.as_error()
return tool_result
async def _execute_skill_command_in_sandbox(
self,
skill: Skill,
command: str,
context: RunContext | None,
sandbox: Any,
**kwargs,
) -> ToolResult:
"""主调度方法"""
session_id = (
context.session_id
if context and context.session_id
else f"skill_{skill.id}_session"
)
executor, target_workspace = await self._ensure_skill_workspace(
skill, session_id, sandbox
)
env_res = self._prepare_skill_env_vars(skill, context, **kwargs)
if isinstance(env_res, ToolResult):
return env_res
env_res["SKILL_DIR"] = target_workspace
cmd_executor = cast(SupportsCommandExecution, executor)
result = await cmd_executor.run_process(
command, cwd=target_workspace, timeout=180, env=env_res
)
return self._format_execution_result(result, command)
class SkillMetaToolkit(BaseToolkit, SkillSandboxExecutionMixin):
"""动态发现模式。提供通用的元工具,供大模型按需加载和执行任意可用技能。"""
default_prefix = ""
def __init__(self, allowed_skills: list[Skill] | None = None, **kwargs):
super().__init__(**kwargs)
self._allowed_skills = allowed_skills
if self._allowed_skills is not None:
self._instance_instructions = (
(self._instance_instructions or self.default_instructions)
+ "\n\n🚨 **[系统安全提示]**:当前运行在严格沙盒隔离模式,"
"你只能访问特定被授权的技能。"
)
async def _get_skill(self, skill_name: str) -> Skill | None:
"""按需从隔离白名单或全局管理器中获取技能"""
if self._allowed_skills is not None:
for s in self._allowed_skills:
if s.id == skill_name or s.name == skill_name:
return s
return None
return await skill_manager.get_skill_details(
skill_name, namespace=infer_plugin_namespace()
)
async def resolve(self, context: RunContext | None = None) -> ResolvedToolPayload:
payload = await super().resolve(context)
if self._allowed_skills is not None:
catalog_parts = []
for skill in self._allowed_skills:
catalog_parts.append(
f" <skill>\n <name>{skill.id}</name>\n"
f" <description>{skill.description}</description>\n </skill>"
)
if catalog_parts:
catalog_xml = (
"<available_skills>\n"
+ "\n".join(catalog_parts)
+ "\n</available_skills>"
)
payload.injected_prompts.append(
f"--- 当前受限的可用技能库 ---\n\n{catalog_xml}"
)
return payload
default_instructions = """\
## 技能元工具系统
你可以通过此工具箱动态加载和执行外部技能。工作流如下:
1. 使用 `read_skill_instructions` 传入技能名称,获取该技能的完整指南。
2. 系统将返回严谨的 `<skill>` XML 节点树,请仔细阅读 `<instructions>` 了解业务规则。
3. **[重点] 环境装配与执行规范**:
- **环境变量**:系统已自动将该技能的物理根目录注入为环境变量 `$SKILL_DIR`,在执行终端命令时可直接使用它来定位文件(例如 `cat $SKILL_DIR/package.json`)。
- **按需安装(懒加载)**:沙箱内通常已通过底层 Blueprint 预装了所需的依赖包。**即使技能指南(说明文档)中写了“安装依赖”的步骤,你也必须忽略它!** 严禁在没有任何报错的情况下主动去执行安装命令(如 `npm install`, `pip install`)。
- **脚本执行**:若指南中要求执行物理存在的脚本文件(如 `<available_scripts>` 节点列出的文件),请统一调用 `execute_skill_command` 并加上解释器和完整路径(如 `python3 $SKILL_DIR/scripts/xxx.py`)。
- **终端执行**:若指南中提供的是纯命令行终端指令(例如 `curl`, `infsh`, `gh` 等),请调用 `execute_skill_command` 在技能专属沙箱中直接执行该命令。
- **智能自愈 (Agentic Healing)**:如果执行脚本或命令时失败(如 Exit Code 非 0),你必须自主阅读输出日志 (Stderr/Stdout),分析报错原因。
- **只有当运行报错且明确提示缺少依赖时**(如 `command not found`, `ModuleNotFoundError` 等),你才可以调用 `execute_skill_command` 安装缺失的依赖(Python 依赖强烈建议使用 `uv pip install <pkg>`,Node 使用 `npm install <pkg>`),安装成功后再次重试。
- 如果是其他错误,请结合技能指南调整参数或操作流程后重试。
4. 如需阅读参考文档,请参考 `<available_references>` 节点并调用 `read_skill_file`。""" # noqa: E501
@tool(
name="read_skill_instructions",
description="加载指定技能的完整使用说明与可用资源清单。返回值为 XML 结构。",
)
@silent()
async def read_skill_instructions(self, skill_name: str) -> ToolResult:
skill = await self._get_skill(skill_name)
if not skill:
return ToolResult(output=f"越权操作或未找到技能: {skill_name}").as_error()
res = skill.to_xml()
res += (
"\n\n**提示**: 你可以使用 `read_skill_file` 工具读取该技能"
"目录下的任何附加文件(如 references/ 或 templates/ 下的文档)。"
)
logger.info(f"已加载技能 {skill.name} 指南。")
return ToolResult(output=res)
@tool(
name="execute_skill_command",
description=(
"在技能专属的安全沙箱内执行任意终端命令行指令"
"(如 curl, npm, gh 等)。仅当技能指南中提供的是终端命令"
"而非特定脚本文件时使用。"
),
)
async def execute_skill_command(
self,
skill_name: str,
command: str,
context: RunContext | None = None,
sandbox: Inject.Sandbox = None,
**kwargs,
) -> ToolResult:
skill = await self._get_skill(skill_name)
if not skill:
return ToolResult(
output=f"越权操作或未找到技能: {skill_name}",
).as_error()
if sandbox is None and context is not None:
sandbox = Inject._providers["sandbox"]["global"](context)
return await self._execute_skill_command_in_sandbox(
skill, command, context, sandbox, **kwargs
)
@tool(
name="read_skill_file",
description=(
"安全读取指定技能目录下的附加文件"
"(如 references/ 里的参考文档或 scripts/ 里的代码)。"
),
)
@silent()
async def read_skill_file(
self,
skill_name: str,
file_path: str,
context: RunContext | None = None,
sandbox: Inject.Sandbox = None,
) -> ToolResult:
skill = await self._get_skill(skill_name)
if not skill:
return ToolResult(output=f"越权操作或未找到技能: {skill_name}").as_error()
content = await skill_manager.read_skill_resource(skill, file_path)
if content is not None:
logger.info(
f"已从本地读取文件 {file_path} (共 {len(content)} 字符)。"
f"内容摘要: {content[:100]}..."
)
return ToolResult(output=content)
session_id = (
context.session_id
if context and context.session_id
else f"skill_{skill.id}_session"
)
bp = SandboxBlueprint(enable_network=skill.frontmatter.enable_network)
try:
if sandbox is None and context is not None:
sandbox = Inject._providers["sandbox"]["global"](context)
executor = await sandbox.get_or_create_session(session_id, blueprint=bp)
fs_executor = cast(SupportsFileSystem, executor)
clean_file_path = file_path.lstrip("/")
sandbox_target_path = (
f"{executor.workspace_path}/{skill.id}/{clean_file_path}"
)
content = await fs_executor.read_raw_file(sandbox_target_path)
if content.startswith("Error: File") or content.startswith("Failed to"):
return ToolResult(
output=(
f"❌ 找不到文件: {file_path} "
f"(Provider {skill.source} 与沙箱中均未找到)"
),
).as_error()
logger.info(
"已从沙箱读取动态生成的文件 "
f"{sandbox_target_path} (共 {len(content)} 字符)。"
)
return ToolResult(output=content)
except Exception as e:
return ToolResult(output=f"❌ 尝试读取沙箱文件时发生异常: {e}").as_error()