✨ 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
+32
View File
@@ -0,0 +1,32 @@
import nonebot
from .bridges.matcher_bridge import MatcherTool, bind_matcher
from .core.decorators import Rules, tool
from .core.toolkit import BaseToolkit
from .engine.registry import tool_provider_manager
from .models import (
ToolOptions,
ToolResult,
)
from .providers.builtin.native import Native
from .providers.mcp.provider import mcp_provider
tool_provider_manager.register(mcp_provider)
@nonebot.get_driver().on_shutdown
async def _shutdown_mcp_provider():
"""在服务关闭时停止所有 MCP 子进程服务器"""
await mcp_provider.shutdown()
__all__ = [
"BaseToolkit",
"MatcherTool",
"Native",
"Rules",
"ToolOptions",
"ToolResult",
"bind_matcher",
"tool",
]
@@ -0,0 +1,9 @@
from .delegate import DelegateTool
from .handoff import HandoffTool
from .matcher_bridge import MatcherTool
__all__ = [
"DelegateTool",
"HandoffTool",
"MatcherTool",
]
@@ -0,0 +1,156 @@
import json
from typing import TYPE_CHECKING, Any
from pydantic import BaseModel, Field
from zhenxun.services.ai.core.exceptions import ControlFlowExit
from zhenxun.services.ai.core.stream_events import ToolStreamChunkEvent
from zhenxun.services.ai.run import RunContext
from zhenxun.services.ai.tools.core.tool import BaseTool
from zhenxun.services.ai.tools.models import ToolResult
from zhenxun.services.log import logger
from zhenxun.utils.pydantic_compat import model_dump
if TYPE_CHECKING:
from zhenxun.services.ai.flow.base import BaseRunnable
STRUCTURED_INPUT_PREAMBLE = (
"\n\n### 🛠️ [嵌套调用前置语境]\n"
"你现在正在作为一个『工具/子节点』被外部主智能体调用。\n"
"以下是外部系统传递给你的结构化输入数据:\n"
"```json\n{payload}\n```\n"
"请严格将上述内容视为你的核心数据和约束条件,专注于解决该子任务,并直接返回结果,不要说多余的废话。\n"
)
class DelegateArgs(BaseModel):
task: str = Field(
..., description="指派给该实体的具体任务描述、指令或需要回答的问题"
)
class DelegateTool(BaseTool):
"""
将任意实现了 run() 方法的实体 (Agent/Team/Workflow 等) 包装为大模型可调用的工具。
(SubRoutine 委派模式)
"""
def __init__(
self,
runnable: "BaseRunnable[Any]",
name: str | None = None,
description: str | None = None,
):
resolved_name = name or getattr(runnable, "name", "SubRunnable")
resolved_desc = description or getattr(
runnable, "description", f"将子任务委派给 {resolved_name} 执行"
)
final_name = (
f"delegate_to_{resolved_name}"
if not resolved_name.startswith("delegate_")
else resolved_name
)
super().__init__(name=final_name, description=resolved_desc)
self.runnable = runnable
self.args_schema = DelegateArgs
async def execute(
self, context: RunContext | None = None, **kwargs: Any
) -> ToolResult:
task = kwargs.get("task", "")
context = context or RunContext()
counts = context.session.shared_state.setdefault("__delegate_counts__", {})
counts[self.name] = counts.get(self.name, 0) + 1
if counts[self.name] > 3:
return ToolResult(
output=(
f"❌ 系统拦截:检测到无限委派死循环!\n"
f"你已经连续 {counts[self.name]} 次将子任务委派给下级实体 "
f"{self.name} 且未获最终成功。\n"
"请立即停止委派,"
"改变你的思考方向或直接向用户汇报失败结论!"
)
).as_error()
depth = context.run.delegate_depth
if depth >= 3:
logger.warning(
f"⚠️ [DelegateTool] 委派深度超限 ({depth}),强制阻断: {self.name}"
)
from zhenxun.services.ai.core.exceptions import AbortException
raise AbortException(
reason="嵌套层级过深,系统已强制拒绝执行委派",
display=f"⚠️ {self.name} 嵌套层级过深",
)
logger.debug(
f"🔄 [DelegateTool] 正在委派下级实体 {self.name} (Task: {task[:30]}...)"
)
sub_context = context.clone_for_member(self.name)
sub_context.run.delegate_depth = depth + 1
payload_str = json.dumps(kwargs, ensure_ascii=False, indent=2)
preamble = STRUCTURED_INPUT_PREAMBLE.format(payload=payload_str)
sub_context.run.add_system_prompt(preamble)
try:
event_bus = context.run.event_bus
async with self.runnable.run_stream(
prompt=task,
context=sub_context,
) as stream_result:
response = await stream_result.forward_to(event_bus, self.name)
if response is None:
raise RuntimeError(f"Sub-agent {self.name} did not return a response.")
if isinstance(response.output, BaseModel):
final_output = model_dump(response.output)
else:
final_output = response.output
if response.handoff:
final_output = (
f"⚠️ 子任务未完成。下级实体主动发起了工作流移交 (Handoff)。\n"
f"移交目标: {response.handoff.target}\n"
f"移交原因: {response.handoff.reason}\n"
f"附带数据: {response.handoff.context_data}"
)
is_fatal = isinstance(final_output, str) and (
"DepthLimitExceeded" in final_output or "嵌套层级过深" in final_output
)
if is_fatal:
from zhenxun.services.ai.core.exceptions import AbortException
raise AbortException(
reason="下级实体遇到深度限制异常",
display=f"⚠️ 实体 {self.name} 委派失败",
)
usage = getattr(response, "usage", None)
if context and context.run.event_bus:
await context.run.event_bus.emit(
ToolStreamChunkEvent(
tool_name=self.name, content=f"🧠 实体 {self.name} 执行完毕"
)
)
return ToolResult(
output=final_output,
usage=usage,
)
except ControlFlowExit as e:
raise e
except Exception as e:
logger.error(f"委派实体 {self.name} 执行失败: {e}", e=e)
from zhenxun.services.ai.core.exceptions import AbortException
raise AbortException(
reason=f"Delegate Execution Error: {e}",
display=f"❌ 实体 {self.name} 执行异常",
)
@@ -0,0 +1,99 @@
from typing import Any
from pydantic import BaseModel, Field, create_model
from zhenxun.services.ai.run import RunContext
from zhenxun.services.ai.tools.core.tool import BaseTool
from zhenxun.services.ai.tools.models import HandoffResult, ToolResult
class HandoffTool(BaseTool):
"""
触发控制权移交 (Handoff) 信号的底层工具。
当大模型调用此工具时,当前 Agent 执行流将被立即熔断并向外抛出 ToolHandoff 信号。
"""
def __init__(
self,
target_name: str,
target_description: str,
input_schema: type[BaseModel] | Any | None = None,
):
super().__init__(
name=f"transfer_to_{target_name}",
description=(
f"将对话控制权移交给 {target_name}。专长/职责:{target_description}"
),
)
self.target_name = target_name
actual_schema = None
if input_schema:
from zhenxun.services.ai.core.options import BaseOutputDefinition
if isinstance(input_schema, BaseOutputDefinition):
actual_schema = input_schema.type_
else:
actual_schema = input_schema
if actual_schema:
fields: dict[str, Any] = {
"reason": (str, Field(..., description="移交的原因或简要状态说明")),
}
schema_fields = getattr(
actual_schema, "model_fields", getattr(actual_schema, "__fields__", {})
)
for k, v in schema_fields.items():
fields[k] = (v.annotation, v)
self.args_schema = create_model(
f"DynamicHandoffArgs_{target_name}", **fields
)
else:
class DefaultHandoffArgs(BaseModel):
reason: str = Field(..., description="移交的原因或简要状态说明")
context_data: Any = Field(
default="",
description="你需要传递给下一个负责人的所有核心数据",
)
self.args_schema = DefaultHandoffArgs
async def execute(
self, context: RunContext | None = None, **kwargs: Any
) -> ToolResult:
reason = kwargs.pop("reason", "")
if "context_data" in kwargs and len(kwargs) == 1:
context_data = kwargs["context_data"]
else:
context_data = kwargs
if context:
counts = context.session.shared_state.setdefault("__handoff_counts__", {})
counts[self.target_name] = counts.get(self.target_name, 0) + 1
if counts[self.target_name] > 3:
return ToolResult(
output=(
f"❌ 系统拦截:检测到严重的踢皮球现象!\n"
f"你所在的团队已经连续 {counts[self.target_name]} 次将任务"
f"移交给 {self.target_name},\n"
"但问题仍未解决。请立刻改变策略,"
"由你亲自处理或得出最终结论,严禁再次移交!"
)
).as_error()
from zhenxun.services.ai.core.messages import HandoffEvent
if context:
context.run.add_event(
HandoffEvent(
target=self.target_name,
reason=reason,
context_data=context_data,
)
)
return HandoffResult(
target=self.target_name, reason=reason, context_data=context_data
)
@@ -0,0 +1,464 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Callable
from typing import Any, cast
from arclet.alconna import Alconna, Subcommand
from nonebot.adapters import Bot, Event
from nonebot.exception import FinishedException, PausedException, RejectedException
from nonebot.internal.matcher import Matcher
from nonebot.typing import T_State
from nonebot_plugin_alconna import UniMessage
from nonebot_plugin_alconna.consts import (
ALCONNA_EXEC_RESULT,
ALCONNA_EXTENSION,
ALCONNA_RESULT,
)
from nonebot_plugin_alconna.model import CommandResult
from pydantic import BaseModel
from zhenxun.services.ai.core.exceptions import ToolRetryError
from zhenxun.services.ai.core.models import ToolDefinition
from zhenxun.services.ai.run import RunContext
from zhenxun.services.ai.tools.core.schema import build_schema_hint
from zhenxun.services.ai.tools.core.tool import BaseTool
from zhenxun.services.ai.tools.models import EndRunResult, ToolOptions, ToolResult
from zhenxun.services.log import logger
from zhenxun.utils.pydantic_compat import model_validate
class MatcherAdapter(ABC):
"""Matcher 桥接适配器基类 (Adapter Pattern)"""
def __init__(
self,
matcher: type[Matcher],
args_schema: type[BaseModel] | None,
command_formatter: Callable[[Any], list[Any] | str] | None = None,
):
self.matcher = matcher
self.args_schema = args_schema
self.command_formatter = command_formatter
def modify_tool_definition(self, tool_def: ToolDefinition) -> ToolDefinition:
"""用于清洗大模型不需要的插件内部元数据"""
return tool_def
@abstractmethod
async def build_state(
self, kwargs: dict[str, Any], bot: Bot, event: Event
) -> T_State:
"""根据传入参数构造目标 Matcher 需要的 Fake State"""
raise NotImplementedError
class AlconnaAdapter(MatcherAdapter):
"""针对 nonebot_plugin_alconna 的高级解析适配器"""
def modify_tool_definition(self, tool_def: ToolDefinition) -> ToolDefinition:
if tool_def and tool_def.parameters and "properties" in tool_def.parameters:
for prop in tool_def.parameters["properties"].values():
if isinstance(prop, dict):
prop.pop("alc_dest", None)
prop.pop("alc_name", None)
return tool_def
def _build_kwargs_mapping(
self, kwargs: dict[str, Any]
) -> tuple[dict[str, Any], dict[str, str], dict[str, str]]:
mapped_kwargs = {}
name_overrides = {}
raw_key_to_dest: dict[str, str] = {}
fields = (
getattr(
self.args_schema,
"model_fields",
getattr(self.args_schema, "__fields__", {}),
)
if self.args_schema
else {}
)
for k, v in kwargs.items():
alc_dest = k
alc_name = None
if k in fields:
field = fields[k]
extra = getattr(
field,
"json_schema_extra",
getattr(getattr(field, "field_info", None), "extra", None),
)
if isinstance(extra, dict):
alc_dest = extra.get("alc_dest", k)
alc_name = extra.get("alc_name")
mapped_kwargs[alc_dest] = v
raw_key_to_dest[k] = alc_dest
if alc_name:
name_overrides[alc_dest] = alc_name
return mapped_kwargs, name_overrides, raw_key_to_dest
def _auto_infer_argv(self, kwargs: dict[str, Any]) -> tuple[list[Any], list[str]]:
command = getattr(self.matcher, "command", lambda: None)()
if not command:
raise RuntimeError(f"Matcher {self.matcher} 并非合法的 AlconnaMatcher")
command = cast(Alconna, command)
mapped_kwargs, name_overrides, raw_key_to_dest = self._build_kwargs_mapping(
kwargs
)
consumed_dests = set()
argv = []
cmd_name = command.command or ""
prefixes = command.prefixes
if prefixes and isinstance(prefixes, list):
prefix = next((p for p in prefixes if p), "")
argv.append(f"{prefix}{cmd_name}")
else:
argv.append(cmd_name)
def _process_node(node: Any, current_argv: list):
if hasattr(node, "args"):
for arg in node.args.argument:
if arg.name in mapped_kwargs:
val = mapped_kwargs[arg.name]
consumed_dests.add(arg.name)
if val is not None and val != "":
current_argv.append(str(val))
if hasattr(node, "options"):
for opt in node.options:
if isinstance(opt, Subcommand):
continue
dest = opt.dest
if dest in mapped_kwargs:
val = mapped_kwargs[dest]
consumed_dests.add(dest)
opt_name = name_overrides.get(dest, opt.name)
if isinstance(val, bool):
if val:
current_argv.append(opt_name)
elif val is not None and val != "":
current_argv.append(opt_name)
current_argv.append(str(val))
_process_node(command, argv)
for subcmd in command.options:
if isinstance(subcmd, Subcommand):
is_active = False
dest = subcmd.dest
if (
mapped_kwargs.get(dest) is True
or mapped_kwargs.get(subcmd.name) is True
):
is_active = True
consumed_dests.add(dest)
consumed_dests.add(subcmd.name)
else:
for k, v in mapped_kwargs.items():
if str(v) == subcmd.name or str(v) == subcmd.dest:
is_active = True
consumed_dests.add(k)
break
if is_active:
subcmd_name = name_overrides.get(dest, subcmd.name)
argv.append(subcmd_name)
_process_node(subcmd, argv)
break
unconsumed_raw_keys = [
raw_k for raw_k, dst in raw_key_to_dest.items() if dst not in consumed_dests
]
return argv, unconsumed_raw_keys
async def build_state(
self, kwargs: dict[str, Any], bot: Bot, event: Event
) -> T_State:
unconsumed_keys = []
if self.command_formatter:
try:
if self.args_schema:
model_inst = model_validate(self.args_schema, kwargs)
argv = self.command_formatter(model_inst)
else:
argv = self.command_formatter(kwargs)
except Exception as e:
logger.error(f"执行 command_formatter 失败: {e}")
raise ToolRetryError(f"格式化参数失败: {e}")
else:
argv, unconsumed_keys = self._auto_infer_argv(kwargs)
msg = UniMessage()
for idx, item in enumerate(argv):
if isinstance(item, str):
msg += f" {item}" if idx > 0 else item
else:
if idx > 0:
msg += " "
msg.append(item)
command_func = getattr(self.matcher, "command", None)
if not command_func:
raise ToolRetryError("无法获取底层 Alconna 命令对象")
command = cast(Alconna, command_func())
if not command:
raise ToolRetryError("底层 Alconna 命令对象为空")
arp = command.parse(msg)
if not arp.matched:
error_info = (
str(arp.error_info) if arp.error_info else "缺失必填参数或格式不匹配"
)
logger.warning(f"Alconna 解析失败: {error_info}. Argv: {argv}")
schema_hint = (
build_schema_hint(self.args_schema) if self.args_schema else ""
)
if unconsumed_keys:
schema_hint += (
f"\n\n⚠️ [参数吸收警告] 字段 {unconsumed_keys} "
"未能被底层的 Alconna 命令树成功接收!\n"
"👉 这通常意味着 Schema 的字段名与实际命令的选项名(dest)不匹配。"
)
raise ToolRetryError(
f"Alconna 内部解析失败: {error_info}。"
f"请检查参数是否遗漏或名称对不齐。{schema_hint}"
)
cmd_result = CommandResult(result=arp, output=None)
fake_state: T_State = {
ALCONNA_RESULT: cmd_result,
ALCONNA_EXEC_RESULT: {},
**kwargs,
}
executor = getattr(self.matcher, "executor", None)
if executor:
selected = executor.select(bot, event)
fake_state[ALCONNA_EXTENSION] = selected
await selected.parse_wrapper(bot, fake_state, event, arp)
logger.debug(f"通过真实解析引擎拉起 Alconna Matcher, Argv: {argv}")
return fake_state
class NativeCommandAdapter(MatcherAdapter):
"""针对 NoneBot 原生 on_command 的适配器"""
async def build_state(
self, kwargs: dict[str, Any], bot: Bot, event: Event
) -> T_State:
if self.command_formatter:
try:
if self.args_schema:
model_inst = model_validate(self.args_schema, kwargs)
arg_str = self.command_formatter(model_inst)
else:
arg_str = self.command_formatter(kwargs)
except Exception as e:
logger.error(f"执行 command_formatter 失败: {e}")
raise ToolRetryError(f"格式化参数失败: {e}")
if isinstance(arg_str, list):
arg_str = " ".join(str(i) for i in arg_str)
else:
arg_str = " ".join(
str(v) for v in kwargs.values() if v is not None and str(v).strip()
)
try:
msg_cls = type(event.get_message())
msg_obj = msg_cls(arg_str)
except Exception:
try:
from nonebot.adapters.onebot.v11 import Message as OB11Message
msg_obj = OB11Message(arg_str)
except Exception:
msg_obj = arg_str
fake_state: T_State = {
"COMMAND": ("llm_bridge",),
"_prefix": {
"command_start": "/",
"command": ("llm_bridge",),
"command_arg": msg_obj,
},
**kwargs,
}
logger.debug(f"原生 Command 适配器注入 State _prefix.command_arg: {arg_str}")
return fake_state
class MatcherTool(BaseTool):
"""
通用状态机穿透工具。
将原有的 nonebot 指令(on_alconna, on_command 等)
无缝包装为大模型可调用的结构化工具。
"""
def __init__(
self,
matcher: type[Matcher],
adapter: MatcherAdapter,
name: str,
description: str,
args_schema: type[BaseModel] | None,
terminal: bool = True,
):
self.matcher = matcher
self.adapter = adapter
self.terminal = terminal
super().__init__(
name=name,
description=description,
settings=ToolOptions(
args_schema=args_schema,
tags=["system:matcher_bridge"],
metadata={
"source": "matcher_bridge",
"adapter_type": adapter.__class__.__name__,
},
),
)
async def get_definition(
self, context: RunContext | None = None
) -> ToolDefinition | None:
tool_def = await super().get_definition(context)
if tool_def:
tool_def = self.adapter.modify_tool_definition(tool_def)
return tool_def
async def execute(
self, context: RunContext | None = None, **kwargs: Any
) -> ToolResult:
if not context:
return ToolResult(
output="缺少 RunContext 依赖,无法执行 Matcher"
).as_error()
bot = context.get_bot()
event = context.get_event()
if not bot or not event:
return ToolResult(
output="上下文缺少 bot 或 event,无法执行 Matcher"
).as_error()
try:
fake_state = await self.adapter.build_state(kwargs, bot, event)
except ToolRetryError as e:
raise e
except Exception as e:
logger.error(f"构造状态失败: {e}", e=e)
return ToolResult(output=f"适配器构造状态失败: {e}").as_error()
logger.debug(
f"拉起 Matcher: {self.name} (Adapter: {self.adapter.__class__.__name__})"
)
matcher_inst = self.matcher()
try:
await matcher_inst.run(bot, event, fake_state)
except FinishedException:
if self.terminal:
return EndRunResult(output="任务已完成。结果已直接发送给用户。")
return ToolResult(output="执行完毕,已直接向用户发送结果。")
except PausedException:
return ToolResult(
output="执行被暂停(大模型不支持交互式Pause)。"
).as_error()
except RejectedException:
return ToolResult(output="执行被拒绝(参数缺失或错误)。").as_error()
except Exception as e:
def _unwrap_err(exc: BaseException) -> str:
exceptions = getattr(exc, "exceptions", None)
if exceptions is not None:
return " | ".join(_unwrap_err(inner) for inner in exceptions)
return f"{type(exc).__name__}: {exc}"
real_err = _unwrap_err(e)
logger.error(
f"Matcher '{self.name}' 穿透执行发生未捕获异常: {real_err}",
e=e,
)
return ToolResult(output=f"执行时发生底层错误: {real_err}").as_error()
if self.terminal:
return EndRunResult(output="执行完毕。")
return ToolResult(output="命令已在后台成功执行完成。")
def bind_matcher(
matcher: type[Matcher],
name: str,
description: str,
args_schema: type[BaseModel] | None = None,
terminal: bool = True,
auto_register: bool = True,
command_formatter: Callable[[Any], list[Any] | str] | None = None,
require_prefix: bool = True,
) -> MatcherTool:
"""
将现有的 Nonebot Matcher (on_command, on_alconna等) 绑定并转化为大模型工具。
使用策略模式(Adapter Pattern)自动推导 Matcher 的类型。
参数:
matcher: 目标 Matcher 对象 (由 on_command/on_alconna 返回的 Type[Matcher])
name: 工具的名称 (仅限英文字母、数字及下划线)
description: 工具的详细说明,大模型将根据此说明决定何时调用
args_schema: 描述 LLM 需要填写的参数 Pydantic Schema。可以为 None
terminal: 是否为"终端工具" (默认为 True,执行完毕后立即中断大模型思考循环)
auto_register: 是否自动注册到系统的全局工具库中
command_formatter: 可选的格式化器。对于原生 on_command,如果不传,
则直接使用 kwargs values 拼接为空格字符串。
"""
if require_prefix:
from zhenxun.utils.utils import infer_plugin_namespace
ns = infer_plugin_namespace(default="global")
if ns and ns not in ("global", "unknown"):
if not name.startswith(f"{ns}_"):
name = f"{ns}_{name}"
is_alconna = (
hasattr(matcher, "command")
and callable(getattr(matcher, "command"))
and hasattr(matcher, "executor")
)
if is_alconna:
adapter = AlconnaAdapter(matcher, args_schema, command_formatter)
else:
adapter = NativeCommandAdapter(matcher, args_schema, command_formatter)
tool_instance = MatcherTool(
matcher=matcher,
adapter=adapter,
name=name,
description=description,
args_schema=args_schema,
terminal=terminal,
)
if auto_register:
from zhenxun.services.ai.tools.engine.registry import tool_provider_manager
tool_provider_manager.register_tool(tool_instance)
return tool_instance
@@ -0,0 +1,23 @@
from .decorators import Rules, tool
from .schema import (
FieldPermission,
RequireAdminLevel,
RequireSuperUser,
)
from .tool import BaseTool, FunctionTool
from .toolkit import (
BaseToolkit,
CompositeToolkit,
)
__all__ = [
"BaseTool",
"BaseToolkit",
"CompositeToolkit",
"FieldPermission",
"FunctionTool",
"RequireAdminLevel",
"RequireSuperUser",
"Rules",
"tool",
]
@@ -0,0 +1,398 @@
import asyncio
from collections.abc import Callable
from typing import Any
from aiocache import SimpleMemoryCache
from zhenxun.services.ai.capabilities import (
AbstractCapability,
WrapToolExecuteHandler,
WrapToolValidateHandler,
)
from zhenxun.services.ai.run import RunContext
from zhenxun.services.ai.run.di import DependencyInjector
from zhenxun.services.ai.tools.models import ToolResult
from zhenxun.services.ai.utils import PermissionUtils
from zhenxun.services.log import logger
from zhenxun.utils.pydantic_compat import model_dump, parse_as
_TOOL_RESULT_CACHE = SimpleMemoryCache(namespace="zhenxun_tool_cache")
class CacheCapability(AbstractCapability):
"""
极速缓存能力中间件 (Tool-Level)
拦截相同的参数调用,直接返回缓存结果,无需请求底层。
"""
def __init__(self, ttl: int = 3600, cache_function: Callable | None = None):
"""
初始化极速缓存能力中间件。
参数:
ttl: 缓存结果的有效存活时长(秒),默认 3600。
cache_function: 自定义判定函数,接收 arguments 与 result,
返回布尔值决定该次结果是否允许缓存,默认 None。
"""
self.ttl = ttl
self.cache_function = cache_function
async def wrap_tool_execute(
self,
context: RunContext,
tool_name: str,
arguments: dict[str, Any],
handler: WrapToolExecuteHandler,
) -> Any:
tool = context.call.current_tool
if tool is None:
return await handler(arguments)
if not hasattr(tool, "_generate_cache_key"):
return await handler(arguments)
cache_key = tool._generate_cache_key(arguments)
cached_data = await _TOOL_RESULT_CACHE.get(cache_key)
if cached_data is not None:
cached_result = (
parse_as(ToolResult, cached_data)
if isinstance(cached_data, dict)
else cached_data
)
logger.info(
f"⚡ [Cache Hit] 工具 {tool_name} 命中极速缓存, "
f"熔断执行! Key: {cache_key}"
)
return cached_result
result = await handler(arguments)
if result and not getattr(result, "is_error", False):
should_cache = True
if self.cache_function:
try:
should_cache = self.cache_function(arguments, result)
except Exception as ce:
logger.warning(f"Cache function 执行失败: {ce}")
should_cache = False
if should_cache:
await _TOOL_RESULT_CACHE.set(
cache_key, model_dump(result), ttl=self.ttl
)
return result
class ApprovalCapability(AbstractCapability):
"""
人工审批能力中间件 (Tool-Level / HITL)
执行高危操作前在群聊中发起授权确认,拒绝则抛出取消异常。
"""
async def wrap_tool_execute(
self,
context: RunContext,
tool_name: str,
arguments: dict[str, Any],
handler: WrapToolExecuteHandler,
) -> Any:
tool = context.call.current_tool
if tool is None:
return await handler(arguments)
import json
args_str = json.dumps(arguments, ensure_ascii=False, indent=2)
confirm_msg = f"即将在本地执行高危工具 [{tool_name}]\n参数:\n{args_str}"
if "global" not in context.run.hitl_locks:
context.run.hitl_locks["global"] = asyncio.Lock()
hitl_lock = context.run.hitl_locks["global"]
await hitl_lock.acquire()
try:
from zhenxun.services.ai.run.hitl import HITLController
hitl = HITLController(context)
await hitl.ask_confirm(f"⚠️ **安全交互审批**\n\n{confirm_msg}", timeout=60.0)
logger.info(f"🛡️ [HITL] 工具 {tool_name} 审批通过。")
finally:
hitl_lock.release()
return await handler(arguments)
class LifecycleCapability(AbstractCapability):
"""
通用生命周期能力中间件 (Generic Lifecycle Capability)
接收开发者传入的简单函数,自动为其提供依赖注入 (DI) 和洋葱模型拦截。
"""
def __init__(
self,
before_execute: Callable | None = None,
after_execute: Callable | None = None,
validate_args: Callable | None = None,
prepare_tool: Callable | None = None,
):
"""
初始化通用生命周期能力中间件。
参数:
before_execute: 在工具开始执行前触发的钩子函数,默认 None。
after_execute: 在工具执行完毕后触发的钩子/拦截函数,默认 None。
validate_args: 在工具参数验证阶段拦截的自定义校验函数,默认 None。
prepare_tool: 在加载并提供大模型 Schema 定义前进行拦截加工的准备函数,
默认 None。
"""
self.before_execute_hook = before_execute
self.after_execute_hook = after_execute
self.validate_args_hook = validate_args
self.prepare_tool_hook = prepare_tool
async def prepare_tools(
self, context: RunContext, tool_defs: list[Any]
) -> list[Any]:
if not self.prepare_tool_hook:
return tool_defs
new_defs = []
for tdef in tool_defs:
res = await DependencyInjector.invoke(
self.prepare_tool_hook, {"tool_def": tdef}, context
)
if res is not None:
new_defs.append(res)
return new_defs
async def wrap_tool_validate(
self,
context: RunContext,
tool_name: str,
args: str | dict[str, Any],
handler: WrapToolValidateHandler,
) -> dict[str, Any]:
if not self.validate_args_hook or not isinstance(args, dict):
return await handler(args)
call_kwargs = dict(args)
call_kwargs["args"] = args
call_kwargs["tool_args"] = args
call_kwargs["tool_name"] = tool_name
await DependencyInjector.invoke(self.validate_args_hook, call_kwargs, context)
return await handler(args)
async def wrap_tool_execute(
self,
context: RunContext,
tool_name: str,
arguments: dict[str, Any],
handler: WrapToolExecuteHandler,
) -> Any:
if not self.before_execute_hook:
pass
else:
call_kwargs = dict(arguments)
call_kwargs["args"] = arguments
call_kwargs["tool_args"] = arguments
call_kwargs["tool_name"] = tool_name
await DependencyInjector.invoke(
self.before_execute_hook, call_kwargs, context
)
result = await handler(arguments)
if not self.after_execute_hook:
return result
call_kwargs = dict(arguments)
call_kwargs["args"] = arguments
call_kwargs["tool_args"] = arguments
call_kwargs["tool_name"] = tool_name
call_kwargs["result"] = result
call_kwargs["tool_result"] = result
res = await DependencyInjector.invoke(
self.after_execute_hook, call_kwargs, context
)
return res if res is not None else result
class SuperuserCapability(AbstractCapability):
"""仅超级管理员可见"""
async def prepare_tools(
self, context: RunContext, tool_defs: list[Any]
) -> list[Any]:
if await PermissionUtils.check_superuser(context):
return tool_defs
return []
class AdminLevelCapability(AbstractCapability):
"""需要指定群聊权限等级"""
def __init__(self, min_level: int):
"""
初始化群聊权限等级限制能力。
参数:
min_level: 允许执行该工具的最小群聊管理权限等级。
"""
self.min_level = min_level
async def prepare_tools(
self, context: RunContext, tool_defs: list[Any]
) -> list[Any]:
if await PermissionUtils.check_admin_level(context, self.min_level):
return tool_defs
return []
class GroupOnlyCapability(AbstractCapability):
"""仅群聊可用"""
async def prepare_tools(
self, context: RunContext, tool_defs: list[Any]
) -> list[Any]:
if context.get_group_id():
return tool_defs
return []
class ConfigDependencyCapability(AbstractCapability):
"""依赖特定配置开关"""
def __init__(self, module: str, key: str, expected_value: Any = True):
"""
初始化配置依赖开关能力。
参数:
module: 目标配置所属的模块名。
key: 目标配置项的键名。
expected_value: 期望该配置项所匹配的值,如果匹配通过才可见/可用该工具,默认 True。
""" # noqa: E501
self.module = module
self.key = key
self.expected_value = expected_value
async def prepare_tools(
self, context: RunContext, tool_defs: list[Any]
) -> list[Any]:
from zhenxun.configs.config import Config
if Config.get_config(self.module, self.key) == self.expected_value:
return tool_defs
return []
class InteractiveCapability(AbstractCapability):
"""交互式补全局部中间件:在验证阶段拦截参数缺失异常并向用户提问"""
async def wrap_tool_validate(
self,
context: RunContext,
tool_name: str,
args: str | dict[str, Any],
handler: WrapToolValidateHandler,
) -> dict[str, Any]:
from zhenxun.services.ai.core.exceptions import (
NeedsInputException,
ToolFatalError,
ToolRetryError,
)
tool = context.call.current_tool
current_kwargs = dict(args) if isinstance(args, dict) else args
while True:
try:
return await handler(current_kwargs)
except NeedsInputException as e:
bot = context.get_bot()
event = context.get_event()
if not bot or not event:
logger.warning(
"交互式工具 "
f"{getattr(tool, 'name', 'unknown')} "
"缺少参数,但处于非交互环境。"
)
raise ToolRetryError(f"缺少必填参数: {e.missing_description}。")
prompt_msg = (
f"执行 {getattr(tool, 'name', '该操作')} "
f"需要补充参数:\n[{e.missing_description}]\n"
"请发送文本补充,或回复“取消”中止。"
)
try:
from zhenxun.services.ai.run.hitl import HITLController
hitl = HITLController(context)
user_input = await hitl.ask_text(prompt_msg, timeout=60.0)
except ToolFatalError:
raise ToolFatalError(
"参数收集超时,用户已离开,任务已中止。",
display_content=(
"❌ 工具 "
f"'{getattr(tool, 'name', '')}' "
"等待参数输入超时被取消。"
),
)
if not isinstance(current_kwargs, dict):
current_kwargs = {}
current_kwargs[e.missing_field] = user_input
class FallbackCapability(AbstractCapability):
"""
降级路由器局部中间件。
拦截执行结果,若发现报错且配置了备用工具,则透明重定向执行流。
"""
def __init__(self, fallback_tool_name: str):
"""
初始化降级路由器局部中间件。
参数:
fallback_tool_name: 当主工具执行遇到错误时,自动透明重定向到的备用工具名称。
"""
self.fallback_tool_name = fallback_tool_name
async def wrap_tool_execute(
self,
context: RunContext,
tool_name: str,
arguments: dict[str, Any],
handler: WrapToolExecuteHandler,
) -> Any:
result = await handler(arguments)
if result and getattr(result, "is_error", False):
available_tools = context.state.get("__available_tools", {})
fallback_executable = available_tools.get(self.fallback_tool_name)
if fallback_executable:
logger.info(
f"🔄 [语义容错路由] 主工具 '{tool_name}' 故障,"
"正在透明重定向至备用工具 "
f"'{self.fallback_tool_name}'..."
)
try:
fallback_result = await fallback_executable.execute(
context=context, **arguments
)
notice = (
f"[系统底座:由于主工具 '{tool_name}' 发生故障,"
"系统已自动路由至备用工具 "
f"'{self.fallback_tool_name}' 并执行成功]\n"
)
if isinstance(fallback_result.output, str):
fallback_result.output = notice + fallback_result.output
return fallback_result
except Exception as fallback_e:
logger.error(
"容错路由工具 "
f"'{self.fallback_tool_name}' "
f"也执行失败: {fallback_e}"
)
context.run.add_system_prompt(
"🚨 [系统警告] 原工具和备用工具"
f"({self.fallback_tool_name})均执行失败,"
"请停止尝试并告知用户。"
)
return result
@@ -0,0 +1,423 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from zhenxun.services.ai.capabilities import AbstractCapability
from zhenxun.services.ai.run import RunContext
from zhenxun.services.ai.tools.models import ToolOptions
def _update_settings(func: Callable, **kwargs) -> Callable:
"""叠加协议底层:创建或更新函数的 __tool_settings__"""
if not hasattr(func, "__tool_settings__"):
setattr(func, "__tool_settings__", ToolOptions())
settings: ToolOptions = getattr(func, "__tool_settings__")
for k, v in kwargs.items():
if k == "capabilities":
settings.capabilities.extend(v)
elif k == "metadata":
settings.metadata.update(v)
else:
setattr(settings, k, v)
return func
def require_sandbox(
python_packages: list[str] | None = None,
node_packages: list[str] | None = None,
system_packages: list[str] | None = None,
):
"""显式声明该工具所需安装的沙箱环境依赖"""
def decorator(func):
setattr(
func,
"__sandbox_requirements__",
{
"python": python_packages or [],
"node": node_packages or [],
"system": system_packages or [],
},
)
return func
return decorator
class ToolkitMethodDescriptor:
"""用于 Toolkit 类方法的描述符,确保在实例化时绑定正确的 self 并生成 FunctionTool"""
def __init__(
self, func: Callable, name: str, description: str, settings: ToolOptions
):
self.func = func
self.name = name
self.description = description
self.settings = settings
self.__toolkit_tool__ = True
self.__tool_original_name__ = name
def __get__(self, instance, owner):
if instance is None:
return self
import types
from zhenxun.services.ai.tools.core.tool import FunctionTool
from zhenxun.utils.pydantic_compat import model_copy
bound_func = types.MethodType(self.func, instance)
return FunctionTool(
func=bound_func,
name=self.name,
description=self.description,
settings=model_copy(self.settings, deep=True),
)
def __call__(self, *args, **kwargs):
"""使其兼容 Callable 协议,避免静态类型检查器报错"""
return self.func(*args, **kwargs)
def tool(
name: str | None = None,
description: str | None = None,
settings: ToolOptions | None = None,
tags: list[str] | None = None,
auto_register: bool = False,
require_prefix: bool = False,
):
"""
将普通函数或类方法注册为 LLM 工具的统一装饰器大一统。
参数:
name: 工具的名称(英文字母及下划线)。
大模型将看到此名称。如果为空则默认使用函数名。
description: 工具描述。
大模型将基于此决定何时、如何使用该工具。如果为空,将读取函数 docstring。
settings: 工具的高阶配置对象 (ToolOptions)。
用于控制极速缓存、人工审批拦截、静默执行等扩展能力。
tags: 工具的标签列表。
用于被 Agent 的智能字符串路由识别并进行能力注入。
auto_register: 是否自动注册到当前命名空间的工具注册表。
默认为 False,开发者需要显式传递实例。若为 True 方可通过字符串调用。
require_prefix: 是否自动添加插件命名空间前缀(仅对游离函数生效)。默认为 False。
返回:
Callable | FunctionTool: 包装后的函数或方法。
"""
def decorator(func: Callable):
base_settings = settings or ToolOptions()
if tags:
base_settings.tags = list(set(base_settings.tags + tags))
existing_settings = getattr(func, "__tool_settings__", None)
if existing_settings and isinstance(existing_settings, ToolOptions):
base_settings = existing_settings.merge(base_settings)
reqs = getattr(func, "__sandbox_requirements__", None)
if reqs:
base_settings.sandbox_requirements = reqs
tool_name = name or func.__name__
tool_desc = description or func.__doc__ or "未提供描述"
import inspect
is_method = False
if (
hasattr(func, "__qualname__")
and "." in func.__qualname__
and "<locals>" not in func.__qualname__
):
is_method = True
else:
try:
sig = inspect.signature(func)
params = list(sig.parameters.keys())
if params and params[0] in ("self", "cls"):
is_method = True
except Exception:
pass
if is_method:
return ToolkitMethodDescriptor(
func=func, name=tool_name, description=tool_desc, settings=base_settings
)
else:
if require_prefix:
from zhenxun.utils.utils import infer_plugin_namespace
ns = infer_plugin_namespace(default="global")
if ns and ns not in ("global", "unknown"):
if not tool_name.startswith(f"{ns}_"):
tool_name = f"{ns}_{tool_name}"
from zhenxun.services.ai.tools.core.tool import FunctionTool
from zhenxun.services.ai.tools.engine.registry import tool_provider_manager
from zhenxun.services.log import logger
func_tool = FunctionTool(
func=func,
name=tool_name,
description=tool_desc,
settings=base_settings,
)
if auto_register:
tool_provider_manager.register_tool(func_tool)
logger.debug(f"已按命名空间隔离注册了工具(Callable): '{tool_name}'")
setattr(func_tool, "__tool_settings__", base_settings)
return func_tool
return decorator
def with_cache(ttl: int = 3600, cache_function: Callable | None = None):
"""开启极速缓存,阻止参数相同的重复请求发往底层。"""
from zhenxun.services.ai.tools.core.capabilities import CacheCapability
def decorator(func: Callable):
return _update_settings(
func,
capabilities=[CacheCapability(ttl=ttl, cache_function=cache_function)],
)
return decorator
def silent():
"""静默执行。工具执行过程与结果不会作为界面流渲染给用户,仅作大模型内部参考。"""
def decorator(func: Callable):
return _update_settings(func, silent=True)
return decorator
def direct_reply():
"""直出模式。工具执行完毕后强制中断大模型的思考循环,将工具输出作为最终回答返回。"""
class DirectReplyCapability(AbstractCapability):
async def wrap_tool_execute(self, context, tool_name, arguments, handler):
result = await handler(arguments)
from zhenxun.services.ai.tools.models import EndRunResult, ToolResult
if isinstance(result, ToolResult):
if getattr(result, "is_error", False):
return result
return EndRunResult(output=result.output)
return EndRunResult(output=result)
def decorator(func: Callable):
return _update_settings(func, capabilities=[DirectReplyCapability()])
return decorator
def interactive():
"""开启交互式参数补全。如果参数缺失或校验失败,
会主动通过 Bot 向用户提问要求补全。"""
from zhenxun.services.ai.tools.core.capabilities import InteractiveCapability
def decorator(func: Callable):
return _update_settings(func, capabilities=[InteractiveCapability()])
return decorator
def require_approval():
"""高危操作标记。调用前将拦截并发送至群组要求超管人工审核。"""
from zhenxun.services.ai.tools.core.capabilities import ApprovalCapability
def decorator(func: Callable):
return _update_settings(func, capabilities=[ApprovalCapability()])
return decorator
def fallback(tool_name: str):
"""降级备用工具。主工具执行失败时自动路由至备用工具。"""
from zhenxun.services.ai.tools.core.capabilities import FallbackCapability
def decorator(func: Callable):
return _update_settings(
func,
capabilities=[FallbackCapability(fallback_tool_name=tool_name)],
)
return decorator
def before_execute(hook_func: Callable):
"""生命周期拦截:在工具即将执行前触发,可在此篡改全局状态或通过依赖注入访问当前参数。"""
from zhenxun.services.ai.tools.core.capabilities import LifecycleCapability
def decorator(func: Callable):
return _update_settings(
func, capabilities=[LifecycleCapability(before_execute=hook_func)]
)
return decorator
def after_execute(hook_func: Callable):
"""生命周期拦截:在工具执行完毕后触发,可在此修改将发往大模型的返回结果。"""
from zhenxun.services.ai.tools.core.capabilities import LifecycleCapability
def decorator(func: Callable):
return _update_settings(
func, capabilities=[LifecycleCapability(after_execute=hook_func)]
)
return decorator
def validate_args(hook_func: Callable):
"""生命周期拦截:在工具反序列化校验前触发。如果校验失败直接抛出异常,将触发大模型重试机制。"""
from zhenxun.services.ai.tools.core.capabilities import LifecycleCapability
def decorator(func: Callable):
return _update_settings(
func, capabilities=[LifecycleCapability(validate_args=hook_func)]
)
return decorator
def prepare_tool(hook_func: Callable):
"""生命周期拦截:在向大模型渲染 JSON Schema 前触发,
可在此动态修改该工具的定义或返回 None 对大模型隐藏。"""
from zhenxun.services.ai.tools.core.capabilities import LifecycleCapability
def decorator(func: Callable):
return _update_settings(
func, capabilities=[LifecycleCapability(prepare_tool=hook_func)]
)
return decorator
def require_superuser():
"""仅限超级管理员使用。若调用者无权限,该工具将直接对大模型隐藏。"""
from zhenxun.services.ai.tools.core.capabilities import SuperuserCapability
def decorator(func: Callable):
return _update_settings(func, capabilities=[SuperuserCapability()])
return decorator
def require_admin_level(min_level: int = 1):
"""仅限满足指定群聊权限等级的用户使用。"""
from zhenxun.services.ai.tools.core.capabilities import AdminLevelCapability
def decorator(func: Callable):
return _update_settings(
func,
capabilities=[AdminLevelCapability(min_level)],
metadata={"admin_level": min_level},
)
return decorator
def require_group():
"""限制该工具仅能在群聊环境中被大模型调用。"""
from zhenxun.services.ai.tools.core.capabilities import GroupOnlyCapability
def decorator(func: Callable):
return _update_settings(func, capabilities=[GroupOnlyCapability()])
return decorator
def require_minimum_gold(amount: int):
"""需满足一定的金币余额才可调用,同时执行后会自动扣除该金币。"""
def decorator(func: Callable):
return _update_settings(func, metadata={"cost_gold": amount})
return decorator
def require_session_state(key: str, expected_value: Any = None):
"""需要当前执行上下文中包含指定状态变量,否则隐藏工具"""
class StateDependencyCapability(AbstractCapability):
async def prepare_tools(
self, context: RunContext, tool_defs: list[Any]
) -> list[Any]:
val = context.state.get(key)
if expected_value is not None:
if val == expected_value:
return tool_defs
elif val is not None and val is not False:
return tool_defs
return []
def decorator(func: Callable):
return _update_settings(func, capabilities=[StateDependencyCapability()])
return decorator
class Rules:
"""
[命名空间] 大模型工具规则与权限装饰器聚合。
用于控制工具的极速缓存、沙箱要求、前端静默以及人机交互审批等。
"""
sandbox = staticmethod(require_sandbox)
"""环境:声明执行该工具所需的物理沙箱及 pip/npm 包依赖"""
approval = staticmethod(require_approval)
"""高危:拦截执行,并在群内发起人工审批 (HITL) 确认"""
superuser = staticmethod(require_superuser)
"""权限:仅允许被超级管理员触发时,该工具才对大模型可见"""
admin_level = staticmethod(require_admin_level)
"""权限:要求触发用户的群等级达到指定级别"""
group_only = staticmethod(require_group)
"""环境:限制该工具只有在群聊场景下才可被调用"""
minimum_gold = staticmethod(require_minimum_gold)
"""经济:要求用户满足金币余额,且执行后将自动扣除该金币"""
session_state = staticmethod(require_session_state)
"""状态:要求当前会话上下文中包含指定的状态变量时才可用"""
before_execute = staticmethod(before_execute)
"""拦截器:在工具正式执行前触发,可动态修改传入参数 (利用 DI 注入)"""
after_execute = staticmethod(after_execute)
"""拦截器:在工具执行完毕后触发,可动态修改返回结果 (利用 DI 注入)"""
validate_args = staticmethod(validate_args)
"""拦截器:在工具参数校验前触发,校验失败抛出异常直接由大模型重试"""
prepare_tool = staticmethod(prepare_tool)
"""拦截器:向大模型渲染 JSON Schema 前触发,可篡改工具定义对象"""
cache = staticmethod(with_cache)
"""性能:开启极速缓存,参数相同时直接返回上次结果,免去重复计算"""
silent = staticmethod(silent)
"""UI:静默执行,执行过程与结果不以气泡形式发送给用户,仅供大模型参考"""
interactive = staticmethod(interactive)
"""交互:开启交互式参数补全,必填参数缺失时自动向用户提问补全"""
fallback = staticmethod(fallback)
"""容错:执行失败时透明降级重定向至备用工具"""
direct_reply = staticmethod(direct_reply)
"""流控:直出模式,执行后直接将结果发给用户并强制中断大模型后续思考"""
__all__ = [
"Rules",
"tool",
]
+226
View File
@@ -0,0 +1,226 @@
from abc import ABC, abstractmethod
from collections.abc import Callable
import inspect
import json
import re
from typing import Annotated, Any, get_args, get_origin, get_type_hints
from pydantic import BaseModel, Field, create_model
from pydantic.fields import FieldInfo
from zhenxun.services.ai.run.context import RunContext
from zhenxun.services.ai.run.di import DependencyInjector
from zhenxun.services.ai.utils import PermissionUtils
from zhenxun.utils.pydantic_compat import model_json_schema
class FieldPermission(ABC):
"""字段级权限校验基类/协议"""
@abstractmethod
async def check(self, context: RunContext) -> bool:
"""返回当前上下文(用户)是否有权使用该字段"""
pass
class RequireSuperUser(FieldPermission):
"""仅限超级管理员可见的字段参数"""
async def check(self, context: RunContext) -> bool:
return await PermissionUtils.check_superuser(context)
class RequireAdminLevel(FieldPermission):
"""仅限满足群等级要求的管理员可见的字段参数"""
def __init__(self, min_level: int = 1):
self.min_level = min_level
async def check(self, context: RunContext) -> bool:
return await PermissionUtils.check_admin_level(context, self.min_level)
def _parse_docstring(docstring: str | None) -> tuple[str, dict[str, str]]:
"""解析文档字符串,解耦提取主描述和参数描述"""
if not docstring:
return "", {}
lines = docstring.strip().splitlines()
main_desc_lines = []
params: dict[str, str] = {}
section_header = re.compile(r"^\s*(?:Args|Arguments|Parameters|参数)\s*[::]\s*$")
return_header = re.compile(r"^\s*(?:Returns|Return|返回)\s*[::]\s*$")
param_pattern = re.compile(r"^\s*(\**\w+)(?:\s*\(.*?\))?\s*[::]\s*(.*)")
current_section = "main"
last_param = None
for line in lines:
stripped = line.strip()
if not stripped:
if current_section == "main":
main_desc_lines.append("")
continue
if section_header.match(line):
current_section = "params"
continue
elif return_header.match(line):
current_section = "returns"
continue
if current_section == "main":
main_desc_lines.append(line.strip())
elif current_section == "params":
match = param_pattern.match(line)
if match:
param_name = match.group(1).lstrip("*")
param_desc = match.group(2).strip()
params[param_name] = param_desc
last_param = param_name
elif last_param and line.startswith(" ") and stripped:
params[last_param] += f" {stripped}"
main_desc = "\n".join(main_desc_lines).strip()
return main_desc, params
def build_tool_model(
func: Callable, strict: bool = False
) -> tuple[dict, type[BaseModel]]:
"""利用 pydantic.create_model 从函数签名动态构建绝对严谨的 JSON Schema 及验证模型"""
sig = inspect.signature(func)
type_hints = get_type_hints(func, include_extras=True)
_, doc_params = _parse_docstring(func.__doc__)
field_definitions = {}
for name, param in sig.parameters.items():
if name in ("self", "cls", "args", "kwargs"):
continue
if DependencyInjector.can_resolve_statically(param):
continue
anno = type_hints.get(name, Any)
desc = doc_params.get(name, "")
default_val = param.default
user_desc = None
if isinstance(default_val, FieldInfo) and default_val.description:
user_desc = default_val.description
elif get_origin(anno) is Annotated:
for arg in get_args(anno)[1:]:
if isinstance(arg, FieldInfo) and arg.description:
user_desc = arg.description
break
final_desc = user_desc or desc
if isinstance(default_val, FieldInfo):
field_info = default_val
if not field_info.description and final_desc:
field_info.description = final_desc
field_definitions[name] = (anno, field_info)
else:
if default_val is inspect.Parameter.empty:
field_info = Field(..., description=final_desc)
else:
field_info = Field(default=default_val, description=final_desc)
field_definitions[name] = (anno, field_info)
try:
DynamicModel = create_model(
f"DynamicToolModel_{func.__name__}", **field_definitions
)
schema_def = model_json_schema(DynamicModel, mode="serialization")
except Exception as e:
error_msg = str(e)
raise ValueError(
f"无法为工具 '{func.__name__}' 生成合法的 JSON Schema。\n"
"原因:函数的参数中包含了大模型无法解析的复杂类型"
"(如 Bot, Event, 自定义类等)。\n"
"💡 修复建议:如果该参数是底层框架依赖(如需要用到 nonebot 的 Bot),"
"请务必使用 `Inject.XXX` 进行注解声明(例如 `bot: Inject.Bot`),"
"这样系统会自动将其隐藏,不会发给大模型。\n"
f"底层报错:{error_msg}"
) from e
schema_def.pop("title", None)
if strict:
schema_def["additionalProperties"] = False
return schema_def, DynamicModel
def build_schema_hint(args_schema: type[BaseModel] | None) -> str:
"""
构建友好的人类可读 Schema 提示,供大模型在参数错误时进行自愈反思。
"""
if args_schema is None:
return ""
try:
schema = model_json_schema(args_schema)
props = schema.get("properties", {})
req = schema.get("required", [])
hint = (
f"\n\n💡 [系统提示] 该工具期望的正确 JSON 参数格式为:\n"
f"```json\n{json.dumps(props, ensure_ascii=False, indent=2)}\n```\n"
f"必填字段: {req}"
)
return hint
except Exception:
return ""
async def prune_schema_by_permissions(
model_class: type[BaseModel], context: RunContext, schema: dict[str, Any]
) -> dict[str, Any]:
"""根据当前运行时上下文 (RunContext) 动态裁剪无权限的 Schema 字段"""
properties = schema.get("properties", {})
required = schema.get("required", [])
fields_to_remove = []
fields = getattr(
model_class, "model_fields", getattr(model_class, "__fields__", {})
)
for field_name, field_info in fields.items():
for meta in field_info.metadata:
if isinstance(meta, FieldPermission):
if not await meta.check(context):
fields_to_remove.append(field_name)
break
for field_name in fields_to_remove:
properties.pop(field_name, None)
if field_name in required:
required.remove(field_name)
if "required" in schema and not required:
schema.pop("required", None)
return schema
async def check_field_permissions(
model_class: type[BaseModel], kwargs: dict[str, Any], context: RunContext
) -> None:
"""验证传入的参数是否绕过了权限限制"""
fields = getattr(
model_class, "model_fields", getattr(model_class, "__fields__", {})
)
for field_name, field_info in fields.items():
if field_name in kwargs:
for meta in field_info.metadata:
if isinstance(meta, FieldPermission):
if not await meta.check(context):
from zhenxun.services.ai.core.exceptions import ToolRetryError
raise ToolRetryError(
f"权限拒绝:您当前的用户身份无权使用参数 '{field_name}'。"
"请移除该参数后重新规划并调用此工具。"
)
break
+441
View File
@@ -0,0 +1,441 @@
from collections.abc import Callable
import hashlib
import inspect
import json
from typing import Any, Literal
from pydantic import BaseModel, ValidationError
from zhenxun.services.ai.core.exceptions import (
NeedsInputException,
ToolFatalError,
ToolRetryError,
)
from zhenxun.services.ai.core.models import ToolDefinition
from zhenxun.services.ai.run import RunContext
from zhenxun.services.ai.tools.models import (
ResolvedToolPayload,
ToolOptions,
ToolResult,
)
from zhenxun.services.log import logger
from zhenxun.utils.pydantic_compat import model_dump, model_json_schema, model_validate
from .schema import (
_parse_docstring,
build_schema_hint,
build_tool_model,
check_field_permissions,
prune_schema_by_permissions,
)
_tool_runner_class: type | None = None
def register_tool_runner(runner_class: type) -> None:
global _tool_runner_class
_tool_runner_class = runner_class
class BaseTool:
"""
面向对象的工具基类。支持多模态契约与纯 Pydantic V2 参数验证。
参数:
name: 工具名称。大模型将基于此识别并调用。如果不提供,默认使用类名。
description: 工具的说明文档。大模型会根据此描述理解工具用途。
settings: 配置选项(包含缓存、拦截、中间件等)。
"""
name: str
description: str
settings: ToolOptions
execution_side: Literal["client", "server"] = "client"
current_usage_count: int = 0
_dynamic_def: Any = None
args_schema: type[BaseModel] | None = None
_base_schema: dict[str, Any] | None = None
_param_model: Any = None
parent_toolkit: Any = None
@property
def metadata(self) -> dict[str, Any]:
return self.settings.metadata
@metadata.setter
def metadata(self, value: dict[str, Any]):
self.settings.metadata = value
def get_execute_target(self) -> Callable:
"""获取依赖注入和实际执行的目标函数。子类可重写(如 FunctionTool)。"""
if not hasattr(self, "run"):
raise NotImplementedError(
f"工具 {self.__class__.__name__} 未实现 run 方法。"
)
return self.run
def get_signature_target(self) -> Callable:
"""获取依赖注入和 Schema 生成的原始函数目标。子类可重写。"""
return self.get_execute_target()
async def run(self, **kwargs: Any) -> Any:
"""供第三方开发者重写的核心执行逻辑。完美支持 Inject 依赖注入。"""
raise NotImplementedError("子类必须实现 run 方法")
def __init__(
self,
name: str | None = None,
description: str | None = None,
settings: ToolOptions | None = None,
):
"""
初始化工具基类。
参数:
name: 工具名,供大模型识别调用,默认取类名。
description: 工具功能说明,供大模型理解,默认取 __doc__ 说明。
settings: 工具的个性化配置选项(例如缓存时间、依赖中间件等),默认 None。
"""
self.name = name or getattr(self, "name", self.__class__.__name__)
self.description = description or getattr(
self, "description", self.__doc__ or "未提供描述"
)
self.settings = settings or getattr(self, "settings", ToolOptions())
self.args_schema = self.settings.args_schema or getattr(
self.__class__, "args_schema", None
)
self.current_usage_count = 0
class_meta = getattr(self.__class__, "metadata", {})
if class_meta and not isinstance(class_meta, property):
self.settings.metadata.update(class_meta)
def clone_with_options(self, override: Any) -> "BaseTool":
"""创建当前工具的浅拷贝,并覆盖 ToolOptions 和基本属性"""
import copy
new_tool = copy.copy(self)
if hasattr(override, "to_tool_options"):
override_settings = override.to_tool_options()
else:
override_settings = getattr(
override, "options", getattr(override, "settings", None)
)
if override_settings:
new_tool.settings = self.settings.merge(override_settings)
new_name = getattr(override, "new_name", None)
if new_name:
new_tool.name = new_name
new_desc = getattr(override, "description", None)
if new_desc:
new_tool.description = new_desc
return new_tool
async def _handle_validation_error(self, e: ValidationError, kwargs: dict) -> None:
"""
将 Pydantic 参数校验失败转化为 ToolRetryError 或 NeedsInputException
"""
error_msgs = []
for err in e.errors():
loc = ".".join(str(x) for x in err["loc"]) or "root"
msg = err.get("msg", "")
ctx = err.get("ctx", {})
ctx_str = f" (期望要求: {ctx})" if ctx else ""
error_msgs.append(f"参数字段 `{loc}`: {msg}{ctx_str}")
err_msg = "\n".join(error_msgs)
validation_model = getattr(self, "args_schema", None) or getattr(
self, "_param_model", None
)
schema_hint = build_schema_hint(validation_model)
from zhenxun.services.ai.tools.core.capabilities import InteractiveCapability
if any(
isinstance(c, InteractiveCapability) for c in self.settings.capabilities
):
first_error = e.errors()[0]
field_name = (
str(first_error["loc"][-1]) if first_error.get("loc") else "unknown"
)
field_desc = f"参数验证失败: {first_error['msg']}"
raise NeedsInputException(field_name, field_desc, kwargs)
raise ToolRetryError(
f"参数验证未通过,请严格根据以下错误信息修正参数后重试:\n{err_msg}\n"
f"(附:你刚才传入的非法参数为 {kwargs}){schema_hint}"
)
async def get_definition(
self, context: RunContext | None = None
) -> ToolDefinition | None:
"""获取工具的 JSON Schema 定义。支持动态修改 Schema 甚至隐藏工具。"""
if hasattr(self, "_dynamic_def") and self._dynamic_def is not None:
return self._dynamic_def
args_schema = getattr(self, "args_schema", None)
if args_schema is not None:
if context is not None:
base_schema = model_json_schema(args_schema)
schema = await prune_schema_by_permissions(
args_schema, context, base_schema
)
else:
schema = model_json_schema(args_schema)
elif getattr(self, "_base_schema", None) is not None:
schema = (
self._base_schema.copy()
if isinstance(self._base_schema, dict)
else {"type": "object", "properties": {}}
)
else:
schema = {"type": "object", "properties": {}}
if getattr(self.settings, "require_intent", False):
if "properties" not in schema:
schema["properties"] = {}
if "_intent" not in schema["properties"]:
new_props = {
"_intent": {
"type": "string",
"description": (
"调用此工具前,必须在此字段简述你的意图、目的或思考过程"
),
}
}
new_props.update(schema["properties"])
schema["properties"] = new_props
if "required" not in schema:
schema["required"] = []
if "_intent" not in schema["required"]:
schema["required"].insert(0, "_intent")
tool_def = ToolDefinition(
name=self.name,
description=self.description,
parameters=schema,
metadata=self.settings.metadata.copy(),
)
if context and self.settings.capabilities:
from zhenxun.services.ai.capabilities import (
CombinedCapability,
)
combined_cap = CombinedCapability(self.settings.capabilities)
defs = await combined_cap.prepare_tools(context, [tool_def])
if not defs:
return None
tool_def = defs[0]
return tool_def
async def resolve(self, context: RunContext | None = None) -> ResolvedToolPayload:
"""实现 ToolResolvable 协议,将自身解析为标准 Payload"""
import copy
definition = await self.get_definition(context)
if definition is None:
return ResolvedToolPayload()
run_scoped_tool = copy.copy(self)
run_scoped_tool._dynamic_def = definition
if not hasattr(run_scoped_tool, "name"):
setattr(run_scoped_tool, "name", definition.name)
return ResolvedToolPayload(tools=[run_scoped_tool])
def _generate_cache_key(self, arguments: dict[str, Any]) -> str:
"""
根据工具名称和参数生成绝对唯一的缓存 Key。
通过对字典 key 进行排序并转化为 json 字符串,
保证参数顺序不同但内容相同时 Hash 结果一致。
"""
args_str = json.dumps(arguments, sort_keys=True, ensure_ascii=False)
key_str = f"{self.name}:{args_str}"
return hashlib.md5(key_str.encode("utf-8")).hexdigest()
async def validate_args(
self, kwargs: dict[str, Any], context: RunContext | None = None
) -> dict[str, Any]:
"""验证参数,返回序列化后的合法字典。如果校验失败则抛出异常。"""
validation_model = getattr(self, "args_schema", None) or getattr(
self, "_param_model", None
)
if validation_model:
if context:
await check_field_permissions(validation_model, kwargs, context)
try:
validated_model = model_validate(validation_model, kwargs)
return model_dump(validated_model, exclude_unset=True)
except ValidationError as e:
await self._handle_validation_error(e, kwargs)
return kwargs
async def execute(
self, context: RunContext | None = None, **kwargs: Any
) -> ToolResult:
"""工具执行的总入口,负责组装和调度中间件管道"""
context_to_pass = context or RunContext()
try:
return await self._core_execution(context_to_pass, **kwargs)
except Exception as e:
from zhenxun.services.ai.core.exceptions import ControlFlowExit
if not isinstance(e, ControlFlowExit):
logger.error(f"工具 {self.name} 执行抛出异常,将交由底层引擎处理: {e}")
raise
async def _core_execution(self, context: RunContext, **kwargs: Any) -> ToolResult:
"""核心执行流水线 (Core Execution Pipeline)"""
_retries = context.run.tool_retries.get(self.name, 0)
if (
self.settings.max_usage_count is not None
and self.current_usage_count >= self.settings.max_usage_count
):
raise ToolFatalError(
f"工具 '{self.name}' 已达到最大调用次数上限 ({self.settings.max_usage_count})" # noqa: E501
)
self.current_usage_count += 1
if _tool_runner_class is None:
raise ToolFatalError(
f"工具 '{self.name}' 运行时错误:未注册 ToolRunner 执行器。"
)
runner = _tool_runner_class()
final_result = await runner.run(tool=self, context=context, **kwargs)
if not final_result.is_error:
context.run.tool_retries[self.name] = 0
return final_result
class ServerSideTool(BaseTool):
"""
云端原生工具的抽象基类。
代表那些由各大模型厂商在云端原生实现的能力(如 Google Search, Code Execution)。
此类工具只产生 Schema 提交给大模型用于触发路由,禁止在本地执行。
"""
execution_side: Literal["client", "server"] = "server"
type_id: str = ""
async def execute(
self, context: RunContext | None = None, **kwargs: Any
) -> ToolResult:
raise ToolFatalError(
f"[{self.name}] 是服务端工具,禁止在本地层执行引擎中运行。"
)
async def get_definition(
self, context: RunContext | None = None
) -> ToolDefinition | None:
if hasattr(self, "_dynamic_def") and self._dynamic_def is not None:
return self._dynamic_def
return ToolDefinition(
name=self.name,
description=self.description,
parameters={"type": "object", "properties": {}},
metadata=self.settings.metadata.copy(),
)
class FunctionTool(BaseTool):
"""
将普通 Python 函数包装为 BaseTool 的实现。
参数:
func: 实际执行的 Python 异步或同步函数。
name: 覆盖函数名的工具名称。
description: 覆盖函数 docstring 的工具描述。
settings: 配置选项。
"""
def __init__(
self,
func: Callable,
name: str | None = None,
description: str | None = None,
settings: ToolOptions | None = None,
):
"""
初始化函数式包装工具。
参数:
func: 被包装的底层 Python 同步或异步函数。
name: 覆盖函数名的自定义工具名,默认 None。
description: 覆盖 docstring 的自定义工具描述,默认 None。
settings: 覆盖默认选项的自定义工具配置项,默认 None。
"""
super().__init__(name=name, description=description, settings=settings)
self._original_func = func
from zhenxun.services.ai.utils.utils import wrap_to_async
self.__name__ = self.name
self._func = wrap_to_async(func)
self._schema_built = False
main_desc, _ = _parse_docstring(self._original_func.__doc__)
if not description or description == "未提供描述":
self.description = main_desc or "未提供描述"
def get_execute_target(self) -> Callable:
"""FunctionTool 的执行目标和依赖注入目标是其包装的原始函数"""
return self._func
def get_signature_target(self) -> Callable:
return self._original_func
async def run(self, **kwargs: Any) -> Any:
"""保持接口完整性,但实际执行由 _core_execution 导向 _func"""
is_async_gen = getattr(
self._func, "_is_async_gen", False
) or inspect.isasyncgenfunction(self._func)
if is_async_gen:
res = []
async for chunk in self._func(**kwargs):
res.append(chunk)
return res
return await self._func(**kwargs)
def _ensure_schema(self):
"""JIT 懒加载:推迟到第一次请求定义或验证参数时再构建 Schema (耗时操作)"""
if not self._schema_built:
if self.args_schema:
self._base_schema = model_json_schema(self.args_schema)
self._param_model = self.args_schema
else:
schema, model = build_tool_model(
self.get_signature_target(), strict=self.settings.strict
)
self._base_schema = schema
self._param_model = model
self._schema_built = True
async def get_definition(
self, context: RunContext | None = None
) -> ToolDefinition | None:
self._ensure_schema()
return await super().get_definition(context)
async def validate_args(
self, kwargs: dict[str, Any], context: RunContext | None = None
) -> dict[str, Any]:
self._ensure_schema()
return await super().validate_args(kwargs, context=context)
def __call__(self, *args: Any, **kwargs: Any) -> Any:
"""允许像普通函数一样被直接调用。对其他插件完全透明。"""
return self._original_func(*args, **kwargs)
+272
View File
@@ -0,0 +1,272 @@
from collections.abc import Callable
import copy
from typing import Any, ClassVar
from zhenxun.services.ai.core.protocols.tool import ToolExecutable
from zhenxun.services.ai.run import RunContext
from zhenxun.services.ai.run.di import DependencyInjector
from zhenxun.services.ai.tools.models import (
ResolvedToolPayload,
ToolkitConfig,
ToolOptions,
)
from zhenxun.utils.pydantic_compat import (
model_copy,
model_dump,
)
from .tool import BaseTool, FunctionTool
class BaseToolkit:
"""
状态工具箱基类。继承此类的对象可以维持自身状态,
其内部被 @tool 标记的方法将被自动解析为工具。
"""
default_instructions: str = ""
config: ToolkitConfig
_default_config: ClassVar[ToolkitConfig] = ToolkitConfig()
def __init__(
self,
prefix: str | None = None,
include: list[str] | None = None,
exclude: list[str] | None = None,
shared_options: ToolOptions | None = None,
config: ToolkitConfig | None = None,
tools: list[Callable | BaseTool | ToolExecutable] | None = None,
instructions: str | None = None,
**kwargs: Any,
):
"""
初始化工具箱实例,装配并隔离工具组件。
参数:
prefix: 工具名称的前缀。如果不指定,则不添加前缀。
include: 允许注册的工具名白名单,仅在此列表中的工具才会被加载。
exclude: 排除注册的工具名黑名单,在此列表中的工具将被忽略。
shared_options: 所有子工具默认继承的高阶配置项 (ToolOptions)。
config: (可选) 显式传递的全局配置对象。若传入此项,将覆盖上述所有独立配置。
tools: 除了带有 @tool 装饰器的方法外,要额外动态注入的独立工具列表。
instructions: 当前工具箱实例的系统提示词补充说明。
"""
if config is not None:
merged_dict = {
**model_dump(self._default_config, exclude_unset=True),
**model_dump(config, exclude_unset=True),
}
self.config = ToolkitConfig(**merged_dict)
else:
self.config = ToolkitConfig(
prefix=prefix,
include=include,
exclude=exclude,
shared_options=shared_options,
)
if self.config.prefix is None:
self.config.prefix = ""
self._instance_filter: Callable[[BaseTool], bool] | None = None
self._injected_tools: list[BaseTool] = []
self._cached_tools: dict[str, BaseTool] | None = None
self._instance_instructions = (
instructions
if instructions is not None
else getattr(self, "default_instructions", "")
)
if tools:
for t in tools:
if isinstance(t, BaseTool):
self._injected_tools.append(t)
elif callable(t):
tool_name = getattr(
t, "__tool_name__", getattr(t, "__name__", "unnamed_tool")
)
tool_desc = getattr(t, "__tool_desc__", "未提供描述")
settings = model_copy(
getattr(t, "__tool_settings__", ToolOptions())
)
func_tool = FunctionTool(
func=t,
name=f"{self.config.prefix}{tool_name}",
description=tool_desc,
settings=settings,
)
self._injected_tools.append(func_tool)
def _apply_global_settings(
self, original_name: str, settings: ToolOptions
) -> ToolOptions:
if self.config.shared_options:
settings = self.config.shared_options.merge(settings)
return settings
async def before_llm_request(
self, context: RunContext, messages: list[Any]
) -> None:
"""
LLM 请求发起前的拦截钩子。允许动态修改消息列表(如在尾部注入最新状态)。
参数:
context: 当前 Agent 的运行时上下文。
messages: 即将发往底层 LLM 的完整消息历史列表,支持就地修改。
"""
pass
async def resolve(self, context: RunContext | None = None) -> ResolvedToolPayload:
"""实现 ToolResolvable 协议,递归解析内部工具并返回标准 Payload"""
tools_dict = await self.get_tools(context)
resolved_tools = []
for t in tools_dict.values():
if hasattr(t, "resolve"):
payload = await t.resolve(context)
if payload and payload.tools:
for rt in payload.tools:
rt.parent_toolkit = self
resolved_tools.extend(payload.tools)
else:
t.parent_toolkit = self
resolved_tools.append(t)
injected_prompts = []
if instructions := self.get_instructions():
injected_prompts.append(instructions)
return ResolvedToolPayload(
tools=resolved_tools, injected_prompts=injected_prompts, toolkits=[self]
)
async def get_tools(self, context: RunContext | None = None) -> dict[str, BaseTool]:
if self._cached_tools is not None:
return self._cached_tools
tools_dict: dict[str, BaseTool] = {}
for t in self._injected_tools:
t.parent_toolkit = self
tools_dict[t.name] = t
import inspect
for name, member in inspect.getmembers(self.__class__):
if hasattr(member, "__toolkit_tool__"):
bound_tool = getattr(self, name)
original_name = getattr(member, "__tool_original_name__", name)
if (
self.config.include is not None
and original_name not in self.config.include
):
continue
if (
self.config.exclude is not None
and original_name in self.config.exclude
):
continue
final_tool_name = f"{self.config.prefix}{original_name}"
bound_tool.name = final_tool_name
bound_tool.settings = self._apply_global_settings(
original_name, bound_tool.settings
)
bound_tool.parent_toolkit = self
tools_dict[final_tool_name] = bound_tool
if self._instance_filter:
tools_dict = {
name: t for name, t in tools_dict.items() if self._instance_filter(t)
}
self._cached_tools = tools_dict
return tools_dict
def get_instructions(self) -> str | None:
"""
获取工具箱的系统提示词说明。
"""
text = self._instance_instructions
if not text:
return None
class_name = self.__class__.__name__
tag_name = (
f"{self.config.prefix}{class_name}_Instructions"
if self.config.prefix
else f"{class_name}_Instructions"
)
return f"<{tag_name}>\n{text}\n</{tag_name}>"
def prefixed(self, prefix: str) -> "BaseToolkit":
new_tk = copy.copy(self)
new_tk.config = model_copy(self.config, deep=True)
current_prefix = new_tk.config.prefix or ""
new_tk.config.prefix = f"{current_prefix}{prefix}"
new_tk._cached_tools = None
return new_tk
def filtered(self, filter_func: Callable[[BaseTool], bool]) -> "BaseToolkit":
new_tk = copy.copy(self)
new_tk.config = model_copy(self.config, deep=True)
old_filter = self._instance_filter
if old_filter:
new_tk._instance_filter = lambda t: old_filter(t) and filter_func(t)
else:
new_tk._instance_filter = filter_func
new_tk._cached_tools = None
return new_tk
class CompositeToolkit(BaseToolkit):
"""
聚合工具箱 (Composite Toolkit)。
将一组独立的 Toolkit 组合在一起,统一其生命周期并合并其工具。
可以为其指定统一的 prefix、全局标签或拦截器,底层会自动向内部的所有 Toolkit 透传。
"""
def __init__(
self,
toolkits: list[BaseToolkit],
config: ToolkitConfig | None = None,
**kwargs: Any,
):
super().__init__(config=config, **kwargs)
self.toolkits = toolkits
async def before_llm_request(
self, context: RunContext, messages: list[Any]
) -> None:
for tk in self.toolkits:
await DependencyInjector.invoke(
tk.before_llm_request, {"messages": messages}, context
)
async def resolve(self, context: RunContext | None = None) -> ResolvedToolPayload:
payload = ResolvedToolPayload()
prefix = self.config.prefix or ""
for tk in self.toolkits:
active_tk = tk
if prefix:
active_tk = active_tk.prefixed(prefix)
if self._instance_filter:
active_tk = active_tk.filtered(self._instance_filter)
child_payload = await active_tk.resolve(context)
for tool in child_payload.tools:
if self.config.shared_options:
tool.settings = self.config.shared_options.merge(tool.settings)
payload.tools.extend(child_payload.tools)
payload.injected_prompts.extend(child_payload.injected_prompts)
payload.toolkits.extend(child_payload.toolkits)
payload.toolkits.append(self)
if instructions := self.get_instructions():
payload.injected_prompts.append(instructions)
return payload
@@ -0,0 +1,7 @@
from .executor import ToolExecutor
from .registry import tool_provider_manager
__all__ = [
"ToolExecutor",
"tool_provider_manager",
]
@@ -0,0 +1,547 @@
from __future__ import annotations
from abc import ABC, abstractmethod
import ast
import asyncio
from contextlib import asynccontextmanager
import inspect
import json
from typing import TYPE_CHECKING, Any, cast
import json_repair
from nonebot.adapters import Message as PlatformMessage
from zhenxun.services.ai.capabilities import CombinedCapability
from zhenxun.services.ai.core.exceptions import (
ToolFatalError,
ToolRetryError,
)
from zhenxun.services.ai.core.messages import AnyLLMMessage, LLMMessage, ToolCallPart
from zhenxun.services.ai.core.stream_events import (
EventBus,
ToolCallEndEvent,
ToolCallStartEvent,
ToolStreamChunkEvent,
UserCustomEvent,
)
from zhenxun.services.ai.run.context import RunContext
from zhenxun.services.ai.run.di import DependencyInjector
from zhenxun.services.ai.tools.models import (
ToolOptions,
ToolResult,
ValidatedToolCall,
)
from zhenxun.services.log import logger
if TYPE_CHECKING:
from zhenxun.services.ai.tools.core.tool import BaseTool
from zhenxun.services.ai.tools.engine.registry import ToolCollection
class ToolExecutor:
"""
全能工具执行器。
负责接收工具调用请求,解析参数,触发回调,执行工具,并返回标准化的结果。
"""
def __init__(self):
pass
def _get_combined_capability(
self, executable: Any, context: RunContext
) -> CombinedCapability:
"""合并 Agent 上下文 (已包含 Global) 与 Tool 私有的 Capability"""
tool_caps = getattr(getattr(executable, "settings", None), "capabilities", [])
agent_caps = getattr(context, "capabilities", [])
all_caps = list(agent_caps)
for c in tool_caps:
if c not in all_caps:
all_caps.append(c)
return CombinedCapability(all_caps)
@asynccontextmanager
async def _tool_stream_scope(
self,
event_bus: EventBus | None,
tool_name: str,
arguments: dict[str, Any],
intent: str | None,
):
"""生命周期上下文管理器:接管事件流的发送与异常包装样板代码"""
if event_bus:
await event_bus.emit(
ToolCallStartEvent(
tool_name=tool_name, arguments=arguments, intent=intent
)
)
result_box: dict[str, Any] = {}
try:
yield result_box
finally:
if event_bus and "result" in result_box:
res = result_box["result"]
await event_bus.emit(
ToolCallEndEvent(
tool_name=tool_name, result=res, is_error=res.is_error
)
)
@staticmethod
def _robust_parse_args(
args_raw: str | dict[str, Any],
) -> tuple[bool, dict[str, Any], str | None, str]:
"""容错解析参数纯函数,返回 (是否成功, 解析后的字典, intent意图, 原始字符串)"""
if isinstance(args_raw, dict):
arguments = args_raw.copy()
parsed_successfully = True
args_str = json.dumps(args_raw, ensure_ascii=False)
else:
args_str = args_raw
arguments = {}
parsed_successfully = False
if not args_str.strip():
parsed_successfully = True
else:
try:
parsed = json.loads(args_str)
if isinstance(parsed, dict):
arguments, parsed_successfully = parsed, True
except json.JSONDecodeError:
try:
parsed = ast.literal_eval(args_str)
if isinstance(parsed, dict):
arguments, parsed_successfully = parsed, True
except (ValueError, SyntaxError):
try:
repaired_str = str(
json_repair.repair_json(args_str, skip_json_loads=True)
)
parsed = json.loads(repaired_str)
if isinstance(parsed, dict):
arguments, parsed_successfully = parsed, True
logger.debug(
"⚒️ 成功修复损坏的工具参数: "
f"{args_str} -> {repaired_str}",
"ToolExecutor",
)
except Exception:
pass
intent_str = None
if parsed_successfully:
_intent = arguments.pop("_intent", None)
if _intent:
intent_str = str(_intent)
return parsed_successfully, arguments, intent_str, args_str
def _prepare_tool_context(
self,
context: RunContext | None,
tool_call_id: str,
tool_name: str,
executable: Any,
event_bus: EventBus | None,
available_tools: "ToolCollection | dict[str, Any] | None" = None,
) -> RunContext:
"""准备/克隆工具调用所使用的隔离 RunContext"""
from zhenxun.services.ai.run import RunContext
safe_context = (
context.clone_for_tool_call(tool_call_id, tool_name)
if context
else RunContext()
)
safe_context.run.event_bus = event_bus
safe_context.call.tool_name = tool_name
safe_context.call.current_tool = executable
if available_tools is not None:
safe_context.state["__available_tools"] = available_tools
return safe_context
async def validate_tool_call(
self,
tool_call: ToolCallPart,
available_tools: "ToolCollection | dict[str, Any] | None",
context: RunContext | None = None,
event_bus: EventBus | None = None,
) -> ValidatedToolCall:
"""验证单一工具调用,完成参数解析、类型检查与交互式补全(如果触发)。"""
if not available_tools:
available_tools = {}
if not tool_call.tool_name:
return ValidatedToolCall(
call=tool_call,
args_valid=False,
validation_error=ToolRetryError("tool_call.tool_name 不能为空"),
)
tool_name = tool_call.tool_name
parsed_successfully, arguments, intent_str, arguments_str = (
self._robust_parse_args(tool_call.args)
)
if tool_call.args and not parsed_successfully:
if context:
context.run.tool_retries[tool_name] = (
context.run.tool_retries.get(tool_name, 0) + 1
)
return ValidatedToolCall(
call=tool_call,
args_valid=False,
validation_error=ToolRetryError(
f"参数JSON解析失败,请检查 JSON 语法是否合法: {arguments_str}"
),
)
elif intent_str:
logger.info(f"🧠 [Agent Intent] 调用工具 {tool_name} 的意图: {intent_str}")
executable = available_tools.get(tool_name)
if not executable or not hasattr(executable, "execute"):
return ValidatedToolCall(
call=tool_call,
args_valid=False,
validation_error=ToolRetryError(f"Tool '{tool_name}' not found."),
)
safe_context = self._prepare_tool_context(
context, tool_call.id, tool_name, executable, event_bus
)
combined_cap = self._get_combined_capability(executable, safe_context)
async def inner_validate(args_inner):
if isinstance(args_inner, dict) and hasattr(executable, "validate_args"):
import inspect
sig = inspect.signature(executable.validate_args)
if "context" in sig.parameters:
return await executable.validate_args(
args_inner, context=safe_context
)
return await executable.validate_args(args_inner)
return args_inner
try:
validated_args = await combined_cap.wrap_tool_validate(
safe_context, tool_name, arguments, inner_validate
)
return ValidatedToolCall(
call=tool_call,
tool=executable,
args_valid=True,
validated_args=validated_args,
intent=intent_str,
)
except BaseException as e:
if isinstance(e, asyncio.CancelledError):
raise e
return ValidatedToolCall(
call=tool_call,
tool=executable,
args_valid=False,
validation_error=e,
)
async def execute_tool_call(
self,
validated: ValidatedToolCall,
available_tools: "ToolCollection | dict[str, Any] | None",
context: RunContext | None = None,
model_name: str | None = None,
max_retries: int = 0,
event_bus: EventBus | None = None,
) -> tuple[ToolCallPart, ToolResult]:
"""核心执行阶段。只接收已经通过 Validation 阶段的 ValidatedToolCall 载体。"""
if not available_tools:
available_tools = {}
tool_name = validated.call.tool_name
if not validated.args_valid or validated.tool is None:
from zhenxun.services.ai.core.exceptions import ControlFlowExit
if isinstance(validated.validation_error, ControlFlowExit):
raise validated.validation_error
err_msg = getattr(
validated.validation_error, "message", str(validated.validation_error)
)
res = ToolResult(output=f"执行被拦截或参数错误: {err_msg}").as_error()
if event_bus:
await event_bus.emit(
ToolCallEndEvent(
tool_name=tool_name, result=res, is_error=res.is_error
)
)
return validated.call, res
executable = validated.tool
arguments = validated.validated_args or {}
safe_context = self._prepare_tool_context(
context,
validated.call.id,
tool_name,
executable,
event_bus,
available_tools,
)
from zhenxun.services.ai.run.context import set_run_context
combined_cap = self._get_combined_capability(executable, safe_context)
async def inner_handler(args_inner: dict) -> Any:
return await executable.execute(context=safe_context, **args_inner)
async with self._tool_stream_scope(
event_bus, tool_name, arguments, validated.intent
) as box:
with set_run_context(safe_context):
try:
result = await combined_cap.wrap_tool_execute(
safe_context, tool_name, arguments, inner_handler
)
if not isinstance(result, ToolResult):
result = ToolResult(output=result)
from zhenxun.services.ai.tools.models import StateSyncResult
if isinstance(result, StateSyncResult) and result.state_notice:
if safe_context:
safe_context.run.add_system_prompt(
f"[系统通知(状态同步)]:{result.state_notice}"
)
except BaseException as e:
from zhenxun.services.ai.core.exceptions import ControlFlowExit
if isinstance(e, ControlFlowExit):
raise e
if isinstance(e, asyncio.CancelledError):
raise e
logger.error(f"洋葱模型异常穿透: {e}")
result = ToolResult(output=f"System Fatal Error: {e}").as_error()
box["result"] = result
return validated.call, box["result"]
async def execute_batch(
self,
tool_calls: list[ToolCallPart],
available_tools: "ToolCollection | dict[str, Any] | None",
context: RunContext | None = None,
model_name: str | None = None,
max_retries: int = 0,
event_bus: EventBus | None = None,
) -> list[AnyLLMMessage]:
"""批量并发执行多个工具调用。"""
if not available_tools:
available_tools = {}
if not tool_calls:
return []
val_tasks = [
self.validate_tool_call(
call,
available_tools,
context,
event_bus=event_bus,
)
for call in tool_calls
]
validated_calls = await asyncio.gather(*val_tasks)
results: list[Any] = [None] * len(validated_calls)
async def _run_tool(index: int, val_call: ValidatedToolCall):
try:
res = await self.execute_tool_call(
val_call,
available_tools,
context,
model_name=model_name,
max_retries=max_retries,
event_bus=event_bus,
)
results[index] = res
except Exception as e:
results[index] = e
chunks: list[list[tuple[int, ValidatedToolCall]]] = []
current_chunk: list[tuple[int, ValidatedToolCall]] = []
for i, val_call in enumerate(validated_calls):
executable = getattr(val_call, "tool", None)
concurrency_mode = getattr(
getattr(executable, "settings", None), "concurrency", "shared"
)
if concurrency_mode == "exclusive":
if current_chunk:
chunks.append(current_chunk)
current_chunk = []
chunks.append([(i, val_call)])
else:
current_chunk.append((i, val_call))
if current_chunk:
chunks.append(current_chunk)
for chunk in chunks:
await asyncio.gather(*[_run_tool(i, call) for i, call in chunk])
tool_messages: list[AnyLLMMessage] = []
for index, result_pair in enumerate(results):
original_call = tool_calls[index]
func_name = original_call.tool_name
if isinstance(result_pair, BaseException):
from zhenxun.services.ai.core.exceptions import ControlFlowExit
if isinstance(result_pair, ControlFlowExit):
raise result_pair
if isinstance(result_pair, asyncio.CancelledError):
raise result_pair
logger.error(f"工具批量执行并发崩溃: {func_name}, 错误: {result_pair}")
result_pair = (
original_call,
ToolResult(output=f"Crash: {result_pair}").as_error(),
)
tool_call_result = cast(tuple[ToolCallPart, ToolResult], result_pair)
_, tool_result = tool_call_result
tool_messages.append(
LLMMessage.tool_response(
tool_call_id=original_call.id,
function_name=func_name,
result=tool_result.output,
)
)
return tool_messages
class ToolExecutionPolicy:
"""
工具执行策略 (Strategy Pattern)。
负责解析工具私有配置与系统全局配置,决定最大重试次数、Fallback 路由目标等流转行为。
"""
def __init__(self, tool: BaseTool, global_max_retries: int = 0):
self.tool = tool
self.settings: ToolOptions = getattr(tool, "settings", ToolOptions())
self.metadata: dict[str, Any] = (
self.settings.metadata if self.settings else getattr(tool, "metadata", {})
)
self.global_max_retries = global_max_retries
@property
def max_retries(self) -> int:
"""
计算当前工具的绝对最大重试次数。
优先使用工具级配置 (ToolOptions.max_retries),如果未设置,则使用全局配置。
由于重试机制是保证 Agent 稳定性的防线,
即使全局为 0,底层默认也会给予至少 1 次的机会。
"""
tool_retries = getattr(self.settings, "max_retries", None)
if tool_retries is not None:
return tool_retries
return max(self.global_max_retries, 1)
class ToolRunner(ABC):
"""
工具运行器基类协议。
负责将参数请求物理落实为目标执行。
"""
@abstractmethod
async def run(
self, tool: BaseTool, context: RunContext, **kwargs: Any
) -> ToolResult:
pass
class NativeToolRunner(ToolRunner):
"""
原生 Python 函数工具运行器。
负责处理依赖注入 (DI)、异步包装、生成器流式收集以及框架级的多模态消息转换。
"""
async def run(
self, tool: BaseTool, context: RunContext, **kwargs: Any
) -> ToolResult:
target_func = tool.get_execute_target()
signature_target = tool.get_signature_target()
if not target_func:
return ToolResult(output="Error: 未找到有效的执行目标(run 方法)").as_error()
call_kwargs = dict(kwargs)
try:
target_call_kwargs = await DependencyInjector.resolve_all(
sig=inspect.signature(signature_target),
call_kwargs=dict(call_kwargs),
context=context,
)
except ValueError as e:
logger.error(f"工具 {tool.name} 依赖注入失败: {e}", e=e)
raise ToolFatalError(f"框架依赖注入失败: {e}")
is_async_gen = getattr(
target_func, "_is_async_gen", False
) or inspect.isasyncgenfunction(target_func)
if is_async_gen:
res = None
async for chunk in target_func(**target_call_kwargs):
if isinstance(chunk, ToolResult):
res = chunk
else:
from zhenxun.services.ai.tools.models import ToolResultChunk
chunk_obj = (
chunk
if isinstance(chunk, ToolResultChunk)
else ToolResultChunk(content=str(chunk))
)
is_silent = (
getattr(tool.settings, "silent", False)
if tool and hasattr(tool, "settings")
else False
)
if context.run.event_bus and not is_silent:
await context.run.event_bus.emit(
ToolStreamChunkEvent(
tool_name=tool.name,
content=chunk_obj.content,
metadata=chunk_obj.metadata,
)
)
if res is None:
res = ToolResult(output="Stream finished successfully.")
else:
res = await target_func(**target_call_kwargs)
if isinstance(res, ToolResult):
final_result = res
else:
if str(type(res)).find("Message") != -1:
from zhenxun.services.ai.message_builder import MessageBuilder
uni_msg = (
MessageBuilder.message_to_unimessage(res)
if isinstance(res, PlatformMessage)
else res
)
parts = await MessageBuilder.unimsg_to_llm_parts(uni_msg)
if context and context.run.event_bus:
await context.run.event_bus.emit(UserCustomEvent(display=uni_msg))
final_result = ToolResult(output=parts)
else:
final_result = ToolResult(output=res)
return final_result
from zhenxun.services.ai.tools.core.tool import register_tool_runner
register_tool_runner(NativeToolRunner)
@@ -0,0 +1,575 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable, Iterable
import inspect
from typing import Any, Generic, SupportsIndex, TypeVar, cast, overload
from typing_extensions import Self
from nonebot.utils import is_coroutine_callable
from zhenxun.services.ai.core.exceptions import ConfigurationException
from zhenxun.services.ai.core.protocols.tool import (
ToolExecutable,
ToolProvider,
)
from zhenxun.services.ai.run.context import RunContext
from zhenxun.services.ai.tools.core.toolkit import BaseToolkit
from zhenxun.services.ai.tools.models import Query, ResolvedToolPayload
from zhenxun.services.log import logger
from zhenxun.utils.utils import infer_plugin_namespace
T = TypeVar("T", bound=ToolExecutable)
class ToolCollection(list[T], Generic[T]):
"""支持按索引和按名称获取的工具集合 (List + Dict)"""
def __init__(self, iterable: Iterable[T] | None = None):
super().__init__(iterable or [])
self._name_cache: dict[str, T] = {}
self._build_name_cache()
def _build_name_cache(self) -> None:
"""构建工具名称小写到工具实例的映射缓存。"""
self._name_cache = {}
for tool in self:
name = getattr(tool, "name", None)
if name:
self._name_cache[name.lower()] = tool
@overload
def __getitem__(self, key: SupportsIndex) -> T: ...
@overload
def __getitem__(self, key: slice) -> list[T]: ...
@overload
def __getitem__(self, key: str) -> T: ...
def __getitem__(self, key: Any) -> Any:
if isinstance(key, str):
return self._name_cache[key.lower()]
return super().__getitem__(key)
@overload
def __setitem__(self, key: SupportsIndex, value: T) -> None: ...
@overload
def __setitem__(self, key: slice, value: Iterable[T]) -> None: ...
@overload
def __setitem__(self, key: str, value: T) -> None: ...
def __setitem__(self, key: Any, value: Any) -> None:
if isinstance(key, str):
name = key.lower()
if name in self._name_cache:
old_tool = self._name_cache[name]
try:
idx = super().index(old_tool)
super().__setitem__(idx, value)
except ValueError:
super().append(value)
else:
super().append(value)
self._name_cache[name] = value
else:
super().__setitem__(key, value)
self._name_cache[value.name.lower()] = value
def get(self, key: str, default: Any = None) -> T | Any:
return self._name_cache.get(key.lower(), default)
def append(self, object: T) -> None:
name = object.name
if name.lower() in self._name_cache:
old_tool = self._name_cache[name.lower()]
try:
idx = super().index(old_tool)
super().__setitem__(idx, object)
except ValueError:
super().append(object)
else:
super().append(object)
self._name_cache[name.lower()] = object
def extend(self, iterable: Iterable[T]) -> None:
for t in iterable:
self.append(t)
def remove(self, value: T) -> None:
super().remove(value)
name = getattr(value, "name", None)
if name and name.lower() in self._name_cache:
del self._name_cache[name.lower()]
def pop(self, index: SupportsIndex = -1) -> T:
tool = super().pop(index)
name = getattr(tool, "name", None)
if name and name.lower() in self._name_cache:
del self._name_cache[name.lower()]
return tool
def filter_by_names(self, names: list[str] | None = None) -> "ToolCollection[T]":
if names is None:
return self
return ToolCollection(
[
tool
for name in names
if (tool := self._name_cache.get(name.lower())) is not None
]
)
def clear(self) -> None:
super().clear()
self._name_cache.clear()
def keys(self):
return self._name_cache.keys()
def values(self):
return self._name_cache.values()
def items(self):
return self._name_cache.items()
class _StringResolver:
"""字符串格式的工具路由解析器。
负责解析像 'ns.tool_name'、'ns.*' 或 'ns.#tag' 的语法路由。
"""
def __init__(
self, name: str, manager: "ToolProviderManager", default_namespace: str
):
self.name = name
self.manager = manager
self.default_namespace = default_namespace
async def resolve(self, context: RunContext | None = None) -> ResolvedToolPayload:
if self.name in self.manager._macro_resolvers:
resolver = self.manager._macro_resolvers[self.name]
resolved = (
await resolver() if is_coroutine_callable(resolver) else resolver()
)
return await self.manager._normalize_to_resolver(
resolved, self.default_namespace
).resolve(context)
s = self.name
if "." in s:
ns, target = s.split(".", 1)
else:
ns = self.default_namespace
target = s
from zhenxun.services.ai.tools.models import Query
if target == "*":
query = Query(namespace=ns)
elif target.startswith("#"):
tags = [t for t in target.split("#") if t]
query = Query(tags=tags, namespace=ns)
else:
query = Query(name=target, namespace=ns)
logger.debug(f"🔍 [StringRouter] 语法解析: '{self.name}' -> {query}")
return await _QueryResolver(
query, self.manager, self.default_namespace
).resolve(context)
class _QueryResolver:
"""Query 查询对象格式的工具路由解析器。
负责根据 namespace、标签或工具名称检索匹配的工具。
"""
def __init__(
self, query: Query, manager: "ToolProviderManager", default_namespace: str
):
self.query = query
self.manager = manager
self.default_namespace = default_namespace
async def resolve(self, context: RunContext | None = None) -> ResolvedToolPayload:
payload = ResolvedToolPayload()
namespaces_to_search = []
target_namespace = self.query.namespace or self.default_namespace
if target_namespace == "global":
namespaces_to_search = list(self.manager._namespaced_tools.keys())
elif target_namespace:
namespaces_to_search = [target_namespace]
else:
raise ValueError(f"Query 对象必须显式指定 namespace 作用域: {self.query}")
for ns in namespaces_to_search:
if ns in self.manager._namespaced_tools:
for tool in self.manager._namespaced_tools[ns]:
if self.query.match(tool):
p = await tool.resolve(context)
if p:
payload.tools.extend(p.tools)
payload.injected_prompts.extend(p.injected_prompts)
payload.toolkits.extend(p.toolkits)
if self.query.name and not payload.tools and not self.query.tags:
specific = await self.manager.resolve_specific_tools([self.query.name])
for t in specific:
if self.query.match(t):
payload.tools.append(t)
return payload
class _CallableResolver:
"""普通 Python 函数/可调用对象格式的工具路由解析器。
负责将其包装为 FunctionTool 实例。
"""
def __init__(self, func: Callable, manager: "ToolProviderManager"):
self.func = func
self.manager = manager
async def resolve(self, context: RunContext | None = None) -> ResolvedToolPayload:
for candidate in (
getattr(self.func, "__tool_name__", None),
getattr(self.func, "__name__", None),
):
if candidate:
for ns_tools in self.manager._namespaced_tools.values():
if t := ns_tools.get(candidate):
return await t.resolve(context)
from zhenxun.services.ai.tools.core.tool import FunctionTool
t = FunctionTool(func=self.func)
return await t.resolve(context)
class _TypeAdapterResolver:
"""基于自定义类型映射注册的工具路由解析器。负责调用对应类型的解析函数。"""
def __init__(
self,
item: Any,
resolver_func: Callable,
manager: "ToolProviderManager",
default_namespace: str,
):
self.item = item
self.resolver_func = resolver_func
self.manager = manager
self.default_namespace = default_namespace
async def resolve(self, context: RunContext | None = None) -> ResolvedToolPayload:
resolved = (
await self.resolver_func(self.item)
if is_coroutine_callable(self.resolver_func)
else self.resolver_func(self.item)
)
return await self.manager._normalize_to_resolver(
resolved, self.default_namespace
).resolve(context)
class ToolProviderManager:
"""工具提供者的中心化管理器,采用单例模式。"""
_instance: "ToolProviderManager | 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 hasattr(self, "_initialized") and self._initialized:
return
self._providers: list[ToolProvider] = []
self._namespaced_tools: dict[str, ToolCollection] = {}
self._resolved_tools: ToolCollection | None = None
self._init_lock = asyncio.Lock()
self._init_promise: asyncio.Task | None = None
self._initialized = True
self._macro_resolvers: dict[str, Callable] = {}
self._type_resolvers: dict[type, Callable] = {}
def register_macro_resolver(self, macro_str: str, resolver_func: Callable) -> None:
self._macro_resolvers[macro_str] = resolver_func
def register_type_resolver(
self, target_type: type, resolver_func: Callable
) -> None:
self._type_resolvers[target_type] = resolver_func
def register(self, provider: ToolProvider):
"""注册一个新的 ToolProvider。"""
if provider not in self._providers:
self._providers.append(provider)
logger.debug(f"已注册工具提供者: {provider.__class__.__name__}")
def register_tool(self, tool: ToolExecutable):
"""注册由 @tool 生成的单一工具"""
ns = infer_plugin_namespace()
if ns not in self._namespaced_tools:
self._namespaced_tools[ns] = ToolCollection()
self._namespaced_tools[ns].append(tool)
self._resolved_tools = None
def register_toolkit(self, toolkit: Any) -> None:
"""
注册一个完整的 Toolkit 实例,使其可通过智能字符串路由(Tag或Name)被动态发现。
"""
ns = infer_plugin_namespace()
if ns not in self._namespaced_tools:
self._namespaced_tools[ns] = ToolCollection()
self._namespaced_tools[ns].append(toolkit)
self._resolved_tools = None
async def initialize(self) -> None:
"""懒加载初始化所有已注册的 ToolProvider。"""
if not self._init_promise:
async with self._init_lock:
if not self._init_promise:
self._init_promise = asyncio.create_task(
self._initialize_providers()
)
await self._init_promise
async def _initialize_providers(self) -> None:
"""并发初始化所有已注册的工具提供者。"""
logger.info(f"开始初始化 {len(self._providers)} 个工具提供者...")
init_tasks = [provider.initialize() for provider in self._providers]
await asyncio.gather(*init_tasks, return_exceptions=True)
logger.info("所有工具提供者初始化完成。")
async def discover_tools(
self,
allowed_servers: list[str] | None = None,
excluded_servers: list[str] | None = None,
) -> dict[str, ToolExecutable]:
"""向所有已初始化的 ToolProvider 并发执行工具发现。"""
discover_tasks = []
provider_indices = []
for i, provider in enumerate(self._providers):
sig = inspect.signature(provider.discover_tools)
params_to_pass = {}
if "allowed_servers" in sig.parameters:
params_to_pass["allowed_servers"] = allowed_servers
if "excluded_servers" in sig.parameters:
params_to_pass["excluded_servers"] = excluded_servers
discover_tasks.append(provider.discover_tools(**params_to_pass))
provider_indices.append(i)
results = await asyncio.gather(*discover_tasks, return_exceptions=True)
provider_tools = {}
for result_idx, provider_result in enumerate(results):
provider = self._providers[provider_indices[result_idx]]
provider_name = provider.__class__.__name__
if isinstance(provider_result, dict):
logger.debug(
f"提供者 '{provider_name}' 发现了 {len(provider_result)} 个工具。"
)
for name, executable in provider_result.items():
if provider_tools.get(name):
logger.warning(
f"发现重复的工具名称 '{name}',后发现的将覆盖前者。"
)
provider_tools[name] = executable
elif isinstance(provider_result, Exception):
logger.error(
f"提供者 '{provider_name}' 在发现工具时出错: {provider_result}"
)
return provider_tools
async def _query_engine(
self,
names: list[str] | None = None,
allowed_servers: list[str] | None = None,
excluded_servers: list[str] | None = None,
include_providers: bool = True,
) -> ToolCollection:
"""统一查询引擎:收敛所有本地与云端的工具检索逻辑"""
await self.initialize()
resolved = ToolCollection()
for ns_tools in self._namespaced_tools.values():
for t in ns_tools:
if names and t.name not in names:
continue
resolved.append(t)
if not include_providers:
return resolved
if names:
missing_names = [n for n in names if not resolved.get(n)]
for name in missing_names:
config = {"name": name}
for provider in self._providers:
try:
if executable := await provider.get_tool_executable(
name, config
):
resolved.append(executable)
break
except Exception as exc:
logger.error(
f"provider '{provider.__class__.__name__}'"
f"解析工具 '{name}' 出错: {exc}"
)
else:
provider_tools = await self.discover_tools(
allowed_servers, excluded_servers
)
for t in provider_tools.values():
resolved.append(t)
return resolved
async def get_resolved_tools(
self,
allowed_servers: list[str] | None = None,
excluded_servers: list[str] | None = None,
namespaces: list[str] | None = None,
) -> ToolCollection:
has_filters = (
allowed_servers is not None
or excluded_servers is not None
or namespaces is not None
)
if not has_filters and self._resolved_tools is not None:
return self._resolved_tools
tools = await self._query_engine(
allowed_servers=allowed_servers, excluded_servers=excluded_servers
)
if not has_filters:
self._resolved_tools = tools
return tools
async def resolve_specific_tools(self, tool_names: list[str]) -> ToolCollection:
return await self._query_engine(names=tool_names, include_providers=True)
async def get_function_tools(
self, names: list[str] | None = None
) -> ToolCollection:
return await self._query_engine(names=names, include_providers=False)
def _normalize_to_resolver(self, item: Any, default_ns: str) -> Any:
"""将任意工具配置或定义包装为标准的多态解析器对象。"""
if hasattr(item, "resolve"):
return item
if isinstance(item, Query):
return _QueryResolver(item, self, default_ns)
if isinstance(item, str):
return _StringResolver(item, self, default_ns)
if type(item) in self._type_resolvers:
return _TypeAdapterResolver(
item, self._type_resolvers[type(item)], self, default_ns
)
if callable(item):
return _CallableResolver(item, self)
if not hasattr(item, "resolve"):
raise TypeError(
f"严格协议校验失败: 工具对象 {type(item)} 必须实现 ToolResolvable 协议 "
"(包含 resolve 方法)。如果你想注册普通函数,请使用 @tool 装饰器。"
)
return item
async def resolve_tools(
self,
tool_definitions: Iterable[Any] | None,
namespace: str | None = None,
context: RunContext | None = None,
) -> ResolvedToolPayload:
"""
统一解析工具配置,全面采用多态解析器与并发聚合管线。
"""
if not tool_definitions:
return ResolvedToolPayload()
if not namespace:
namespace = infer_plugin_namespace()
logger.debug(
f"🔍 [StringRouter] 自动推断当前调用者所在插件为: '{namespace}'"
)
defs = []
def _flatten(items):
for item in items:
if isinstance(item, list):
_flatten(item)
else:
defs.append(item)
_flatten(tool_definitions)
resolvers = [self._normalize_to_resolver(t, namespace) for t in defs]
for i, r in enumerate(resolvers):
if asyncio.iscoroutine(r):
r = await r
resolvers[i] = self._normalize_to_resolver(r, namespace)
tasks = [r.resolve(context) for r in resolvers]
payloads = await asyncio.gather(*tasks, return_exceptions=False)
final_payload = ResolvedToolPayload()
global_toolkit = BaseToolkit(prefix="")
for p in payloads:
if not p:
continue
p = cast(ResolvedToolPayload, p)
for t in p.tools:
if not getattr(t, "parent_toolkit", None):
t.parent_toolkit = global_toolkit
final_payload.tools.append(t)
final_payload.injected_prompts.extend(p.injected_prompts)
final_payload.toolkits.extend(p.toolkits)
final_payload.tools = ToolCollection(final_payload.tools)
return final_payload
tool_provider_manager = ToolProviderManager()
async def _dict_ad_hoc_resolver(config: dict):
"""针对字典类型的 ad-hoc 工具配置的类型解析器。"""
name = config.get("name")
if not name:
raise ConfigurationException(
"工具配置字典必须包含 'name' 字段。",
)
for provider in tool_provider_manager._providers:
executable = await provider.get_tool_executable(name, config)
if executable:
return executable
raise ConfigurationException(
f"没有为 ad-hoc 工具 '{name}' 找到合适的提供者。",
)
tool_provider_manager.register_type_resolver(dict, _dict_ad_hoc_resolver)
+329
View File
@@ -0,0 +1,329 @@
"""
工具系统域类型定义
"""
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Literal
from pydantic import BaseModel, ConfigDict, Field
from zhenxun.services.ai.core.messages import ToolCallPart, UsageInfo
from zhenxun.services.ai.run.context import RunContext
from zhenxun.utils.pydantic_compat import model_dump, model_validate
if TYPE_CHECKING:
from zhenxun.services.ai.tools.core.tool import BaseTool
class DirectivePayload(BaseModel):
"""工具执行产生的副作用控制流指令载荷"""
name: str
"""指令名称,对应 directive_manager 中的注册名"""
payload: dict[str, Any] = Field(default_factory=dict)
"""传递给指令处理器的具体数据"""
class ToolResult(BaseModel):
"""结构化的工具执行结果模型"""
model_config = ConfigDict(arbitrary_types_allowed=True)
output: Any = Field(...)
"""大模型实际看到的执行结果,可以是字符串、字典等序列化对象。"""
usage: UsageInfo | None = Field(default=None)
"""子智能体或复杂工具消耗的 Token 统计,将向主流程冒泡累加。"""
is_error: bool = Field(default=False)
"""是否发生了业务级别的错误"""
is_retryable: bool = Field(default=True)
"""标记该错误是否允许大模型进行自愈反思重试"""
directive: DirectivePayload | None = Field(default=None)
"""工具执行产生的副作用指令(如移交、结束运行等),供底层的指令路由引擎调度"""
def as_error(self, is_retryable: bool = True) -> "ToolResult":
"""链式方法:标记此结果为错误,并引导大模型在下一轮进行重试自愈"""
self.is_error = True
self.is_retryable = is_retryable
return self
def as_fatal(self) -> "ToolResult":
"""链式方法:标记此结果为致命错误,立即中断大模型的思考"""
self.is_error = True
self.is_retryable = False
return self
class EndRunResult(ToolResult):
"""强制结束运行并返回结果给用户"""
def __init__(self, output: Any, **kwargs):
super().__init__(
output=output,
directive=DirectivePayload(name="end_run", payload={"output": output}),
**kwargs,
)
class HandoffResult(ToolResult):
"""触发智能体控制权物理移交"""
def __init__(self, target: str, reason: str = "", context_data: Any = "", **kwargs):
super().__init__(
output=f"已触发控制权移交 -> {target}。原因: {reason}",
directive=DirectivePayload(
name="handoff",
payload={
"target": target,
"reason": reason,
"context_data": context_data,
},
),
**kwargs,
)
class StructuredSubmissionResult(ToolResult):
"""提交结构化解析结果并结束运行"""
def __init__(self, output: Any, parsed_obj: Any, **kwargs):
super().__init__(
output=output,
directive=DirectivePayload(
name="submit_structured", payload={"parsed_obj": parsed_obj}
),
**kwargs,
)
class StateSyncResult(ToolResult):
"""
状态同步结果模型。
除了返回工具输出外,允许开发者向大模型发送一条“系统通知”,系统会自动将其追加到上下文中,防止大模型产生幻觉。
"""
state_notice: str | None = Field(default=None)
"""状态同步通知文本,将被自动转化为 SystemPrompt 发送给大模型。"""
def with_state_notice(self, notice: str) -> "StateSyncResult":
"""链式方法:设置状态同步通知"""
self.state_notice = notice
return self
class ToolResultChunk(BaseModel):
"""流式工具执行结果片段模型"""
content: str = Field(...)
"""流式输出的文本片段"""
status: str = Field(default="running")
"""当前状态 (如 running, finished)"""
metadata: dict[str, Any] | None = Field(default=None)
"""携带的附加数据 (如进度比例、图片等)"""
class ToolOptions(BaseModel):
"""工具的高阶配置选项"""
model_config = ConfigDict(arbitrary_types_allowed=True)
silent: bool = Field(default=False)
"""是否静默执行,工具执行过程与结果不会作为界面流渲染给用户。"""
strict: bool = Field(default=False)
"""是否开启严格的 JSON Schema 验证模式,开启后大模型的参数将不接受额外属性。"""
max_usage_count: int | None = Field(default=None)
"""单次 Agent 会话中的最大允许调用次数,用于防止大模型陷入死循环调用。"""
capabilities: list[Any] = Field(default_factory=list)
"""当前工具专属的生命周期拦截器 (Capabilities) 列表。"""
metadata: dict[str, Any] = Field(default_factory=dict)
"""额外扩展元数据字典,可供其他系统或自定义中间件读取。"""
sandbox_requirements: dict[str, list[str]] | None = Field(default=None)
"""声明该工具在沙箱中执行时的环境依赖要求。"""
tags: list[str] = Field(default_factory=list)
"""用于智能字符串路由和能力聚合的标签列表。"""
max_retries: int | None = Field(default=None)
"""工具级别的局部重试上限。优先级高于全局配置。"""
args_schema: type[BaseModel] | None = Field(default=None)
"""工具的 Pydantic 数据模型约束。大模型将以此 Schema 输出 JSON。"""
require_intent: bool = Field(default=False)
"""是否强制要求大模型在调用此工具时提供意图 (_intent)。
有助于减少幻觉和提高调用准确率。
"""
concurrency: Literal["shared", "exclusive"] = Field(default="shared")
"""工具在批量调用时的并发策略。
shared 可与其他 shared 并行,exclusive 会阻塞前后工具的执行。
"""
def merge(self, other: "ToolOptions | None") -> "ToolOptions":
"""组合模式底层:合并另一个 ToolOptions,other 中的非默认值将覆盖当前值"""
if not other:
return self
merged_data = model_dump(self, exclude_unset=False)
other_data = model_dump(other, exclude_unset=True)
if other.capabilities:
merged_data["capabilities"] = self.capabilities + other.capabilities
if other.metadata:
merged_data["metadata"] = {**self.metadata, **other.metadata}
if other.tags:
merged_data["tags"] = list(set(self.tags + other.tags))
for k, v in other_data.items():
if k not in ("capabilities", "metadata", "tags"):
merged_data[k] = v
return model_validate(ToolOptions, merged_data)
class ToolkitConfig(BaseModel):
"""工具箱全局配置对象 (用于声明式配置解析)"""
model_config = ConfigDict(arbitrary_types_allowed=True)
prefix: str | None = Field(default=None)
"""工具名称前缀"""
include: list[str] | None = Field(default=None)
"""允许注册的工具名白名单"""
exclude: list[str] | None = Field(default=None)
"""排除注册的工具名黑名单"""
shared_options: ToolOptions | None = Field(default=None)
"""所有子工具默认继承的高阶配置项"""
class ToolOverride(BaseModel):
"""工具配置动态覆盖载体"""
model_config = ConfigDict(arbitrary_types_allowed=True)
name: str
"""要覆盖的目标工具名称(在全局注册表或 Provider 中的原始名称)"""
new_name: str | None = None
"""克隆后的新工具名称。如果为空,则保持原名称"""
description: str | None = None
"""覆盖后的新描述。大模型将根据此新描述决定工具调用时机"""
options: ToolOptions | None = None
"""用于覆盖该工具底层行为的高阶配置项"""
def to_tool_options(self) -> ToolOptions:
return self.options or ToolOptions()
async def resolve(self, context: RunContext | None = None) -> "ResolvedToolPayload":
from zhenxun.services.ai.tools.engine.registry import tool_provider_manager
from zhenxun.services.ai.tools.models import ResolvedToolPayload
from zhenxun.services.log import logger
found_tools = await tool_provider_manager.resolve_specific_tools([self.name])
if found_tools:
base_tool = found_tools[0]
if hasattr(base_tool, "clone_with_options"):
cloned_tool = base_tool.clone_with_options(self)
if hasattr(cloned_tool, "resolve"):
return await cloned_tool.resolve(context)
return ResolvedToolPayload(tools=[cloned_tool])
else:
logger.warning(f"工具 {self.name} 不支持动态覆盖,将原样装配。")
if hasattr(base_tool, "resolve"):
return await base_tool.resolve(context)
return ResolvedToolPayload(tools=[base_tool])
logger.warning(f"ToolOverride 找不到目标基础工具: {self.name}")
return ResolvedToolPayload()
class GlobalToolFilter(BaseModel):
"""全局宏观工具过滤器"""
allowed_servers: list[str] | None = None
"""仅允许的服务端名称列表"""
excluded_servers: list[str] | None = None
"""需要排除的服务端名称列表"""
class ValidatedToolCall(BaseModel):
"""工具调用验证结果载体(解耦验证与执行)"""
model_config = ConfigDict(arbitrary_types_allowed=True)
call: ToolCallPart = Field(...)
"""原始工具调用部件"""
tool: Any | None = Field(default=None)
"""匹配到的目标工具实例"""
args_valid: bool = Field(default=False)
"""参数是否成功通过校验"""
validated_args: dict[str, Any] | None = Field(default=None)
"""通过验证并反序列化后的参数字典"""
validation_error: BaseException | None = Field(default=None)
"""验证失败时的异常信息"""
intent: str | None = Field(default=None)
"""从参数中剥离出的大模型调用意图 (_intent)"""
class Query(BaseModel):
"""
工具的声明式查询对象。
用于在 Agent 中精确或批量筛选加载特定命名空间、特定标签的工具。
"""
name: str | None = Field(default=None)
"""如果提供,则必须与工具的名称完全一致。"""
tags: list[str] | None = Field(default=None)
"""如果提供,则工具必须包含这里列出的所有标签 (交集/AND匹配)。"""
namespace: str | None = Field(default=None)
"""必填(由系统补充或显式声明)。限制搜索的插件命名空间,'global' 将跨全插件搜索。"""
metadata_filter: dict[str, Any] | None = Field(default=None)
"""如果提供,则工具的 metadata 必须包含这里列出的所有键值对。"""
def match(self, tool: "BaseTool") -> bool:
"""判断某个工具或工具箱是否符合当前 Query 的筛选条件"""
if self.name:
tool_name = getattr(tool, "name", getattr(tool, "__class__", type).__name__)
if tool_name != self.name:
return False
if self.tags:
tool_config = getattr(tool, "config", None)
if (
tool_config
and hasattr(tool_config, "shared_options")
and tool_config.shared_options
):
tool_tags = tool_config.shared_options.tags
else:
tool_settings = getattr(tool, "settings", None)
tool_tags = getattr(tool_settings, "tags", []) if tool_settings else []
if not all(tag in tool_tags for tag in self.tags):
return False
if self.metadata_filter:
tool_settings = getattr(tool, "settings", None)
tool_metadata = (
getattr(tool_settings, "metadata", getattr(tool, "metadata", {}))
if tool_settings
else getattr(tool, "metadata", {})
)
for k, v in self.metadata_filter.items():
if tool_metadata.get(k) != v:
return False
return True
@dataclass
class ResolvedToolPayload:
"""解析后的工具上下文包"""
tools: list[Any] = field(default_factory=list)
injected_prompts: list[str] = field(default_factory=list)
toolkits: list[Any] = field(default_factory=list)
__all__ = [
"GlobalToolFilter",
"Query",
"ResolvedToolPayload",
"ToolOptions",
"ToolOverride",
"ToolResult",
"ToolResultChunk",
"ToolkitConfig",
"ValidatedToolCall",
]
@@ -0,0 +1,15 @@
from .blackboard import BlackboardToolkit
from .hitl import HITLToolkit
from .memory import MemoryManagementToolkit
from .rest_api import RestApiToolkit
from .sandbox import SandboxToolkit
from .slots import MemorySlotToolkit
__all__ = [
"BlackboardToolkit",
"HITLToolkit",
"MemoryManagementToolkit",
"MemorySlotToolkit",
"RestApiToolkit",
"SandboxToolkit",
]
@@ -0,0 +1,72 @@
from typing import Any
from pydantic import Field, create_model
from zhenxun.services.ai.core.exceptions import ToolRetryError
from zhenxun.services.ai.run.blackboard import BlackboardManager
from zhenxun.services.ai.tools.core.tool import FunctionTool
from zhenxun.services.ai.tools.core.toolkit import BaseToolkit
from zhenxun.services.ai.tools.models import ToolOptions, ToolResult
from zhenxun.utils.pydantic_compat import model_fields
class BlackboardToolkit(BaseToolkit):
"""
黑板工具箱,将底层的 BlackboardManager 桥接给大模型,提供动态的读写工具。
"""
default_prefix = ""
def __init__(self, manager: BlackboardManager, **kwargs: Any):
super().__init__(**kwargs)
self.manager = manager
self._injected_tools.extend(self._create_dynamic_tools())
def _create_dynamic_tools(self) -> list[FunctionTool]:
async def read_blackboard() -> ToolResult:
"""读取当前团队的全局共享黑板状态。"""
content = await self.manager.read()
return ToolResult(output=content)
read_tool = FunctionTool(
func=read_blackboard,
name="read_blackboard",
description="读取当前团队的全局共享黑板最新状态。当你需要获取最新数据或了解现状时请调用此工具。",
)
optional_fields = {}
for field in model_fields(self.manager.schema):
desc = (
getattr(field.field_info, "description", None)
if hasattr(field, "field_info")
else None
)
optional_fields[field.name] = (
field.annotation | None,
Field(default=None, description=desc),
)
UpdateSchema = create_model(
f"Update_{self.manager.schema.__name__}", **optional_fields
)
async def update_blackboard(**kwargs) -> ToolResult:
valid_kwargs = {k: v for k, v in kwargs.items() if v is not None}
if not valid_kwargs:
return ToolResult(
output="警告:没有提供任何有效的更新字段,黑板状态未发生改变。"
)
try:
res = await self.manager.update(**valid_kwargs)
return ToolResult(output=res)
except ValueError as e:
raise ToolRetryError(str(e))
update_tool = FunctionTool(
func=update_blackboard,
name="update_blackboard",
description="局部更新全局黑板状态。请仅提供你需要修改的字段即可,不需要改变的字段请留空。",
settings=ToolOptions(args_schema=UpdateSchema),
)
return [read_tool, update_tool]
@@ -0,0 +1,56 @@
from zhenxun.services.ai.core.exceptions import AbortException, ToolFatalError
from zhenxun.services.ai.core.stream_events import ToolStreamChunkEvent
from zhenxun.services.ai.run import Inject, RunContext
from zhenxun.services.ai.tools.core.decorators import tool
from zhenxun.services.ai.tools.core.toolkit import BaseToolkit
from zhenxun.services.ai.tools.models import ToolResult
from zhenxun.services.log import logger
class HITLToolkit(BaseToolkit):
"""
Human-in-the-Loop (HITL) 工具箱。
提供大模型主动挂起执行流并向用户提问求助的能力。
"""
default_prefix = ""
default_instructions = (
"## 人机协同求助系统\n"
"你拥有向用户发起提问的权限。当你遇到困难时(例如缺少关键信息、连续执行某个操作出错、找不到文件等),"
"可以使用 `ask_user_for_help` 工具请求用户协助。"
"用户回答后,你将收到答案并可以继续任务。"
)
@tool(
name="ask_user_for_help",
description="向当前对话的用户提出问题以获取信息或指导。当你无法独立完成任务时,请调用此工具。",
)
async def ask_user_for_help(
self,
question: str,
hitl: Inject.HITL,
context: RunContext,
) -> ToolResult:
try:
user_reply = await hitl.ask_text(
f"🤖 [AI 提问]\n{question}\n\n(请在 60 秒内回复,或回复'取消')",
timeout=60.0,
)
except ToolFatalError:
return ToolResult(
output="用户未在规定时间内回复 (超时未响应),"
"你必须尝试自行解决或中止任务。",
).as_error()
except AbortException as e:
raise e
logger.info(f"收到用户求助回复: {user_reply}")
if context and context.run.event_bus:
await context.run.event_bus.emit(
ToolStreamChunkEvent(
tool_name=context.call.tool_name, content="🗣️ 已收到用户的回复"
)
)
return ToolResult(output=f"用户提供的回复是: {user_reply}")
@@ -0,0 +1,324 @@
from typing import Any, Literal, Optional
from pydantic import Field, create_model
from zhenxun.services.ai.context.memory.manager import memory_manager
from zhenxun.services.ai.context.memory.models import MemoryConfig
from zhenxun.services.ai.context.memory.types import SessionMetadata
from zhenxun.services.ai.run.context import RunContext
from zhenxun.services.ai.tools.core.decorators import tool
from zhenxun.services.ai.tools.core.tool import BaseTool
from zhenxun.services.ai.tools.core.toolkit import BaseToolkit
from zhenxun.services.ai.tools.models import ToolOptions, ToolResult
from zhenxun.services.log import logger
class MemoryManagementToolkit(BaseToolkit):
"""
主动记忆管理工具箱 (Agentic Memory Toolkit)。
赋予大模型自主存取、修改和删除用户长期设定的能力。
"""
default_prefix = ""
class Config:
"""声明式配置:该工具箱下的所有工具默认静默"""
shared_options = ToolOptions(silent=True)
default_instructions = """\
## 🧠 长期记忆管理系统 (Long-Term Memory)
该系统是你的「无限档案馆」。⚠️ **注意:系统默认不会主动向你提供所有历史信息,你必须通过主动搜索来回忆。**
### 📝 何时使用长期记忆?
- **记录离散事实与经验**:当需要记录某个独立事件、历史经验、问题解决方案或具体事实时(使用 `save_memory`)。
- **寻找历史线索**:当遇到未知情况,或用户提及过去的事情、特定设定时,必须主动检索历史库(使用 `search_memory`)。
### ⚙️ 操作规范
1. **隐式记录**:当接收到值得记忆的重要信息时,请静默记录。除非用户主动提问,否则无需向用户显式汇报"我已记住"。
2. **按需更新**:如果发现某项历史记录已过时或状态发生扭转,请先检索出它的 ID,再进行修改(使用 `update_memory`)或废弃(使用 `delete_memory`)。
3. **精准提炼**:保存记忆时请提炼核心价值,避免保存无意义的闲聊。\
""" # noqa: E501
def __init__(
self,
memory_config: MemoryConfig | None = None,
namespace: str | None = None,
**kwargs: Any,
):
"""
初始化主动记忆管理工具箱。
参数:
memory_config: 记忆系统的全局配置对象,为空则使用全局默认。
namespace: 当前隔离环境的命名空间。
kwargs: 其他透传给 BaseToolkit 的参数。
"""
super().__init__(**kwargs)
self.memory_config = memory_config
self._namespace = namespace
def _get_runtime_meta_and_scope(
self, context: RunContext, scope_name: str | None = None
) -> tuple[Any, SessionMetadata]:
"""动态获取当前运行时的数据库实例与会话元信息,实现无状态化"""
ns = self._namespace or getattr(context.session, "namespace", "global")
scope = memory_manager.get_long_term_memory(self.memory_config, namespace=ns)
scope_builder = None
if (
self.memory_config
and self.memory_config.long_term
and self.memory_config.long_term.scopes
):
scopes_dict = self.memory_config.long_term.scopes
if not scope_name:
scope_builder = next(iter(scopes_dict.values()))
else:
scope_builder = scopes_dict.get(scope_name)
if not scope_builder:
scope_builder = getattr(self.memory_config, "base_isolation", None)
if not scope_builder:
from zhenxun.services.ai.context.memory.types import Isolation
scope_builder = Isolation.AGENT_USER()
selector = scope_builder.resolve(
deps=context.deps,
prefix="",
default_namespace=ns,
default_agent=context.run.agent_name,
)
parts = selector.get_scope_parts()
all_scopes = {"/"}
current_path = ""
for part in parts:
current_path += f"/{part}"
all_scopes.add(current_path)
accessible_scopes = list(all_scopes)
accessible_scopes.sort(key=lambda x: len(x.split("/")))
scope_name_mapping = {}
if (
self.memory_config
and self.memory_config.long_term
and self.memory_config.long_term.scopes
):
for name, builder in self.memory_config.long_term.scopes.items():
sel = builder.resolve(
deps=context.deps,
prefix="",
default_namespace=ns,
default_agent=context.run.agent_name,
)
scope_name_mapping[sel.scope_prefix] = name
session_meta = SessionMetadata(
session_id=context.session_id or "default_session",
selector=selector,
scope_prefix=selector.scope_prefix,
accessible_scopes=accessible_scopes,
scope_name_mapping=scope_name_mapping,
)
return scope, session_meta
async def get_tools(self, context: RunContext | None = None) -> dict[str, BaseTool]:
tools = await super().get_tools(context)
if not self.memory_config or not self.memory_config.long_term.enable:
return tools
scopes_dict = self.memory_config.long_term.scopes
if not scopes_dict:
return tools
scope_keys = tuple(scopes_dict.keys())
if len(scope_keys) > 1:
ScopeType = Literal[scope_keys]
SaveArgs = create_model(
"SaveMemoryArgs",
content=(str, Field(..., description="要保存的记忆内容")),
importance=(float, Field(default=0.5, description="重要性(0-1)")),
scope=(
ScopeType,
Field(..., description="选择记忆存储的隔离分区"),
),
)
SearchArgs = create_model(
"SearchMemoryArgs",
query=(str, Field(..., description="搜索关键词或问题")),
filters=(
str | None,
Field(default=None, description="可选的元数据过滤JSON字符串"),
),
scope=(
Optional[ScopeType], # noqa
Field(
default=None,
description="搜索特定的分区。留空则跨所有有权分区混合检索!",
),
),
)
UpdateArgs = create_model(
"UpdateMemoryArgs",
record_id=(str, Field(..., description="要更新的记忆ID")),
new_content=(str, Field(..., description="新的记忆内容")),
importance=(float, Field(default=0.5, description="重要性(0-1)")),
scope=(
ScopeType,
Field(..., description="指定该记忆所在的隔离分区"),
),
)
DeleteArgs = create_model(
"DeleteMemoryArgs",
record_id=(str, Field(..., description="要删除的记忆ID")),
scope=(
ScopeType,
Field(..., description="指定该记忆所在的隔离分区"),
),
)
for t_name, t in tools.items():
if t_name.endswith("save_memory"):
t.args_schema = SaveArgs
elif t_name.endswith("search_memory"):
t.args_schema = SearchArgs
elif t_name.endswith("update_memory"):
t.args_schema = UpdateArgs
elif t_name.endswith("delete_memory"):
t.args_schema = DeleteArgs
return tools
@tool(
name="save_memory",
description="保存关于用户的重要设定、偏好或事实到长期记忆中。",
)
async def save_memory(
self, content: str, context: RunContext, importance: float = 0.5, **kwargs
) -> ToolResult:
scope_name = kwargs.get("scope")
scope, meta = self._get_runtime_meta_and_scope(context, scope_name)
if not scope:
return ToolResult(output="错误:未启用或未配置长期记忆后端。").as_error()
await scope.remember(session=meta, content=content, importance=importance)
logger.debug(
f"[Agentic Memory] AI 主动存入记忆: {content} (重要性: {importance})"
)
return ToolResult(output="记忆已成功排入系统后台合并存入。")
@tool(
name="search_memory",
description=(
"在长期记忆中主动检索关于用户的设定和事实。"
"支持自然语言模糊搜索。"
"可选的 filters 参数用于元数据精确匹配。如果不需要过滤,"
"请直接省略该参数,不要传入空字典或空字符串。"
),
)
async def search_memory(
self,
query: str,
context: RunContext,
**kwargs,
) -> ToolResult:
scope_name = kwargs.get("scope")
scope, meta = self._get_runtime_meta_and_scope(context, scope_name)
if not scope:
return ToolResult(output="错误:未启用或未配置长期记忆后端。").as_error()
filters = kwargs.get("filters")
parsed_filters = None
if isinstance(filters, dict):
parsed_filters = filters
elif isinstance(filters, str) and filters.strip() and filters.strip() != "{}":
import json
try:
parsed_filters = json.loads(filters)
except Exception:
pass
if scope_name is not None:
meta.accessible_scopes = [meta.scope_prefix]
matches = await scope.recall(
session=meta,
query=query,
limit=5,
metadata_filter=parsed_filters,
)
if not matches:
return ToolResult(
output="未检索到相关记忆。这可能是你们关于此话题的首次探讨,请直接根据常识回答或向用户确认。"
)
results = [
(
f"ID: {m.record.id} | 内容: {m.record.content} | "
f"重要性: {m.record.metadata.get('importance', 0.5)}"
)
for m in matches
]
return ToolResult(output="检索到的记忆如下:\n" + "\n".join(results))
@tool(
name="update_memory",
description="更新指定ID的长期记忆内容。当你发现用户的某个旧设定发生改变时,请使用此工具覆盖旧记忆。",
)
async def update_memory(
self,
record_id: str,
new_content: str,
context: RunContext,
importance: float = 0.5,
**kwargs,
) -> ToolResult:
scope_name = kwargs.get("scope")
scope, meta = self._get_runtime_meta_and_scope(context, scope_name)
if not scope:
return ToolResult(output="错误:未启用或未配置长期记忆后端。").as_error()
success = await scope.update(
session=meta,
record_id=record_id,
new_content=new_content,
importance=importance,
)
if success:
logger.debug(
f"[Agentic Memory] AI 主动更新记忆: {record_id} -> {new_content}"
)
return ToolResult(
output=f"记忆 {record_id} 更新成功。新内容:{new_content}"
)
return ToolResult(
output=f"更新失败:未找到ID为 {record_id} 的记忆。"
).as_error()
@tool(
name="delete_memory",
description="根据记忆的唯一ID删除已经作废或过期的用户记忆。",
)
async def delete_memory(
self, record_id: str, context: RunContext, **kwargs
) -> ToolResult:
scope_name = kwargs.get("scope")
scope, meta = self._get_runtime_meta_and_scope(context, scope_name)
if not scope:
return ToolResult(output="错误:未启用或未配置长期记忆后端。").as_error()
deleted_count = await scope.forget(session=meta, record_ids=[record_id])
if deleted_count > 0:
logger.info(f"[Agentic Memory] AI 主动删除记忆: {record_id}")
return ToolResult(output=f"记忆 {record_id} 删除成功。")
return ToolResult(
output=f"删除失败:未找到ID为 {record_id} 的记忆。"
).as_error()
@@ -0,0 +1,129 @@
from zhenxun.services.ai.tools.core.tool import ServerSideTool
class WebSearchTool(ServerSideTool):
"""原生网页搜索工具"""
type_id = "web_search"
def __init__(
self,
name: str = "google_search",
description: str = "使用 Google 搜索查找实时信息。",
dynamic_threshold: float | None = None,
domain_filters: list[str] | None = None,
):
super().__init__(name=name, description=description)
self.dynamic_threshold = dynamic_threshold
self.domain_filters = domain_filters
class CodeExecutionTool(ServerSideTool):
"""原生代码沙箱执行工具"""
type_id = "code_execution"
def __init__(self, name: str = "code_execution", timeout: int | None = None):
super().__init__(
name=name, description="执行 Python 代码来解决数学或数据问题。"
)
self.timeout = timeout
class ComputerUseTool(ServerSideTool):
"""原生的桌面环境控制工具"""
type_id = "computer_use"
def __init__(self, display_width_px: int = 1024, display_height_px: int = 768):
super().__init__(name="computer_use", description="控制电脑用户界面。")
self.display_width_px = display_width_px
self.display_height_px = display_height_px
class FileSearchTool(ServerSideTool):
"""原生文件检索工具"""
type_id = "file_search"
def __init__(self, name: str = "file_search"):
super().__init__(name=name, description="搜索已索引的文件。")
class GoogleMapsTool(ServerSideTool):
"""原生谷歌地图检索工具"""
type_id = "google_map"
def __init__(self):
super().__init__(name="google_map", description="搜索 Google 地图数据。")
class UrlContextTool(ServerSideTool):
"""原生 URL 上下文拉取工具"""
type_id = "url_context"
def __init__(self):
super().__init__(name="url_context", description="从指定 URL 获取上下文信息。")
class Native:
"""
云端原生工具命名空间工厂 (Namespace Factory)。
为开发者提供统一的云端内置工具调用入口,享受顶级 IDE 补全体验。
此类工具仅会向大模型提供描述,物理执行发生在各大模型厂商的服务端。
"""
@classmethod
def web_search(
cls,
name: str = "google_search",
description: str = "使用 Google 搜索查找实时信息。",
dynamic_threshold: float | None = None,
domain_filters: list[str] | None = None,
) -> WebSearchTool:
"""
原生网页搜索引擎工具 (如 Google Search, Bing)。
"""
return WebSearchTool(name, description, dynamic_threshold, domain_filters)
@classmethod
def code_execution(
cls, name: str = "code_execution", timeout: int | None = None
) -> CodeExecutionTool:
"""
原生代码沙箱执行工具 (如 Gemini Code Execution, OpenAI Advanced Data Analysis)。
"""
return CodeExecutionTool(name, timeout)
@classmethod
def computer_use(
cls, display_width_px: int = 1024, display_height_px: int = 768
) -> ComputerUseTool:
"""
原生桌面环境控制工具 (如 Claude Computer Use)。
"""
return ComputerUseTool(display_width_px, display_height_px)
@classmethod
def file_search(cls, name: str = "file_search") -> FileSearchTool:
"""
原生文件检索工具。
"""
return FileSearchTool(name)
@classmethod
def google_map(cls) -> GoogleMapsTool:
"""
原生谷歌地图检索工具 (Gemini 独占)。
"""
return GoogleMapsTool()
@classmethod
def url_context(cls) -> UrlContextTool:
"""
原生 URL 上下文拉取工具。
"""
return UrlContextTool()
@@ -0,0 +1,132 @@
import json
from typing import Any, Literal
import httpx
from zhenxun.services.ai.core.stream_events import ToolStreamChunkEvent
from zhenxun.services.ai.run import RunContext
from zhenxun.services.ai.tools.core.decorators import tool
from zhenxun.services.ai.tools.core.toolkit import BaseToolkit
from zhenxun.services.ai.tools.models import ToolResult
from zhenxun.services.log import logger
class RestApiToolkit(BaseToolkit):
"""
通用 REST API 工具箱。
让大模型无需编写任何代码即可调用外部 HTTP 接口获取或提交数据。
"""
default_prefix = ""
default_instructions = (
"你拥有调用外部 REST API 接口的能力。\n"
"1. 使用 `make_request` 工具发起 HTTP 请求。\n"
"2. 如果请求失败,请分析返回的错误信息。可能需要调整请求参数 (params) 或请求体 (json_data)。\n" # noqa: E501
"3. 务必根据返回的 JSON 数据,提取对用户有用的信息进行回答。"
)
def __init__(
self,
base_url: str = "",
default_headers: dict[str, str] | None = None,
default_params: dict[str, Any] | None = None,
timeout: int = 30,
**kwargs: Any,
):
"""
初始化通用 REST API 工具箱。
参数:
base_url: 接口基础 URL,默认发起请求时将与其拼接。
default_headers: 发起请求时携带的默认 Headers 请求头。
default_params: 发起请求时携带的默认 URL 查询参数。
timeout: 请求超时时间,单位为秒。
kwargs: 其他透传给 BaseToolkit 的参数。
"""
super().__init__(**kwargs)
self.base_url = base_url.rstrip("/") if base_url else ""
self.default_headers = default_headers or {}
self.default_params = default_params or {}
self.timeout = timeout
@tool(name="make_request")
async def make_request(
self,
endpoint: str,
method: Literal["GET", "POST", "PUT", "DELETE", "PATCH"] = "GET",
params: dict[str, Any] | None = None,
json_data: dict[str, Any] | None = None,
context: RunContext | None = None,
) -> ToolResult:
"""
发起 HTTP 请求获取外部数据。
Args:
endpoint: API的路径(若初始化了base_url,将自动拼接)。如果是一个完整的URL(以http开头),则直接请求该URL。
method: HTTP 请求方法。
params: URL 查询参数字典。
json_data: JSON 请求体字典。
""" # noqa: E501
if endpoint.startswith("http://") or endpoint.startswith("https://"):
url = endpoint
else:
url = (
f"{self.base_url}/{endpoint.lstrip('/')}" if self.base_url else endpoint
)
merged_params = self.default_params.copy()
if params:
merged_params.update(params)
logger.info(f"🌐 [RestApiToolkit] 正在请求 {method} {url}")
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.request(
method=method,
url=url,
params=merged_params,
json=json_data,
headers=self.default_headers,
)
try:
response_data = response.json()
except json.JSONDecodeError:
response_data = response.text
result_dict = {"status_code": response.status_code, "data": response_data}
is_error = response.status_code >= 400
result = ToolResult(output=result_dict)
if context and context.run.event_bus:
msg = (
f"🌐 已调用 API: {url}"
if not is_error
else f"❌ API 调用失败 (Status: {response.status_code})"
)
await context.run.event_bus.emit(
ToolStreamChunkEvent(
tool_name=context.call.tool_name if context else "make_request",
content=msg,
)
)
if is_error:
result = result.as_error()
return result
except Exception as e:
logger.error(f"RestApiToolkit 请求失败: {e}")
if context and context.run.event_bus:
await context.run.event_bus.emit(
ToolStreamChunkEvent(
tool_name=context.call.tool_name if context else "make_request",
content="❌ API 网络请求发生框架级错误",
)
)
return ToolResult(
output=f"网络请求失败: {type(e).__name__} - {e}"
).as_error()
@@ -0,0 +1,425 @@
import asyncio
from typing import Any, Protocol
from zhenxun.services.ai.core.stream_events import ToolStreamChunkEvent, UserCustomEvent
from zhenxun.services.ai.run import Inject, RunContext
from zhenxun.services.ai.sandbox.models import (
SandboxBlueprint,
SandboxExecutionResult,
)
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 ToolResult
from zhenxun.services.log import logger
from zhenxun.utils.pydantic_compat import model_copy
class PythonPluginProtocol(Protocol):
@property
def supports_state(self) -> bool: ...
async def execute(
self,
code: str,
timeout: int = 30,
injected_code: str | None = None,
) -> SandboxExecutionResult: ...
class SandboxToolkit(BaseToolkit):
default_prefix = ""
default_instructions = (
"## 🖥️ 沙箱工作区交互规范\n"
"你拥有物理隔离的沙箱环境。请严格遵循以下调度规则:\n"
"1. **短时/非交互任务**:直接使用 `execute_code`(如数据计算、算法运行)。"
"⚠️ 该工具**严禁包含 `input()`** 等阻塞式交互。\n"
"2. **长驻/交互式任务**:若需运行 Web Server 或含 `input()` 的交互程序,"
"**必须**:\n"
" - 先用 `write_sandbox_file` 将代码保存至当前工作区。\n"
" - 再用 `execute_terminal_command(is_interactive=True)` 启动并挂起进程。\n"
" - 通过 `send_sandbox_input` / `read_sandbox_screen` 与屏幕画面交互。\n"
"3. **终端互斥锁**:虚拟终端只能单线程运行前台程序。"
"若程序报错或死循环卡死,**必须**先调用 `interrupt_sandbox` "
"打断它,方可进行后续修改。"
)
def __init__(
self,
blueprint: SandboxBlueprint | None = None,
sandbox_session_id: str | None = None,
**kwargs: Any,
):
"""
初始化沙箱工作区工具箱。
参数:
blueprint: 沙箱蓝图配置对象,包含沙箱运行时的各种参数定义(例如是否需要状态、端口映射等)。
sandbox_session_id: 显式指定的沙箱会话 ID。如果不指定,则自动与当前执行上下文绑定。
kwargs: 其他透传给 BaseToolkit 的参数。
""" # noqa: E501
super().__init__(**kwargs)
self.blueprint = blueprint or SandboxBlueprint()
self.sandbox_session_id = sandbox_session_id
async def _get_session(self, context: RunContext, sandbox: Any) -> Any:
"""获取或创建沙箱会话实例"""
session_id = (
self.sandbox_session_id or context.session_id or "default_sandbox_session"
)
return await sandbox.get_or_create_session(session_id, self.blueprint)
def _get_pty(self, context: RunContext) -> Any:
"""从上下文获取虚拟终端"""
session_id = self.sandbox_session_id or context.session_id or "default"
return context.session.shared_state.get(f"pty_{session_id}")
@tool(
name="execute_code",
description=(
"在沙箱环境中执行代码。\n"
"支持多语言执行,请在 language 参数中指定具体的编程语言"
"(如 python, bash 等)。\n"
"默认超时时间为 45 秒。如果你的代码需要更长的时间或等待用户输入,"
"它将被挂在后台运行。\n"
"你将收到目前的屏幕输出,并可以决定后续操作。"
),
)
async def execute_code(
self,
code: str,
language: str,
context: RunContext,
sandbox: Inject.Sandbox,
) -> ToolResult:
session_id = (
self.sandbox_session_id or context.session_id or "default_sandbox_session"
)
logger.info(
f"大模型请求执行 {language} 代码 (Session: {session_id}, "
f"长度: {len(code)} 字符)"
)
from zhenxun.services.ai.sandbox.runtimes import CodeExecutorRegistry
ns = getattr(context.session, "namespace", "global") if context else "global"
supported = CodeExecutorRegistry.get_supported_languages(ns)
if CodeExecutorRegistry._normalize_lang(language) not in supported:
from zhenxun.services.ai.core.exceptions import ToolRetryError
raise ToolRetryError(
f"当前沙箱不支持该语言 '{language}'。支持的语言有: {supported}。"
"请换用支持的语言重新编写代码!"
)
bp = model_copy(self.blueprint, deep=True)
if context.run.event_bus:
await context.run.event_bus.emit(
ToolStreamChunkEvent(
tool_name="Sandbox", content="正在分析代码依赖并分配沙箱环境..."
)
)
executor = await self._get_session(context, sandbox)
state_key = f"code_exec_{session_id}_{language}"
code_executor = context.session.shared_state.get(state_key)
if code_executor and getattr(code_executor, "session", None) is not executor:
code_executor = None
if not code_executor:
code_executor = CodeExecutorRegistry.create_executor(
language, bp.needs_state, executor, namespace=ns
)
context.session.shared_state[state_key] = code_executor
if context.run.event_bus:
await context.run.event_bus.emit(
ToolStreamChunkEvent(
tool_name="Sandbox",
content=f"沙箱已就绪,正在后台执行 {language} 代码...",
)
)
import re
clean_code = code.strip()
match = re.search(
r"^```[a-zA-Z0-9_-]*\r?\n(.*?)\r?\n```$",
clean_code,
re.IGNORECASE | re.DOTALL,
)
if match:
clean_code = match.group(1)
line_buffer = ""
async def _stream_output(stream_type: str, data: bytes):
nonlocal line_buffer
line_buffer += data.decode("utf-8", errors="replace")
try:
result = await code_executor.execute_code(
code=clean_code,
timeout=45,
on_output=_stream_output,
)
except Exception as e:
logger.error(f"沙箱执行框架异常: {e}")
from zhenxun.services.ai.core.exceptions import AbortException
raise AbortException(
reason=f"System Error: 容器执行环境不可用,异常信息: {e}",
display=f"❌ 沙箱框架异常: {e}",
)
output_parts = []
if result.exit_code != 0 or result.stderr:
output_parts.append(f"Exit Code: {result.exit_code}")
if result.stdout:
output_parts.append(f"Stdout:\n{result.stdout.strip()[:2000]}")
if result.stderr:
output_parts.append(f"Stderr:\n{result.stderr.strip()[:2000]}")
if result.error:
output_parts.append(f"System Error:\n{result.error}")
output_text = "\n".join(output_parts)
system_notice = ""
if result.is_timeout:
system_notice = (
"\n\n⚠️ 警告: 代码执行超时!进程仍在后台挂起,"
"若为死循环请立刻使用 `interrupt_sandbox`。"
)
if result.stderr and "StdinNotImplementedError" in result.stderr:
system_notice += (
"\n\n🚨 致命错误: 当前环境不支持 input()。"
"请将代码写入文件并通过 "
"execute_terminal_command(is_interactive=True) 运行!"
)
image_bytes_list = []
if getattr(result, "images", None):
import base64
for b64_str in result.images:
try:
image_bytes_list.append(base64.b64decode(b64_str))
except Exception:
pass
if getattr(result, "artifacts", None):
for filename, file_bytes in result.artifacts.items():
if filename.endswith((".png", ".jpg", ".jpeg")):
image_bytes_list.append(file_bytes)
from zhenxun.services.ai.core.messages import (
ImagePart,
LLMContentPart,
TextPart,
)
final_output: list[LLMContentPart] = [TextPart(text=output_text.strip())]
for img_bytes in image_bytes_list:
final_output.append(ImagePart(raw=img_bytes))
final_output_text = output_text.strip()
if system_notice:
final_output_text += f"\n\n{system_notice}"
final_output[0] = TextPart(text=final_output_text)
result = ToolResult(
output=final_output if len(final_output) > 1 else final_output_text
)
if len(image_bytes_list) > 0 and context and context.run.event_bus:
await context.run.event_bus.emit(UserCustomEvent(display=final_output))
return result
@tool(
name="execute_terminal_command",
description=(
"在沙箱的终端中执行 Shell 命令"
"(例如 `python3 script.py` 或 `npm start`)。\n"
"如果只是执行普通的短时非交互脚本,"
"保持 is_interactive=False 即可(执行速度极快且稳定)。\n"
"如果程序包含 `input()` 或需要长期驻留(如 Server),"
"请务必设置 is_interactive=True 开启虚拟屏幕模式!"
),
)
async def execute_terminal_command(
self,
command: str,
context: RunContext,
sandbox: Inject.Sandbox,
is_interactive: bool = False,
) -> ToolResult:
session_id = self.sandbox_session_id or context.session_id or "default"
if context.run.event_bus:
await context.run.event_bus.emit(
ToolStreamChunkEvent(
tool_name="Sandbox", content=f"正在虚拟终端执行命令: {command} ..."
)
)
executor = await self._get_session(context, sandbox)
if not is_interactive:
res = await executor.run_process(command)
if getattr(res, "is_timeout", False):
return ToolResult(
output=(
"⚠️ 警告: 命令执行超时!若程序需常驻或等待输入,"
f"请务必设置 is_interactive=True。\n"
f"Stdout: {res.stdout}\nStderr: {res.stderr}"
)
).as_error()
return ToolResult(
output=(
f"Exit Code: {res.exit_code}\n"
f"Stdout: {res.stdout}\n"
f"Stderr: {res.stderr}"
)
)
pty = self._get_pty(context)
if pty:
await pty.close()
interactive_session = await executor.create_pty_session()
context.session.shared_state[f"pty_{session_id}"] = interactive_session
try:
await interactive_session.start(command)
await asyncio.sleep(1.5)
screen = await interactive_session.read_output()
return ToolResult(
output=f"📺 虚拟终端已启动,屏幕快照:\n```text\n{screen}\n```"
)
except Exception as e:
return ToolResult(output=f"虚拟屏幕启动异常: {e}").as_error()
@tool(
name="send_sandbox_input",
description=(
"向当前沙箱中正在挂起运行的后台进程"
"(如等待 input() 的 Python 脚本)发送输入文本。\n"
"注意:你需要自己在文本末尾加上换行符 \\n 来模拟回车键。"
),
)
async def send_sandbox_input(self, text: str, context: RunContext) -> ToolResult:
interactive_session = self._get_pty(context)
if not interactive_session:
return ToolResult(
output=(
"错误:没有运行中的交互式虚拟屏幕。"
"请先调用 execute_terminal_command(is_interactive=True)。"
)
).as_error()
text = text.replace("\\n", "\n").replace("\\r", "\r").replace("\\t", "\t")
await interactive_session.send_input(text)
await asyncio.sleep(1.5)
output = await interactive_session.read_output(timeout=5)
if context and context.run.event_bus:
await context.run.event_bus.emit(
ToolStreamChunkEvent(
tool_name=context.call.tool_name, content="⌨️ 已向后台进程发送输入"
)
)
return ToolResult(
output=f"已发送按键。📺 屏幕刷新后快照如下:\n```text\n{output}\n```"
)
@tool(
name="read_sandbox_screen",
description=(
"主动窥探并读取当前虚拟屏幕的画面。当你觉得后台程序可能已经渲染出新内容时,可以使用此工具。"
),
)
async def read_sandbox_screen(self, context: RunContext) -> ToolResult:
interactive_session = self._get_pty(context)
if not interactive_session:
return ToolResult(output="没有运行中的虚拟屏幕。").as_error()
output = await interactive_session.read_output()
return ToolResult(output=f"📺 当前屏幕快照:\n```text\n{output}\n```")
@tool(
name="interrupt_sandbox",
description=(
"向当前沙箱发送 Ctrl+C (SIGINT) 信号,强制中断正在死循环或挂起的后台进程。"
),
)
async def interrupt_sandbox(self, context: RunContext) -> ToolResult:
interactive_session = self._get_pty(context)
if not interactive_session:
return ToolResult(output="没有运行中的虚拟屏幕需要中断。").as_error()
await interactive_session.interrupt()
await asyncio.sleep(1)
output = await interactive_session.read_output()
if context and context.run.event_bus:
await context.run.event_bus.emit(
ToolStreamChunkEvent(
tool_name=context.call.tool_name, content="🛑 已强制中断后台进程"
)
)
return ToolResult(
output="✅ 成功发送 Ctrl+C 中断信号。"
f"📺 当前屏幕快照:\n```text\n{output}\n```"
)
@tool(
name="write_sandbox_file",
description="将文本内容写入沙箱文件系统中,支持保存大块数据或配置,避免超过对话上下文。",
)
@silent()
async def write_sandbox_file(
self, path: str, content: str, context: RunContext, sandbox: Inject.Sandbox
) -> ToolResult:
executor = await self._get_session(context, sandbox)
success = await executor.write(path, content.encode("utf-8"))
if success:
logger.info(f"📝 已向沙箱写入文件: {path}")
return ToolResult(output=f"成功将内容写入文件: {path}")
else:
from zhenxun.services.ai.core.exceptions import AbortException
raise AbortException(
reason="写入文件失败 (当前沙箱环境失联或不支持持久化IO)",
display="❌ 写入文件失败:沙箱失联",
)
@tool(
name="read_sandbox_file",
description="从沙箱文件系统中读取指定文件的文本内容。",
)
@silent()
async def read_sandbox_file(
self, path: str, context: RunContext, sandbox: Inject.Sandbox
) -> ToolResult:
executor = await self._get_session(context, sandbox)
try:
content_bytes = await executor.read(path)
content = content_bytes.decode("utf-8", errors="replace")
if content.startswith("Error:") or content.startswith("Failed to"):
return ToolResult(output=content).as_error()
logger.info(f"已读取沙箱文件 {path} (共 {len(content)} 字符)")
return ToolResult(output=content)
except Exception as e:
from zhenxun.services.ai.core.exceptions import AbortException
raise AbortException(reason=f"读取文件发生框架级异常: {e}")
@@ -0,0 +1,368 @@
import asyncio
from contextlib import asynccontextmanager
import time
from typing import Any, Literal
from pydantic import Field, create_model
from zhenxun.services.ai.context.memory.manager import memory_manager
from zhenxun.services.ai.context.memory.models import MemoryConfig
from zhenxun.services.ai.context.memory.types import (
MemorySlot,
SessionMetadata,
)
from zhenxun.services.ai.run.context import RunContext
from zhenxun.services.ai.tools.core.decorators import tool
from zhenxun.services.ai.tools.core.tool import BaseTool
from zhenxun.services.ai.tools.core.toolkit import BaseToolkit
from zhenxun.services.ai.tools.models import ToolOptions, ToolResult
_SLOT_LOCKS: dict[str, asyncio.Lock] = {}
_GLOBAL_LOCK = asyncio.Lock()
@asynccontextmanager
async def _slot_lock(session_id: str, label: str):
key = f"{session_id}:{label}"
async with _GLOBAL_LOCK:
if key not in _SLOT_LOCKS:
_SLOT_LOCKS[key] = asyncio.Lock()
lock = _SLOT_LOCKS[key]
async with lock:
yield
class MemorySlotToolkit(BaseToolkit):
"""
中期记忆槽工具箱。
向大模型开放直接编辑上下文 XML 节点的能力。
"""
default_prefix = ""
class Config:
"""声明式配置:该工具箱下的所有工具默认静默"""
shared_options = ToolOptions(silent=True)
default_instructions = """\
## 📋 状态与规则面板 (Memory Slots / 中期记忆)
该系统是你的「桌面便利贴」或「共享黑板」,用于保存你当前需要随时查阅的核心状态与全局规范。
### 💡 核心机制
- 被保存在记忆槽中的内容(如果已置顶),会在每次对话时**直接注入到你的上下文提示词中**,你无需任何搜索即可看见。
- 槽位容量极其有限,仅用于维持当前最新的运行状态。
### 📝 何时使用记忆槽?
- **维护全局规则**:例如设定"用户整体偏好"、"沟通口吻"、"全局指导原则"等需要时刻遵守的规范(使用 `update_slot`)。
- **追踪当前进度**:例如记录"待办事项清单"、"当前任务进度"、"上下文摘要"(使用 `append_slot` 列表或 `update_slot` 覆盖)。
### ⚙️ 操作规范
1. **探索可用面板**:接手新任务时,可使用 `list_slots` 宏观查看当前存在哪些状态面板。
2. **保持精简**:槽位有严格的字符数限制。当内容过长时,请主动将其归档到长期记忆后,重新提炼并覆盖槽位,或直接调用 `delete_slot` 删除不再需要的槽位。\
""" # noqa: E501
def __init__(
self,
memory_config: MemoryConfig | None = None,
namespace: str | None = None,
**kwargs: Any,
):
"""
初始化中期记忆槽工具箱。
参数:
memory_config: 记忆系统的全局配置对象,为空则使用全局默认。
namespace: 当前隔离环境的命名空间。
kwargs: 其他透传给 BaseToolkit 的参数。
"""
super().__init__(**kwargs)
self.memory_config = memory_config
self._namespace = namespace
def _get_runtime_meta_and_ctx(
self, context: RunContext, scope_name: str | None = None
) -> tuple[Any, SessionMetadata]:
ns = self._namespace or getattr(context.session, "namespace", "global")
slot_ctx = memory_manager.get_slot_context(self.memory_config, namespace=ns)
scope_builder = None
if (
self.memory_config
and self.memory_config.slots
and self.memory_config.slots.scopes
):
scopes_dict = self.memory_config.slots.scopes
if not scope_name:
scope_builder = next(iter(scopes_dict.values()))
else:
scope_builder = scopes_dict.get(scope_name)
if not scope_builder:
scope_builder = getattr(self.memory_config, "base_isolation", None)
if not scope_builder:
from zhenxun.services.ai.context.memory.types import Isolation
scope_builder = Isolation.AGENT_USER()
selector = scope_builder.resolve(
deps=context.deps,
prefix="",
default_namespace=ns,
default_agent=context.run.agent_name,
)
parts = selector.get_scope_parts()
all_scopes = {"/"}
current_path = ""
for part in parts:
current_path += f"/{part}"
all_scopes.add(current_path)
accessible_scopes = list(all_scopes)
accessible_scopes.sort(key=lambda x: len(x.split("/")))
scope_name_mapping = {}
if (
self.memory_config
and self.memory_config.slots
and self.memory_config.slots.scopes
):
for name, builder in self.memory_config.slots.scopes.items():
sel = builder.resolve(
deps=context.deps,
prefix="",
default_namespace=ns,
default_agent=context.run.agent_name,
)
scope_name_mapping[sel.scope_prefix] = name
session_meta = SessionMetadata(
session_id=context.session_id or "default_session",
selector=selector,
scope_prefix=selector.scope_prefix,
accessible_scopes=accessible_scopes,
scope_name_mapping=scope_name_mapping,
)
return slot_ctx, session_meta
async def get_tools(self, context: RunContext | None = None) -> dict[str, BaseTool]:
tools = await super().get_tools(context)
if (
not self.memory_config
or not self.memory_config.slots
or not self.memory_config.slots.enable
):
return tools
scopes_dict = self.memory_config.slots.scopes
if not scopes_dict:
return tools
scope_keys = tuple(scopes_dict.keys())
if len(scope_keys) > 1:
ScopeType = Literal[scope_keys]
UpdateArgs = create_model(
"UpdateSlotArgs",
label=(str, Field(..., description="槽位标签名称")),
content=(str, Field(..., description="槽位内容(全量覆写)")),
description=(str, Field(default="", description="槽位的简要说明")),
scope=(
ScopeType,
Field(..., description="选择记忆存储的隔离分区"),
),
)
AppendArgs = create_model(
"AppendSlotArgs",
label=(str, Field(..., description="槽位标签名称")),
text=(str, Field(..., description="要追加的文本内容")),
scope=(
ScopeType,
Field(..., description="选择记忆存储的隔离分区"),
),
)
ReadArgs = create_model(
"ReadSlotArgs",
label=(str, Field(..., description="槽位标签名称")),
scope=(
ScopeType,
Field(..., description="选择记忆存储的隔离分区"),
),
)
DeleteArgs = create_model(
"DeleteSlotArgs",
label=(str, Field(..., description="槽位标签名称")),
scope=(
ScopeType,
Field(..., description="选择记忆存储的隔离分区"),
),
)
ListArgs = create_model(
"ListSlotsArgs",
scope=(
ScopeType,
Field(..., description="选择记忆存储的隔离分区"),
),
)
for t_name, t in tools.items():
if t_name.endswith("update_slot"):
t.args_schema = UpdateArgs
elif t_name.endswith("append_slot"):
t.args_schema = AppendArgs
elif t_name.endswith("read_slot"):
t.args_schema = ReadArgs
elif t_name.endswith("delete_slot"):
t.args_schema = DeleteArgs
elif t_name.endswith("list_slots"):
t.args_schema = ListArgs
return tools
@tool(
description="列出当前所有可用的记忆槽(包括未置顶显示的槽位),方便你了解有哪些信息可供读取或更新。"
)
async def list_slots(self, context: RunContext, **kwargs) -> ToolResult:
scope_name = kwargs.get("scope")
slot_ctx, meta = self._get_runtime_meta_and_ctx(context, scope_name)
if not slot_ctx:
return ToolResult(output="错误:未配置记忆槽后端").as_error()
slots = await slot_ctx.list_all_slots(meta)
if not slots:
return ToolResult(output="当前没有任何记忆槽。")
res = ["已创建的记忆槽列表:"]
show_scope = False
if (
self.memory_config
and self.memory_config.slots
and self.memory_config.slots.scopes
and len(self.memory_config.slots.scopes) > 1
):
show_scope = True
for s in slots:
pin_str = "置顶" if s.pinned else "隐藏"
if show_scope:
semantic_name = meta.scope_name_mapping.get(s.scope, "未知")
res.append(
f"- [{s.label}] (分区: "
f"{semantic_name}, {pin_str}) - {s.description}"
)
else:
res.append(f"- [{s.label}] ({pin_str}) - {s.description}")
return ToolResult(output="\n".join(res))
@tool(description="读取某个尚未展示在上下文中的记忆槽完整内容。")
async def read_slot(self, label: str, context: RunContext, **kwargs) -> ToolResult:
scope_name = kwargs.get("scope")
slot_ctx, meta = self._get_runtime_meta_and_ctx(context, scope_name)
if not slot_ctx:
return ToolResult(output="错误:未配置记忆槽后端").as_error()
slot = await slot_ctx.get_slot(meta, label)
if not slot:
return ToolResult(output=f"未找到标签为 '{label}' 的槽位。").as_error()
return ToolResult(output=f"[{label}] 内容:\n{slot.content}")
@tool(description=("更新或新建记忆槽的内容(全量覆写)。"))
async def update_slot(
self,
label: str,
content: str,
context: RunContext,
description: str = "",
**kwargs,
) -> ToolResult:
scope_name = kwargs.get("scope")
slot_ctx, meta = self._get_runtime_meta_and_ctx(context, scope_name)
if not slot_ctx:
return ToolResult(output="错误:未配置记忆槽后端").as_error()
target_sid = meta.scope_prefix
async with _slot_lock(target_sid, label):
slot = await slot_ctx.get_slot(meta, label)
if not slot:
slot = MemorySlot(
label=label,
content=content,
scope=meta.scope_prefix,
description=description,
)
else:
slot.content = content
slot.scope = meta.scope_prefix
if description:
slot.description = description
slot.updated_at = time.time()
if len(content) > slot.size_limit:
return ToolResult(
output=(
f"错误:内容长度超过限制 ({len(content)} > {slot.size_limit})。"
)
).as_error()
await slot_ctx.set_slot(meta, slot)
return ToolResult(output=f"已成功将 '{label}' 更新至记忆槽中。")
@tool(description="在指定记忆槽的末尾追加文本(例如追加待办事项清单)。")
async def append_slot(
self, label: str, text: str, context: RunContext, **kwargs
) -> ToolResult:
scope_name = kwargs.get("scope")
slot_ctx, meta = self._get_runtime_meta_and_ctx(context, scope_name)
if not slot_ctx:
return ToolResult(output="错误:未配置记忆槽后端").as_error()
slot = await slot_ctx.get_slot(meta, label)
if not slot:
return ToolResult(
output=(
f"错误:标签为 '{label}' 的槽位不存在,请先使用 update_slot 创建。"
)
).as_error()
target_sid = meta.scope_prefix
async with _slot_lock(target_sid, label):
slot = await slot_ctx.get_slot(meta, label)
if not slot:
return ToolResult(
output="错误:并发写入异常,槽位已被删除。"
).as_error()
sep = "\n" if slot.content and not slot.content.endswith("\n") else ""
new_content = f"{slot.content}{sep}{text}"
if len(new_content) > slot.size_limit:
return ToolResult(
output=(
"错误:追加后总长度超过限制 "
f"({len(new_content)} > {slot.size_limit})。"
)
).as_error()
slot.content = new_content
slot.updated_at = time.time()
await slot_ctx.set_slot(meta, slot)
return ToolResult(output=f"已成功追加至 '{label}'。")
@tool(description="删除不再需要的记忆槽(全量删除)。")
async def delete_slot(
self, label: str, context: RunContext, **kwargs
) -> ToolResult:
scope_name = kwargs.get("scope")
slot_ctx, meta = self._get_runtime_meta_and_ctx(context, scope_name)
if not slot_ctx:
return ToolResult(output="错误:未配置记忆槽后端").as_error()
target_sid = meta.scope_prefix
async with _slot_lock(target_sid, label):
await slot_ctx.delete_slot(meta, label, meta.scope_prefix)
return ToolResult(output=f"已成功删除槽位 '{label}'。")
@@ -0,0 +1,3 @@
from .provider import GlobalMCPProvider, MCPSource, mcp_provider
__all__ = ["GlobalMCPProvider", "MCPSource", "mcp_provider"]
@@ -0,0 +1,366 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
import json
import re
from typing import Any, Literal, cast
from pydantic import BaseModel, Field
from zhenxun.configs.path_config import DATA_PATH
from zhenxun.services.ai.core.protocols.tool import (
ToolExecutable,
ToolProvider,
ToolResolvable,
)
from zhenxun.services.ai.sandbox.models import SandboxBlueprint
from zhenxun.services.ai.tools.models import ResolvedToolPayload
from zhenxun.services.ai.tools.providers.mcp.toolkit import MCPToolkit
from zhenxun.services.log import logger
from zhenxun.utils.pydantic_compat import model_dump, model_validate, model_validator
MCP_PATH = DATA_PATH / "ai" / "mcp.json"
class MCPServerConfig(BaseModel):
transport: Literal["stdio", "sse", "streamable-http", "sandbox_proxy"] = Field(
default="stdio"
)
"""传输协议类型:stdio / sse / streamable-http / sandbox_proxy"""
url: str | None = Field(default=None)
"""远端地址(用于 sse 或 streamable-http)"""
timeout: int = Field(default=30)
"""请求超时时间(秒)"""
command: str | None = None
"""启动命令(用于 stdio 或 sandbox_proxy)"""
args: list[str] = Field(default_factory=list)
"""命令参数列表"""
env: dict[str, str] | None = None
"""启动子进程的环境变量(可选)"""
cwd: str | None = Field(default=None)
"""进程的工作目录"""
install_command: str | None = Field(default=None)
"""首次运行前的安装/预热命令"""
description: str | None = None
"""服务描述信息(用于配置可读性与展示)"""
enabled: bool = Field(default=True)
"""是否全局启用"""
admin_level: int = Field(default=0)
"""执行该服务器工具所需的群管等级"""
sandbox_blueprint: SandboxBlueprint | None = Field(default=None)
"""沙箱环境装配配置(用于 sandbox_proxy 自动处理依赖)"""
class MCPToolsConfig(BaseModel):
mcpServers: dict[str, MCPServerConfig] = Field(default_factory=dict)
class GlobalMCPProvider(ToolProvider):
def __init__(self):
self._config: MCPToolsConfig | None = None
self._toolkits: dict[str, MCPToolkit] = {}
self._discovery_lock = asyncio.Lock()
self._discovered_tools: dict[str, ToolExecutable] | None = None
self.env_provider: Callable[[Any], dict[str, str]] | None = None
self.header_provider: Callable[[Any], dict[str, str]] | None = None
self._code_registered_servers: dict[str, tuple[MCPServerConfig, bool]] = {}
def register_server(
self, name: str, config: MCPServerConfig, persist: bool = False
) -> None:
"""代码优先:动态注册 MCP 服务 (支持无痕内存级注册与热重载)"""
self._code_registered_servers[name] = (config, persist)
if self._config is not None:
if persist:
if name not in self._config.mcpServers:
self._config.mcpServers[name] = config
self._save_config()
else:
json_conf = self._config.mcpServers[name]
code_dict = model_dump(config, exclude_unset=True)
json_dict = model_dump(json_conf, exclude_unset=True)
merged_dict = {**code_dict, **json_dict}
merged_conf = model_validate(MCPServerConfig, merged_dict)
if model_dump(merged_conf) != model_dump(json_conf):
self._config.mcpServers[name] = merged_conf
self._save_config()
if name not in self._toolkits:
self._setup_toolkit(name, config)
logger.info(
f"🔄 MCP 代理 '{name}' 已完成动态热重载注册 (持久化: {persist})"
)
async def unregister_server(self, name: str) -> None:
"""动态注销 MCP 服务,关闭底层进程并从配置文件中移除"""
if name in self._code_registered_servers:
self._code_registered_servers.pop(name)
if self._config and name in self._config.mcpServers:
self._config.mcpServers.pop(name)
self._save_config()
if tk := self._toolkits.pop(name, None):
await tk.close()
logger.debug(f"🛑 MCP 代理 '{name}' 已注销并销毁所有连接")
def _save_config(self) -> None:
"""将当前配置持久化到 JSON 文件"""
if not self._config:
return
try:
with MCP_PATH.open("w", encoding="utf-8") as f:
data = model_dump(self._config, exclude_unset=True)
json.dump(data, f, ensure_ascii=False, indent=2)
except Exception as e:
logger.error(f"保存 MCP 配置失败: {e}", e=e)
async def initialize(self) -> None:
if self._config is not None:
return
if not MCP_PATH.exists():
MCP_PATH.parent.mkdir(parents=True, exist_ok=True)
self._config = MCPToolsConfig(
mcpServers={
"amap-maps": MCPServerConfig(
command="npx",
args=["-y", "@amap/amap-maps-mcp-server"],
env={"AMAP_MAPS_API_KEY": "Your_API_Key"},
enabled=False,
),
"bilibili-search": MCPServerConfig(
command="npx",
args=["bilibili-mcp"],
),
"fetch": MCPServerConfig(
command="uvx",
args=["mcp-server-fetch"],
),
"bingcn": MCPServerConfig(
command="npx",
args=["bing-cn-mcp"],
),
}
)
with MCP_PATH.open("w", encoding="utf-8") as f:
data = model_dump(self._config, exclude_unset=True)
json.dump(data, f, ensure_ascii=False, indent=2)
else:
try:
with MCP_PATH.open("r", encoding="utf-8") as f:
self._config = model_validate(MCPToolsConfig, json.load(f))
except Exception as e:
logger.error(f"加载 MCP 工具配置文件失败: {e}", e=e)
self._config = MCPToolsConfig()
config_changed = False
for name, (code_conf, persist) in self._code_registered_servers.items():
if persist:
if name not in self._config.mcpServers:
self._config.mcpServers[name] = code_conf
config_changed = True
else:
json_conf = self._config.mcpServers[name]
code_dict = model_dump(code_conf, exclude_unset=True)
json_dict = model_dump(json_conf, exclude_unset=True)
merged_dict = {**code_dict, **json_dict}
merged_conf = model_validate(MCPServerConfig, merged_dict)
if model_dump(merged_conf) != model_dump(json_conf):
self._config.mcpServers[name] = merged_conf
config_changed = True
if config_changed or not MCP_PATH.exists():
self._save_config()
for name, conf in self._config.mcpServers.items():
self._setup_toolkit(name, conf)
for name, (code_conf, persist) in self._code_registered_servers.items():
if not persist and name not in self._toolkits:
self._setup_toolkit(name, code_conf)
def _setup_toolkit(self, name: str, conf: MCPServerConfig) -> None:
"""辅助方法:装载单个 MCP Toolkit"""
clean_name = re.sub(r"[^a-zA-Z0-9]", "_", name)
prefix = f"mcp_{clean_name}_"
self._toolkits[name] = MCPToolkit(
server_name=name,
prefix=prefix,
transport=conf.transport,
command=conf.command,
args=conf.args,
url=conf.url,
env=conf.env,
cwd=conf.cwd,
install_command=conf.install_command,
timeout=conf.timeout,
admin_level=conf.admin_level,
header_provider=self.header_provider,
env_provider=self.env_provider,
sandbox_blueprint=conf.sandbox_blueprint,
)
async def shutdown(self):
for tk in self._toolkits.values():
await tk.close()
self._toolkits.clear()
async def get_tools_for_server(self, server_name: str) -> dict[str, ToolExecutable]:
return await self.discover_tools(allowed_servers=[server_name])
async def discover_tools(
self,
allowed_servers: list[str] | None = None,
excluded_servers: list[str] | None = None,
) -> dict[str, ToolExecutable]:
async with self._discovery_lock:
if not self._config:
await self.initialize()
if not self._config:
return {}
if (
self._discovered_tools is not None
and allowed_servers is None
and excluded_servers is None
):
return self._discovered_tools
servers_to_query: list[str] = []
for name, conf in self._config.mcpServers.items():
if allowed_servers and name not in allowed_servers:
continue
if excluded_servers and name in excluded_servers:
continue
if not conf.enabled and not allowed_servers:
continue
servers_to_query.append(name)
for name, (conf, persist) in self._code_registered_servers.items():
if persist:
continue
if allowed_servers and name not in allowed_servers:
continue
if excluded_servers and name in excluded_servers:
continue
if not conf.enabled and not allowed_servers:
continue
if name not in servers_to_query:
servers_to_query.append(name)
all_tools: dict[str, ToolExecutable] = {}
tasks = []
for s_name in servers_to_query:
if tk := self._toolkits.get(s_name):
tasks.append(tk.get_tools())
if tasks:
results = await asyncio.gather(*tasks, return_exceptions=True)
for res in results:
if isinstance(res, dict):
for name, t in res.items():
all_tools[name] = t
elif isinstance(res, list):
for t in res:
all_tools[t.name] = t
elif isinstance(res, Exception):
logger.error(f"MCP 并发发现工具失败: {res}")
if allowed_servers is None and excluded_servers is None:
self._discovered_tools = all_tools
return all_tools
async def get_tool_executable(
self, name: str, config: dict[str, Any]
) -> ToolExecutable | None:
_ = name, config
return None
class MCPSource(BaseModel):
"""显式定义的 MCP 工具源 (动态解析器)"""
server_name: str | None = None
"""目标 MCP 服务名(走全局注册表模式时使用)"""
namespace: str | None = None
"""可选命名空间标识(用于上层路由或分组)"""
config: MCPServerConfig | None = None
"""内联临时配置 (MCPServerConfig 实例)"""
fetch_all_enabled: bool = Field(default=False)
"""内部标识:是否拉取所有已启用的 MCP 服务"""
exclude_servers: list[str] | None = Field(default=None)
"""排除的服务名列表(仅当 fetch_all_enabled 为 True 时生效)"""
def __hash__(self):
return hash((self.server_name, self.namespace))
@classmethod
def all_enabled(cls, exclude: list[str] | None = None) -> "MCPSource":
"""
声明式获取所有已启用的 MCP 服务器下的工具。
参数:
exclude: 需要显式排除的 MCP 服务名称列表。
"""
return cls(fetch_all_enabled=True, exclude_servers=exclude)
@model_validator(mode="after")
def validate_source(self) -> "MCPSource":
if not self.server_name and not self.config and not self.fetch_all_enabled:
raise ValueError(
"MCPSource 必须提供 server_name 或 config,或者开启 fetch_all_enabled"
)
return self
async def resolve(self, context: Any | None = None) -> Any:
tools = []
if self.fetch_all_enabled:
server_tools_dict = await mcp_provider.discover_tools(
excluded_servers=self.exclude_servers
)
for t in server_tools_dict.values():
p = await cast(ToolResolvable, t).resolve(context)
if p:
tools.extend(p.tools)
return ResolvedToolPayload(tools=tools)
elif self.config:
import uuid
temp_server_name = self.server_name or f"inline_mcp_{uuid.uuid4().hex[:8]}"
inline_toolkit = MCPToolkit(
server_name=temp_server_name,
transport=self.config.transport,
command=self.config.command,
args=self.config.args,
url=self.config.url,
env=self.config.env,
cwd=self.config.cwd,
install_command=self.config.install_command,
timeout=self.config.timeout,
admin_level=self.config.admin_level,
sandbox_blueprint=self.config.sandbox_blueprint,
)
payload = await inline_toolkit.resolve(context)
payload.toolkits.insert(0, inline_toolkit)
return payload
else:
assert self.server_name is not None
server_tools = await mcp_provider.get_tools_for_server(self.server_name)
for t in server_tools.values():
p = await cast(ToolResolvable, t).resolve(context)
if p:
tools.extend(p.tools)
return ResolvedToolPayload(tools=tools)
mcp_provider = GlobalMCPProvider()
__all__ = ["GlobalMCPProvider", "MCPSource", "mcp_provider"]
@@ -0,0 +1,612 @@
import asyncio
import base64
from collections.abc import AsyncGenerator, Callable
from contextlib import AsyncExitStack, asynccontextmanager
import re
from typing import Any, Literal, cast
import anyio
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
import httpx
from mcp import ClientSession # type: ignore
from mcp.client.sse import sse_client
from mcp.client.stdio import StdioServerParameters, stdio_client
from mcp.client.streamable_http import streamable_http_client
from mcp.shared.message import SessionMessage
from pydantic import ValidationError
from zhenxun.services.ai.core.models import ToolDefinition
from zhenxun.services.ai.core.stream_events import UserCustomEvent
from zhenxun.services.ai.run import RunContext
from zhenxun.services.ai.sandbox.addons.base import BaseMcpProxyExtension
from zhenxun.services.ai.sandbox.models import SandboxBlueprint
from zhenxun.services.ai.tools.core.tool import BaseTool
from zhenxun.services.ai.tools.core.toolkit import BaseToolkit
from zhenxun.services.ai.tools.models import ToolkitConfig, ToolResult
from zhenxun.services.log import logger
from zhenxun.utils.lifespan import LifespanManager
from zhenxun.utils.pydantic_compat import model_dump
_MESSAGE_START_CHARS = {"{", "["}
_LITERAL_PREFIXES: tuple[str, ...] = ("true", "false", "null")
def _should_ignore_exception(exc: Exception) -> bool:
"""
判断该异常是否是由非 JSON 的脏数据标准输出引起的。
如果是脏数据,则可以安全忽略。
"""
if not isinstance(exc, ValidationError):
return False
errors = exc.errors()
first = next(iter(errors), None)
if not first or first.get("type") != "json_invalid":
return False
input_value = first.get("input")
if not isinstance(input_value, str):
return False
stripped = input_value.strip()
if not stripped:
return True
first_char = stripped[0]
lowered = stripped.lower()
if first_char in _MESSAGE_START_CHARS or any(
lowered.startswith(prefix) for prefix in _LITERAL_PREFIXES
):
return False
return True
@asynccontextmanager
async def filtered_stdio_client(
server_name: str, server: StdioServerParameters
) -> AsyncGenerator[
tuple[
MemoryObjectReceiveStream[SessionMessage | Exception],
MemoryObjectSendStream[SessionMessage],
],
None,
]:
"""
包裹官方的 stdio_client,拦截并过滤掉非 JSON 格式的标准输出噪音。
"""
async with stdio_client(server=server) as (read_stream, write_stream):
filtered_send, filtered_recv = anyio.create_memory_object_stream[
SessionMessage | Exception
](0)
async def _forward_stdout() -> None:
try:
async with read_stream:
async for item in read_stream:
if isinstance(item, Exception) and _should_ignore_exception(
item
):
if isinstance(item, ValidationError):
err_input = item.errors()[0].get("input", "")
logger.debug(
f"🔇 [MCP Stdout Filter] {server_name} 忽略脏数据: "
f"{str(err_input)[:100].strip()}..."
)
continue
await filtered_send.send(item)
except anyio.ClosedResourceError:
pass
finally:
await filtered_send.aclose()
async with anyio.create_task_group() as tg:
tg.start_soon(_forward_stdout)
try:
yield filtered_recv, write_stream
finally:
tg.cancel_scope.cancel()
class MCPRemoteTool(BaseTool):
"""远端 MCP 工具在本地的代理对象"""
def __init__(
self,
name: str,
original_tool_name: str,
description: str,
parameters: dict,
toolkit: "MCPToolkit",
):
super().__init__(name=name, description=description)
self.original_tool_name = original_tool_name
self.parameters = parameters
self.toolkit = toolkit
self.parent_toolkit = toolkit
self.args_schema = None
self.metadata = (
{"admin_level": toolkit.admin_level} if toolkit.admin_level > 0 else {}
)
@property
def effective_ttl(self) -> float:
"""动态获取当前最新的 TTL 配置,支持无缝热重载"""
try:
from zhenxun.services.ai.config import get_llm_config
mcp_ttl = get_llm_config().agent_settings.mcp_cleanup_timeout
return 31536000.0 if mcp_ttl <= 0 else float(mcp_ttl)
except Exception:
return 31536000.0 if self.toolkit.ttl <= 0 else float(self.toolkit.ttl)
async def get_definition(
self, context: RunContext | None = None
) -> ToolDefinition | None:
if hasattr(self, "_dynamic_def") and self._dynamic_def is not None:
return self._dynamic_def
tool_def = ToolDefinition(
name=self.name,
description=self.description,
parameters=self.parameters,
metadata=self.metadata or {},
)
if context and self.settings.capabilities:
from zhenxun.services.ai.capabilities import (
CombinedCapability,
)
combined_cap = CombinedCapability(self.settings.capabilities)
defs = await combined_cap.prepare_tools(context, [tool_def])
if not defs:
return None
tool_def = defs[0]
return tool_def
async def execute(self, context: RunContext | None = None, **kwargs) -> ToolResult:
max_retries = 3
last_error = None
for attempt in range(max_retries):
await self.toolkit.lifespan_manager.touch(
self.toolkit.server_name, self.toolkit.effective_ttl
)
session = await self.toolkit.get_session(context)
if not session:
return ToolResult(output="MCP 连接错误").as_error()
try:
result = await session.call_tool(self.original_tool_name, kwargs)
break
except Exception as e:
last_error = e
logger.warning(
f"MCP 工具 '{self.name}' 执行失败 "
f"(尝试 {attempt + 1}/{max_retries}): {e}。"
"正在尝试自动重连自愈..."
)
if attempt < max_retries - 1:
await self.toolkit.close()
await asyncio.sleep(2**attempt)
else:
logger.error(
f"MCP 工具 '{self.name}' "
f"在重试 {max_retries} 次后仍然执行失败: {last_error}"
)
return ToolResult(output=f"MCP 错误: {last_error}").as_error()
if result.isError:
return ToolResult(output=str(result.content)).as_error()
from zhenxun.services.ai.core.messages import ImagePart, TextPart
output_content = []
img_count = 0
for item in result.content:
item_type = getattr(item, "type", "text")
if item_type == "image":
b64_data = getattr(item, "data", "")
mime_type = getattr(item, "mimeType", "image/png")
if b64_data:
try:
img_bytes = base64.b64decode(b64_data)
output_content.append(
ImagePart(raw=img_bytes, mime_type=mime_type)
)
img_count += 1
continue
except Exception as e:
logger.warning(f"MCP 图片 Base64 解码失败: {e}")
output_content.append(TextPart(text="[图片解码失败]"))
elif item_type == "text":
text = getattr(item, "text", str(item))
output_content.append(TextPart(text=text))
md_images = re.findall(r"!\[.*?\]\((https?://[^\)]+)\)", text)
for img_url in md_images:
output_content.append(ImagePart(url=img_url))
img_count += 1
else:
dumped = model_dump(item) if hasattr(item, "model_dump") else str(item)
output_content.append(TextPart(text=str(dumped)))
tool_result = ToolResult(output=output_content)
logger.info(
f"获取到 {len(output_content)} 条返回数据,提取了 {img_count} 张图片"
)
if img_count > 0 and context and context.run.event_bus:
await context.run.event_bus.emit(UserCustomEvent(display=output_content))
return tool_result
class MCPToolkit(BaseToolkit):
"""模型上下文协议 (MCP) 的工具箱封装 (支持声明式挂载与动态隔离)"""
def __init__(
self,
server_name: str,
prefix: str | None = None,
transport: Literal[
"stdio", "sse", "streamable-http", "sandbox_proxy"
] = "stdio",
command: str | None = None,
args: list[str] | None = None,
url: str | None = None,
env: dict | None = None,
cwd: str | None = None,
install_command: str | None = None,
isolation: Literal["shared", "per_session"] = "shared",
timeout: int = 30,
admin_level: int = 0,
header_provider: Callable[[RunContext], dict[str, str]] | None = None,
env_provider: Callable[[RunContext], dict[str, str]] | None = None,
ttl: int = 600,
sandbox_session_id: str | None = None,
sandbox_blueprint: SandboxBlueprint | None = None,
):
super().__init__(
config=ToolkitConfig(prefix=prefix) if prefix is not None else None
)
self.server_name = server_name
self.transport = transport
self.command = command
self.args = args or []
self.url = url
self.env = env or {}
self.cwd = cwd
self.install_command = install_command
self.timeout = timeout
self.admin_level = admin_level
self.header_provider = header_provider
self.env_provider = env_provider
self.sandbox_session_id = sandbox_session_id
self.sandbox_blueprint = sandbox_blueprint
self.ttl = ttl
self._shared_session: ClientSession | None = None
self._tools: list[BaseTool] = []
self._is_initialized = False
self.lifespan_manager = LifespanManager()
self._shared_task: asyncio.Task | None = None
self._stop_event: asyncio.Event = asyncio.Event()
self._ready_event: asyncio.Event = asyncio.Event()
self._init_exception: Exception | None = None
@property
def effective_ttl(self) -> float:
"""动态获取当前最新的 TTL 配置,支持无缝热重载"""
try:
from zhenxun.services.ai.config import get_llm_config
mcp_ttl = get_llm_config().agent_settings.mcp_cleanup_timeout
return 31536000.0 if mcp_ttl <= 0 else float(mcp_ttl)
except Exception:
return 31536000.0 if self.ttl <= 0 else float(self.ttl)
async def _run_install_command(self, force: bool = False):
"""执行环境预安装(热加载依赖)"""
if not self.install_command or not self.cwd:
return
from pathlib import Path
marker_file = Path(self.cwd) / ".zx_installed"
heuristic_missing = False
if (
"npm" in self.install_command
and not (Path(self.cwd) / "node_modules").exists()
):
heuristic_missing = True
if not force and not heuristic_missing and marker_file.exists():
return
reason = "强制触发自愈重装" if force else "首次启动或检测到环境缺失"
logger.info(
f"🔧 [{self.server_name}] {reason},正在执行: `{self.install_command}` ..."
)
try:
proc = await asyncio.create_subprocess_shell(
self.install_command,
cwd=self.cwd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
err_msg = (
stderr.decode("utf-8", errors="ignore")
if stderr
else stdout.decode("utf-8", errors="ignore")
)
logger.error(
f"❌ [{self.server_name}] 环境安装失败!\n错误输出:\n{err_msg}"
)
raise RuntimeError(f"安装命令执行失败: {self.install_command}")
else:
marker_file.touch()
logger.info(f"✅ [{self.server_name}] 环境装配成功!")
except Exception as e:
logger.error(f"执行预热安装异常: {e}")
raise e
async def get_session(
self, context: RunContext | None = None
) -> ClientSession | None:
await self.lifespan_manager.register(
self.server_name,
ttl=self.effective_ttl,
cleanup_callback=self.release_resource,
)
if not self._is_initialized:
await self.initialize(context)
return self._shared_session
async def _spawn_session_task(self, dynamic_headers: dict, dynamic_env: dict):
"""生成后台连接任务"""
max_attempts = 2 if (self.transport == "stdio" and self.install_command) else 1
try:
for attempt in range(1, max_attempts + 1):
try:
if self.transport == "stdio":
await self._run_install_command(force=(attempt > 1))
async with AsyncExitStack() as stack:
read_stream: Any = None
write_stream: Any = None
if self.transport == "stdio":
if not self.command:
raise ValueError("stdio requires 'command'")
params = StdioServerParameters(
command=self.command,
args=self.args,
env=dynamic_env,
cwd=self.cwd,
)
transport_ctx = filtered_stdio_client(
self.server_name, params
)
elif self.transport == "sse":
if not self.url:
raise ValueError("sse requires 'url'")
transport_ctx = sse_client(
url=self.url,
headers=dynamic_headers,
timeout=self.timeout,
)
elif self.transport == "streamable-http":
if not self.url:
raise ValueError("streamable-http requires 'url'")
http_client = httpx.AsyncClient(
headers=dynamic_headers, timeout=self.timeout
)
await stack.enter_async_context(http_client)
transport_ctx = streamable_http_client(
url=self.url, http_client=http_client
)
elif self.transport == "sandbox_proxy":
from zhenxun.services.ai.sandbox.manager import (
sandbox_manager,
)
target_session_id = (
self.sandbox_session_id or "mcp_global_session"
)
bp = self.sandbox_blueprint or SandboxBlueprint()
bp.enable_network = True
driver = await sandbox_manager.get_or_create_session(
target_session_id,
blueprint=bp,
)
plugin_name = "universal_mcp"
await driver.mount_extension(plugin_name)
mcp_plugin = cast(
BaseMcpProxyExtension, driver.get_extension(plugin_name)
)
if not self.command:
raise ValueError("sandbox_proxy requires 'command'")
streams = await stack.enter_async_context(
mcp_plugin.connect_mcp(
self.command, self.args, dynamic_env
)
)
read_stream, write_stream = streams[0], streams[1]
transport_ctx = None
else:
raise ValueError(f"Unknown transport: {self.transport}")
if transport_ctx:
transport = await stack.enter_async_context(transport_ctx)
read_stream, write_stream = transport[0], transport[1]
client_session = await stack.enter_async_context(
ClientSession(read_stream, write_stream)
)
await client_session.initialize()
self._shared_session = client_session
if not self._is_initialized:
mcp_tools_res = await client_session.list_tools()
tools_list = list(mcp_tools_res.tools)
cursor = getattr(mcp_tools_res, "nextCursor", None)
while cursor:
mcp_tools_res = await client_session.list_tools(
cursor=cursor
)
tools_list.extend(mcp_tools_res.tools)
cursor = getattr(mcp_tools_res, "nextCursor", None)
for t in tools_list:
t_name = t.name.replace("-", "_")
final_name = (
f"{self.config.prefix}{t_name}"
if self.config.prefix
else t_name
)
self._tools.append(
MCPRemoteTool(
name=final_name,
original_tool_name=t.name,
description=t.description or "",
parameters=t.inputSchema,
toolkit=self,
)
)
self._is_initialized = True
self._ready_event.set()
logger.info(
f"成功连接 MCP 服务器: {self.server_name}, "
f"获取了 {len(self._tools)} 个工具"
)
await self._stop_event.wait()
return
except Exception as e:
from zhenxun.services.ai.core.exceptions import SandboxFatalError
if isinstance(e, SandboxFatalError):
raise e
if attempt < max_attempts:
logger.warning(
f"⚠️ [{self.server_name}] 进程启动或运行异常崩溃,"
"疑似环境损坏。触发自愈机制 (准备重试)..."
)
self._init_exception = e
self._shared_session = None
if not self._is_initialized:
self._is_initialized = False
continue
raise e
except Exception as e:
self._init_exception = e
logger.error(
f"MCP 服务器 '{self.server_name}' "
f"初始化连接失败(模式: {self.transport})。"
f"错误原因: {e}"
)
if "Connection closed" in str(e):
logger.error(
"提示:若是使用沙箱隧道,请进入 Docker Desktop 检查容器的日志。"
"多半是因为 npx 命令执行出错"
"(例如:镜像中缺少 Node 环境或国内网络无法连接 npm 等)。"
)
finally:
if not self._is_initialized:
self._is_initialized = False
self._shared_session = None
self._ready_event.set()
async def initialize(self, context: RunContext | None = None):
if self._is_initialized:
return
self._tools.clear()
self._stop_event.clear()
self._ready_event.clear()
self._init_exception = None
dynamic_headers = {}
dynamic_env = self.env.copy()
self._shared_task = asyncio.create_task(
self._spawn_session_task(dynamic_headers, dynamic_env)
)
await self._ready_event.wait()
if self._init_exception:
raise self._init_exception
async def get_tools(self, context: RunContext | None = None) -> dict[str, BaseTool]:
await self.lifespan_manager.register(
self.server_name,
ttl=self.effective_ttl,
cleanup_callback=self.release_resource,
)
if not self._is_initialized:
await self.initialize(context)
tools_dict = {}
for t in self._tools:
tools_dict[t.name] = t
return tools_dict
async def release_resource(self, resource_id: str):
await self.close()
async def close(self):
current_task = asyncio.current_task()
if (
self.lifespan_manager._watchdog_task
and self.lifespan_manager._watchdog_task is not current_task
):
await self.lifespan_manager.stop()
self._stop_event.set()
self._is_initialized = False
self._shared_session = None
self._tools.clear()
if (
self._shared_task
and not self._shared_task.done()
and self._shared_task is not current_task
):
try:
await asyncio.wait_for(self._shared_task, timeout=5.0)
except asyncio.TimeoutError:
logger.warning(
f"MCP 服务器 '{self.server_name}' 的任务执行超时,正在取消。"
)
self._shared_task.cancel()
self._shared_task = None
@@ -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()