Files
group_fqcd_jr/app/service/tool_executor.py
T

137 lines
5.7 KiB
Python
Raw Normal View History

2026-09-09 21:55:37 +08:00
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,
)
2026-09-09 21:55:37 +08:00
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 = ""
if intent not in configured_tools:
reason = "该意图未配置工具白名单"
detail = (
f"意图 {intent!r} 在发布版本里没有任何工具白名单"
f"(agent_tools / <agent_type>:{intent}),工具失败关闭"
)
elif name not in configured_tools[intent]:
2026-09-09 21:55:37 +08:00
reason = "工具不在当前意图白名单"
detail = (
f"工具 {name!r} 不在意图 {intent!r} 的白名单 "
f"{list(configured_tools[intent])} 内"
)
2026-09-09 21:55:37 +08:00
elif definition.required_permission not in context.permissions:
reason = "缺少工具权限"
detail = f"缺少权限 {definition.required_permission!r}"
2026-09-09 21:55:37 +08:00
elif not set(definition.allowed_roles).intersection(context.roles):
reason = "角色不能使用工具"
detail = (
f"角色 {list(context.roles)} 与工具允许的角色 "
f"{list(definition.allowed_roles)} 无交集"
)
2026-09-09 21:55:37 +08:00
if reason:
await self._audit(name, intent, context, "denied", detail)
2026-09-09 21:55:37 +08:00
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
2026-09-09 21:55:37 +08:00
except Exception as exc:
await self._audit(name, intent, context, "failed", type(exc).__name__)
raise DependencyUnavailableError("工具调用失败") from exc
2026-09-09 21:55:37 +08:00
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),
))