fix(platform): 让"工具用不了"的三种原因可区分,并消掉选端点的隐式顺序依赖
基座层面的两处缺陷,都属于"静默失败"——排查成本高,且本项目已经各踩过一次。
1. tool_executor.py 的拒绝原因原先无法区分:
- "意图压根没发布白名单"与"白名单里没这个工具"共用一句「工具不在当前意图白名单」,
运维不知道该去补发布配置、还是改白名单内容(客服与风控的意图码都要求三处对齐,
两次都因此多花排查时间);
- 权限与角色两处只说「缺少工具权限」,不说是哪一个。
现在四种情况各有独立 message,各自指向不同的处置动作。
同时把**审计与异常分离**:白名单内容、权限码、角色集属于内部配置,只写进审计;
异常 message 会随 API 响应返回给调用方,保持通用、不泄漏配置。
2. model_gateway.py 的 TASK_CAPABILITY 补齐风控的几处 task_type
(risk_agent_chat / risk_analysis / risk_script / risk_summary / daily_report_suggestion)。
它们要的都是文本生成端点;不登记就会落到"未映射 → 返回全部 active 端点"的分支,
而能否选对端点取决于 model_endpoint_config 的**行顺序**——实测风控能跑通,仅仅因为
deepseek-flash(id=3) 恰好排在 qwen-embedding(id=5) 前面。这个隐式依赖现在消掉了。
未登记的 task_type 仍退回全部端点(保持原有保守策略:让故障表现为调用失败而不是
解析为空),但会记 warning,不再静默。
新增 tests/unit/service/test_tool_executor_denials.py(4 条),锁住"四种拒绝可区分"
与"内部细节只进审计、不进 message"。
ruff / mypy(135 文件) / 607 unit+contract 全绿。
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
"""ToolExecutor 的拒绝路径:三种"用不了"必须能区分,且细节不得外泄。
|
||||
|
||||
**背景**:意图码要求三处对齐(`AgentDefinition.supported_intents` / `agent_intent_config` /
|
||||
发布版 `agent_tools` 白名单),而缺配置时是**失败关闭**。原先"意图压根没发布白名单"与
|
||||
"白名单里没这个工具"共用一句「工具不在当前意图白名单」,运维无法判断该去补发布配置、
|
||||
还是该改白名单内容——本项目已因此踩坑两次(客服、风控)。
|
||||
|
||||
这个文件锁两件事:
|
||||
|
||||
1. 三种拒绝的 message **互不相同**,各自指向不同的处置动作;
|
||||
2. 白名单内容、权限码、角色集这些**内部配置只进审计**,不进异常 message ——
|
||||
后者会随 API 响应返回给调用方。
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.contracts import RequestContext
|
||||
from app.core.errors import ForbiddenAgentError
|
||||
from app.service.tool_executor import ToolDefinition, ToolExecutor, ToolRegistry
|
||||
|
||||
PERMISSION = "knowledge:reference:read"
|
||||
|
||||
|
||||
class _Args(BaseModel):
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
query: str = ""
|
||||
|
||||
|
||||
async def _handler(arguments: BaseModel, context: RequestContext) -> Any:
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def _executor() -> tuple[ToolExecutor, list[tuple[Any, ...]]]:
|
||||
registry = ToolRegistry()
|
||||
registry.register(ToolDefinition(
|
||||
name="search_knowledge",
|
||||
input_model=_Args,
|
||||
handler=_handler,
|
||||
required_permission=PERMISSION,
|
||||
allowed_roles=("customer", "advisor"),
|
||||
))
|
||||
executor = ToolExecutor(registry)
|
||||
audits: list[tuple[Any, ...]] = []
|
||||
|
||||
async def fake_audit(name: str, intent: str, context: RequestContext,
|
||||
status: str, reason: str) -> None:
|
||||
audits.append((name, intent, status, reason))
|
||||
|
||||
executor._audit = fake_audit # type: ignore[method-assign]
|
||||
return executor, audits
|
||||
|
||||
|
||||
def _context(*, permissions: tuple[str, ...] = (), roles: tuple[str, ...] = ("customer",)
|
||||
) -> RequestContext:
|
||||
return RequestContext(user_id="1", trace_id="t", permissions=permissions, roles=roles)
|
||||
|
||||
|
||||
async def _reject(
|
||||
executor: ToolExecutor, context: RequestContext, *,
|
||||
intent: str, configured: dict[str, tuple[str, ...]],
|
||||
) -> str:
|
||||
with pytest.raises(ForbiddenAgentError) as excinfo:
|
||||
await executor.execute(
|
||||
name="search_knowledge", arguments={"query": "x"}, intent=intent,
|
||||
configured_tools=configured, context=context,
|
||||
)
|
||||
return str(excinfo.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_intent_config_differs_from_missing_tool() -> None:
|
||||
"""「意图没发布白名单」与「白名单里没这个工具」必须是两句不同的话。"""
|
||||
executor, _ = _executor()
|
||||
context = _context(permissions=(PERMISSION,))
|
||||
|
||||
no_config = await _reject(executor, context, intent="faq", configured={})
|
||||
not_listed = await _reject(
|
||||
executor, context, intent="faq", configured={"faq": ("other_tool",)}
|
||||
)
|
||||
|
||||
assert no_config != not_listed
|
||||
assert "未配置" in no_config
|
||||
assert "白名单" in not_listed
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_permission_and_role_failures_have_their_own_messages() -> None:
|
||||
executor, _ = _executor()
|
||||
configured = {"faq": ("search_knowledge",)}
|
||||
|
||||
no_permission = await _reject(executor, _context(), intent="faq", configured=configured)
|
||||
wrong_role = await _reject(
|
||||
executor, _context(permissions=(PERMISSION,), roles=("risk_operator",)),
|
||||
intent="faq", configured=configured,
|
||||
)
|
||||
|
||||
assert "权限" in no_permission
|
||||
assert "角色" in wrong_role
|
||||
assert no_permission != wrong_role
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_internal_detail_goes_to_audit_not_to_the_message() -> None:
|
||||
"""权限码这类内部配置只进审计,不进会返回给调用方的异常 message。"""
|
||||
executor, audits = _executor()
|
||||
|
||||
message = await _reject(executor, _context(), intent="faq",
|
||||
configured={"faq": ("search_knowledge",)})
|
||||
|
||||
assert PERMISSION not in message
|
||||
assert any(PERMISSION in str(entry[3]) for entry in audits), "审计里应当有具体缺哪个权限"
|
||||
assert audits[0][2] == "denied"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audit_names_what_to_fix_when_config_is_absent() -> None:
|
||||
"""缺配置时审计要指出该去补哪一类配置,而不是只说"不在白名单"。"""
|
||||
executor, audits = _executor()
|
||||
|
||||
await _reject(executor, _context(permissions=(PERMISSION,)), intent="faq", configured={})
|
||||
|
||||
detail = str(audits[0][3])
|
||||
assert "没有任何工具白名单" in detail
|
||||
assert "faq" in detail
|
||||
Reference in New Issue
Block a user