相对第一版 46fc976 的完整变更。组员迁移对照表见 docs/20。
一、对外契约对齐 docs/05(破坏性,共 4 处,组员需按 docs/20 调整)
1) 配置发布端点改为文档规定的复数资源名:submit→validations、
approve→reviews(需 body decision)、activate→activations、
rollback→rollbacks;第一版这 4 个动词式路径 docs/05 从未定义过。
2) 错误码由 8 个笼统码改为 15 个具体语义码(FORBIDDEN→AGENT_PERMISSION_DENIED、
UNAUTHORIZED→AUTHENTICATION_REQUIRED、CONFLICT→RESOURCE_VERSION_CONFLICT、
RESOURCE_NOT_FOUND→RUN_NOT_FOUND/SESSION_NOT_FOUND 等),
输入类错误状态码 400→422。
3) POST /api/v1/agent-runs 与 GET /api/v1/agent-runs/{run_id} 统一为
{data, meta} 信封(data 内字段名与语义未变)。
4) 错误响应体统一为 {error:{code,message,retryable,field_errors}, meta:{trace_id}},
不再返回 FastAPI 默认的 {"detail": ...}。
二、数据库基线与约束
新增 39 张表的基线迁移(链根)与联合唯一键纠偏(4 张表、删 8 增 4,幂等收敛);
撤下 config_release 的双人复核 CHECK(应用层已允许自审,审核节点保留,
自审如实写入 reviewer_id);记忆 active key 生成列与唯一键;
activate 开始记录 supersedes_release_id 使版本链可追溯。
docs/00 基线未修改,未重命名或删除任何表与字段。
三、修复会静默出错或无报错的缺陷
- 跑完集成测试后平台会静默失去生效配置:清理只删自己创建的版本,却没有恢复被它
顶成 superseded 的原生效版本,且审计一并删除因而完全无痕,表现为所有工具被拒
但没有任何报错。已修清理逻辑并加恢复。
- Worker 单轮异常导致进程退出;记忆抽取调用方的“事务已开始”异常;
召回缓存丢失 degraded 标记;连接时区未生效导致 created_at/updated_at 差 8 小时;
.env 与 os.getenv 密钥来源分裂导致“没有可用的已批准模型端点”。
- 记忆信号识别漏判与跨键误命中;SSE 未带 Accept 的协商行为。
四、功能补齐
记忆链路 P1/P2/P3(抽取、受控词表、召回与缓存、生命周期级联及投影事件)、
fin_* 场内交易只读 ORM 层、agent_intent_config 状态流转并在运行期真正生效、
限流(Redis 固定窗口、故障一律放行)、游标校验、trace_id 中间件、
示例业务 Agent fund_query_demo 与一键端到端验证脚本,以及审计/指纹/迁移状态工具。
五、文档与验证
新增 docs/19(业务 Agent 接入实操)、docs/20(第一版迁移指南)与 docs/evidence 证据;
docs/01/02/06/08/09/17 同步实现现状。
验证结果:ruff 通过、mypy 103 文件无错、unit+contract 447 passed、
integration 29 passed、acceptance_check --production 7 PASS、
demo_agent_e2e 9/9 PASS(含失败关闭反证)。
281 lines
10 KiB
Python
281 lines
10 KiB
Python
"""独立验收脚本:真实 HTTP + 真实 JWT + 真实 MySQL + 显式注册的测试 Agent。
|
||
|
||
覆盖 P0-1 越权拦截、P0-2 未注册拒绝、P0-3 端到端闭环、P1-3 SSE delta。
|
||
生产代码不注册业务 Agent,本脚本按 TODO 阶段 8 的约定显式注入测试注册表。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import datetime as dt
|
||
import sys
|
||
import uuid
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import httpx
|
||
import jwt
|
||
from sqlalchemy import delete, select
|
||
|
||
import app.service.admin_service as admin_service
|
||
import app.service.agent_run_application_service as run_service
|
||
import app.service.public_platform_service as public_platform_service
|
||
from app.core.contracts import (
|
||
AgentDefinition,
|
||
AgentRequest,
|
||
CoreResult,
|
||
RequestContext,
|
||
ResolvedAgentConfig,
|
||
)
|
||
from app.infrastructure.db import SessionFactory
|
||
from app.main import create_app
|
||
from app.model.audit import InteractionAudit
|
||
from app.model.conversation import ConversationMessage
|
||
from app.model.platform import AgentRun, DomainEventOutbox, OutboxDelivery, RequestIdempotency
|
||
from app.service.agent.base import BaseAgent
|
||
from app.service.agent.bootstrap import get_agent_factory
|
||
from app.service.agent.factory import AgentFactory
|
||
from app.worker.runtime import WorkerRuntime
|
||
|
||
PRIVATE_KEY = Path("config/jwt/jwt-private.pem").read_text(encoding="utf-8")
|
||
CUSTOMER_ID = "9001" # tools/seed_test_rbac.py 造的数据
|
||
|
||
_results: list[tuple[str, str, str]] = []
|
||
|
||
|
||
def record(name: str, expected: str, actual: str) -> None:
|
||
_results.append(("PASS" if expected == actual else "FAIL", name, f"期望 {expected} / 实际 {actual}"))
|
||
|
||
|
||
def token(sub: str) -> str:
|
||
now = dt.datetime.now(dt.UTC)
|
||
return jwt.encode(
|
||
{
|
||
"sub": sub,
|
||
"iss": "jr-local",
|
||
"aud": "jr-agent-platform",
|
||
"exp": now + dt.timedelta(minutes=30),
|
||
"nbf": now - dt.timedelta(seconds=5),
|
||
"jti": str(uuid.uuid4()),
|
||
},
|
||
PRIVATE_KEY,
|
||
algorithm="RS256",
|
||
)
|
||
|
||
|
||
class StubGovernance:
|
||
async def resolve(self, definition: AgentDefinition, context: RequestContext) -> ResolvedAgentConfig:
|
||
del definition, context
|
||
return ResolvedAgentConfig(
|
||
config_version="acceptance", prompt_version="acceptance", model_endpoint="stub"
|
||
)
|
||
|
||
async def recall(self, context: RequestContext) -> tuple[Any, ...]:
|
||
del context
|
||
return ()
|
||
|
||
async def review(
|
||
self, result: Any, context: RequestContext, config: Any, memories: Any
|
||
) -> Any:
|
||
del context, config, memories
|
||
return result
|
||
|
||
|
||
class AcceptanceAgent(BaseAgent):
|
||
async def handle(self, request: AgentRequest, context: RequestContext) -> CoreResult:
|
||
del request, context
|
||
return CoreResult(text="独立验收通过")
|
||
|
||
|
||
def register_probes(factory: AgentFactory, *, version: str = "acceptance") -> None:
|
||
"""向工厂注册两个探针 Agent(customer_service / risk)。
|
||
|
||
生产代码不注册业务 Agent(底座只提供框架),而验收需要走完整 HTTP 链路,
|
||
因此通过工厂的公开 `register` 接口注册——这与业务组员接入 Agent 的方式一致。
|
||
"""
|
||
for agent_type, roles in (
|
||
("customer_service", ("customer",)),
|
||
("risk", ("risk_operator",)),
|
||
):
|
||
definition = AgentDefinition(
|
||
agent_type=agent_type,
|
||
version=version,
|
||
allowed_roles=roles,
|
||
allowed_portals=("api",),
|
||
)
|
||
factory.register(definition, lambda _ctx, _def=definition: AcceptanceAgent(_def))
|
||
|
||
|
||
def build_registry() -> AgentFactory:
|
||
"""替身治理装配:供 HTTP 契约验收使用(**不验证生产装配**)。"""
|
||
factory = AgentFactory(StubGovernance())
|
||
register_probes(factory)
|
||
return factory
|
||
|
||
|
||
def production_factory() -> AgentFactory:
|
||
"""生产装配 + 探针 Agent,并**断言生产装配完整**。
|
||
|
||
与旧做法的关键差别:不再无条件把测试工厂塞进生产模块的全局名字——那样做会让
|
||
"生产装配到底可不可用"完全验收不到。这里直接用生产工厂,先断言模型服务、
|
||
工具执行器、意图分类器都已注入,再把探针 Agent 注册进去。
|
||
"""
|
||
factory = get_agent_factory()
|
||
missing = [
|
||
name
|
||
for name, value in (
|
||
("model_service", factory._model_service),
|
||
("tool_executor", factory._tool_executor),
|
||
("intent_classifier", factory._intent_classifier),
|
||
)
|
||
if value is None
|
||
]
|
||
if missing:
|
||
raise SystemExit(f"生产装配不完整,缺少:{missing}")
|
||
register_probes(factory, version="acceptance-production")
|
||
return factory
|
||
|
||
|
||
def inject_factory(factory: AgentFactory) -> None:
|
||
"""把替身工厂绑定到服务模块的全局名字——**仅限默认的 HTTP 契约验收模式**。
|
||
|
||
这是本脚本唯一的注入手段:Controller 直接构造 `AgentRunApplicationService(session)`
|
||
而不接收工厂,因此没有依赖注入点可用。生产装配的验证由 `--production` 模式与
|
||
`tools/memory_chain_probe.py` 承担,不会被这个注入掩盖。
|
||
"""
|
||
for module in (run_service, public_platform_service, admin_service):
|
||
if hasattr(module, "get_agent_factory"):
|
||
module.get_agent_factory = lambda: factory # type: ignore[attr-defined]
|
||
|
||
|
||
async def _collect_events(
|
||
client: httpx.AsyncClient, run_id: str, auth: dict[str, str]
|
||
) -> list[str]:
|
||
events: list[str] = []
|
||
async with client.stream(
|
||
"GET", f"/api/v1/agent-runs/{run_id}/events", headers=auth, timeout=30
|
||
) as stream:
|
||
async for line in stream.aiter_lines():
|
||
if line.startswith("event:"):
|
||
events.append(line.split(":", 1)[1].strip())
|
||
if events[-1] in {"done", "error"}:
|
||
break
|
||
return events
|
||
|
||
|
||
async def cleanup(session_id: str, run_id: str | None) -> None:
|
||
async with SessionFactory() as session, session.begin():
|
||
if run_id:
|
||
event_ids = select(DomainEventOutbox.event_id).where(
|
||
DomainEventOutbox.aggregate_id == run_id
|
||
)
|
||
await session.execute(
|
||
delete(OutboxDelivery).where(OutboxDelivery.event_id.in_(event_ids))
|
||
)
|
||
await session.execute(
|
||
delete(DomainEventOutbox).where(DomainEventOutbox.aggregate_id == run_id)
|
||
)
|
||
await session.execute(
|
||
delete(InteractionAudit).where(InteractionAudit.session_id == session_id)
|
||
)
|
||
await session.execute(delete(AgentRun).where(AgentRun.session_id == session_id))
|
||
await session.execute(
|
||
delete(RequestIdempotency).where(RequestIdempotency.session_id == session_id)
|
||
)
|
||
await session.execute(
|
||
delete(ConversationMessage).where(ConversationMessage.session_id == session_id)
|
||
)
|
||
|
||
|
||
async def main() -> None:
|
||
if "--production" in sys.argv:
|
||
factory = production_factory()
|
||
print("模式:生产装配验收(真实治理链 + 探针 Agent)")
|
||
else:
|
||
factory = build_registry()
|
||
inject_factory(factory)
|
||
print("模式:HTTP 契约验收(替身治理)——未验证生产装配;加 --production 验生产链路")
|
||
|
||
app = create_app()
|
||
session_id = f"acceptance-{uuid.uuid4()}"
|
||
run_id: str | None = None
|
||
try:
|
||
async with httpx.AsyncClient(
|
||
transport=httpx.ASGITransport(app=app), base_url="http://test", timeout=30
|
||
) as client:
|
||
auth = {"Authorization": f"Bearer {token(CUSTOMER_ID)}"}
|
||
|
||
response = await client.post(
|
||
"/api/v1/agent-runs",
|
||
json={
|
||
"agent_type": "customer_service",
|
||
"message": "独立验收消息",
|
||
"session_id": session_id,
|
||
"idempotency_key": uuid.uuid4().hex,
|
||
},
|
||
headers=auth,
|
||
)
|
||
record("客户调用已授权 Agent", "202", str(response.status_code))
|
||
if response.status_code == 202:
|
||
run_id = response.json().get("data", {}).get("run_id")
|
||
|
||
response = await client.post(
|
||
"/api/v1/agent-runs",
|
||
json={
|
||
"agent_type": "risk",
|
||
"message": "越权尝试",
|
||
"session_id": session_id,
|
||
"idempotency_key": uuid.uuid4().hex,
|
||
},
|
||
headers=auth,
|
||
)
|
||
record("客户越权调用 risk Agent", "403", str(response.status_code))
|
||
|
||
response = await client.post(
|
||
"/api/v1/agent-runs",
|
||
json={
|
||
"agent_type": "no_such_agent",
|
||
"message": "x",
|
||
"session_id": session_id,
|
||
"idempotency_key": uuid.uuid4().hex,
|
||
},
|
||
headers=auth,
|
||
)
|
||
record("未注册 agent_type", "404", str(response.status_code))
|
||
|
||
if run_id:
|
||
runtime = WorkerRuntime(factory)
|
||
await runtime.dispatch_one(run_id=run_id)
|
||
|
||
# 先建立 SSE 订阅(此时 run 仍为 queued),再执行,才能观察到 delta 分块
|
||
subscription = asyncio.create_task(_collect_events(client, run_id, auth))
|
||
await asyncio.sleep(1.0)
|
||
await runtime.execute(run_id)
|
||
events = await subscription
|
||
|
||
record("SSE 含 delta 事件", "yes", "yes" if "delta" in events else "no")
|
||
record("SSE 终止事件", "done", events[-1] if events else "无事件")
|
||
|
||
response = await client.get(f"/api/v1/agent-runs/{run_id}", headers=auth)
|
||
# 文档 §3.3:单资源成功响应是 {data, meta} 信封。
|
||
body = response.json().get("data") or {}
|
||
record("运行终态", "succeeded", str(body.get("status")))
|
||
record(
|
||
"运行结果内容",
|
||
"独立验收通过",
|
||
str((body.get("result") or {}).get("content")),
|
||
)
|
||
finally:
|
||
await cleanup(session_id, run_id)
|
||
|
||
width = max(len(name) for _, name, _ in _results)
|
||
print()
|
||
for verdict, name, detail in _results:
|
||
print(f"[{verdict}] {name.ljust(width)} {detail}")
|
||
failed = sum(1 for verdict, _, _ in _results if verdict == "FAIL")
|
||
print(f"\n合计 {len(_results)} 项,失败 {failed} 项")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
asyncio.run(main())
|