✨ 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,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()