import asyncio from dataclasses import dataclass from datetime import UTC, datetime from typing import Any, Protocol from pydantic import BaseModel, ValidationError from app.core.contracts import RequestContext, SourceReference, ToolCallRecord from app.core.errors import ( DependencyUnavailableError, ForbiddenAgentError, UpstreamTimeoutError, ValidationAgentError, ) from app.infrastructure.db import SessionFactory from app.model.audit import InteractionAudit class ToolHandler(Protocol): async def __call__(self, arguments: BaseModel, context: RequestContext) -> Any: ... @dataclass(frozen=True) class ToolDefinition: name: str input_model: type[BaseModel] handler: ToolHandler required_permission: str allowed_roles: tuple[str, ...] read_only: bool = True timeout_seconds: float = 5 @dataclass(frozen=True) class ToolExecution: output: Any record: ToolCallRecord references: tuple[SourceReference, ...] = () class ToolRegistry: def __init__(self) -> None: self._tools: dict[str, ToolDefinition] = {} def register(self, definition: ToolDefinition) -> None: if definition.name in self._tools: raise ValidationAgentError("工具名称重复") if not definition.read_only: raise ValidationAgentError("Agent 公共工具仅允许只读") self._tools[definition.name] = definition def get(self, name: str) -> ToolDefinition: definition = self._tools.get(name) if definition is None: raise ForbiddenAgentError("工具未注册") return definition class ToolExecutor: def __init__(self, registry: ToolRegistry) -> None: self.registry = registry async def execute( self, *, name: str, arguments: dict[str, Any], intent: str, configured_tools: dict[str, tuple[str, ...]], context: RequestContext, ) -> ToolExecution: definition = self.registry.get(name) # 把三种"用不了"分开,并且**审计写详细、异常给通用**: # # 原先前两种情况共用一句"工具不在当前意图白名单",运维无法判断该去补发布配置、 # 还是该改白名单内容——本项目已经因此踩坑两次(客服、风控的意图码都要求三处对齐, # 而缺配置时是静默失败关闭)。权限与角色两处也只说"缺少工具权限",不说是哪一个。 # # 细节只进审计:异常 message 会随 API 响应返回给调用方, # 白名单内容、权限码、角色集属于内部配置,不该出现在客户可见的响应里。 reason = "" detail = "" configured = configured_tools.get(intent, ()) if name not in configured: if not configured: # 白名单为空。**注意**:经过 governance 装配后,"意图完全没配"与"配了空列表" # 在这里无法区分——governance 会为每个 supported_intent 预填条目,所以 # `intent not in configured_tools` 这个判断在真实链路上永远不会成立 # (这一点是端到端跑探针才发现的,单元测试里手工构造空 dict 掩盖了它)。 # 好在两者的运维动作相同:都该去看 agent_tools 里 : # 这一项,所以合并成一句准确的话,不假装能区分。 reason = "该意图未配置工具白名单" detail = ( f"意图 {intent!r} 的工具白名单为空:发布配置里没有为它配置任何工具" f"(agent_tools / :{intent}),工具失败关闭" ) else: reason = "工具不在当前意图白名单" detail = ( f"工具 {name!r} 不在意图 {intent!r} 的白名单 {list(configured)} 内" ) elif definition.required_permission not in context.permissions: reason = "缺少工具权限" detail = f"缺少权限 {definition.required_permission!r}" elif not set(definition.allowed_roles).intersection(context.roles): reason = "角色不能使用工具" detail = ( f"角色 {list(context.roles)} 与工具允许的角色 " f"{list(definition.allowed_roles)} 无交集" ) if reason: await self._audit(name, intent, context, "denied", detail) raise ForbiddenAgentError(reason) try: validated = definition.input_model.model_validate(arguments) except ValidationError as exc: await self._audit(name, intent, context, "denied", "参数校验失败") raise ValidationAgentError("工具参数校验失败") from exc try: async with asyncio.timeout(definition.timeout_seconds): output = await definition.handler(validated, context) except TimeoutError as exc: await self._audit(name, intent, context, "failed", "timeout") raise UpstreamTimeoutError("工具调用超时") from exc except Exception as exc: await self._audit(name, intent, context, "failed", type(exc).__name__) raise DependencyUnavailableError("工具调用失败") from exc record = ToolCallRecord( tool_name=name, status="succeeded", input_summary={key: "[redacted]" for key in arguments}, output_summary={"result_type": type(output).__name__}, ) await self._audit(name, intent, context, "succeeded", "ok") reference = SourceReference(source_type="tool", source_id=f"{context.trace_id}:{name}", title=name) return ToolExecution(output=output, record=record, references=(reference,)) async def _audit( self, name: str, intent: str, context: RequestContext, status: str, reason: str ) -> None: async with SessionFactory() as session, session.begin(): session.add(InteractionAudit( actor_type="agent", actor_id=int(context.user_id), portal=context.portal, action_type="agent.tool_executed", detail={ "tool_name": name, "intent": intent, "status": status, "reason": reason, "trace_id": context.trace_id, }, created_at=datetime.now(UTC).replace(tzinfo=None), ))