**为什么造探针**:ToolExecutor 的四种拒绝在真实链路上很难安全触发——要么改客服、风控的
生效配置,要么动 RBAC,两条路都会影响正在工作的 Agent。platform_probe 是个只读、无副作用
的探针:它只声明 probe 一个意图(所以意图分类只可能返回它)、没有发布工具白名单
(天然处于"未配置"状态)、工具只回显参数不碰业务数据。
**它立刻查出一个死分支**:探针报的是「工具不在当前意图白名单」,而不是我新加的
「该意图未配置工具白名单」。原因是 governance.resolve 会为每个 supported_intents
**预填条目**(governance.py:55-61),未配置时得到的是**空元组**——所以
intent not in configured_tools 在运行期**永远不成立**,那个分支是死代码。
单元测试没能发现它,因为我在测试里手工构造了 configured={},而真实链路不产生这个形状。
**这正是端到端测试的价值**:单元测试验证的是我设想的形状,端到端验证的是真实形状。
修法:改判"白名单为空"而非"缺键",文案改为"该意图的工具白名单为空",并注明经过 governance
装配后"完全没配"与"配了空列表"无法区分、也不假装能区分(两者运维动作相同)。新增一条按
**真实形状**({"faq": ()})构造的用例把它锁住。
实测:探针调用 → failed / AGENT_PERMISSION_DENIED,stderr 为
ForbiddenAgentError: 该意图未配置工具白名单(tool_executor.py:108)。
ruff / mypy(136 文件) / 611 unit+contract 全绿。
144 lines
6.4 KiB
Python
144 lines
6.4 KiB
Python
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 里 <agent_type>:<intent>
|
||
# 这一项,所以合并成一句准确的话,不假装能区分。
|
||
reason = "该意图未配置工具白名单"
|
||
detail = (
|
||
f"意图 {intent!r} 的工具白名单为空:发布配置里没有为它配置任何工具"
|
||
f"(agent_tools / <agent_type>:{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),
|
||
))
|