Files
group_fqcd_jr/app/service/tool_executor.py
T
张胜宇 e239eb778b docs: 品牌全量口径统一为「南方基金」+ 作废文档清理
1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富
   统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」;
   同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。
2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本),
   新增《文档规整方案与开发前待决事项-2026-09-17》。
3) 客服agent 四份交付文档首次纳入本分支。
2026-09-17 15:15:22 +08:00

144 lines
6.4 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.
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),
))