Files
zhenxun_bot/zhenxun/services/ai/tools/engine/executor.py
T
922d092650 ♻️ 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>
2026-07-10 09:14:06 +08:00

531 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from abc import ABC, abstractmethod
import ast
import asyncio
from contextlib import asynccontextmanager
import inspect
import json
from typing import 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 (
ControlFlowExit,
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.message_builder import MessageBuilder
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,
ToolResult,
ToolResultChunk,
ValidatedToolCall,
)
from zhenxun.services.ai.utils.logger import log_tool as logger
from .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}"
)
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"""
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"):
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:
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,
)
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)
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:
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):
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:
"""
工具执行策略。
负责解析工具私有配置与系统全局配置,决定最大重试次数、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),如果未设置,则使用全局配置。
"""
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:
"""运行原生 Python 函数工具并返回结果。"""
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:
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 not is_silent:
await context.run.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:
uni_msg = (
MessageBuilder.message_to_unimessage(res)
if isinstance(res, PlatformMessage)
else res
)
parts = await MessageBuilder.unimsg_to_llm_parts(uni_msg)
await context.run.emit(UserCustomEvent(display=uni_msg))
final_result = ToolResult(output=parts)
else:
final_result = ToolResult(output=res)
return final_result
register_tool_runner(NativeToolRunner)