✨ feat!(llm): 重构并升级大语言模型服务为全新 AI 智能体框架 (#2146)

* ✨ feat!(llm): 重构并升级大语言模型服务为全新 AI 智能体框架

- 【重构】将原 services/llm 重构并迁移至全新的 services/ai 架构,提供向下兼容垫片
- 【新增】引入 Agent、Team、Workflow 三大智能体与工作流编排范式
- 【新增】引入基于 RAG 的长期向量记忆与中期槽位记忆系统
- 【新增】引入基于 Docker 的安全代码执行沙箱环境
- 【新增】支持 MCP 协议,允许动态管理和调用 MCP 服务
- 【新增】引入输入输出安全合规护栏与自愈反思机制
- 【优化】重构并优化多厂商 API 适配器 (Gemini, OpenAI, DeepSeek, GLM 等)
- 【优化】优化日志脱敏与 Token 预估机制
- 【移除】移除旧版 llm default 和 llm reset-key 命令,新增 llm mcp 管理命令

* 🔧 chore(deps): 更新项目依赖与配置

- 添加 mcp、jieba 和 aiodocker 依赖到配置文件及 requirements.txt
- 在 pyright 配置中设置 reportMissingImports 为 none
- 调整 .gitignore 中 resources 目录的忽略规则

* ♻️ refactor(tools): 重构工具终止机制并清理知识库日志输出

- 统一使用 `context.state["__end_run__"]` 替代 `EndRunResult` 控制任务结束
- 移除文件系统和向量知识库检索工具中 `ToolResult` 的 `.with_log` 调用
- 调整指令处理器(Directive)的返回值为 `tool_res.output`
- 修复部分类型检查警告并优化联合类型判断语法

* ♻️ refactor(tools): 重构工具副作用指令与控制流熔断机制

- 引入 `DirectivePayload` 及 `ToolResult` 的子类以结构化表达工具副作用
- 移除通过 `context.state` 传递魔术变量的隐式控制流设计
- 重构 `DirectiveManager` 处理器接口,直接在处理器中修改 `AgentState` 并构建 `AgentRunResult`
- 在 `StandardAgentExecutor` 中统一通过 `directive_manager` 调度工具返回的副作用指令
- 补全 `MessageBuilder` 中部分核心方法的文档注释

* 🐛 fix(sandbox): 修复 Docker 沙箱容器状态检测与会话清理逻辑

-【修复】修正 `is_alive` 中直接读取私有属性的问题,改用 `show()` 返回值
-【修复】解决 `execute_code` 中缓存的执行器与当前会话不一致的问题
-【优化】在清理工作区前增加容器存活检测,避免向已死容器发送请求
-【优化】创建容器时增加运行状态校验,若已停止则自动从缓存中移除并重建
-【优化】优化容器销毁和清理逻辑,静默处理容器不存在 (404) 的异常

* 📝 docs(core): 补充核心模块初始化方法的文档注释

* 🚨 auto fix by pre-commit hooks

---------

Co-authored-by: webjoin111 <455457521@qq.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Rumio
2026-07-03 08:53:56 +08:00
committed by GitHub
co-authored by webjoin111 pre-commit-ci[bot]
parent bdc1374848
commit 80fc5b86a7
223 changed files with 41885 additions and 10006 deletions
+38
View File
@@ -0,0 +1,38 @@
from zhenxun.services.ai.core.models import CancellationToken
from .blackboard import BlackboardManager
from .capabilities import GLOBAL_CAPABILITIES, register_global_capability
from .context import (
NoneBotDeps,
RunContext,
get_current_run_context,
)
from .di import Hidden, Inject
from .hitl import HITLController
from .hooks import Hooks
from .models import (
AgentRunResult,
StreamedRunResult,
Task,
)
from .session import session_manager
from .ui import UIController
__all__ = [
"GLOBAL_CAPABILITIES",
"AgentRunResult",
"BlackboardManager",
"CancellationToken",
"HITLController",
"Hidden",
"Hooks",
"Inject",
"NoneBotDeps",
"RunContext",
"StreamedRunResult",
"Task",
"UIController",
"get_current_run_context",
"register_global_capability",
"session_manager",
]
+75
View File
@@ -0,0 +1,75 @@
import asyncio
import json
from typing import Any, Generic, TypeVar
from pydantic import BaseModel, ValidationError
from zhenxun.utils.pydantic_compat import (
model_dump,
model_json_schema,
model_validate,
)
T = TypeVar("T", bound=BaseModel)
class BlackboardManager(Generic[T]):
"""
结构化黑板管理器,提供带锁的并发读写和 Schema 校验。
解决多智能体 (Team) 并发状态竞争及无结构脏写的问题。
"""
def __init__(self, schema: type[T], initial_state: T | None = None):
self.schema = schema
if initial_state is not None:
self._state = initial_state
else:
try:
self._state = schema()
except ValidationError as e:
raise ValueError(
"初始化黑板状态失败,因为 Schema 存在无默认值的必填字段,"
f"请显式提供 initial_state: {e}"
)
self._lock = asyncio.Lock()
async def read(self) -> str:
"""安全读取当前状态的 JSON 字符串表示"""
async with self._lock:
state_dict = model_dump(self._state)
return json.dumps(state_dict, ensure_ascii=False)
async def update(self, **kwargs: Any) -> str:
"""部分更新状态"""
async with self._lock:
current_dict = model_dump(self._state)
def deep_update(d: dict, u: dict) -> dict:
for k, v in u.items():
if isinstance(v, dict) and k in d and isinstance(d[k], dict):
deep_update(d[k], v)
elif isinstance(v, list) and k in d and isinstance(d[k], list):
d[k].extend(x for x in v if x not in d[k])
else:
d[k] = v
return d
merged_dict = deep_update(current_dict, kwargs)
try:
new_state = model_validate(self.schema, merged_dict)
self._state = new_state
return "全局黑板状态更新成功"
except ValidationError as e:
error_msgs = []
for err in e.errors():
loc = ".".join(str(x) for x in err["loc"])
msg = err.get("msg", "")
error_msgs.append(f"字段 `{loc}`: {msg}")
err_msg = "\n".join(error_msgs)
raise ValueError(f"黑板状态更新失败,非法的数据格式:\n{err_msg}")
def get_schema(self) -> dict[str, Any]:
"""获取结构化 Schema"""
return model_json_schema(self.schema)
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
from collections import defaultdict
from zhenxun.services.ai.capabilities import (
AbstractCapability,
)
from zhenxun.services.log import logger
from zhenxun.utils.utils import infer_plugin_namespace
GLOBAL_CAPABILITIES: dict[str, list[AbstractCapability]] = defaultdict(list)
from zhenxun.services.ai.capabilities.builtin import (
BillingCapability,
GlobalCycleLimitCapability,
PermissionCapability,
ReflexionCapability,
StuckDetectionCapability,
ToolRetryAndReflectionCapability,
)
for _cap in [
GlobalCycleLimitCapability(),
StuckDetectionCapability(),
PermissionCapability(),
BillingCapability(),
ToolRetryAndReflectionCapability(),
ReflexionCapability(),
]:
GLOBAL_CAPABILITIES["global"].append(_cap)
def register_global_capability(
capability: AbstractCapability, scope: str | None = None
) -> None:
ns = scope if scope is not None else infer_plugin_namespace()
GLOBAL_CAPABILITIES[ns].append(capability)
logger.debug(
f"已注册全局 Capability: {capability.__class__.__name__} -> Namespace: {ns}"
)
+410
View File
@@ -0,0 +1,410 @@
import asyncio
from collections.abc import Awaitable, Callable
from contextlib import contextmanager
from contextvars import ContextVar
import dataclasses
import inspect
from typing import Any, Generic, cast, get_origin
from typing_extensions import TypeVar
from nonebot.adapters import Bot, Event
from nonebot.matcher import Matcher
from pydantic import BaseModel, ConfigDict, Field
from zhenxun.services.ai.core.messages import AgentEvent, AgentMessage
from zhenxun.services.ai.utils import ContextUtils
from zhenxun.utils.utils import infer_plugin_namespace
AgentDepsT = TypeVar("AgentDepsT", default=Any)
"""泛型类型变量:外部环境依赖对象 (Agent Dependencies)。"""
ProviderFunc = Callable[["RunContext"], Any | Awaitable[Any]]
"""函数签名类型别名:依赖提供者函数 (Dependency Provider)。"""
ToolsPrepareFunc = Callable[["RunContext[AgentDepsT]", list[Any]], Any | Awaitable[Any]]
"""全局/Agent 级动态工具干预函数类型"""
class NoneBotDeps(BaseModel):
"""NoneBot 环境下的标准依赖容器。"""
model_config = ConfigDict(arbitrary_types_allowed=True)
bot: Bot | None = Field(default=None)
"""当前触发事件的 Bot 实例"""
event: Event | None = Field(default=None)
"""当前触发的事件实例"""
matcher: Matcher | None = Field(default=None)
"""当前处理该事件的 Matcher 实例"""
@classmethod
def get_current(cls) -> "NoneBotDeps | None":
"""利用 NoneBot 原生魔法,基于 ContextVars 隐式提取当前执行上下文"""
try:
from nonebot.matcher import current_bot, current_event, current_matcher
bot = current_bot.get(None)
event = current_event.get(None)
matcher = current_matcher.get(None)
if bot or event:
return cls(bot=bot, event=event, matcher=matcher)
except Exception:
pass
return None
@dataclasses.dataclass
class SessionContext(Generic[AgentDepsT]):
"""
生命周期:Session(会话)层。
跨越多次对话轮次,负责保存长线状态与物理隔离信息。
"""
session_id: str
"""核心会话标识符,用于区分不同用户或群组的上下文隔离。"""
deps: AgentDepsT
"""强类型的外部依赖注入对象(如 Bot, Event),供跨工具共享。"""
shared_state: dict[str, Any] = dataclasses.field(default_factory=dict)
"""共享状态字典:全局引用穿透,用于主智能体与嵌套子智能体之间的数据通信。"""
auth_tokens: dict[str, str] = dataclasses.field(default_factory=dict)
"""授权凭证字典:保存用户针对各 Provider 的 OAuth 或 API Token。"""
blackboard: Any | None = None
"""结构化黑板管理器,作为共享状态的高级替代方案,提供并发锁和强类型校验。"""
namespace: str = "global"
"""触发事件的插件命名空间"""
append_only_manager: Any = dataclasses.field(default=None)
"""用于大模型前缀缓存命中优化的追加写入管理器。"""
@property
def memory(self) -> Any:
"""
获取当前会话的持久化记忆访问门面 (AgentSessionFacade)。
提供极简的 history 和 slots 操作 API。
"""
from zhenxun.services.ai.context.memory.facades import AgentSessionFacade
from zhenxun.services.ai.context.memory.manager import memory_manager
from zhenxun.services.ai.context.memory.types import SessionMetadata
from zhenxun.services.ai.utils.scope import ScopeSelector
user_id = ContextUtils.extract_user_id(self.deps)
group_id = ContextUtils.extract_group_id(self.deps)
platform = ContextUtils.extract_platform(self.deps)
meta = SessionMetadata(
session_id=self.session_id,
selector=ScopeSelector(
user_id=user_id,
group_id=group_id,
platform=platform,
namespace=self.namespace,
),
)
return AgentSessionFacade(memory_manager, meta)
@dataclasses.dataclass
class AgentRunContext(Generic[AgentDepsT]):
"""
生命周期:Run(运行)层。
伴随 Agent 的单次执行 (run_stream),保存大模型推理时的状态与原生消息历史。
"""
session: SessionContext[AgentDepsT]
"""指向所属 Session 层上下文的引用。"""
state: dict[str, Any] = dataclasses.field(default_factory=dict)
"""状态字典:用于在当前 Agent 执行轮次、工具和中间件中透传动态变量。"""
agent_name: str | None = None
"""当前正在执行的 Agent 名称。"""
current_model: str | None = None
"""当前实际调用的底层大模型名称 (Provider/Model)。"""
user_input: str | None = None
"""当前轮次用户的原始文本输入。"""
messages: list[AgentMessage] = dataclasses.field(default_factory=list)
"""大模型原生上下文 (LLMMessage 列表),与执行器中的执行历史保持内存引用同步。"""
hitl_locks: dict[str, asyncio.Lock] = dataclasses.field(default_factory=dict)
"""人机交互 (HITL) 并发锁,防止同群组内并发审批冲突。"""
delegate_depth: int = 0
"""子智能体委派深度标记,用于防范无限递归嵌套。"""
tool_retries: dict[str, int] = dataclasses.field(default_factory=dict)
"""记录当前轮次内各个工具的累积失败重试次数,用于系统熔断。"""
cancellation_token: Any | None = None
"""全局级联取消令牌,用于跨 Agent 的协程挂起中断。"""
event_bus: Any | None = None
"""底层的事件流发射器 (EventBus),由执行引擎在运行时挂载。"""
dynamic_prompts: dict[str, str] = dataclasses.field(default_factory=dict)
"""动态提示词字典(保持插入顺序并去重)。
仅在 HTTP 请求前 JIT 渲染,不会污染持久化的上下文对话历史。"""
def add_system_prompt(self, prompt: str, key: str | None = None) -> None:
"""动态追加临时系统提示词到大模型上下文中(实时生效且不污染历史)。"""
dict_key = key or prompt
if prompt:
self.dynamic_prompts[dict_key] = prompt
def add_event(self, event: AgentEvent) -> None:
"""向当前运行上下文中安全追加业务事件"""
self.messages.append(event)
@dataclasses.dataclass
class ToolCallContext(Generic[AgentDepsT]):
"""
生命周期:Call(工具调用)层。
单次工具调用分配的绝对私有状态,彻底消灭并发调用时的属性污染。
"""
run: AgentRunContext[AgentDepsT]
"""指向所属 Run 层上下文的引用。"""
tool_call_id: str = "unknown"
"""大模型为本次工具调用分配的唯一 ID。"""
tool_name: str = "unknown"
"""本次调用的工具名称。"""
retry_count: int = 0
"""当前工具调用的重试序号 (第几次重试)。"""
current_tool: Any | None = None
"""当前工具的可执行实例 (ToolExecutable)。"""
@dataclasses.dataclass
class RunContext(Generic[AgentDepsT]):
"""
依赖注入容器(DI Container),保留原有上下文信息的同时提升获取类型的能力。
"""
session_id: str | None = None
"""当前运行所在的会话ID,用于区分不同用户的独立上下文"""
di_cache: dict[str, Any] = dataclasses.field(default_factory=dict)
"""依赖注入引擎的缓存容器,支持父子层级浅拷贝隔离"""
state: dict[str, Any] = dataclasses.field(default_factory=dict)
"""状态字典:用于在会话轮次、工具和中间件中透传动态变量"""
shared_state: dict[str, Any] = dataclasses.field(default_factory=dict)
"""共享状态字典:全局引用穿透,用于主智能体与嵌套子智能体之间的
黑板模式 (Blackboard) 数据通信"""
upstream_results: dict[str, Any] = dataclasses.field(default_factory=dict)
"""前置节点产出字典:标准化的数据流载荷契约,键为 Agent Name,值为输出内容"""
capabilities: list[Any] = dataclasses.field(default_factory=list)
"""当前上下文绑定的拦截器 (Capabilities) 链,用于生命周期拦截"""
deps: AgentDepsT = dataclasses.field(default=cast(AgentDepsT, None))
"""强类型的外部依赖注入对象,用于跨工具共享业务状态或配置"""
session: SessionContext[AgentDepsT] = dataclasses.field(
init=False, repr=False, compare=False
)
"""会话层上下文:承载跨轮次共享的依赖与共享状态引用。"""
run: AgentRunContext[AgentDepsT] = dataclasses.field(
init=False, repr=False, compare=False
)
"""运行层上下文:承载当前 Agent 执行轮次的模型状态与运行时元信息。"""
call: ToolCallContext[AgentDepsT] = dataclasses.field(
init=False, repr=False, compare=False
)
"""调用层上下文:承载单次工具调用的私有状态与执行引用。"""
_is_auto_session_id: bool = dataclasses.field(
default=False, init=False, repr=False, compare=False
)
"""标记 session_id 是否为框架隐式生成的。"""
def get_bot(self) -> Bot | None:
"""强类型安全地提取 Bot 实例"""
if not self.deps:
return None
bot = getattr(self.deps, "bot", None)
return bot if isinstance(bot, Bot) else None
def get_event(self) -> Event | None:
"""强类型安全地提取 Event 实例"""
if not self.deps:
return None
event = getattr(self.deps, "event", None)
return event if isinstance(event, Event) else None
def get_matcher(self) -> Matcher | None:
"""强类型安全地提取 Matcher 实例"""
if not self.deps:
return None
matcher = getattr(self.deps, "matcher", None)
return matcher if isinstance(matcher, Matcher) else None
def get_user_id(self) -> str | None:
"""安全提取当前触发任务的用户 ID"""
return ContextUtils.extract_user_id(self.deps)
def get_group_id(self) -> str | None:
"""安全提取当前触发任务的群组 ID(私聊则为 None)"""
return ContextUtils.extract_group_id(self.deps)
def get_platform(self) -> str:
"""安全提取当前连接的适配器平台标识"""
return ContextUtils.extract_platform(self.deps)
def __post_init__(self):
if self.deps is None:
self.deps = cast(AgentDepsT, NoneBotDeps.get_current())
if not self.session_id and self.deps:
from zhenxun.services.ai.context.memory.types import (
Isolation,
)
bot = self.get_bot()
event = self.get_event()
if bot and event:
meta = ContextUtils.generate_session_meta(
bot, event, scope_builder=Isolation.AGENT_USER()
)
self.session_id = meta.session_id
self._is_auto_session_id = True
else:
uid = self.get_user_id()
gid = self.get_group_id()
if uid and gid:
self.session_id = f"auto_{gid}_{uid}"
self._is_auto_session_id = True
elif uid:
self.session_id = f"auto_private_{uid}"
self._is_auto_session_id = True
ns = "global"
if self.deps:
ns = getattr(self.deps, "namespace", None) or (
self.deps.get("namespace") if isinstance(self.deps, dict) else None
)
if not ns:
ns = infer_plugin_namespace(default="global")
self.session = SessionContext(
session_id=self.session_id or "default_session",
deps=self.deps,
shared_state=self.shared_state,
namespace=ns,
)
from zhenxun.services.ai.core.engine.append_only import (
AppendOnlyContextManager,
)
self.session.append_only_manager = AppendOnlyContextManager()
self.run = AgentRunContext(session=self.session, state=self.state)
self.call = ToolCallContext(run=self.run)
def clone_for_execution(self, **kwargs) -> "RunContext":
new_state = self.state.copy()
changes = {k: v for k, v in kwargs.items() if hasattr(self, k)}
if "state" not in changes:
changes["state"] = new_state
if "shared_state" not in changes:
changes["shared_state"] = self.shared_state
new_ctx = dataclasses.replace(cast(Any, self), **changes)
new_ctx.upstream_results = self.upstream_results.copy()
new_ctx.di_cache = self.di_cache.copy()
new_ctx.session = self.session
new_ctx.run = AgentRunContext(
session=new_ctx.session,
state=new_ctx.state,
agent_name=self.run.agent_name,
current_model=self.run.current_model,
user_input=self.run.user_input,
messages=list(self.run.messages),
hitl_locks=self.run.hitl_locks,
delegate_depth=self.run.delegate_depth,
tool_retries=self.run.tool_retries,
cancellation_token=self.run.cancellation_token,
event_bus=self.run.event_bus,
dynamic_prompts=self.run.dynamic_prompts.copy(),
)
new_ctx.call = ToolCallContext(run=new_ctx.run)
return new_ctx
def clone_for_tool_call(self, tool_call_id: str, tool_name: str) -> "RunContext":
"""
为每个并发的工具调用派生绝对独立的 ToolCallContext。消除多工具并行时的属性污染。
"""
new_ctx = dataclasses.replace(cast(Any, self))
new_ctx.di_cache = self.di_cache.copy()
new_ctx.session = self.session
new_ctx.run = self.run
new_ctx.call = ToolCallContext(
run=new_ctx.run,
tool_call_id=tool_call_id,
tool_name=tool_name,
retry_count=new_ctx.run.tool_retries.get(tool_name, 0),
)
return new_ctx
def clone_for_member(self, member_name: str = "unknown") -> "RunContext":
"""
为团队子成员克隆上下文。
强制清空消息历史并分配独立 SessionID,实现绝对的记忆沙箱物理隔离。
"""
new_ctx = self.clone_for_execution()
new_ctx.run.delegate_depth = 0
new_ctx.run.messages = []
new_ctx.capabilities = []
import uuid
base_sid = self.session_id or "default"
new_ctx.session_id = f"{base_sid}/sub_{member_name}_{uuid.uuid4().hex[:6]}"
new_ctx.session.session_id = new_ctx.session_id
return new_ctx
_CURRENT_RUN_CONTEXT: ContextVar[RunContext | None] = ContextVar(
"current_run_context", default=None
)
def get_current_run_context() -> RunContext | None:
"""
[全局逃生舱] 获取当前运行中的上下文对象。
适用于深层嵌套业务逻辑,无需层层透传 context 参数。
"""
return _CURRENT_RUN_CONTEXT.get()
@contextmanager
def set_run_context(ctx: RunContext):
"""[内部 API] 挂载当前上下文至全局"""
token = _CURRENT_RUN_CONTEXT.set(ctx)
try:
yield
finally:
_CURRENT_RUN_CONTEXT.reset(token)
def _is_run_context_type(annotation: Any) -> bool:
if annotation is RunContext:
return True
origin = get_origin(annotation)
if origin is RunContext:
return True
if inspect.isclass(origin) and issubclass(origin, RunContext):
return True
if inspect.isclass(annotation) and issubclass(annotation, RunContext):
return True
if "RunContext" in str(annotation):
return True
return False
__all__ = [
"AgentDepsT",
"AgentRunContext",
"NoneBotDeps",
"RunContext",
"SessionContext",
"ToolCallContext",
"ToolsPrepareFunc",
"get_current_run_context",
"set_run_context",
]
+387
View File
@@ -0,0 +1,387 @@
from collections import defaultdict
from collections.abc import Awaitable, Callable
import inspect
from typing import Annotated, Any, ClassVar, cast
from nonebot.adapters import Bot, Event
from nonebot.matcher import Matcher
from nonebot.utils import is_coroutine_callable
from nonebot_plugin_session import EventSession, extract_session
from zhenxun.services.ai.context.memory.facades import AgentSessionFacade
from zhenxun.services.ai.run.blackboard import BlackboardManager
from zhenxun.utils.utils import infer_plugin_namespace
from .context import ProviderFunc, RunContext, _is_run_context_type
from .hitl import HITLController
from .ui import UIController
class Hidden:
"""
标记一个参数为隐藏参数。
在生成大模型工具 Schema 时,带有该标记的 Annotated 参数将被剔除。
"""
pass
class _InjectMarker:
"""内部魔术标记,用于识别 DI 类型糖"""
def __init__(self, key: str):
self.key = key
class _UpstreamResultMarker:
"""内部魔术标记,用于精准捕获上游特定节点的产出"""
def __init__(self, step_name: str):
self.step_name = step_name
CurrentUserId = Annotated[str | None, Hidden(), _InjectMarker("user_id")]
CurrentGroupId = Annotated[str | None, Hidden(), _InjectMarker("group_id")]
CurrentPlatform = Annotated[str, Hidden(), _InjectMarker("platform")]
CurrentBot = Annotated[Bot | None, Hidden(), _InjectMarker("bot")]
CurrentEvent = Annotated[Event | None, Hidden(), _InjectMarker("event")]
CurrentMatcher = Annotated[Matcher | None, Hidden(), _InjectMarker("matcher")]
CurrentSession = Annotated[EventSession | None, Hidden(), _InjectMarker("session")]
CurrentUI = Annotated[UIController, Hidden(), _InjectMarker("ui")]
CurrentHITL = Annotated[HITLController, Hidden(), _InjectMarker("hitl")]
CurrentModelName = Annotated[str | None, Hidden(), _InjectMarker("model_name")]
CurrentToolRetries = Annotated[int, Hidden(), _InjectMarker("tool_retries")]
CurrentState = Annotated[dict[str, Any], Hidden(), _InjectMarker("state")]
CurrentSharedState = Annotated[dict[str, Any], Hidden(), _InjectMarker("shared_state")]
CurrentOriginalInput = Annotated[str, Hidden(), _InjectMarker("original_input")]
UpstreamResults = Annotated[dict[str, Any], Hidden(), _InjectMarker("upstream_results")]
CurrentEventPayload = Annotated[Any, Hidden(), _InjectMarker("stream_event")]
CurrentBlackboard = Annotated[
BlackboardManager | None, Hidden(), _InjectMarker("blackboard")
]
CurrentMemory = Annotated[AgentSessionFacade, Hidden(), _InjectMarker("memory")]
CurrentSandbox = Annotated[Any, Hidden(), _InjectMarker("sandbox")]
class Inject:
"""
[命名空间] 大模型工具依赖注入类型糖。
用于在工具参数中快捷获取群聊/用户的上下文,或注册自定义的依赖提供者。
"""
_providers: ClassVar[dict[str, dict[str, ProviderFunc]]] = defaultdict(dict)
@classmethod
def register_provider(
cls, key: str, provider: ProviderFunc, scope: str | None = None
) -> None:
"""
(底层) 注册一个自定义的依赖提供者。
"""
ns = scope if scope is not None else infer_plugin_namespace()
cls._providers[key][ns] = provider
@classmethod
def provider(cls, key: str, scope: str | None = None):
def decorator(func: ProviderFunc) -> ProviderFunc:
cls.register_provider(key, func, scope)
return func
return decorator
@classmethod
def bind(cls, key: str, type_hint: Any = Any) -> Any:
"""
动态生成一个用于类型注解的 Inject 标记。
示例:DbSession = Inject.bind("db_session", AsyncSession)
"""
return Annotated[type_hint, Hidden(), _InjectMarker(key)]
@classmethod
def UpstreamResult(cls, step_name: str) -> Any:
"""
自动注入:精准获取工作流中指定前置节点的产出。可作为参数的默认值使用以通过静态检查。
示例: def my_step(data: str = Inject.UpstreamResult("AgentA")):
"""
return _UpstreamResultMarker(step_name)
UserId = CurrentUserId
"""自动注入:触发当前任务的用户 ID"""
GroupId = CurrentGroupId
"""自动注入:触发当前任务的群聊 ID(私聊时为 None)"""
Platform = CurrentPlatform
"""自动注入:当前对接的平台名称(如 qq)"""
Bot = CurrentBot
"""自动注入:当前的 NoneBot Bot 实例"""
Event = CurrentEvent
"""自动注入:当前的 NoneBot Event 实例"""
Matcher = CurrentMatcher
"""自动注入:当前的 NoneBot Matcher 实例"""
Session = CurrentSession
"""自动注入:当前的 nonebot_plugin_session 会话实例"""
UI = CurrentUI
"""自动注入:前端 UI 控制器实例"""
HITL = CurrentHITL
"""自动注入:人机协同控制器实例 (提供底层 waiter 交互封装)"""
ModelName = CurrentModelName
"""自动注入:当前大模型正在执行的底层模型名称"""
ToolRetries = CurrentToolRetries
"""自动注入:当前工具正在经历的重试次数 (Int)"""
State = CurrentState
"""自动注入:当前 Agent 轮次隔离的业务状态字典"""
OriginalInput = CurrentOriginalInput
"""自动注入:当前工作流最初始的用户输入文本"""
SharedState = CurrentSharedState
"""自动注入:当前 Team 或全局穿透的共享黑板状态字典"""
UpstreamResults = UpstreamResults
"""自动注入:当前工作流中所有上游节点的产出字典 (Key: Agent Name)"""
EventPayload = CurrentEventPayload
"""自动注入:当前生命周期钩子流转中的 EventBus 载荷实体"""
Blackboard = CurrentBlackboard
"""自动注入:当前工作流/团队挂载的强类型黑板 (BlackboardManager) 实例"""
Memory = CurrentMemory
"""自动注入:当前会话的持久化记忆存取门面 (AgentSessionFacade) 实例"""
Sandbox = CurrentSandbox
"""自动注入:当前沙箱环境管理器实例"""
class BaseParamResolver:
"""可插拔参数解析器基类协议"""
def match(
self,
name: str,
param: inspect.Parameter,
context: RunContext,
) -> bool:
return False
def static_match(self, param: inspect.Parameter) -> bool:
return False
async def resolve(
self,
name: str,
param: inspect.Parameter,
context: RunContext,
) -> Any:
raise NotImplementedError
class RunContextResolver(BaseParamResolver):
def match(self, name, param, context) -> bool:
return _is_run_context_type(param.annotation)
def static_match(self, param: inspect.Parameter) -> bool:
return _is_run_context_type(param.annotation)
async def resolve(self, name, param, context) -> Any:
return context
class TypeSugarResolver(BaseParamResolver):
def _get_marker(self, param: inspect.Parameter) -> Any | None:
anno = param.annotation
if hasattr(anno, "__metadata__"):
for arg in anno.__metadata__:
if isinstance(arg, _InjectMarker | _UpstreamResultMarker):
return arg
default_val = param.default
if isinstance(default_val, _InjectMarker | _UpstreamResultMarker):
return default_val
if hasattr(default_val, "__metadata__"):
for arg in default_val.__metadata__:
if isinstance(arg, _InjectMarker | _UpstreamResultMarker):
return arg
return None
def match(self, name, param, context) -> bool:
return self._get_marker(param) is not None
def static_match(self, param: inspect.Parameter) -> bool:
return self._get_marker(param) is not None
async def resolve(self, name, param, context) -> Any:
marker = self._get_marker(param)
if not marker:
raise ValueError(f"参数 {name} 缺失 Inject 标记")
if isinstance(marker, _UpstreamResultMarker):
return context.upstream_results.get(marker.step_name)
marker_key = marker.key
if marker_key in context.di_cache:
return context.di_cache[marker_key]
provider_dict = Inject._providers.get(marker_key, {})
ns = getattr(context.session, "namespace", "global")
provider = provider_dict.get(ns) or provider_dict.get("global")
if not provider:
raise ValueError(f"未知的类型糖标记或未注册的 Provider: {marker_key}")
if is_coroutine_callable(provider):
result = await provider(context)
else:
result = provider(context)
context.di_cache[marker_key] = result
return result
class DependencyInjector:
"""可插拔依赖注入管线 (Resolver Pipeline)"""
_resolvers: ClassVar[list[BaseParamResolver]] = []
@classmethod
def register(cls, resolver: BaseParamResolver) -> None:
cls._resolvers.append(resolver)
@classmethod
def can_resolve_statically(cls, param: inspect.Parameter) -> bool:
for resolver in cls._resolvers:
if resolver.static_match(param):
return True
return False
@classmethod
async def resolve_all(
cls,
sig: inspect.Signature,
call_kwargs: dict[str, Any],
context: RunContext,
) -> dict[str, Any]:
resolved_kwargs = dict(call_kwargs)
for k, v in call_kwargs.items():
context.di_cache[k] = v
for name, param in sig.parameters.items():
if name in ("self", "cls") or name in resolved_kwargs:
continue
if param.kind in (
inspect.Parameter.VAR_POSITIONAL,
inspect.Parameter.VAR_KEYWORD,
):
continue
resolved = False
for resolver in cls._resolvers:
if resolver.match(name, param, context):
val = await resolver.resolve(name, param, context)
resolved_kwargs[name] = val
resolved = True
break
if not resolved:
if param.default is not inspect.Parameter.empty:
continue
raise ValueError(
f"参数 '{name}' 未被大模型提供且缺少显式的依赖注入标记"
"(如 Inject.* 或 RunContext)。"
)
return resolved_kwargs
@classmethod
async def invoke(
cls,
func: Callable[..., Any],
call_kwargs: dict[str, Any],
context: RunContext,
) -> Any:
"""统一执行带有依赖注入的函数 (支持同步/异步)"""
sig = inspect.signature(func)
resolved_kwargs = await cls.resolve_all(sig, call_kwargs, context)
filtered_kwargs = {
k: v for k, v in resolved_kwargs.items() if k in sig.parameters
}
if is_coroutine_callable(func):
return await cast(Callable[..., Awaitable[Any]], func)(**filtered_kwargs)
return cast(Callable[..., Any], func)(**filtered_kwargs)
DependencyInjector.register(RunContextResolver())
DependencyInjector.register(TypeSugarResolver())
Inject.register_provider("user_id", lambda ctx: ctx.get_user_id(), scope="global")
Inject.register_provider("group_id", lambda ctx: ctx.get_group_id(), scope="global")
Inject.register_provider("platform", lambda ctx: ctx.get_platform(), scope="global")
Inject.register_provider("bot", lambda ctx: ctx.get_bot(), scope="global")
Inject.register_provider("event", lambda ctx: ctx.get_event(), scope="global")
Inject.register_provider("matcher", lambda ctx: ctx.get_matcher(), scope="global")
Inject.register_provider("hitl", lambda ctx: HITLController(ctx), scope="global")
Inject.register_provider(
"model_name", lambda ctx: ctx.run.current_model, scope="global"
)
Inject.register_provider(
"tool_retries", lambda ctx: ctx.call.retry_count, scope="global"
)
Inject.register_provider(
"original_input", lambda ctx: ctx.run.user_input, scope="global"
)
Inject.register_provider("state", lambda ctx: ctx.state, scope="global")
Inject.register_provider(
"upstream_results", lambda ctx: ctx.upstream_results, scope="global"
)
Inject.register_provider(
"shared_state", lambda ctx: ctx.session.shared_state, scope="global"
)
Inject.register_provider("memory", lambda ctx: ctx.session.memory, scope="global")
def _resolve_blackboard(ctx: RunContext):
bb = ctx.session.blackboard
if bb is None:
raise ValueError(
"Inject.Blackboard 注入失败:"
"当前会话上下文中未挂载 BlackboardManager 实例。"
)
return bb
Inject.register_provider("blackboard", _resolve_blackboard, scope="global")
def _resolve_session(ctx):
bot = ctx.get_bot()
event = ctx.get_event()
return extract_session(bot, event) if bot and event else None
Inject.register_provider("session", _resolve_session, scope="global")
Inject.register_provider("ui", lambda ctx: UIController(ctx), scope="global")
from zhenxun.services.ai.sandbox.manager import sandbox_manager
Inject.register_provider("sandbox", lambda ctx: sandbox_manager, scope="global")
__all__ = [
"DependencyInjector",
"Hidden",
"Inject",
]
+144
View File
@@ -0,0 +1,144 @@
import asyncio
from collections.abc import Callable
import time
from typing import TYPE_CHECKING
from nonebot.adapters import Event
from nonebot.permission import SUPERUSER
from nonebot_plugin_waiter import waiter
from zhenxun.services.ai.core.exceptions import AbortException, ToolFatalError
from zhenxun.services.log import logger
if TYPE_CHECKING:
from zhenxun.services.ai.run.context import RunContext
CANCEL_WORDS = {"取消", "cancel", "0", "退出", "quit"}
CONFIRM_WORDS = {"y", "yes", "是", "1", "ok", "确认"}
REJECT_WORDS = {"n", "no", "否", "0", "拒绝"}
class HITLController:
"""
人机协同控制器 (Human-in-the-Loop Controller)。
封装底层的物理环境隔离等待逻辑,供上层工具和中间件发起提问或审批。
"""
def __init__(self, context: "RunContext"):
self.context = context
async def wait_event(
self,
prompt_msg: str | None = None,
timeout: float = 60.0,
custom_checker: Callable[[Event], bool] | None = None,
strict_user: bool = True,
) -> Event | None:
"""
底层交互方法:进行严格群组与用户隔离的挂起等待。
"""
bot = self.context.get_bot()
event = self.context.get_event()
if not bot or not event:
logger.warning("HITLController: 当前环境无 Bot/Event 实例,无法发起交互。")
return None
if prompt_msg:
await bot.send(event, prompt_msg)
orig_user_id = self.context.get_user_id()
orig_group_id = self.context.get_group_id()
@waiter(waits=["message"], keep_session=False)
async def event_waiter(e: Event):
raw_curr_group = getattr(e, "group_id", getattr(e, "channel_id", None))
curr_group_id = str(raw_curr_group) if raw_curr_group else None
tgt_group_id = str(orig_group_id) if orig_group_id else None
if curr_group_id != tgt_group_id:
return None
if strict_user and str(e.get_user_id()) != orig_user_id:
return None
if custom_checker and not custom_checker(e):
return None
return e
task = asyncio.create_task(event_waiter.wait(timeout=timeout))
cancellation_token = self.context.run.cancellation_token
if cancellation_token:
cancellation_token.link_future(task)
return await task
async def ask_text(self, prompt_msg: str, timeout: float = 60.0) -> str:
"""
高阶交互:发起文本提问,内置超时与取消词检测。
如用户取消或超时,自动抛出异常切断 Agent 思考循环。
"""
event = await self.wait_event(prompt_msg, timeout=timeout, strict_user=True)
if event is None:
logger.warning("🛡️ [HITL] 文本参数收集超时,任务已被系统强杀。")
raise ToolFatalError("参数收集超时,用户已离开,任务已中止。")
user_input = event.get_plaintext().strip()
if user_input.lower() in CANCEL_WORDS:
raise AbortException(reason="用户主动取消了操作")
return user_input
async def ask_confirm(self, prompt_msg: str, timeout: float = 60.0) -> bool:
"""
高阶交互:发起 Y/N 确认审批。
支持超管越权代批。用户拒绝或超时自动抛出异常切断循环。
"""
orig_user_id = self.context.get_user_id()
bot = self.context.get_bot()
if not bot:
raise ToolFatalError("非交互环境无法发起人工审批。")
full_prompt = (
f"{prompt_msg}\n\n请在规定时间内回复 [Y/是] 确认,或 [N/否/取消] 拒绝。"
)
start_time = time.time()
is_first_prompt = True
while True:
remaining = timeout - (time.time() - start_time)
if remaining <= 0:
raise ToolFatalError("审批超时")
event = await self.wait_event(
prompt_msg=full_prompt if is_first_prompt else None,
timeout=remaining,
strict_user=False,
)
is_first_prompt = False
if event is None:
raise ToolFatalError("审批超时")
text = event.get_plaintext().strip().lower()
if (
text not in CONFIRM_WORDS
and text not in REJECT_WORDS
and text not in CANCEL_WORDS
):
continue
curr_user_id = str(event.get_user_id())
is_authorized = curr_user_id == orig_user_id or await SUPERUSER(bot, event)
if not is_authorized:
await bot.send(event, "⚠️ 权限不足,你无权审批此操作。")
continue
if text in CONFIRM_WORDS:
return True
raise AbortException(reason="审批被拒绝")
+705
View File
@@ -0,0 +1,705 @@
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any, Generic, Protocol, TypeVar
import anyio
from nonebot.utils import is_coroutine_callable
from zhenxun.services.ai.capabilities import (
AbstractCapability,
WrapModelRequestHandler,
WrapRunHandler,
WrapToolExecuteHandler,
WrapToolValidateHandler,
)
from zhenxun.services.ai.core.messages import ChatRequest, ChatResponse
from zhenxun.services.ai.core.models import LLMContext
from .context import RunContext
from .models import AgentRunResult
_FuncT = TypeVar("_FuncT", bound=Callable[..., Any])
class HookTimeoutError(TimeoutError):
"""当 Hook 函数执行超过配置的时间时抛出此异常。"""
def __init__(self, hook_name: str, func_name: str, timeout: float):
self.hook_name = hook_name
self.func_name = func_name
self.timeout = timeout
super().__init__(
f"Hook {hook_name!r} 中的函数 {func_name!r} 执行超时 ({timeout}s)"
)
@dataclass
class _HookEntry(Generic[_FuncT]):
"""基础 Hook 注册实体,支持超时配置"""
func: _FuncT
timeout: float | None = None
@dataclass
class _ToolHookEntry(_HookEntry[_FuncT]):
"""工具层 Hook 注册实体,支持工具过滤器"""
tools: frozenset[str] | None = None
class BeforeRunHookFunc(Protocol):
def __call__(self, ctx: RunContext[Any], /) -> None | Awaitable[None]: ...
class AfterRunHookFunc(Protocol):
def __call__(
self, ctx: RunContext[Any], /, *, result: AgentRunResult[Any]
) -> AgentRunResult[Any] | Awaitable[AgentRunResult[Any]]: ...
class WrapRunHookFunc(Protocol):
def __call__(
self, ctx: RunContext[Any], /, *, handler: WrapRunHandler
) -> AgentRunResult[Any] | Awaitable[AgentRunResult[Any]]: ...
class OnRunErrorHookFunc(Protocol):
def __call__(
self, ctx: RunContext[Any], /, *, error: BaseException
) -> AgentRunResult[Any] | Awaitable[AgentRunResult[Any]]: ...
class BeforeModelRequestHookFunc(Protocol):
def __call__(
self,
ctx: RunContext[Any],
request_context: LLMContext[ChatRequest, ChatResponse],
/,
) -> (
LLMContext[ChatRequest, ChatResponse]
| Awaitable[LLMContext[ChatRequest, ChatResponse]]
): ...
class AfterModelRequestHookFunc(Protocol):
def __call__(
self,
ctx: RunContext[Any],
/,
*,
request_context: LLMContext[ChatRequest, ChatResponse],
response: ChatResponse,
) -> ChatResponse | Awaitable[ChatResponse]: ...
class WrapModelRequestHookFunc(Protocol):
def __call__(
self,
ctx: RunContext[Any],
/,
*,
request_context: LLMContext[ChatRequest, ChatResponse],
handler: WrapModelRequestHandler,
) -> ChatResponse | Awaitable[ChatResponse]: ...
class OnModelRequestErrorHookFunc(Protocol):
def __call__(
self,
ctx: RunContext[Any],
/,
*,
request_context: LLMContext[ChatRequest, ChatResponse],
error: Exception,
) -> ChatResponse | Awaitable[ChatResponse]: ...
class PrepareToolsHookFunc(Protocol):
def __call__(
self, ctx: RunContext[Any], tool_defs: list[Any], /
) -> list[Any] | Awaitable[list[Any]]: ...
class BeforeToolValidateHookFunc(Protocol):
def __call__(
self, ctx: RunContext[Any], /, *, tool_name: str, args: str | dict[str, Any]
) -> str | dict[str, Any] | Awaitable[str | dict[str, Any]]: ...
class AfterToolValidateHookFunc(Protocol):
def __call__(
self, ctx: RunContext[Any], /, *, tool_name: str, args: dict[str, Any]
) -> dict[str, Any] | Awaitable[dict[str, Any]]: ...
class WrapToolValidateHookFunc(Protocol):
def __call__(
self,
ctx: RunContext[Any],
/,
*,
tool_name: str,
args: str | dict[str, Any],
handler: WrapToolValidateHandler,
) -> dict[str, Any] | Awaitable[dict[str, Any]]: ...
class OnToolValidateErrorHookFunc(Protocol):
def __call__(
self,
ctx: RunContext[Any],
/,
*,
tool_name: str,
args: str | dict[str, Any],
error: Exception,
) -> dict[str, Any] | Awaitable[dict[str, Any]]: ...
class BeforeToolExecuteHookFunc(Protocol):
def __call__(
self, ctx: RunContext[Any], /, *, tool_name: str, arguments: dict[str, Any]
) -> dict[str, Any] | Awaitable[dict[str, Any]]: ...
class AfterToolExecuteHookFunc(Protocol):
def __call__(
self,
ctx: RunContext[Any],
/,
*,
tool_name: str,
arguments: dict[str, Any],
result: Any,
) -> Any | Awaitable[Any]: ...
class WrapToolExecuteHookFunc(Protocol):
def __call__(
self,
ctx: RunContext[Any],
/,
*,
tool_name: str,
arguments: dict[str, Any],
handler: WrapToolExecuteHandler,
) -> Any | Awaitable[Any]: ...
class OnToolExecuteErrorHookFunc(Protocol):
def __call__(
self, ctx: RunContext[Any], /, *, tool_name: str, error: Exception
) -> Any | Awaitable[Any]: ...
async def _call_func(func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
if is_coroutine_callable(func):
return await func(*args, **kwargs)
return func(*args, **kwargs)
async def _call_entry(
entry: _HookEntry[Any], hook_name: str, *args: Any, **kwargs: Any
) -> Any:
"""调用 Hook 函数实体,并自动应用熔断超时保护"""
func = entry.func
if entry.timeout is not None:
try:
with anyio.fail_after(entry.timeout):
return await _call_func(func, *args, **kwargs)
except TimeoutError:
raise HookTimeoutError(
hook_name=hook_name,
func_name=getattr(func, "__name__", repr(func)),
timeout=entry.timeout,
) from None
return await _call_func(func, *args, **kwargs)
def _filter_tool_entries(
entries: list[_HookEntry[Any]], *, tool_name: str
) -> list[_HookEntry[Any]]:
"""按工具名称过滤 Hook 实体"""
return [
entry
for entry in entries
if not (
isinstance(entry, _ToolHookEntry)
and entry.tools is not None
and tool_name not in entry.tools
)
]
def _bare_or_parameterized(
registry: dict[str, list[_HookEntry[Any]]],
key: str,
func: _FuncT | None,
*,
timeout: float | None = None,
) -> _FuncT | Callable[[_FuncT], _FuncT]:
"""处理无参数钩子的带参/不带参装饰器逻辑"""
if func is not None:
registry.setdefault(key, []).append(_HookEntry(func, timeout=timeout))
return func
def decorator(f: _FuncT) -> _FuncT:
registry.setdefault(key, []).append(_HookEntry(f, timeout=timeout))
return f
return decorator
def _tool_bare_or_parameterized(
registry: dict[str, list[_HookEntry[Any]]],
key: str,
func: _FuncT | None,
*,
tools: list[str] | None = None,
timeout: float | None = None,
) -> _FuncT | Callable[[_FuncT], _FuncT]:
"""处理工具钩子的带参/不带参装饰器逻辑"""
frozen_tools = frozenset(tools) if tools is not None else None
if func is not None:
registry.setdefault(key, []).append(
_ToolHookEntry(func, timeout=timeout, tools=frozen_tools)
)
return func
def decorator(f: _FuncT) -> _FuncT:
registry.setdefault(key, []).append(
_ToolHookEntry(f, timeout=timeout, tools=frozen_tools)
)
return f
return decorator
class _HookRegistration:
"""
Hooks 的装饰器命名空间。
利用 @overload 提供完美的 IDE 强类型补全和文档提示。
"""
def __init__(self, hooks: "Hooks"):
self._hooks = hooks
@property
def _r(self) -> dict[str, list[_HookEntry[Any]]]:
return self._hooks._registry
from typing import overload
@overload
def before_run(self, func: BeforeRunHookFunc, /) -> BeforeRunHookFunc: ...
@overload
def before_run(
self, *, timeout: float | None = None
) -> Callable[[BeforeRunHookFunc], BeforeRunHookFunc]: ...
def before_run(
self, func: BeforeRunHookFunc | None = None, *, timeout: float | None = None
) -> Any:
"""注册运行前钩子。在 Agent 启动任何流转前触发。"""
return _bare_or_parameterized(self._r, "before_run", func, timeout=timeout)
@overload
def after_run(self, func: AfterRunHookFunc, /) -> AfterRunHookFunc: ...
@overload
def after_run(
self, *, timeout: float | None = None
) -> Callable[[AfterRunHookFunc], AfterRunHookFunc]: ...
def after_run(
self, func: AfterRunHookFunc | None = None, *, timeout: float | None = None
) -> Any:
"""注册运行后钩子。在 Agent 获取最终结果后触发,可修改结果。"""
return _bare_or_parameterized(self._r, "after_run", func, timeout=timeout)
@overload
def wrap_run(self, func: WrapRunHookFunc, /) -> WrapRunHookFunc: ...
@overload
def wrap_run(
self, *, timeout: float | None = None
) -> Callable[[WrapRunHookFunc], WrapRunHookFunc]: ...
def wrap_run(
self, func: WrapRunHookFunc | None = None, *, timeout: float | None = None
) -> Any:
"""注册运行包裹钩子。以洋葱模型接管整个 Agent 运行过程。"""
return _bare_or_parameterized(self._r, "wrap_run", func, timeout=timeout)
@overload
def on_run_error(self, func: OnRunErrorHookFunc, /) -> OnRunErrorHookFunc: ...
@overload
def on_run_error(
self, *, timeout: float | None = None
) -> Callable[[OnRunErrorHookFunc], OnRunErrorHookFunc]: ...
def on_run_error(
self, func: OnRunErrorHookFunc | None = None, *, timeout: float | None = None
) -> Any:
"""注册运行异常钩子。捕获 Agent 级别的致命错误。"""
return _bare_or_parameterized(self._r, "on_run_error", func, timeout=timeout)
@overload
def before_model_request(
self, func: BeforeModelRequestHookFunc, /
) -> BeforeModelRequestHookFunc: ...
@overload
def before_model_request(
self, *, timeout: float | None = None
) -> Callable[[BeforeModelRequestHookFunc], BeforeModelRequestHookFunc]: ...
def before_model_request(
self,
func: BeforeModelRequestHookFunc | None = None,
*,
timeout: float | None = None,
) -> Any:
"""注册大模型请求前钩子。可在此修改发送给 LLM 的 Messages 等上下文。"""
return _bare_or_parameterized(
self._r, "before_model_request", func, timeout=timeout
)
@overload
def after_model_request(
self, func: AfterModelRequestHookFunc, /
) -> AfterModelRequestHookFunc: ...
@overload
def after_model_request(
self, *, timeout: float | None = None
) -> Callable[[AfterModelRequestHookFunc], AfterModelRequestHookFunc]: ...
def after_model_request(
self,
func: AfterModelRequestHookFunc | None = None,
*,
timeout: float | None = None,
) -> Any:
"""注册大模型请求后钩子。可在此验证或修改 LLM 的原始 Response。"""
return _bare_or_parameterized(
self._r, "after_model_request", func, timeout=timeout
)
@overload
def wrap_model_request(
self, func: WrapModelRequestHookFunc, /
) -> WrapModelRequestHookFunc: ...
@overload
def wrap_model_request(
self, *, timeout: float | None = None
) -> Callable[[WrapModelRequestHookFunc], WrapModelRequestHookFunc]: ...
def wrap_model_request(
self,
func: WrapModelRequestHookFunc | None = None,
*,
timeout: float | None = None,
) -> Any:
"""注册大模型请求包裹钩子。以洋葱模型接管 LLM 的网络请求过程。"""
return _bare_or_parameterized(
self._r, "wrap_model_request", func, timeout=timeout
)
@overload
def on_model_request_error(
self, func: OnModelRequestErrorHookFunc, /
) -> OnModelRequestErrorHookFunc: ...
@overload
def on_model_request_error(
self, *, timeout: float | None = None
) -> Callable[[OnModelRequestErrorHookFunc], OnModelRequestErrorHookFunc]: ...
def on_model_request_error(
self,
func: OnModelRequestErrorHookFunc | None = None,
*,
timeout: float | None = None,
) -> Any:
"""注册大模型请求异常钩子。捕获超时或网络等异常。"""
return _bare_or_parameterized(
self._r, "on_model_request_error", func, timeout=timeout
)
@overload
def before_tool_execute(
self, func: BeforeToolExecuteHookFunc, /
) -> BeforeToolExecuteHookFunc: ...
@overload
def before_tool_execute(
self, *, tools: list[str] | None = None, timeout: float | None = None
) -> Callable[[BeforeToolExecuteHookFunc], BeforeToolExecuteHookFunc]: ...
def before_tool_execute(
self,
func: BeforeToolExecuteHookFunc | None = None,
*,
tools: list[str] | None = None,
timeout: float | None = None,
) -> Any:
"""注册工具执行前钩子。可通过 tools 参数指定拦截特定工具,可篡改传入参数。"""
return _tool_bare_or_parameterized(
self._r, "before_tool_execute", func, tools=tools, timeout=timeout
)
@overload
def after_tool_execute(
self, func: AfterToolExecuteHookFunc, /
) -> AfterToolExecuteHookFunc: ...
@overload
def after_tool_execute(
self, *, tools: list[str] | None = None, timeout: float | None = None
) -> Callable[[AfterToolExecuteHookFunc], AfterToolExecuteHookFunc]: ...
def after_tool_execute(
self,
func: AfterToolExecuteHookFunc | None = None,
*,
tools: list[str] | None = None,
timeout: float | None = None,
) -> Any:
"""注册工具执行后钩子。可通过 tools 参数指定拦截特定工具,可篡改返回结果。"""
return _tool_bare_or_parameterized(
self._r, "after_tool_execute", func, tools=tools, timeout=timeout
)
@overload
def wrap_tool_execute(
self, func: WrapToolExecuteHookFunc, /
) -> WrapToolExecuteHookFunc: ...
@overload
def wrap_tool_execute(
self, *, tools: list[str] | None = None, timeout: float | None = None
) -> Callable[[WrapToolExecuteHookFunc], WrapToolExecuteHookFunc]: ...
def wrap_tool_execute(
self,
func: WrapToolExecuteHookFunc | None = None,
*,
tools: list[str] | None = None,
timeout: float | None = None,
) -> Any:
"""注册工具执行包裹钩子。以洋葱模型接管特定工具的执行逻辑。"""
return _tool_bare_or_parameterized(
self._r, "wrap_tool_execute", func, tools=tools, timeout=timeout
)
@overload
def on_tool_execute_error(
self, func: OnToolExecuteErrorHookFunc, /
) -> OnToolExecuteErrorHookFunc: ...
@overload
def on_tool_execute_error(
self, *, tools: list[str] | None = None, timeout: float | None = None
) -> Callable[[OnToolExecuteErrorHookFunc], OnToolExecuteErrorHookFunc]: ...
def on_tool_execute_error(
self,
func: OnToolExecuteErrorHookFunc | None = None,
*,
tools: list[str] | None = None,
timeout: float | None = None,
) -> Any:
"""注册工具执行异常钩子。捕获特定工具的崩溃异常,可用于自愈重试。"""
return _tool_bare_or_parameterized(
self._r, "on_tool_execute_error", func, tools=tools, timeout=timeout
)
class Hooks(AbstractCapability):
"""
面向开发者的极简拦截器语法糖。
允许通过 `@hooks.on.xxx` 装饰器快速介入大模型及工具生命周期的各个阶段。
"""
def __init__(self):
self._registry: dict[str, list[_HookEntry[Any]]] = {}
self.on = _HookRegistration(self)
async def wrap_run(
self, context: RunContext, handler: WrapRunHandler
) -> AgentRunResult[Any]:
"""派发:洋葱模型组装与全周期执行接管"""
for entry in self._registry.get("before_run", []):
await _call_entry(entry, "before_run", context)
entries = self._registry.get("wrap_run", [])
chain = handler
if entries:
for entry in reversed(entries):
def _wrap(
e: _HookEntry[Any], h: Callable[..., Any]
) -> Callable[..., Any]:
async def _wrapped() -> Any:
return await _call_entry(e, "wrap_run", context, h)
return _wrapped
chain = _wrap(entry, chain)
try:
result = await chain()
except BaseException as error:
for err_entry in reversed(self._registry.get("on_run_error", [])):
try:
return await _call_entry(err_entry, "on_run_error", context, error)
except BaseException as new_err:
error = new_err
raise error
for after_entry in reversed(self._registry.get("after_run", [])):
result = await _call_entry(after_entry, "after_run", context, result)
return result
async def wrap_model_request(
self,
context: RunContext,
llm_context: LLMContext[ChatRequest, ChatResponse],
handler: WrapModelRequestHandler,
) -> ChatResponse:
"""派发:洋葱模型接管网络请求及前后生命周期"""
for entry in self._registry.get("before_model_request", []):
llm_context = await _call_entry(
entry, "before_model_request", context, llm_context
)
entries = self._registry.get("wrap_model_request", [])
chain = handler
if entries:
for entry in reversed(entries):
def _make_chain(
e: _HookEntry[Any], h: Callable[..., Any]
) -> Callable[..., Any]:
async def _wrapped(
ctx_inner: LLMContext[ChatRequest, ChatResponse],
) -> Any:
return await _call_entry(
e, "wrap_model_request", context, ctx_inner, h
)
return _wrapped
chain = _make_chain(entry, chain)
try:
response = await chain(llm_context)
except Exception as error:
for err_entry in reversed(self._registry.get("on_model_request_error", [])):
try:
return await _call_entry(
err_entry, "on_model_request_error", context, llm_context, error
)
except Exception as new_err:
error = new_err
raise error
for after_entry in reversed(self._registry.get("after_model_request", [])):
response = await _call_entry(
after_entry, "after_model_request", context, llm_context, response
)
return response
async def wrap_tool_validate(
self,
context: RunContext,
tool_name: str,
args: str | dict[str, Any],
handler: WrapToolValidateHandler,
) -> dict[str, Any]:
"""派发:洋葱模型接管工具参数校验及生命周期"""
for entry in self._registry.get("before_tool_validate", []):
args = await _call_entry(
entry, "before_tool_validate", context, tool_name, args
)
entries = self._registry.get("wrap_tool_validate", [])
chain = handler
if entries:
for entry in reversed(entries):
def _wrap(
e: _HookEntry[Any], h: Callable[..., Any]
) -> Callable[..., Any]:
async def _wrapped(args_inner: str | dict[str, Any]) -> Any:
return await _call_entry(
e, "wrap_tool_validate", context, tool_name, args_inner, h
)
return _wrapped
chain = _wrap(entry, chain)
try:
validated_args = await chain(args)
except Exception as error:
for err_entry in reversed(self._registry.get("on_tool_validate_error", [])):
try:
return await _call_entry(
err_entry,
"on_tool_validate_error",
context,
tool_name,
args,
error,
)
except Exception as new_err:
error = new_err
raise error
for after_entry in reversed(self._registry.get("after_tool_validate", [])):
validated_args = await _call_entry(
after_entry, "after_tool_validate", context, tool_name, validated_args
)
return validated_args
async def wrap_tool_execute(
self,
context: RunContext,
tool_name: str,
arguments: dict[str, Any],
handler: WrapToolExecuteHandler,
) -> Any:
"""派发:洋葱模型接管工具执行(支持匹配名称)及生命周期"""
for entry in _filter_tool_entries(
self._registry.get("before_tool_execute", []), tool_name=tool_name
):
arguments = await _call_entry(
entry, "before_tool_execute", context, tool_name, arguments
)
entries = _filter_tool_entries(
self._registry.get("wrap_tool_execute", []), tool_name=tool_name
)
chain = handler
if entries:
for entry in reversed(entries):
def _wrap(
e: _HookEntry[Any], h: Callable[..., Any]
) -> Callable[..., Any]:
async def _wrapped(args_inner: dict[str, Any]) -> Any:
return await _call_entry(
e, "wrap_tool_execute", context, tool_name, args_inner, h
)
return _wrapped
chain = _wrap(entry, chain)
try:
result = await chain(arguments)
except Exception as error:
for err_entry in reversed(
_filter_tool_entries(
self._registry.get("on_tool_execute_error", []), tool_name=tool_name
)
):
try:
return await _call_entry(
err_entry, "on_tool_execute_error", context, tool_name, error
)
except Exception as new_err:
error = new_err
raise error
for after_entry in reversed(
_filter_tool_entries(
self._registry.get("after_tool_execute", []), tool_name=tool_name
)
):
result = await _call_entry(
after_entry, "after_tool_execute", context, tool_name, arguments, result
)
return result
+279
View File
@@ -0,0 +1,279 @@
"""
运行时(Run)相关核心类型定义
"""
from collections.abc import AsyncIterator
import json
from typing import Any, Generic, cast
from typing_extensions import TypeVar
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
from zhenxun.services.ai.core.messages import AgentMessage, LLMMessage, UsageInfo
from zhenxun.services.ai.core.stream_events import (
AgentStreamEvent,
EventBus,
ToolCallStartEvent,
ToolStreamChunkEvent,
)
from zhenxun.utils.pydantic_compat import model_dump, model_validator
class ChatSummary(BaseModel):
total: int = 0
"""大模型调用总次数"""
total_latency_ms: float = 0.0
"""大模型调用总耗时(毫秒)"""
by_stop_reason: dict[str, int] = Field(default_factory=dict)
"""按停止原因分类的大模型调用计数"""
class ToolSummary(BaseModel):
total: int = 0
"""工具执行总次数"""
ok: int = 0
"""工具成功执行次数"""
error: int = 0
"""工具执行失败次数"""
total_latency_ms: float = 0.0
"""工具执行总耗时(毫秒)"""
by_name: dict[str, dict[str, Any]] = Field(default_factory=dict)
"""按工具名称细分的执行状态统计"""
class AgentRunSummary(BaseModel):
"""Agent 单次运行的全局可观测性遥测摘要"""
chats: ChatSummary = Field(default_factory=ChatSummary)
"""大模型调用遥测摘要"""
tools: ToolSummary = Field(default_factory=ToolSummary)
"""工具执行遥测摘要"""
usage: UsageInfo = Field(default_factory=UsageInfo)
"""Token 消耗总计"""
total_latency_ms: float = 0.0
"""智能体运行总耗时(毫秒)"""
class HandoffPayload(BaseModel):
"""移交信息载荷"""
target: str
"""移交的目标智能体名称"""
reason: str = ""
"""移交的原因描述"""
context_data: Any = ""
"""随移交传递的上下文数据"""
OutputDataT = TypeVar("OutputDataT", default=str)
class AgentRunResult(BaseModel, Generic[OutputDataT]):
"""Agent 单次无状态运行的结果"""
output: OutputDataT
"""最终输出数据"""
messages: list[AgentMessage] = Field(default_factory=list)
"""本次运行产生/更新的历史消息"""
usage: UsageInfo = Field(default_factory=UsageInfo)
"""本次运行的Token消耗总计"""
structured_data: Any | None = None
"""拦截到的结构化结果字典"""
telemetry: AgentRunSummary | None = None
"""单次运行的完整可观测性遥测摘要"""
handoff: HandoffPayload | None = None
"""向外抛出的软移交载荷(存在时说明Agent发起了移交请求)"""
class Config:
arbitrary_types_allowed = True
@property
def llm_messages(self) -> list[LLMMessage]:
"""动态视图:过滤掉内部业务事件 (AgentEvent),
仅返回纯净的底层聊天消息历史"""
return [m for m in self.messages if isinstance(m, LLMMessage)]
class AgentRunStart(AgentStreamEvent):
"""智能体运行开始"""
agent_name: str
"""启动运行的智能体名称"""
class AgentRunError(AgentStreamEvent):
"""智能体运行发生异常"""
error: BaseException
"""智能体运行过程中抛出的异常"""
class AgentRunEnd(AgentStreamEvent):
"""智能体运行完全结束"""
result: AgentRunResult[Any]
"""智能体运行结束后的完整结果"""
class StreamedRunResult(Generic[OutputDataT]):
"""
智能体流式运行的结果代理对象。
提供高度解耦的方法来消费底层事件流,支持获取纯净文本或全部事件。
"""
def __init__(self, event_bus: EventBus):
self._event_bus = event_bus
self.is_complete: bool = False
self._result: AgentRunResult[OutputDataT] | None = None
async def stream_events(self) -> AsyncIterator[Any]:
"""获取底层的所有原始事件(包含工具调用过程等)"""
from zhenxun.services.ai.run.models import AgentRunEnd, AgentRunError
async for event in self._event_bus:
if isinstance(event, AgentRunEnd):
self._result = cast(AgentRunResult[OutputDataT], event.result)
self.is_complete = True
elif isinstance(event, AgentRunError):
raise event.error.with_traceback(None) from None
yield event
async def stream_text(self, delta: bool = False) -> AsyncIterator[str]:
"""
过滤大模型的输出文本。
"""
full_text = ""
async for _ in self.stream_events():
pass
if self._result is not None:
output = self._result.output
if isinstance(output, str):
full_text = output
else:
if isinstance(output, BaseModel):
full_text = json.dumps(model_dump(output), ensure_ascii=False)
else:
full_text = str(output)
if delta:
yield full_text
else:
yield full_text
async def get_output(self) -> OutputDataT:
"""阻塞并等待整个 Agent 执行完毕,返回最终的解析输出数据"""
if self._result is not None:
return self._result.output
async for _ in self.stream_events():
pass
if self._result is None:
raise RuntimeError("Agent 运行异常结束,未产生最终结果。")
return self._result.output
async def get_run_result(self) -> AgentRunResult[OutputDataT]:
"""获取完整的运行结果对象(包含 Token 消耗等)"""
if self._result is not None:
return self._result
await self.get_output()
return cast(AgentRunResult[OutputDataT], self._result)
async def forward_to(
self, target_event_bus: EventBus | None, prefix_name: str
) -> AgentRunResult[OutputDataT]:
"""将底层的事件流自动格式化并转发给另一个事件发射器,
常用于 DelegateTool 嵌套调用"""
async for event in self.stream_events():
if not target_event_bus:
continue
if isinstance(event, ToolStreamChunkEvent):
await target_event_bus.emit(
ToolStreamChunkEvent(
tool_name=f"{prefix_name} -> {event.tool_name}",
content=event.content,
metadata=event.metadata,
)
)
elif isinstance(event, ToolCallStartEvent):
intent_str = (
f" (意图: {event.intent})" if getattr(event, "intent", None) else ""
)
await target_event_bus.emit(
ToolStreamChunkEvent(
tool_name=prefix_name,
content=f"🔁 正在调用工具: {event.tool_name}...{intent_str}",
)
)
return await self.get_run_result()
class TaskResult(BaseModel):
"""单个数据契约任务的执行结果"""
task_id: str
"""关联的任务唯一 ID"""
output: Any
"""任务的实际产出(如果是结构化任务,则为解析后的 Pydantic 实例;否则为纯文本)"""
raw_response: str | None = None
"""大模型返回的原始纯文本内容"""
usage: UsageInfo = Field(default_factory=UsageInfo)
"""该任务执行期间的 Token 消耗统计"""
model_config = ConfigDict(arbitrary_types_allowed=True)
class Task(BaseModel):
"""标准化数据契约(意图载体 Payload),定义大模型需要做什么及产出什么格式"""
id: str = Field(default_factory=lambda: __import__("uuid").uuid4().hex)
"""任务的唯一标识符"""
name: str | None = None
"""任务的简短名称"""
description: str
"""详细的任务指令(告诉大模型具体需要做什么)"""
expected_output: str
"""预期输出的自然语言描述(指导大模型如何组织最终答案)"""
response_model: Any | None = None
"""强制要求返回的强类型结构 (Pydantic Model) 或
OutputDefinition,为空则返回普通文本"""
tools: list[str | Any] | None = None
"""针对此特定任务动态追加或覆盖的工具列表"""
guardrails: list[Any] | None = None
"""护栏验证列表。支持传入函数、BaseGuardrail 实例,
或直接传入自然语言字符串规则(自动转为 LLM 裁判)"""
_parsed_guardrails: list[Any] = PrivateAttr(default_factory=list)
model_config = ConfigDict(arbitrary_types_allowed=True)
@model_validator(mode="after")
def _parse_and_set_guardrails(self) -> "Task":
from zhenxun.services.ai.guardrails import parse_guardrails
self._parsed_guardrails = parse_guardrails(self.guardrails)
return self
__all__ = [
"AgentRunResult",
"OutputDataT",
"StreamedRunResult",
"Task",
"TaskResult",
]
+169
View File
@@ -0,0 +1,169 @@
import asyncio
from contextlib import asynccontextmanager
from contextvars import ContextVar
import time
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from zhenxun.services.ai.core.models import CancellationToken
from zhenxun.services.ai.utils.scope import BaseScopeBuilder, ScopeSelector
class PendingMessageQueue:
"""简单的运行时挂起消息队列"""
def __init__(self):
self._queue: list[Any] = []
def enqueue(self, msg: Any):
self._queue.append(msg)
def drain(self) -> list[Any]:
msgs = list(self._queue)
self._queue.clear()
return msgs
def has_items(self) -> bool:
return len(self._queue) > 0
class TaskStopper(BaseScopeBuilder["TaskStopper"]):
"""
声明式任务中止器 (Fluent Task Stopper)。
为第三方开发者提供友好的链式 API,精准中止正在运行或排队的大模型任务。
"""
def __init__(self, manager: "AgentSessionManager"):
super().__init__()
self.manager = manager
async def cancel(self) -> int:
"""执行中止动作,返回被成功中止的任务数量。"""
return await self.manager.cancel_by_query(self._selector)
class SessionInfo(BaseModel):
"""会话信息的元数据视图。"""
model_config = ConfigDict(arbitrary_types_allowed=True)
session_id: str
"""会话的唯一标识符"""
state: dict[str, Any] = Field(default_factory=dict)
"""业务流转的强类型载荷"""
created_at: float = Field(default_factory=time.time)
"""会话创建的时间戳"""
updated_at: float = Field(default_factory=time.time)
"""会话最后更新的时间戳"""
active_task: Any | None = Field(default=None)
"""当前正在执行的 asyncio.Task"""
cancel_token: CancellationToken | None = Field(default=None)
"""当前任务的取消令牌"""
steer_queue: PendingMessageQueue = Field(default_factory=PendingMessageQueue)
"""动态转向指令干预队列"""
follow_up_queue: PendingMessageQueue = Field(default_factory=PendingMessageQueue)
"""后续追加指令干预队列"""
class LockContext(BaseModel):
"""并发锁的执行追踪器(解决 INTERRUPT 需要跨 Session 取消任务的问题)"""
model_config = ConfigDict(arbitrary_types_allowed=True)
active_task: Any | None = None
cancel_token: CancellationToken | None = None
class AgentSessionManager:
"""
Agent 会话状态管理器。
彻底拥抱无状态:只维护业务强类型载荷 (state payload) 以及并发锁,不干涉 LLM 历史。
"""
def __init__(self):
self._sessions: dict[str, SessionInfo] = {}
self._locks: dict[str, asyncio.Lock] = {}
self._exec_locks: dict[str, asyncio.Lock] = {}
self.lock_contexts: dict[str, LockContext] = {}
self.live_tasks: dict[str, list[tuple[CancellationToken, Any]]] = {}
def stopper(self) -> TaskStopper:
"""获取声明式任务中止器,供第三方开发者极速中止运行中/排队中的任务"""
return TaskStopper(self)
async def cancel_by_query(self, query: ScopeSelector) -> int:
"""根据查询条件取消符合条件的会话任务。返回取消的数量"""
count = 0
scope_prefix = query.scope_prefix
for sid, tasks in list(self.live_tasks.items()):
if sid.startswith(scope_prefix) or (
query.session_id and sid == query.session_id
):
for token, task in tasks:
if not token.is_cancelled():
token.cancel()
count += 1
if task and not task.done():
task.cancel()
from zhenxun.services.log import logger
if count > 0:
logger.info(
f"🛑 [TaskStopper] 已强制终止排队或执行中的会话任务: {sid}"
)
return count
def _get_lock(self, session_id: str) -> asyncio.Lock:
if session_id not in self._locks:
self._locks[session_id] = asyncio.Lock()
return self._locks[session_id]
def get_exec_lock(self, session_id: str) -> asyncio.Lock:
if session_id not in self._exec_locks:
self._exec_locks[session_id] = asyncio.Lock()
return self._exec_locks[session_id]
async def get_or_create(self, session_id: str) -> SessionInfo:
async with self._get_lock(session_id):
if session_id not in self._sessions:
self._sessions[session_id] = SessionInfo(session_id=session_id)
return self._sessions[session_id]
async def get(self, session_id: str) -> SessionInfo | None:
async with self._get_lock(session_id):
return self._sessions.get(session_id)
async def update_state(self, session_id: str, new_state: dict[str, Any]):
async with self._get_lock(session_id):
if session_id in self._sessions:
self._sessions[session_id].state.update(new_state)
self._sessions[session_id].updated_at = time.time()
async def delete(self, session_id: str):
async with self._get_lock(session_id):
self._sessions.pop(session_id, None)
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
default_ctx = memory_manager.get_chat_context(MemoryConfig())
if default_ctx:
await default_ctx.clear(SessionMetadata(session_id=session_id))
session_manager = AgentSessionManager()
active_session_id: ContextVar[str | None] = ContextVar(
"active_session_id", default=None
)
@asynccontextmanager
async def agent_session_scope(session_id: str):
"""声明式上下文包装器。进入此作用域后的 Agent 都会自动吸附到指定的 SessionID 上。"""
await session_manager.get_or_create(session_id)
token = active_session_id.set(session_id)
try:
yield session_id
finally:
active_session_id.reset(token)
+138
View File
@@ -0,0 +1,138 @@
import time
from typing import Any
from nonebot_plugin_alconna import UniMessage
from zhenxun.services.ai.core.messages import BaseContentPart, ImagePart, TextPart
from zhenxun.services.ai.core.stream_events import (
EventBus,
LLMEndEvent,
LLMStartEvent,
ToolCallEndEvent,
ToolCallStartEvent,
ToolStreamChunkEvent,
UserCustomEvent,
)
from zhenxun.services.ai.run.context import RunContext
from zhenxun.services.ai.run.models import AgentRunEnd, AgentRunStart, AgentRunSummary
from zhenxun.services.log import logger
class TelemetrySubscriber:
"""纯粹的数据观察者:默默记录时间戳与 Token 消耗"""
def __init__(self):
self.summary = AgentRunSummary()
self._start_times: dict[str, float] = {}
def attach(self, bus: EventBus):
bus.subscribe(AgentRunStart, self.on_run_start)
bus.subscribe(AgentRunEnd, self.on_run_end)
bus.subscribe(LLMStartEvent, self.on_llm_start)
bus.subscribe(LLMEndEvent, self.on_llm_end)
bus.subscribe(ToolCallStartEvent, self.on_tool_start)
bus.subscribe(ToolCallEndEvent, self.on_tool_end)
async def on_run_start(self, event: AgentRunStart):
self._start_times["run"] = time.monotonic()
logger.debug(f"🚀 [Telemetry] 智能体 {event.agent_name} 开始运行")
async def on_run_end(self, event: AgentRunEnd):
dur = (time.monotonic() - self._start_times.get("run", time.monotonic())) * 1000
self.summary.total_latency_ms = dur
event.result.telemetry = self.summary
logger.debug(f"🏁 [Telemetry] 智能体运行结束 (总耗时: {dur:.2f}ms)")
async def on_llm_start(self, event: LLMStartEvent):
self._start_times["llm"] = time.monotonic()
async def on_llm_end(self, event: LLMEndEvent):
dur = (time.monotonic() - self._start_times.pop("llm", time.monotonic())) * 1000
self.summary.chats.total += 1
self.summary.chats.total_latency_ms += dur
response = event.response
if response:
stop_reason = "tool_calls" if response.tool_calls else "stop"
self.summary.chats.by_stop_reason[stop_reason] = (
self.summary.chats.by_stop_reason.get(stop_reason, 0) + 1
)
logger.debug(f"🧠 [Telemetry] 模型调用完成 (耗时: {dur:.2f}ms)")
async def on_tool_start(self, event: ToolCallStartEvent):
self._start_times[f"tool_{event.tool_name}"] = time.monotonic()
async def on_tool_end(self, event: ToolCallEndEvent):
dur = (
time.monotonic()
- self._start_times.pop(f"tool_{event.tool_name}", time.monotonic())
) * 1000
self.summary.tools.total += 1
self.summary.tools.total_latency_ms += dur
tool_stat = self.summary.tools.by_name.setdefault(
event.tool_name, {"total": 0, "ok": 0, "error": 0, "latency_ms": 0.0}
)
tool_stat["total"] += 1
tool_stat["latency_ms"] += dur
if event.is_error:
self.summary.tools.error += 1
tool_stat["error"] += 1
else:
self.summary.tools.ok += 1
tool_stat["ok"] += 1
logger.debug(
f"🛠️ [Telemetry] 工具 {event.tool_name} 执行完毕 (耗时: {dur:.2f}ms)"
)
class DefaultUISubscriber:
"""纯粹的 UI 观察者:负责将特定事件转化为群聊气泡发送"""
def __init__(
self, context: RunContext, reply_to: bool = False, verbose: bool = False
):
self.context = context
self.reply_to = reply_to
self.verbose = verbose
self.bot = context.get_bot()
self.event = context.get_event()
def attach(self, bus: EventBus):
bus.subscribe(ToolStreamChunkEvent, self.on_tool_stream)
bus.subscribe(UserCustomEvent, self.on_custom_event)
async def _send_to_platform(self, display: Any):
if not self.bot or not self.event or not display:
return
if (
isinstance(display, list)
and len(display) > 0
and isinstance(display[0], BaseContentPart)
):
msg = UniMessage()
for part in display:
if isinstance(part, TextPart) and part.text:
msg = msg.text(part.text)
elif isinstance(part, ImagePart):
if part.raw:
msg = msg.image(raw=part.raw)
elif part.url:
msg = msg.image(url=part.url)
elif part.path:
msg = msg.image(path=part.path)
display = msg
if isinstance(display, UniMessage):
await display.send(self.event, bot=self.bot, reply_to=self.reply_to)
else:
await self.bot.send(self.event, str(display))
async def on_tool_stream(self, event: ToolStreamChunkEvent):
if self.verbose and event.content:
await self._send_to_platform(event.content)
async def on_custom_event(self, event: UserCustomEvent):
await self._send_to_platform(event.display)
+83
View File
@@ -0,0 +1,83 @@
from typing import TYPE_CHECKING, Any
from nonebot_plugin_alconna import UniMessage
from zhenxun.services.ai.core.stream_events import ToolStreamChunkEvent, UserCustomEvent
if TYPE_CHECKING:
from zhenxun.services.ai.run.context import RunContext
class UIController:
"""前端 UI 富交互流式控制器 (按需生成模式)"""
def __init__(self, context: "RunContext"):
self.context = context
@property
def tool_name(self) -> str:
"""从上下文中动态获取当前调用的工具名"""
return getattr(self.context.call, "tool_name", "UnknownTool")
@property
def _event_bus(self) -> Any | None:
"""从运行时上下文中获取底层的事件发射器"""
return getattr(self.context.run, "event_bus", None)
async def send_text(self, text: str, status: str = "running") -> None:
"""向前端流式反馈执行进度文本"""
if self._event_bus:
await self._event_bus.emit(
ToolStreamChunkEvent(
tool_name=self.tool_name, content=text, metadata={"status": status}
)
)
async def send_image(self, image: bytes | str) -> None:
"""向前端发送富文本图片气泡(字节流或 URL)"""
if self._event_bus:
display_msg = UniMessage()
if isinstance(image, bytes):
display_msg = display_msg.image(raw=image)
else:
display_msg = display_msg.image(url=image)
await self._event_bus.emit(UserCustomEvent(display=display_msg))
async def send_display(self, display: Any) -> None:
"""向前端发送任意展示载体"""
if self._event_bus and display is not None:
await self._event_bus.emit(UserCustomEvent(display=display))
@staticmethod
async def handle_control_flow_exit_display(
e: BaseException, context: "RunContext | None", reply_to: bool = False
) -> None:
"""统一处理 ControlFlowExit 异常带来的 UI 反馈逻辑"""
from zhenxun.services.ai.core.exceptions import ControlFlowExit
if not isinstance(e, ControlFlowExit):
return
display_msg = getattr(e, "display", None) or getattr(e, "display_content", None)
if not display_msg:
return
try:
from nonebot_plugin_alconna import UniMessage
bot = context.get_bot() if context else None
event = context.get_event() if context else None
if bot and event:
if isinstance(display_msg, UniMessage):
await display_msg.send(event, bot=bot, reply_to=reply_to)
else:
await bot.send(event, str(display_msg))
else:
from zhenxun.utils.message import MessageUtils
await MessageUtils.build_message(str(display_msg)).send(
reply_to=reply_to
)
except Exception:
pass