Files
Agentswarm/scripts/test-audit-trace.py
Songhaoz666andClaude Opus 4.8 3411daf00b 复审整改(PR #30):审计接口读取完整事件流,消除 >500 事件静默截断
Fasthei 复审阻塞点:build_audit_trace_for_run 只调用一次 list_events(limit=500),
而 list_events 把 limit 钳到 500 并用 next_cursor 暴露下一页——501+ 事件的 run 只返回
前 500 条审计记录,无报错/无 truncated 标记,违背 #17「每步 trace 可回放」核心 DoD。

修复:build_audit_trace_for_run 改为循环跟随 next_cursor 读到为空,拼出完整事件流再
装配审计记录(main.py)。

测试:test-audit-trace.py 新增 test_pagination_no_truncation——存 612 条事件(>500 且非
500 整数倍),断言 audit count == 完整事件数(613,含创建事件)、replay 行数 == count 不截断、
audit_id 连续到末条(aud_000613)。本地全绿。

影响范围:仅 agent_swarm(orchestrator 只读审计装配 + 测试);无契约/计费/审批/密钥改动。

Refs #17

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:14:50 +08:00

173 lines
8.2 KiB
Python

"""Audit / lineage trace tests (issue #17).
Covers the pure assembler (orchestrator/audit.py) and the orchestrator wiring
(build_audit_trace_for_run) that reconstructs a replayable per-step trail —
who / when / which model / which tool / which approval / result — from the
event stream + task lineage + approvals, carrying NO prompt content or secrets.
Run from agent_swarm_v6 (install deps first — needs fakeredis):
pip install -r orchestrator/requirements.txt
REDIS_FAKE=1 python scripts/test-audit-trace.py
"""
import asyncio
import json
import os
import sys
from pathlib import Path
os.environ["REDIS_FAKE"] = "1"
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from orchestrator import audit as audit_mod
from orchestrator.audit import TaskAuditFacts, build_audit_trace, replay, classify_result
failures = []
def check(name, cond):
print(("PASS" if cond else "FAIL"), "-", name)
if not cond:
failures.append(name)
def test_pure():
events = [
{"event_type": "task.created", "task_id": "t1", "occurred_at": "T0", "payload": {"title": "x"}},
{"event_type": "task.claimed", "task_id": "t1", "agent_instance_id": "agent-7", "occurred_at": "T1", "payload": {}},
{"event_type": "task.completed", "task_id": "t1", "agent_instance_id": "agent-7", "occurred_at": "T2", "payload": {}},
{"event_type": "approval.requested", "occurred_at": "T3", "payload": {"approval_id": "ap1", "operation": "git.push", "risk_level": "high"}},
{"event_type": "approval.rejected", "occurred_at": "T4", "payload": {"approval_id": "ap1"}},
{"event_type": "swarm.failed", "occurred_at": "T5", "payload": {"status": "failed"}},
]
task_facts = {
"t1": TaskAuditFacts(agent_role="impl", assigned_agent_id="agent-7",
model_id="gpt-x", tool_count=3, source="agent_proposed"),
}
approvals = {"ap1": {"decision": "rejected", "reason": "blocked by policy"}}
lineage = {"manager_deployment_id": "m1", "deployment_id": "d1", "swarm_id": "s1", "correlation_id": "c1"}
recs = build_audit_trace(events, task_facts, approvals, lineage=lineage)
check("one record per event, in order", len(recs) == len(events))
check("audit_id is deterministic/sequential", [r["audit_id"] for r in recs][:2] == ["aud_000001", "aud_000002"])
check("every record redacted + carries full lineage",
all(r["redacted"] is True and r["lineage"]["swarm_id"] == "s1" for r in recs))
# who
claimed = recs[1]
check("who: actor resolves agent_instance_id", claimed["actor"]["agent_instance_id"] == "agent-7")
check("who: actor resolves agent_role from task facts", claimed["actor"]["agent_role"] == "impl")
# which model / tool
check("which model: surfaced from task usage", claimed["model"] == {"model_id": "gpt-x"})
check("which tool: count surfaced from task usage", claimed["tool"] == {"count": 3})
# which approval
appr_evt = recs[4]
check("which approval: id + decision surfaced", appr_evt["approval"] == {"approval_id": "ap1", "decision": "rejected"})
# result classification
check("result: completed -> success", recs[2]["result"] == "success")
check("result: approval.rejected -> blocked", appr_evt["result"] == "blocked")
check("result: swarm.failed -> failed", recs[5]["result"] == "failed")
check("result: unknown -> info (no fabrication)", classify_result("some.weird.event") == "info")
# no content / no secrets anywhere in the serialized trail
blob = json.dumps(recs)
check("no secret_ref / credential leaked into audit records",
"secret_ref" not in blob and "azkv://" not in blob and "credential" not in blob)
# replay is ordered and human-readable
lines = replay(recs)
check("replay yields one line per record, ordered", len(lines) == len(recs) and lines[0].startswith("aud_000001"))
check("replay surfaces who/model/approval", "agent-7" in lines[1] and "model=gpt-x" in lines[1]
and "approval=ap1:rejected" in lines[4])
async def test_integration():
from orchestrator.redis_client import redis_client
from orchestrator.swarm_runtime import swarm_runtime
from orchestrator.task_queue import task_queue
from orchestrator import main as orch
await redis_client.connect()
body = {"mode": "swarm", "orchestration_plan": {"objective": "audit it"},
"callback": {"url": "", "subscribed_events": []},
"metadata": {"manager_deployment_id": "m-audit"}}
run, _ = await swarm_runtime.get_or_create_run(body=body, idempotency_key=None, correlation_id="c-audit")
task = await task_queue.create_task(
task_id=f"{run.swarm_id}-t", description="impl", agent_role="impl",
required_capabilities=["python"], enqueue=True,
)
# Agent-reported result carrying model usage + tool calls + a would-be secret (must NOT leak).
await task_queue.complete_task(task.task_id, result=json.dumps({
"summary": "done",
"usage": {"model_id": "gpt-x", "model_tokens": 10},
"tool_calls": [{"name": "write_file"}, {"name": "run_git"}],
"secret_ref": "azkv://vault/secrets/x",
}))
await swarm_runtime.attach_task(run, task.task_id)
run.approvals["ap1"] = {"approval_id": "ap1", "decision": "approved"}
await swarm_runtime.save_run(run)
await swarm_runtime.emit_event(run, "task.claimed", task_id=task.task_id,
agent_instance_id="agent-1", payload={"agent_role": "impl"})
await swarm_runtime.emit_event(run, "task.completed", task_id=task.task_id, agent_instance_id="agent-1")
data = await orch.build_audit_trace_for_run(run)
recs = data["records"]
check("integration: trace assembled from real run", data["count"] >= 1 and bool(recs))
by_type = {r["action"]: r for r in recs}
check("integration: model_id reconstructed from task usage",
by_type.get("task.completed", {}).get("model") == {"model_id": "gpt-x"})
check("integration: tool count reconstructed (2 tool_calls)",
by_type.get("task.completed", {}).get("tool") == {"count": 2})
check("integration: no secret leaked into audit trace",
"secret_ref" not in json.dumps(data) and "azkv://" not in json.dumps(data))
async def test_pagination_no_truncation():
"""A run with >500 events must produce a COMPLETE audit trail (no silent truncation).
list_events clamps each page to 500; build_audit_trace_for_run must follow next_cursor to
exhaustion. Regression guard for the #17 'every step replayable' DoD.
"""
from orchestrator.redis_client import redis_client
from orchestrator.swarm_runtime import swarm_runtime
from orchestrator import main as orch
await redis_client.connect()
body = {"mode": "swarm", "orchestration_plan": {"objective": "long run"},
"callback": {"url": "", "subscribed_events": []},
"metadata": {"manager_deployment_id": "m-long"}}
run, _ = await swarm_runtime.get_or_create_run(body=body, idempotency_key=None, correlation_id="c-long")
# Count whatever creation already emitted, then add enough to cross two page boundaries.
baseline = len((await swarm_runtime.list_events(run.swarm_id, limit=500)).get("events", []))
extra = 612 # > 500 and not a multiple of it, so truncation/off-by-page would show up
for i in range(extra):
await swarm_runtime._store_event(run.swarm_id, json.dumps({
"event_type": "task.heartbeat", "task_id": None, "occurred_at": f"T{i}",
"swarm_id": run.swarm_id, "payload": {},
}))
total = baseline + extra
data = await orch.build_audit_trace_for_run(run)
check(f"pagination: audit count == full event count ({total}), not capped at 500",
data["count"] == total and total > 500)
check("pagination: replay is not truncated (one line per event)", len(data["replay"]) == total)
check("pagination: audit_id runs unbroken to the last event",
data["records"][-1]["audit_id"] == f"aud_{total:06d}")
async def main():
test_pure()
await test_integration()
await test_pagination_no_truncation()
print()
if failures:
print(f"{len(failures)} audit-trace check(s) FAILED: {failures}")
sys.exit(1)
print("all audit-trace checks passed")
if __name__ == "__main__":
asyncio.run(main())