Files
zhenxun_bot/zhenxun/services/ai/tools/providers/skills/toolkit.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

322 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from typing import Any, cast
from zhenxun.services.ai.run.context import RunContext
from zhenxun.services.ai.run.di import Inject
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 Rules, 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 结构。",
rules=[Rules.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/ 里的代码)。"
),
rules=[Rules.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()