♻️ refactor(core): 重构 AI 能力与定时任务调度系统 (#2148)

* ♻️ refactor(core): 重构 AI 能力与定时任务调度系统

- 【AI 能力与工具】重构 Capability 注册与管理机制,引入 CapabilityManager 统一管理
- 移除全局能力注册表,改用声明式装饰器 `@capability` 进行解耦注册
- 重构工具解析器链,使用统一的 BaseToolResolver 代替原有的多个特定解析器
- 增强工具查询过滤,支持通配符匹配、工具箱过滤和排除标签
- 【定时任务调度】重构定时任务管理器,引入 SchedulerRegistry 统一管理任务元数据
- 引入 JobConfig 聚合定时任务配置,支持用户维度的定时任务调度
- 重构执行分发器,支持并发限制、串行间隔和随机延迟打散
- 【运行上下文】引入 ScheduledDeps 以支持后台和定时任务环境下的依赖注入
- 优化 RunContext,支持从定时任务上下文快速构造,并提供 emit 辅助方法
- 【日志与监控】引入 AILoggerProxy,实现 AI 各模块的专属日志输出
- 将各模块的全局 logger 替换为对应的模块专属日志代理
- 【其他优化】修复 Pydantic V1 兼容层中 model_validator 的装饰器兼容性问题
- 在非交互式环境(如定时任务)中自动隐藏 HITL 交互工具以节省 Token

* ♻️ refactor(core): 优化内部导入路径并提升 Pydantic 兼容性

- 【重构】将 `services/ai` 模块内的绝对导入重构为相对导入,优化包结构
- 【重构】移除不必要的 `if TYPE_CHECKING` 保护,通过 `from __future__ import annotations` 直接导入类型
- 【清理】清理 `core/messages/types.py` 中未使用的 `AssistantContentUnion` 等联合类型定义
- 【优化】在 `utils/pydantic_compat.py` 中新增 `model_rebuild` 兼容函数,统一 Pydantic V1/V2 的模型重建逻辑
- 【优化】将部分函数内部的延迟导入提升至模块顶部,规范代码结构

* ♻️ refactor(imports): 优化导入路径为相对导入并清理冗余导入

- 【重构】将 AI 服务相关模块中的绝对导入路径修改为相对导入,提升模块内聚性与可移植性
- 【清理】移除多处函数内部或类方法中未使用的冗余导入,避免循环引用和资源浪费
- 【格式化】微调部分工具装饰器和返回语句的格式与尾随逗号

* 🚨 auto fix by pre-commit hooks

---------

Co-authored-by: webjoin111 <455457521@qq.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Rumio
2026-07-10 09:14:06 +08:00
committed by GitHub
co-authored by webjoin111 pre-commit-ci[bot]
parent 0b32d69c9c
commit 922d092650
132 changed files with 2372 additions and 1972 deletions
+21 -27
View File
@@ -1,19 +1,19 @@
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any
from typing import Any
from pydantic import BaseModel, Field
from zhenxun.services.ai.core.exceptions import ControlFlowExit
from zhenxun.services.ai.core.exceptions import AbortException, ControlFlowExit
from zhenxun.services.ai.core.stream_events import ToolStreamChunkEvent
from zhenxun.services.ai.flow.base import BaseRunnable
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.services.ai.utils.logger import log_tool as 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"
@@ -30,24 +30,22 @@ class DelegateArgs(BaseModel):
class DelegateTool(BaseTool):
"""
将任意实现了 run() 方法的实体 (Agent/Team/Workflow 等) 包装为大模型可调用的工具。
(SubRoutine 委派模式)
"""
"""将可运行实体 (Agent/Team/Workflow) 包装为子例程委派工具。"""
def __init__(
self,
runnable: "BaseRunnable[Any]",
runnable: BaseRunnable[Any],
name: str | None = None,
description: str | None = None,
max_delegations: int = 3,
):
"""
初始化委派工具,将可运行实体包装为子例程工具。
"""初始化委派工具。
参数:
runnable: 被包装的可运行实体,可以是 Agent、Team 或 Workflow 等。
name: 自定义工具名称,若为 None 则默认推导为 runnable 实体名。
description: 工具描述信息,用于指导大模型何时代用此工具。
runnable: 被包装的可运行实体。
name: 自定义工具名称。
description: 工具描述信息。
max_delegations: 允许向同一个实体连续委派且未获成功的最大次数限制。
"""
resolved_name = name or getattr(runnable, "name", "SubRunnable")
resolved_desc = description or getattr(
@@ -61,6 +59,7 @@ class DelegateTool(BaseTool):
super().__init__(name=final_name, description=resolved_desc)
self.runnable = runnable
self.args_schema = DelegateArgs
self.max_delegations = max_delegations
async def execute(
self, context: RunContext | None = None, **kwargs: Any
@@ -70,12 +69,13 @@ class DelegateTool(BaseTool):
counts = context.session.shared_state.setdefault("__delegate_counts__", {})
counts[self.name] = counts.get(self.name, 0) + 1
if counts[self.name] > 3:
if counts[self.name] > self.max_delegations:
return ToolResult(
output=(
f"❌ 系统拦截:检测到无限委派死循环!\n"
f"❌ 系统拦截:委派重试次数已达上限。\n"
f"你已经连续 {counts[self.name]} 次将子任务委派给下级实体 "
f"{self.name} 且未获最终成功。\n"
f"{self.name} 且未获最终成功"
f"(超出最大允许次数 {self.max_delegations})。\n"
"请立即停止委派,"
"改变你的思考方向或直接向用户汇报失败结论!"
)
@@ -86,8 +86,6 @@ class DelegateTool(BaseTool):
logger.warning(
f"⚠️ [DelegateTool] 委派深度超限 ({depth}),强制阻断: {self.name}"
)
from zhenxun.services.ai.core.exceptions import AbortException
raise AbortException(
reason="嵌套层级过深,系统已强制拒绝执行委派",
display=f"⚠️ {self.name} 嵌套层级过深",
@@ -132,8 +130,6 @@ class DelegateTool(BaseTool):
"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} 委派失败",
@@ -141,8 +137,8 @@ class DelegateTool(BaseTool):
usage = getattr(response, "usage", None)
if context and context.run.event_bus:
await context.run.event_bus.emit(
if context:
await context.run.emit(
ToolStreamChunkEvent(
tool_name=self.name, content=f"🧠 实体 {self.name} 执行完毕"
)
@@ -156,8 +152,6 @@ class DelegateTool(BaseTool):
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} 执行异常",
+13 -11
View File
@@ -2,6 +2,8 @@ from typing import Any
from pydantic import BaseModel, Field, create_model
from zhenxun.services.ai.core.messages import HandoffEvent
from zhenxun.services.ai.core.options import BaseOutputDefinition
from zhenxun.services.ai.run.context import RunContext
from zhenxun.services.ai.tools.core.tool import BaseTool
from zhenxun.services.ai.tools.models import HandoffResult, ToolResult
@@ -18,6 +20,7 @@ class HandoffTool(BaseTool):
target_name: str,
target_description: str,
input_schema: type[BaseModel] | Any | None = None,
max_handoffs: int = 3,
):
"""
初始化移交工具,为模型赋予转移对话控制权到指定实体的能力。
@@ -26,7 +29,8 @@ class HandoffTool(BaseTool):
target_name: 被转移的目标接收者(Agent 或负责人)的唯一标识名称。
target_description: 目标接收者的职责或专长说明,供模型决策是否移交。
input_schema: 自定义移交数据结构,指定转移时所需携带的结构化参数。
"""
max_handoffs: 允许在同一个会话中向同一个实体发起移交的最大次数,防止无限踢皮球,默认 3。
""" # noqa: E501
super().__init__(
name=f"transfer_to_{target_name}",
description=(
@@ -34,11 +38,10 @@ class HandoffTool(BaseTool):
),
)
self.target_name = target_name
self.max_handoffs = max_handoffs
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:
@@ -80,19 +83,18 @@ class HandoffTool(BaseTool):
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:
if counts[self.target_name] > self.max_handoffs:
return ToolResult(
output=(
f"❌ 系统拦截:检测到严重的踢皮球现象!\n"
f"你所在的团队已经连续 {counts[self.target_name]} 次将任务"
f"移交给 {self.target_name},\n"
"但问题仍未解决。请立刻改变策略,"
"由你亲自处理或得出最终结论,严禁再次移交!"
f"❌ 系统拦截:移交次数已达上限。\n"
f"你所在的团队已经向 {self.target_name} 尝试移交了 "
f"{counts[self.target_name]} 次,"
f"超出了最大允许次数 ({self.max_handoffs})。\n"
"为防止任务陷入停滞,请立即改变策略,"
"由你亲自处理当前任务或得出最终结论,严禁再次移交!"
)
).as_error()
from zhenxun.services.ai.core.messages import HandoffEvent
if context:
context.run.add_event(
HandoffEvent(
@@ -24,8 +24,9 @@ from zhenxun.services.ai.run.context 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.services.ai.utils.logger import log_tool as logger
from zhenxun.utils.pydantic_compat import model_validate
from zhenxun.utils.utils import infer_plugin_namespace
class MatcherAdapter(ABC):
@@ -370,7 +371,8 @@ class MatcherTool(BaseTool):
if not bot or not event:
return ToolResult(
output="上下文缺少 bot 或 event,无法执行 Matcher"
output="❌ 当前处于无状态的自动化后台环境,缺乏真实的用户上下文(Event),"
"严禁调用该原生平台指令工具!请换用其他方法解决。"
).as_error()
try:
@@ -448,8 +450,6 @@ def bind_matcher(
"""
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}_"):
@@ -21,7 +21,7 @@ from zhenxun.services.ai.run.di import DependencyInjector
from zhenxun.services.ai.run.hitl import HITLController
from zhenxun.services.ai.tools.models import ToolResult
from zhenxun.services.ai.utils import PermissionUtils
from zhenxun.services.log import logger
from zhenxun.services.ai.utils.logger import log_tool as logger
from zhenxun.utils.pydantic_compat import model_dump, parse_as
_TOOL_RESULT_CACHE = SimpleMemoryCache(namespace="zhenxun_tool_cache")
@@ -58,7 +58,7 @@ class CacheCapability(AbstractCapability):
if not hasattr(tool, "_generate_cache_key"):
return await handler(arguments)
cache_key = tool._generate_cache_key(arguments)
cache_key = getattr(tool, "_generate_cache_key")(arguments)
cached_data = await _TOOL_RESULT_CACHE.get(cache_key)
if cached_data is not None:
cached_result = (
+31 -15
View File
@@ -1,11 +1,23 @@
from __future__ import annotations
from collections.abc import Callable
import inspect
import types
from typing import Any
from zhenxun.services.ai.capabilities import AbstractCapability
from zhenxun.services.ai.run.context import RunContext
from zhenxun.services.ai.tools.core.capabilities import (
from zhenxun.services.ai.tools.engine.registry import tool_provider_manager
from zhenxun.services.ai.tools.models import (
EndRunResult,
ToolkitConfig,
ToolOptions,
ToolResult,
)
from zhenxun.services.ai.utils.logger import log_tool as logger
from zhenxun.utils.pydantic_compat import model_copy
from .capabilities import (
AdminLevelCapability,
ApprovalCapability,
CacheCapability,
@@ -15,14 +27,15 @@ from zhenxun.services.ai.tools.core.capabilities import (
LifecycleCapability,
SuperuserCapability,
)
from zhenxun.services.ai.tools.models import ToolOptions
from zhenxun.utils.pydantic_compat import model_copy
from .tool import FunctionTool
def toolkit(
rules: list[ToolOptions] | ToolOptions | None = None,
prefix: str = "",
instructions: str | None = None,
auto_register: bool = False,
tags: list[str] | None = None,
):
"""
类级别的工具箱装饰器,用于向内部所有 @tool 统一下发配置规则、名称前缀以及工具箱级系统提示词说明。
@@ -31,6 +44,8 @@ def toolkit(
rules: 声明式规则集合或单个规则,应用于工具箱内所有工具。
prefix: 工具箱内所有工具的名称前缀,通常以下划线结尾。
instructions: 工具箱级别的系统提示词补充说明,大模型可见。
auto_register: 是否在加载时自动实例化该类并注册到全局工具箱列表中。
tags: 工具箱级别的路由标签,大模型路由系统将基于此发现整个工具箱。
返回:
Callable: 装饰器函数,接收一个类并返回该类。
@@ -44,7 +59,8 @@ def toolkit(
if isinstance(r, ToolOptions):
merged_options = merged_options.merge(r)
from zhenxun.services.ai.tools.models import ToolkitConfig
if tags:
merged_options.tags = list(set(merged_options.tags + tags))
base_config = getattr(cls, "_default_config", None) or ToolkitConfig()
new_config = model_copy(base_config, deep=True)
@@ -62,6 +78,15 @@ def toolkit(
if instructions is not None:
cls.default_instructions = instructions
if auto_register:
try:
tool_provider_manager.register_toolkit(cls())
except Exception as e:
logger.error(
f"自动注册工具箱 '{cls.__name__}' 失败"
f"(通常是因为自定义了必填参数的 __init__): {e}"
)
return cls
return decorator
@@ -107,10 +132,6 @@ class ToolkitMethodDescriptor:
def __get__(self, instance, owner):
if instance is None:
return self
import types
from zhenxun.services.ai.tools.core.tool import FunctionTool
bound_func = types.MethodType(self.func, instance)
return FunctionTool(
func=bound_func,
@@ -173,8 +194,6 @@ def tool(
tool_name = name or func.__name__
tool_desc = description
import inspect
is_method = False
if (
hasattr(func, "__qualname__")
@@ -204,9 +223,9 @@ def tool(
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
from .tool import FunctionTool
func_tool = FunctionTool(
func=func,
@@ -216,7 +235,6 @@ def tool(
)
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
@@ -242,8 +260,6 @@ def direct_reply() -> ToolOptions:
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
+2 -3
View File
@@ -16,16 +16,16 @@ from zhenxun.services.ai.core.exceptions import (
)
from zhenxun.services.ai.core.models import ToolDefinition
from zhenxun.services.ai.run.context import RunContext
from zhenxun.services.ai.tools.core.capabilities import InteractiveCapability
from zhenxun.services.ai.tools.models import (
ResolvedToolPayload,
ToolOptions,
ToolResult,
)
from zhenxun.services.ai.utils.logger import log_tool as logger
from zhenxun.services.ai.utils.utils import wrap_to_async
from zhenxun.services.log import logger
from zhenxun.utils.pydantic_compat import model_dump, model_json_schema, model_validate
from .capabilities import InteractiveCapability
from .schema import (
_parse_docstring,
build_schema_hint,
@@ -289,7 +289,6 @@ class BaseTool:
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
@@ -29,6 +29,11 @@ class BaseToolkit:
config: ToolkitConfig
_default_config: ClassVar[ToolkitConfig] = ToolkitConfig()
@property
def name(self) -> str:
"""工具箱的默认名称标识(取类名),主要用于全局注册表的 Hash 与展示"""
return self.__class__.__name__
def __init__(
self,
prefix: str | None = None,
+18 -23
View File
@@ -6,7 +6,7 @@ import asyncio
from contextlib import asynccontextmanager
import inspect
import json
from typing import TYPE_CHECKING, Any, cast
from typing import Any, cast
import json_repair
from nonebot.adapters import Message as PlatformMessage
@@ -26,8 +26,9 @@ from zhenxun.services.ai.core.stream_events import (
UserCustomEvent,
)
from zhenxun.services.ai.message_builder import MessageBuilder
from zhenxun.services.ai.run.context import RunContext
from zhenxun.services.ai.run.context import RunContext, set_run_context
from zhenxun.services.ai.run.di import DependencyInjector
from zhenxun.services.ai.tools.core.tool import BaseTool, register_tool_runner
from zhenxun.services.ai.tools.models import (
StateSyncResult,
ToolOptions,
@@ -35,11 +36,9 @@ from zhenxun.services.ai.tools.models import (
ToolResultChunk,
ValidatedToolCall,
)
from zhenxun.services.log import logger
from zhenxun.services.ai.utils.logger import log_tool as logger
if TYPE_CHECKING:
from zhenxun.services.ai.tools.core.tool import BaseTool
from zhenxun.services.ai.tools.engine.registry import ToolCollection
from .registry import ToolCollection
class ToolExecutor:
@@ -49,6 +48,7 @@ class ToolExecutor:
"""
def __init__(self):
"""初始化工具执行器。"""
pass
def _get_combined_capability(
@@ -126,8 +126,7 @@ class ToolExecutor:
arguments, parsed_successfully = parsed, True
logger.debug(
"⚒️ 成功修复损坏的工具参数: "
f"{args_str} -> {repaired_str}",
"ToolExecutor",
f"{args_str} -> {repaired_str}"
)
except Exception:
pass
@@ -145,7 +144,7 @@ class ToolExecutor:
tool_name: str,
executable: Any,
event_bus: EventBus | None,
available_tools: "ToolCollection | dict[str, Any] | None" = None,
available_tools: ToolCollection | dict[str, Any] | None = None,
) -> RunContext:
"""准备/克隆工具调用所使用的隔离 RunContext"""
safe_context = (
@@ -163,7 +162,7 @@ class ToolExecutor:
async def validate_tool_call(
self,
tool_call: ToolCallPart,
available_tools: "ToolCollection | dict[str, Any] | None",
available_tools: ToolCollection | dict[str, Any] | None,
context: RunContext | None = None,
event_bus: EventBus | None = None,
) -> ValidatedToolCall:
@@ -213,8 +212,6 @@ class ToolExecutor:
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(
@@ -247,7 +244,7 @@ class ToolExecutor:
async def execute_tool_call(
self,
validated: ValidatedToolCall,
available_tools: "ToolCollection | dict[str, Any] | None",
available_tools: ToolCollection | dict[str, Any] | None,
context: RunContext | None = None,
model_name: str | None = None,
max_retries: int = 0,
@@ -285,8 +282,6 @@ class ToolExecutor:
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:
@@ -322,7 +317,7 @@ class ToolExecutor:
async def execute_batch(
self,
tool_calls: list[ToolCallPart],
available_tools: "ToolCollection | dict[str, Any] | None",
available_tools: ToolCollection | dict[str, Any] | None,
context: RunContext | None = None,
model_name: str | None = None,
max_retries: int = 0,
@@ -415,11 +410,12 @@ class ToolExecutor:
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] = (
@@ -449,6 +445,7 @@ class ToolRunner(ABC):
async def run(
self, tool: BaseTool, context: RunContext, **kwargs: Any
) -> ToolResult:
"""执行工具调用的抽象方法。"""
pass
@@ -461,6 +458,7 @@ class NativeToolRunner(ToolRunner):
async def run(
self, tool: BaseTool, context: RunContext, **kwargs: Any
) -> ToolResult:
"""运行原生 Python 函数工具并返回结果。"""
target_func = tool.get_execute_target()
signature_target = tool.get_signature_target()
@@ -498,8 +496,8 @@ class NativeToolRunner(ToolRunner):
if tool and hasattr(tool, "settings")
else False
)
if context.run.event_bus and not is_silent:
await context.run.event_bus.emit(
if not is_silent:
await context.run.emit(
ToolStreamChunkEvent(
tool_name=tool.name,
content=chunk_obj.content,
@@ -521,8 +519,7 @@ class NativeToolRunner(ToolRunner):
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))
await context.run.emit(UserCustomEvent(display=uni_msg))
final_result = ToolResult(output=parts)
else:
final_result = ToolResult(output=res)
@@ -530,6 +527,4 @@ class NativeToolRunner(ToolRunner):
return final_result
from zhenxun.services.ai.tools.core.tool import register_tool_runner
register_tool_runner(NativeToolRunner)
+269 -366
View File
@@ -1,122 +1,76 @@
from __future__ import annotations
from abc import ABC, abstractmethod
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,
ToolResolvable,
)
from zhenxun.services.ai.run.context import RunContext
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 Query, ResolvedToolPayload
from zhenxun.services.log import logger
from zhenxun.services.ai.utils.logger import log_tool as logger
from zhenxun.services.ai.utils.utils import parse_routing_string
from zhenxun.utils.utils import infer_plugin_namespace
T = TypeVar("T", bound=ToolExecutable)
class ToolCollection(list[T], Generic[T]):
"""支持按索引和按名称获取的工具集合 (List + Dict)"""
class ToolCollection(Generic[T]):
"""不可变的工具集合"""
def __init__(self, iterable: Iterable[T] | None = None):
"""初始化工具集合。"""
super().__init__(iterable or [])
self._tuple: tuple[T, ...] = tuple(iterable) if iterable else ()
self._name_cache: dict[str, T] = {}
self._build_name_cache()
def _build_name_cache(self) -> None:
"""构建工具名称小写到工具实例的映射缓存。"""
self._name_cache = {}
for tool in self:
self._name_cache.clear()
for tool in self._tuple:
name = getattr(tool, "name", None)
if name:
self._name_cache[name.lower()] = tool
def __iter__(self):
"""获取工具元组的迭代器。"""
return iter(self._tuple)
def __len__(self):
"""获取工具集合中的工具数量。"""
return len(self._tuple)
def __bool__(self):
"""检查工具集合是否非空。"""
return bool(self._tuple)
@overload
def __getitem__(self, key: SupportsIndex) -> T: ...
@overload
def __getitem__(self, key: slice) -> list[T]: ...
def __getitem__(self, key: slice) -> tuple[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
return self._tuple[key]
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:
@@ -129,11 +83,6 @@ class ToolCollection(list[T], Generic[T]):
]
)
def clear(self) -> None:
"""清空集合及所有名称缓存。"""
super().clear()
self._name_cache.clear()
def keys(self):
"""获取所有工具名称缓存的键。"""
return self._name_cache.keys()
@@ -147,155 +96,231 @@ class ToolCollection(list[T], Generic[T]):
return self._name_cache.items()
class _StringResolver:
"""字符串格式的工具路由解析器。
class LocalToolProvider(ToolProvider):
"""本地工具提供者。统一管理基于 @tool 注册的普通函数或工具箱。"""
负责解析像 'ns.tool_name'、'ns.*' 或 'ns.#tag' 的语法路由。
"""
def __init__(self):
"""初始化本地工具提供者。"""
self._namespaced_tools: dict[str, list[Any]] = {}
def __init__(
self, name: str, manager: "ToolProviderManager", default_namespace: str
):
"""初始化字符串路由解析器。"""
self.name = name
self.manager = manager
self.default_namespace = default_namespace
def register_tool(self, tool: ToolExecutable, namespace: str):
"""向指定命名空间注册单一工具。"""
if namespace not in self._namespaced_tools:
self._namespaced_tools[namespace] = []
self._namespaced_tools[namespace].append(tool)
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)
def register_toolkit(self, toolkit: Any, namespace: str):
"""向指定命名空间注册工具箱。"""
if namespace not in self._namespaced_tools:
self._namespaced_tools[namespace] = []
self._namespaced_tools[namespace].append(toolkit)
s = self.name
async def initialize(self) -> None:
"""初始化本地工具提供者。"""
pass
if "." in s:
ns, target = s.split(".", 1)
else:
ns = self.default_namespace
target = s
async def discover_tools(self) -> dict[str, ToolExecutable]:
"""发现并获取所有注册的本地工具。"""
res = {}
for tools in self._namespaced_tools.values():
for t in tools:
name = getattr(t, "name", getattr(t, "__class__", type).__name__)
res[name] = t
return res
from zhenxun.services.ai.tools.models import Query
async def get_tool_executable(
self, name: str, config: dict[str, Any]
) -> ToolExecutable | None:
"""根据名称获取本地工具实例。"""
tools = await self.discover_tools()
return tools.get(name)
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()
async def query_tools(self, query: Query) -> list[ToolExecutable]:
"""根据查询条件检索本地工具。"""
matched_tools = []
target_namespace = query.namespace
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())
namespaces_to_search = self.get_all_namespaces()
elif target_namespace:
namespaces_to_search = [target_namespace]
else:
raise ValueError(f"Query 对象必须显式指定 namespace 作用域: {self.query}")
namespaces_to_search = self.get_all_namespaces()
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)
for tool in self.get_tools_by_namespace(ns):
if query.match(tool):
matched_tools.append(tool)
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 matched_tools
def get_tools_by_namespace(self, namespace: str) -> list[Any]:
"""获取指定命名空间下的所有工具和工具箱。"""
return self._namespaced_tools.get(namespace, [])
def get_all_namespaces(self) -> list[str]:
"""获取所有已注册工具的命名空间列表。"""
return list(self._namespaced_tools.keys())
class BaseToolResolver(ABC):
"""工具载荷解析器抽象基类协议 (责任链模式)"""
@abstractmethod
def match(self, item: Any) -> bool:
"""判断解析器是否匹配当前工具对象。"""
pass
@abstractmethod
async def resolve(
self,
item: Any,
manager: "ToolProviderManager",
default_namespace: str,
context: RunContext | None = None,
) -> ResolvedToolPayload:
"""解析工具对象并返回其载荷。"""
pass
class ProtocolResolver(BaseToolResolver):
"""处理已经实现了 ToolResolvable 协议的对象"""
def match(self, item: Any) -> bool:
"""检查是否实现 ToolResolvable 协议。"""
return hasattr(item, "resolve")
async def resolve(
self,
item: Any,
manager: "ToolProviderManager",
default_namespace: str,
context: RunContext | None = None,
) -> ResolvedToolPayload:
"""直接调用对象的 resolve 方法进行解析。"""
return await item.resolve(context)
class StringRoutingResolver(BaseToolResolver):
"""处理带语法的路由字符串"""
def match(self, item: Any) -> bool:
"""检查是否为非宏的路由字符串。"""
return isinstance(item, str)
async def resolve(
self,
item: Any,
manager: "ToolProviderManager",
default_namespace: str,
context: RunContext | None = None,
) -> ResolvedToolPayload:
s = cast(str, item)
parsed_args = parse_routing_string(s, default_namespace)
query = Query(**parsed_args)
logger.debug(f"🔍 [StringRouter] 语法解析: '{item}' -> {query}")
return await manager._resolve_single(query, default_namespace, context)
class QueryResolver(BaseToolResolver):
"""处理 Query 查询对象"""
def match(self, item: Any) -> bool:
"""检查对象是否为 Query 实例。"""
return isinstance(item, Query)
async def resolve(
self,
item: Any,
manager: "ToolProviderManager",
default_namespace: str,
context: RunContext | None = None,
) -> ResolvedToolPayload:
query = cast(Query, item)
payload = ResolvedToolPayload()
if query.namespace == "*":
query.namespace = "global"
elif not query.namespace:
query.namespace = default_namespace
matched_tools = await manager.local_provider.query_tools(query)
for tool in matched_tools:
if hasattr(tool, "resolve"):
resolvable_tool = cast(ToolResolvable, tool)
p = await resolvable_tool.resolve(context)
if p:
for t in p.tools:
if query.match(t):
payload.tools.append(t)
payload.injected_prompts.extend(p.injected_prompts)
for tk in p.toolkits:
if tk not in payload.toolkits:
payload.toolkits.append(tk)
else:
payload.tools.append(tool)
return payload
class _CallableResolver:
"""普通 Python 函数/可调用对象格式的工具路由解析器。
class CallableResolver(BaseToolResolver):
"""处理普通 Python 函数"""
负责将其包装为 FunctionTool 实例。
"""
def match(self, item: Any) -> bool:
"""检查对象是否为可调用对象。"""
return callable(item)
def __init__(self, func: Callable, manager: "ToolProviderManager"):
"""初始化可调用对象路由解析器。"""
self.func = func
self.manager = manager
async def resolve(self, context: RunContext | None = None) -> ResolvedToolPayload:
"""将可调用对象转换为函数工具并返回其解析载荷。"""
async def resolve(
self,
item: Any,
manager: "ToolProviderManager",
default_namespace: str,
context: RunContext | None = None,
) -> ResolvedToolPayload:
func = cast(Callable, item)
for candidate in (
getattr(self.func, "__tool_name__", None),
getattr(self.func, "__name__", None),
getattr(func, "__tool_name__", None),
getattr(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
for ns in manager.local_provider.get_all_namespaces():
for t in manager.local_provider.get_tools_by_namespace(ns):
if getattr(t, "name", None) == candidate:
return await t.resolve(context)
t = FunctionTool(func=self.func)
t = FunctionTool(func=func)
return await t.resolve(context)
class _TypeAdapterResolver:
"""基于自定义类型映射注册的工具路由解析器。负责调用对应类型的解析函数。"""
class DictToolResolver(BaseToolResolver):
"""处理字典类型的动态/按需工具配置解析器"""
def __init__(
def match(self, item: Any) -> bool:
"""检查对象是否为配置字典。"""
return isinstance(item, dict)
async def resolve(
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
context: RunContext | None = None,
) -> ResolvedToolPayload:
config = cast(dict, item)
name = config.get("name")
if not name:
raise ConfigurationException("工具配置字典必须包含 'name' 字段。")
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)
for provider in manager._providers:
executable = await provider.get_tool_executable(name, config)
if executable:
return await manager._resolve_single(
executable, default_namespace, context
)
raise ConfigurationException(f"没有为 ad-hoc 工具 '{name}' 找到合适的提供者。")
class ToolProviderManager:
@@ -314,25 +339,23 @@ class ToolProviderManager:
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.local_provider = LocalToolProvider()
self._providers: list[ToolProvider] = [self.local_provider]
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] = {}
self._resolvers: list[BaseToolResolver] = [
ProtocolResolver(),
QueryResolver(),
StringRoutingResolver(),
DictToolResolver(),
CallableResolver(),
]
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_resolver(self, resolver: BaseToolResolver) -> None:
"""注册自定义的工具载荷解析器(插在兜底的 CallableResolver 之前)。"""
self._resolvers.insert(-1, resolver)
def register(self, provider: ToolProvider):
"""注册一个新的 ToolProvider。"""
@@ -342,21 +365,26 @@ class ToolProviderManager:
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
ns = infer_plugin_namespace(default="global")
self.local_provider.register_tool(tool, ns)
tags = getattr(getattr(tool, "settings", None), "tags", [])
tag_str = f" | Tags: {tags}" if tags else ""
tool_name = getattr(tool, "name", "unknown")
logger.debug(f"已注册工具: '{tool_name}' -> Namespace: '{ns}'{tag_str}")
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
ns = infer_plugin_namespace(default="global")
self.local_provider.register_toolkit(toolkit, ns)
tk_name = getattr(toolkit, "__class__", type).__name__
config = getattr(toolkit, "config", None)
shared_options = getattr(config, "shared_options", None) if config else None
tags = getattr(shared_options, "tags", []) if shared_options else []
tag_str = f" | Tags: {tags}" if tags else ""
logger.debug(f"已注册工具箱: '{tk_name}' -> Namespace: '{ns}'{tag_str}")
async def initialize(self) -> None:
"""懒加载初始化所有已注册的 ToolProvider。"""
@@ -375,28 +403,11 @@ class ToolProviderManager:
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,
def _process_discovery_results(
self, results: list[Any], provider_indices: list[int]
) -> 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 = {}
"""处理并合并来自多个工具提供者的并发发现结果"""
provider_tools: dict[str, ToolExecutable] = {}
for result_idx, provider_result in enumerate(results):
provider = self._providers[provider_indices[result_idx]]
provider_name = provider.__class__.__name__
@@ -406,7 +417,7 @@ class ToolProviderManager:
f"提供者 '{provider_name}' 发现了 {len(provider_result)} 个工具。"
)
for name, executable in provider_result.items():
if provider_tools.get(name):
if name in provider_tools:
logger.warning(
f"发现重复的工具名称 '{name}',后发现的将覆盖前者。"
)
@@ -417,106 +428,29 @@ class ToolProviderManager:
)
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()
async def discover_tools(self) -> dict[str, ToolExecutable]:
"""向所有已初始化的 ToolProvider 并发执行工具发现。"""
discover_tasks = []
provider_indices = []
for i, provider in enumerate(self._providers):
discover_tasks.append(provider.discover_tools())
provider_indices.append(i)
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)
results = await asyncio.gather(*discover_tasks, return_exceptions=True)
provider_tools = self._process_discovery_results(results, provider_indices)
return provider_tools
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
async def _resolve_single(
self, item: Any, default_namespace: str, context: RunContext | None
) -> ResolvedToolPayload:
"""通过责任链解析单个工具意图并返回载荷"""
for r in self._resolvers:
if r.match(item):
return await r.resolve(item, self, default_namespace, context)
raise TypeError(
f"严格协议校验失败: 工具对象 {type(item)} 必须实现 ToolResolvable 协议 "
"(包含 resolve 方法)。如果你想注册普通函数,请使用 @tool 装饰器。"
)
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,
@@ -547,14 +481,7 @@ class ToolProviderManager:
_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]
tasks = [self._resolve_single(t, namespace, context) for t in defs]
payloads = await asyncio.gather(*tasks, return_exceptions=False)
final_payload = ResolvedToolPayload()
@@ -568,34 +495,10 @@ class ToolProviderManager:
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.merge(p)
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)
+72 -21
View File
@@ -3,12 +3,14 @@
"""
from dataclasses import dataclass, field
import fnmatch
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.services.ai.utils.logger import log_tool as logger
from zhenxun.utils.pydantic_compat import model_dump, model_validate
if TYPE_CHECKING:
@@ -210,12 +212,12 @@ class ToolOverride(BaseModel):
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]
payload = await tool_provider_manager.resolve_tools(
[self.name], context=context
)
if payload and payload.tools:
base_tool = payload.tools[0]
if hasattr(base_tool, "clone_with_options"):
cloned_tool = base_tool.clone_with_options(self)
if hasattr(cloned_tool, "resolve"):
@@ -231,17 +233,8 @@ class ToolOverride(BaseModel):
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)
@@ -265,22 +258,68 @@ class Query(BaseModel):
用于在 Agent 中精确或批量筛选加载特定命名空间、特定标签的工具。
"""
name: str | None = Field(default=None)
"""如果提供,则必须与工具的名称完全一致。"""
name: str | list[str] | None = Field(default=None)
"""如果提供,则工具的最终解析名称必须等于该字符串或在列表中。"""
toolkit: str | list[str] | None = Field(default=None)
"""如果提供,则工具必须属于指定的 Toolkit (支持字符串或列表)。"""
tags: list[str] | None = Field(default=None)
"""如果提供,则工具必须包含这里列出的所有标签 (交集/AND匹配)。"""
exclude_tags: list[str] | None = Field(default=None)
"""如果提供,则工具不能包含这里列出的任何标签 (排斥过滤)。"""
namespace: str | None = Field(default=None)
"""必填(由系统补充或显式声明)。限制搜索的插件命名空间,'global' 将跨全插件搜索。"""
"""限制搜索的插件命名空间。如果不指定,将自动推导为调用者所在的插件;
'global' 将跨全插件搜索。"""
metadata_filter: dict[str, Any] | None = Field(default=None)
"""如果提供,则工具的 metadata 必须包含这里列出的所有键值对。"""
def match(self, tool: "BaseTool") -> bool:
"""判断某个工具或工具箱是否符合当前 Query 的筛选条件"""
def _match_pattern(val: str, pattern: str | list[str]) -> bool:
patterns = [pattern] if isinstance(pattern, str) else pattern
for p in patterns:
if "*" in p or "?" in p:
if fnmatch.fnmatch(val, p):
return True
elif val == p:
return True
return False
is_toolkit_itself = hasattr(tool, "get_tools") and not hasattr(tool, "execute")
if self.toolkit:
if is_toolkit_itself:
tk_name = getattr(
tool, "name", getattr(tool, "__class__", type).__name__
)
else:
parent_tk = getattr(tool, "parent_toolkit", None)
tk_name = (
getattr(
parent_tk,
"name",
getattr(parent_tk, "__class__", type).__name__,
)
if parent_tk
else None
)
if not tk_name:
return False
if not _match_pattern(tk_name, self.toolkit):
return False
if is_toolkit_itself and (self.name or self.tags or self.exclude_tags):
return True
if self.name:
tool_name = getattr(tool, "name", getattr(tool, "__class__", type).__name__)
if tool_name != self.name:
if not _match_pattern(tool_name, self.name):
return False
if self.tags:
tool_tags = []
if self.tags or self.exclude_tags:
tool_config = getattr(tool, "config", None)
if (
tool_config
@@ -291,9 +330,15 @@ class Query(BaseModel):
else:
tool_settings = getattr(tool, "settings", None)
tool_tags = getattr(tool_settings, "tags", []) if tool_settings else []
if self.tags:
if not all(tag in tool_tags for tag in self.tags):
return False
if self.exclude_tags:
if any(tag in tool_tags for tag in self.exclude_tags):
return False
if self.metadata_filter:
tool_settings = getattr(tool, "settings", None)
tool_metadata = (
@@ -315,9 +360,15 @@ class ResolvedToolPayload:
injected_prompts: list[str] = field(default_factory=list)
toolkits: list[Any] = field(default_factory=list)
def merge(self, other: "ResolvedToolPayload") -> "ResolvedToolPayload":
"""将另一个解析载荷合并到当前载荷中"""
self.tools.extend(other.tools)
self.injected_prompts.extend(other.injected_prompts)
self.toolkits.extend(other.toolkits)
return self
__all__ = [
"GlobalToolFilter",
"Query",
"ResolvedToolPayload",
"ToolOptions",
@@ -4,8 +4,8 @@ from zhenxun.services.ai.run.context import RunContext
from zhenxun.services.ai.run.di import Inject
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
from zhenxun.services.ai.tools.models import ResolvedToolPayload, ToolResult
from zhenxun.services.ai.utils.logger import log_tool as logger
class HITLToolkit(BaseToolkit):
@@ -23,6 +23,12 @@ class HITLToolkit(BaseToolkit):
"用户回答后,你将收到答案并可以继续任务。"
)
async def resolve(self, context: RunContext | None = None) -> ResolvedToolPayload:
"""智能环境感知:如果处于无真实用户交互的后台环境(如定时任务),自动隐身以节省Token"""
if context is None or context.get_event() is None:
return ResolvedToolPayload()
return await super().resolve(context)
@tool(
name="ask_user_for_help",
description="向当前对话的用户提出问题以获取信息或指导。当你无法独立完成任务时,请调用此工具。",
@@ -47,8 +53,8 @@ class HITLToolkit(BaseToolkit):
raise e
logger.info(f"收到用户求助回复: {user_reply}")
if context and context.run.event_bus:
await context.run.event_bus.emit(
if context:
await context.run.emit(
ToolStreamChunkEvent(
tool_name=context.call.tool_name, content="🗣️ 已收到用户的回复"
)
@@ -10,7 +10,7 @@ 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
from zhenxun.services.ai.utils.logger import log_tool as logger
class MemoryManagementToolkit(BaseToolkit):
@@ -8,7 +8,7 @@ from zhenxun.services.ai.run.context 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
from zhenxun.services.ai.utils.logger import log_tool as logger
class RestApiToolkit(BaseToolkit):
@@ -101,15 +101,15 @@ class RestApiToolkit(BaseToolkit):
is_error = response.status_code >= 400
result = ToolResult(output=result_dict)
if context and context.run.event_bus:
if context:
msg = (
f"🌐 已调用 API: {url}"
if not is_error
else f"❌ API 调用失败 (Status: {response.status_code})"
)
await context.run.event_bus.emit(
await context.run.emit(
ToolStreamChunkEvent(
tool_name=context.call.tool_name if context else "make_request",
tool_name=context.call.tool_name,
content=msg,
)
)
@@ -120,10 +120,10 @@ class RestApiToolkit(BaseToolkit):
except Exception as e:
logger.error(f"RestApiToolkit 请求失败: {e}")
if context and context.run.event_bus:
await context.run.event_bus.emit(
if context:
await context.run.emit(
ToolStreamChunkEvent(
tool_name=context.call.tool_name if context else "make_request",
tool_name=context.call.tool_name,
content="❌ API 网络请求发生框架级错误",
)
)
@@ -10,7 +10,7 @@ from zhenxun.services.ai.sandbox.models import (
from zhenxun.services.ai.tools.core.decorators import Rules, tool
from zhenxun.services.ai.tools.core.toolkit import BaseToolkit
from zhenxun.services.ai.tools.models import ToolResult
from zhenxun.services.log import logger
from zhenxun.services.ai.utils.logger import log_tool as logger
from zhenxun.utils.pydantic_compat import model_copy
@@ -103,8 +103,8 @@ class SandboxToolkit(BaseToolkit):
bp = model_copy(self.blueprint, deep=True)
if context.run.event_bus:
await context.run.event_bus.emit(
if context:
await context.run.emit(
ToolStreamChunkEvent(
tool_name="Sandbox", content="正在分析代码依赖并分配沙箱环境..."
)
@@ -122,8 +122,8 @@ class SandboxToolkit(BaseToolkit):
)
context.session.shared_state[state_key] = code_executor
if context.run.event_bus:
await context.run.event_bus.emit(
if context:
await context.run.emit(
ToolStreamChunkEvent(
tool_name="Sandbox",
content=f"沙箱已就绪,正在后台执行 {language} 代码...",
@@ -225,8 +225,8 @@ class SandboxToolkit(BaseToolkit):
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))
if len(image_bytes_list) > 0 and context:
await context.run.emit(UserCustomEvent(display=final_output))
return result
@tool(
@@ -249,8 +249,8 @@ class SandboxToolkit(BaseToolkit):
) -> ToolResult:
session_id = self.sandbox_session_id or context.session_id or "default"
if context.run.event_bus:
await context.run.event_bus.emit(
if context:
await context.run.emit(
ToolStreamChunkEvent(
tool_name="Sandbox", content=f"正在虚拟终端执行命令: {command} ..."
)
@@ -319,8 +319,8 @@ class SandboxToolkit(BaseToolkit):
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(
if context:
await context.run.emit(
ToolStreamChunkEvent(
tool_name=context.call.tool_name, content="⌨️ 已向后台进程发送输入"
)
@@ -357,8 +357,8 @@ class SandboxToolkit(BaseToolkit):
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(
if context:
await context.run.emit(
ToolStreamChunkEvent(
tool_name=context.call.tool_name, content="🛑 已强制中断后台进程"
)
@@ -16,10 +16,11 @@ from zhenxun.services.ai.core.protocols.tool import (
)
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.services.ai.utils.logger import log_tool as logger
from zhenxun.utils.pydantic_compat import model_dump, model_validate, model_validator
from .toolkit import MCPToolkit
MCP_PATH = DATA_PATH / "ai" / "mcp.json"
@@ -30,6 +31,8 @@ class MCPServerConfig(BaseModel):
"""传输协议类型:stdio / sse / streamable-http / sandbox_proxy"""
url: str | None = Field(default=None)
"""远端地址(用于 sse 或 streamable-http)"""
headers: dict[str, str] | None = Field(default=None)
"""HTTP 请求头(用于 sse 或 streamable-http 的接口鉴权)"""
timeout: int = Field(default=30)
"""请求超时时间(秒)"""
command: str | None = None
@@ -51,6 +54,34 @@ class MCPServerConfig(BaseModel):
sandbox_blueprint: SandboxBlueprint | None = Field(default=None)
"""沙箱环境装配配置(用于 sandbox_proxy 自动处理依赖)"""
@model_validator(mode="before")
@classmethod
def _normalize_config(cls, values: Any) -> Any:
if not isinstance(values, dict):
return values
if "type" in values and "transport" not in values:
values["transport"] = values.pop("type")
transport_val = values.get("transport")
if isinstance(transport_val, str) and transport_val.lower() == "streamablehttp":
values["transport"] = "streamable-http"
headers = values.get("headers")
if isinstance(headers, dict):
new_headers = {}
for k, v in headers.items():
if k.lower() == "authorization" and isinstance(v, str):
if not any(
v.startswith(prefix)
for prefix in ("Bearer ", "Basic ", "Digest ")
):
v = f"Bearer {v}"
new_headers[k] = v
values["headers"] = new_headers
return values
class MCPToolsConfig(BaseModel):
mcpServers: dict[str, MCPServerConfig] = Field(default_factory=dict)
@@ -193,6 +224,7 @@ class GlobalMCPProvider(ToolProvider):
command=conf.command,
args=conf.args,
url=conf.url,
headers=conf.headers,
env=conf.env,
cwd=conf.cwd,
install_command=conf.install_command,
@@ -339,6 +371,7 @@ class MCPSource(BaseModel):
command=self.config.command,
args=self.config.args,
url=self.config.url,
headers=self.config.headers,
env=self.config.env,
cwd=self.config.cwd,
install_command=self.config.install_command,
@@ -23,7 +23,7 @@ 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.services.ai.utils.logger import log_tool as logger
from zhenxun.utils.lifespan import LifespanManager
from zhenxun.utils.pydantic_compat import model_dump
@@ -270,10 +270,10 @@ class MCPToolkit(BaseToolkit):
command: str | None = None,
args: list[str] | None = None,
url: str | None = None,
headers: dict[str, 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,
@@ -292,10 +292,10 @@ class MCPToolkit(BaseToolkit):
command: 用于 stdio 或 sandbox_proxy 模式启动服务器的可执行命令。
args: 启动服务器时附加的命令行参数。
url: 用于 sse 或 streamable-http 模式的服务器连接 URL。
headers: 静态配置的 HTTP 请求头。
env: 启动服务器时的进程环境变量。
cwd: 启动服务器时的进程工作目录。
install_command: 首次启动前执行的环境热装配/依赖安装命令。
isolation: 环境隔离模式,可选 "shared" (共享模式) 或 "per_session" (每个会话独立)。
timeout: 初始化和请求网络接口时的超时秒数,默认 30。
admin_level: 调用工具所需的管理权限等级,默认 0 (无限制)。
header_provider: 动态生成 HTTP 头部信息的工厂函数。
@@ -312,6 +312,7 @@ class MCPToolkit(BaseToolkit):
self.command = command
self.args = args or []
self.url = url
self.headers = headers or {}
self.env = env or {}
self.cwd = cwd
self.install_command = install_command
@@ -587,8 +588,13 @@ class MCPToolkit(BaseToolkit):
self._ready_event.clear()
self._init_exception = None
dynamic_headers = {}
dynamic_headers = self.headers.copy()
if self.header_provider and context:
dynamic_headers.update(self.header_provider(context))
dynamic_env = self.env.copy()
if self.env_provider and context:
dynamic_env.update(self.env_provider(context))
self._shared_task = asyncio.create_task(
self._spawn_session_task(dynamic_headers, dynamic_env)
@@ -4,11 +4,12 @@ from typing import Any
from zhenxun.services.ai.capabilities import AbstractCapability
from zhenxun.services.ai.run.context 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
from .manager import skill_manager
from .models import Skill, SkillSource
from .toolkit import SkillMetaToolkit
class SkillCapability(AbstractCapability):
"""技能库挂载能力组件"""
@@ -11,7 +11,11 @@ import aiofiles
import yaml
from zhenxun.configs.path_config import DATA_PATH
from zhenxun.services.ai.tools.providers.skills.models import (
from zhenxun.services.ai.utils.logger import log_tool as logger
from zhenxun.utils.pydantic_compat import model_dump, model_validate
from zhenxun.utils.utils import infer_plugin_namespace
from .models import (
INSTRUCTIONS,
METADATA,
RESOURCES,
@@ -20,9 +24,6 @@ from zhenxun.services.ai.tools.providers.skills.models import (
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:
@@ -10,14 +10,15 @@ from zhenxun.services.ai.sandbox.protocols import (
from zhenxun.services.ai.tools.core.decorators import Rules, tool
from zhenxun.services.ai.tools.core.toolkit import BaseToolkit
from zhenxun.services.ai.tools.models import ResolvedToolPayload, ToolResult
from zhenxun.services.ai.tools.providers.skills.manager import (
from zhenxun.services.ai.utils.logger import log_tool as logger
from zhenxun.utils.pydantic_compat import model_copy
from zhenxun.utils.utils import infer_plugin_namespace
from .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
from .models import Skill
class SkillSandboxExecutionMixin: