mirror of
https://github.com/zhenxun-org/zhenxun_bot.git
synced 2026-10-18 02:30:08 +08:00
✨ feat!(llm): 重构并升级大语言模型服务为全新 AI 智能体框架 (#2146)
* ✨ feat!(llm): 重构并升级大语言模型服务为全新 AI 智能体框架 - 【重构】将原 services/llm 重构并迁移至全新的 services/ai 架构,提供向下兼容垫片 - 【新增】引入 Agent、Team、Workflow 三大智能体与工作流编排范式 - 【新增】引入基于 RAG 的长期向量记忆与中期槽位记忆系统 - 【新增】引入基于 Docker 的安全代码执行沙箱环境 - 【新增】支持 MCP 协议,允许动态管理和调用 MCP 服务 - 【新增】引入输入输出安全合规护栏与自愈反思机制 - 【优化】重构并优化多厂商 API 适配器 (Gemini, OpenAI, DeepSeek, GLM 等) - 【优化】优化日志脱敏与 Token 预估机制 - 【移除】移除旧版 llm default 和 llm reset-key 命令,新增 llm mcp 管理命令 * 🔧 chore(deps): 更新项目依赖与配置 - 添加 mcp、jieba 和 aiodocker 依赖到配置文件及 requirements.txt - 在 pyright 配置中设置 reportMissingImports 为 none - 调整 .gitignore 中 resources 目录的忽略规则 * ♻️ refactor(tools): 重构工具终止机制并清理知识库日志输出 - 统一使用 `context.state["__end_run__"]` 替代 `EndRunResult` 控制任务结束 - 移除文件系统和向量知识库检索工具中 `ToolResult` 的 `.with_log` 调用 - 调整指令处理器(Directive)的返回值为 `tool_res.output` - 修复部分类型检查警告并优化联合类型判断语法 * ♻️ refactor(tools): 重构工具副作用指令与控制流熔断机制 - 引入 `DirectivePayload` 及 `ToolResult` 的子类以结构化表达工具副作用 - 移除通过 `context.state` 传递魔术变量的隐式控制流设计 - 重构 `DirectiveManager` 处理器接口,直接在处理器中修改 `AgentState` 并构建 `AgentRunResult` - 在 `StandardAgentExecutor` 中统一通过 `directive_manager` 调度工具返回的副作用指令 - 补全 `MessageBuilder` 中部分核心方法的文档注释 * 🐛 fix(sandbox): 修复 Docker 沙箱容器状态检测与会话清理逻辑 -【修复】修正 `is_alive` 中直接读取私有属性的问题,改用 `show()` 返回值 -【修复】解决 `execute_code` 中缓存的执行器与当前会话不一致的问题 -【优化】在清理工作区前增加容器存活检测,避免向已死容器发送请求 -【优化】创建容器时增加运行状态校验,若已停止则自动从缓存中移除并重建 -【优化】优化容器销毁和清理逻辑,静默处理容器不存在 (404) 的异常 * 📝 docs(core): 补充核心模块初始化方法的文档注释 * 🚨 auto fix by pre-commit hooks --------- Co-authored-by: webjoin111 <455457521@qq.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
webjoin111
pre-commit-ci[bot]
parent
bdc1374848
commit
80fc5b86a7
@@ -0,0 +1,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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user