299 lines
14 KiB
Python
299 lines
14 KiB
Python
"""示例业务 Agent(fund_query_demo)的端到端接入验证脚本。
|
||||
|
|
|
|||
|
|
用途:机器化复现 docs/19 的接入步骤——发布配置 → 真实 JWT 受理 → Worker 执行 →
|
|||
|
|
核对工具调用与审计 → **反证**(撤掉工具白名单后工具必须被拒)→ 恢复配置 → 清理测试数据。
|
|||
|
|
其中意图配置会走完 `draft → approved → active`,因为运行期只读 `status='active'`。
|
|||
|
|
|
|||
|
|
用法(在仓库根目录、用项目解释器执行):
|
|||
|
|
|
|||
|
|
python tools/demo_agent_e2e.py # 全流程并清理 run 数据
|
|||
|
|
python tools/demo_agent_e2e.py --keep-run # 保留 run/审计数据便于排查
|
|||
|
|
|
|||
|
|
前置:数据库与配置中心已就绪;`config/jwt/jwt-private.pem` 存在;
|
|||
|
|
**运行前请停掉常驻 Worker**,否则它会抢走本次 run 并使用同一队列的执行路径。
|
|||
|
|
|
|||
|
|
复核方式:配置发布保留"提交审核 → 审核 → 激活"状态机,但**不再要求审核人不是创建人**,
|
|||
|
|
因此 9003 一个 admin 身份即可完成发布,无需再临时创建第二个复核身份。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import asyncio
|
|||
|
|
import datetime as dt
|
|||
|
|
import json
|
|||
|
|
import sys
|
|||
|
|
import uuid
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import Any
|
|||
|
|
|
|||
|
|
import httpx
|
|||
|
|
import jwt
|
|||
|
|
from sqlalchemy import delete, select, text
|
|||
|
|
|
|||
|
|
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.worker.runtime import WorkerRuntime
|
|||
|
|
|
|||
|
|
PRIVATE_KEY = Path("config/jwt/jwt-private.pem").read_text(encoding="utf-8")
|
|||
|
|
ADMIN = "9003"
|
|||
|
|
CUSTOMER = "9001"
|
|||
|
|
AGENT_TYPE = "fund_query_demo"
|
|||
|
|
INTENT = "fund_quote"
|
|||
|
|
TOOL = "query_fund_quote"
|
|||
|
|
MESSAGE = "帮我看下 159382 这只场内基金的行情"
|
|||
|
|
|
|||
|
|
_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",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def idempotency_key() -> str:
|
|||
|
|
return uuid.uuid4().hex
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def etag_of(client: httpx.AsyncClient, release_id: int, auth: dict[str, str]) -> str:
|
|||
|
|
response = await client.get(f"/api/v1/admin/config-releases/{release_id}", headers=auth)
|
|||
|
|
if response.status_code != 200:
|
|||
|
|
raise SystemExit(f"读取发布版本失败:{response.status_code} {response.text}")
|
|||
|
|
etag = response.headers.get("ETag")
|
|||
|
|
if not etag:
|
|||
|
|
raise SystemExit("发布版本缺少 ETag")
|
|||
|
|
return etag
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def post(client: httpx.AsyncClient, path: str, *, auth: dict[str, str],
|
|||
|
|
payload: dict[str, Any] | None = None, if_match: str | None = None) -> httpx.Response:
|
|||
|
|
headers = {**auth, "Idempotency-Key": idempotency_key()}
|
|||
|
|
if if_match is not None:
|
|||
|
|
headers["If-Match"] = if_match
|
|||
|
|
return await client.post(path, json=payload if payload is not None else {}, headers=headers)
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def activate_release(
|
|||
|
|
client: httpx.AsyncClient, admin_auth: dict[str, str], *, with_whitelist: bool,
|
|||
|
|
) -> int:
|
|||
|
|
"""走管理 API 完成一次完整发布:建版本 → 写配置 → 提交复核 → 自审 → 激活。
|
|||
|
|
|
|||
|
|
审核仍走 `reviews` 状态机节点,但审核人就是创建人(已取消双人复核约束)。
|
|||
|
|
"""
|
|||
|
|
created = await post(client, "/api/v1/admin/config-releases", auth=admin_auth, payload={
|
|||
|
|
"release_no": f"demo-fund-{uuid.uuid4().hex[:12]}",
|
|||
|
|
"title": f"示例 Agent {AGENT_TYPE} 接入配置",
|
|||
|
|
"change_summary": "为示例 Agent 发布意图工具白名单",
|
|||
|
|
})
|
|||
|
|
if created.status_code != 201:
|
|||
|
|
raise SystemExit(f"创建发布版本失败:{created.status_code} {created.text}")
|
|||
|
|
release_id = int(created.json()["data"]["id"])
|
|||
|
|
|
|||
|
|
if with_whitelist:
|
|||
|
|
item = await post(client, f"/api/v1/admin/config-releases/{release_id}/platform-config-items",
|
|||
|
|
auth=admin_auth, payload={
|
|||
|
|
"namespace": "agent_tools",
|
|||
|
|
"item_key": f"{AGENT_TYPE}:{INTENT}",
|
|||
|
|
"value_json": {"allowed_tools": [TOOL]},
|
|||
|
|
"schema_version": "1",
|
|||
|
|
})
|
|||
|
|
if item.status_code != 201:
|
|||
|
|
raise SystemExit(f"写入工具白名单失败:{item.status_code} {item.text}")
|
|||
|
|
await ensure_intent_config(client, admin_auth)
|
|||
|
|
|
|||
|
|
submitted = await post(client, f"/api/v1/admin/config-releases/{release_id}/validations",
|
|||
|
|
auth=admin_auth, if_match=await etag_of(client, release_id, admin_auth))
|
|||
|
|
reviewed = await post(client, f"/api/v1/admin/config-releases/{release_id}/reviews",
|
|||
|
|
auth=admin_auth, payload={"decision": "approved", "comment": "创建人自审"},
|
|||
|
|
if_match=await etag_of(client, release_id, admin_auth))
|
|||
|
|
activated = await post(client, f"/api/v1/admin/config-releases/{release_id}/activations",
|
|||
|
|
auth=admin_auth, if_match=await etag_of(client, release_id, admin_auth))
|
|||
|
|
print(f"[发布配置] release={release_id} 白名单={'有' if with_whitelist else '无'} "
|
|||
|
|
f"submit={submitted.status_code} review={reviewed.status_code} "
|
|||
|
|
f"activate={activated.status_code} status={activated.json()['data']['status']}")
|
|||
|
|
return release_id
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def etag_of_row(
|
|||
|
|
client: httpx.AsyncClient, path: str, row_id: int, auth: dict[str, str]
|
|||
|
|
) -> str:
|
|||
|
|
response = await client.get(f"{path}/{row_id}", headers=auth)
|
|||
|
|
if response.status_code != 200:
|
|||
|
|
raise SystemExit(f"读取资源失败:{response.status_code} {response.text}")
|
|||
|
|
etag = response.headers.get("ETag")
|
|||
|
|
if not etag:
|
|||
|
|
raise SystemExit("资源缺少 ETag")
|
|||
|
|
return etag
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def ensure_intent_config(client: httpx.AsyncClient, admin_auth: dict[str, str]) -> None:
|
|||
|
|
"""意图配置属于交付物的一部分:不存在则创建,然后走"审核 → 生效"状态流转。
|
|||
|
|
|
|||
|
|
运行期只读 `status='active'` 的意图配置(`RuntimeConfigService.active_intents`),
|
|||
|
|
因此这里必须把 `draft` 推成 `active`,否则配置只是存了一张表、分类链路看不到。
|
|||
|
|
"""
|
|||
|
|
path = "/api/v1/admin/agent-intent-configs"
|
|||
|
|
listed = await client.get(f"{path}?limit=100", headers=admin_auth)
|
|||
|
|
rows = listed.json().get("data", []) if listed.status_code == 200 else []
|
|||
|
|
existing = next(
|
|||
|
|
(row for row in rows
|
|||
|
|
if row.get("agent_type") == AGENT_TYPE and row.get("intent_code") == INTENT),
|
|||
|
|
None,
|
|||
|
|
)
|
|||
|
|
status = str(existing.get("status")) if existing else ""
|
|||
|
|
if status == "active":
|
|||
|
|
print(f"[意图配置] id={existing['id']} 已生效,跳过")
|
|||
|
|
return
|
|||
|
|
if existing is not None and status in {"draft", "approved"}:
|
|||
|
|
config_id = int(existing["id"])
|
|||
|
|
else:
|
|||
|
|
# 没有历史行或历史行已归档:新版本号(该表 agent_type+intent_code+version 唯一)。
|
|||
|
|
version = int(existing.get("version", 0)) + 1 if existing else 1
|
|||
|
|
created = await post(client, path, auth=admin_auth, payload={
|
|||
|
|
"agent_type": AGENT_TYPE, "intent_code": INTENT, "intent_name": "场内基金行情查询",
|
|||
|
|
"description": "查询场内基金最新净值与涨跌信息",
|
|||
|
|
"examples": ["帮我看看 159382 的行情", "查一下这只基金净值"],
|
|||
|
|
"confidence_threshold": "0.6000", "allowed_tools": [TOOL], "version": version,
|
|||
|
|
})
|
|||
|
|
if created.status_code != 201:
|
|||
|
|
raise SystemExit(f"创建意图配置失败:{created.status_code} {created.text}")
|
|||
|
|
config_id = int(created.json()["data"]["id"])
|
|||
|
|
for action, payload in (
|
|||
|
|
("reviews", {"decision": "approved", "comment": "创建人自审"}),
|
|||
|
|
("activations", None),
|
|||
|
|
):
|
|||
|
|
response = await post(
|
|||
|
|
client, f"{path}/{config_id}/{action}", auth=admin_auth, payload=payload,
|
|||
|
|
if_match=await etag_of_row(client, path, config_id, admin_auth),
|
|||
|
|
)
|
|||
|
|
if response.status_code != 200:
|
|||
|
|
raise SystemExit(f"意图配置 {action} 失败:{response.status_code} {response.text}")
|
|||
|
|
status = str(response.json()["data"]["status"])
|
|||
|
|
print(f"[意图配置] id={config_id} 已生效(status={status},运行期按 status='active' 读取)")
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def audit_rows(trace_id: str) -> list[dict[str, Any]]:
|
|||
|
|
async with SessionFactory() as session:
|
|||
|
|
rows = (await session.execute(text(
|
|||
|
|
"SELECT id, action_type, detail FROM interaction_audit"
|
|||
|
|
" WHERE JSON_UNQUOTE(JSON_EXTRACT(detail,'$.trace_id'))=:trace ORDER BY id"),
|
|||
|
|
{"trace": trace_id})).mappings().all()
|
|||
|
|
audits: list[dict[str, Any]] = []
|
|||
|
|
for row in rows:
|
|||
|
|
detail = row["detail"]
|
|||
|
|
if isinstance(detail, str):
|
|||
|
|
detail = json.loads(detail)
|
|||
|
|
audits.append({"id": row["id"], "action_type": row["action_type"], "detail": detail})
|
|||
|
|
return audits
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def run_case(client: httpx.AsyncClient, *, label: str) -> tuple[str, str, dict[str, Any]]:
|
|||
|
|
"""受理一次真实 run 并手动执行,返回(run_id, session_id, 证据字典)。"""
|
|||
|
|
session_id = f"demo-fund-{uuid.uuid4()}"
|
|||
|
|
auth = {"Authorization": f"Bearer {token(CUSTOMER)}"}
|
|||
|
|
accepted = await client.post("/api/v1/agent-runs", json={
|
|||
|
|
"agent_type": AGENT_TYPE, "message": MESSAGE, "session_id": session_id,
|
|||
|
|
"idempotency_key": idempotency_key(),
|
|||
|
|
}, headers=auth)
|
|||
|
|
record(f"{label}:受理返回 202", "202", str(accepted.status_code))
|
|||
|
|
if accepted.status_code != 202:
|
|||
|
|
raise SystemExit(f"{label} 受理失败:{accepted.text}")
|
|||
|
|
# 文档 §3.3/§6.2:受理响应也是 {data, meta} 信封。
|
|||
|
|
run_id = str(accepted.json()["data"]["run_id"])
|
|||
|
|
trace_id = str(accepted.json()["data"]["trace_id"])
|
|||
|
|
|
|||
|
|
await WorkerRuntime().execute(run_id)
|
|||
|
|
# 文档 §3.3/§6.3:查询运行是 {data, meta} 信封,业务字段在 data 里。
|
|||
|
|
body = (await client.get(f"/api/v1/agent-runs/{run_id}", headers=auth)).json()
|
|||
|
|
detail: dict[str, Any] = body.get("data") or {}
|
|||
|
|
core: dict[str, Any] = detail.get("result") or {}
|
|||
|
|
rows = await audit_rows(trace_id)
|
|||
|
|
tool_rows = [row for row in rows if row["action_type"] == "agent.tool_executed"]
|
|||
|
|
print(f"[{label}] run={run_id} status={detail.get('status')} "
|
|||
|
|
f"error_code={detail.get('error_code')}")
|
|||
|
|
print(f" 工具调用={core.get('tool_calls')} 来源引用={core.get('source_references')}")
|
|||
|
|
print(f" 审计 {len(rows)} 条:{[(row['action_type'], row['detail'].get('status')) for row in rows]}")
|
|||
|
|
return run_id, session_id, {"detail": detail, "tool_rows": tool_rows, "core": core}
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def cleanup_run(session_id: str, run_id: str) -> None:
|
|||
|
|
async with SessionFactory() as session, session.begin():
|
|||
|
|
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))
|
|||
|
|
print(f"[清理] 已删除 session={session_id} run={run_id} 及其消息/事件/审计/幂等回执")
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def main() -> None:
|
|||
|
|
keep_run = "--keep-run" in sys.argv
|
|||
|
|
app = create_app()
|
|||
|
|
admin_auth = {"Authorization": f"Bearer {token(ADMIN)}"}
|
|||
|
|
async with httpx.AsyncClient(
|
|||
|
|
transport=httpx.ASGITransport(app=app), base_url="http://test", timeout=60
|
|||
|
|
) as client:
|
|||
|
|
print("== 1. 发布并激活带工具白名单的版本 ==")
|
|||
|
|
await activate_release(client, admin_auth, with_whitelist=True)
|
|||
|
|
|
|||
|
|
print("\n== 2. 正常路径:工具白名单生效 ==")
|
|||
|
|
run_id, session_id, evidence = await run_case(client, label="正常路径")
|
|||
|
|
detail, core = evidence["detail"], evidence["core"]
|
|||
|
|
record("正常路径:run 终态", "succeeded", str(detail.get("status")))
|
|||
|
|
calls = (core.get("tool_calls") or {}).get("calls") or []
|
|||
|
|
record("正常路径:工具调用记录", "query_fund_quote/succeeded",
|
|||
|
|
f"{calls[0].get('tool_name')}/{calls[0].get('status')}" if calls else "无")
|
|||
|
|
references = core.get("source_references") or []
|
|||
|
|
record("正常路径:来源引用", "tool", str(references[0].get("source_type")) if references else "无")
|
|||
|
|
record("正常路径:工具审计", "succeeded",
|
|||
|
|
str(evidence["tool_rows"][0]["detail"].get("status")) if evidence["tool_rows"] else "无")
|
|||
|
|
if not keep_run:
|
|||
|
|
await cleanup_run(session_id, run_id)
|
|||
|
|
|
|||
|
|
print("\n== 3. 反证:撤掉工具白名单后必须失败关闭 ==")
|
|||
|
|
await activate_release(client, admin_auth, with_whitelist=False)
|
|||
|
|
denied_run, denied_session, denied = await run_case(client, label="反证空白名单")
|
|||
|
|
record("反证:run 终态", "failed", str(denied["detail"].get("status")))
|
|||
|
|
record("反证:错误码", "AGENT_PERMISSION_DENIED", str(denied["detail"].get("error_code")))
|
|||
|
|
record("反证:工具审计为 denied", "denied",
|
|||
|
|
str(denied["tool_rows"][0]["detail"].get("status")) if denied["tool_rows"] else "无")
|
|||
|
|
if not keep_run:
|
|||
|
|
await cleanup_run(denied_session, denied_run)
|
|||
|
|
|
|||
|
|
print("\n== 4. 恢复带白名单的配置 ==")
|
|||
|
|
await activate_release(client, admin_auth, with_whitelist=True)
|
|||
|
|
|
|||
|
|
width = max(len(name) for _, name, _ in _results)
|
|||
|
|
print()
|
|||
|
|
for verdict, name, detail_text in _results:
|
|||
|
|
print(f"[{verdict}] {name.ljust(width)} {detail_text}")
|
|||
|
|
failed = sum(1 for verdict, _, _ in _results if verdict == "FAIL")
|
|||
|
|
print(f"\n合计 {len(_results)} 项,失败 {failed} 项")
|
|||
|
|
if failed:
|
|||
|
|
raise SystemExit(1)
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
asyncio.run(main())
|