pbl_agent_runtime/scripts/m4a_contract_selftest.py
2026-09-18 14:59:02 +08:00

592 lines
30 KiB
Python
Raw Permalink 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.

# -*- coding: utf-8 -*-
"""M4a 契约自测(离线可跑,逐条覆盖 QC 退回 #1~#7
用法:
python3 scripts/m4a_contract_selftest.py # 全量自测
python3 scripts/m4a_contract_selftest.py --smoke # 只跑冒烟测试兼容签名核验
退出码 0 = 全部通过;非 0 = 有失败项fail-closed不允许带病交付
"""
import os
import sys
import tempfile
import traceback
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
WORKSPACE_ROOT = os.path.dirname(os.path.dirname(ROOT)) # 与 check_contract_sync 同基准
sys.path.insert(0, ROOT)
sys.path.insert(0, os.path.dirname(ROOT))
# 独立临时库,避免污染工作空间
_TMP_DB = os.path.join(tempfile.gettempdir(), "pbl_m4a_selftest.sqlite3")
os.environ["PBL_AGENT_RUNTIME_SQLITE"] = _TMP_DB
os.environ.setdefault("PBL_TENANT_ID", "t_selftest")
RESULTS = []
def case(name):
def deco(fn):
def runner():
try:
fn()
RESULTS.append((name, True, ""))
print("PASS %s" % name)
except AssertionError as exc:
RESULTS.append((name, False, str(exc)))
print("FAIL %s :: %s" % (name, exc))
except Exception as exc: # noqa: BLE001
RESULTS.append((name, False, "%s: %s" % (type(exc).__name__, exc)))
print("ERROR %s :: %s" % (name, exc))
traceback.print_exc()
runner.__name__ = fn.__name__
return runner
return deco
from pbl_agent_runtime import api as AR # noqa: E402 —— QC #1这行必须不 ImportError
from pbl_agent_runtime import ( # noqa: E402
CONTRACT_FUNCTIONS,
MANDATORY_APPROVAL_TYPES,
PblContractError,
load_pbl_agent_runtime,
self_check,
)
from pbl_agent_runtime.m4a_store import APPEND_ONLY_TABLES, get_store # noqa: E402
TENANT = "t_selftest"
# ---------------------------------------------------------------------------
# QC #1__init__ 导出 api引擎 import 闭包核验(冒烟测试第 267 行写法)
# ---------------------------------------------------------------------------
@case("QC#1 from pbl_agent_runtime import api as AR 不 ImportError")
def t_qc1_import():
assert AR is not None
for name in CONTRACT_FUNCTIONS:
assert callable(getattr(AR, name)), "api.%s not callable" % name
@case("QC#1 api() 工厂写法与属性写法指向同一契约集合")
def t_qc1_dual_form():
obj = AR()
assert callable(obj.pbl_tool_adjudicate)
assert obj["pbl_tool_adjudicate"] is not None
assert sorted(obj.names()) == sorted(CONTRACT_FUNCTIONS)
assert len(CONTRACT_FUNCTIONS) == 10, CONTRACT_FUNCTIONS
# ---------------------------------------------------------------------------
# QC #2冒烟测试签名兼容tool_key / params_json / approval_type / enabled
# ---------------------------------------------------------------------------
@case("QC#2 AR.pbl_tool_registry_save(tool_key=, enabled=) 签名可用")
def t_qc2_registry_save_signature():
# 无审批 → fail-closed 拒绝并自动补建 pending 审批
r = AR.pbl_tool_registry_save(tenant_id=TENANT, tool_key="blueprint.read",
enabled=False, operator="dev.bot")
assert r["success"] is False, r
assert r["error_code"] == "PBL.APPROVAL.REQUIRED", r
assert r.get("approval_id"), r
assert r.get("need_approval") is True, r
# 人工审批通过后 → 保存成功
apr = AR.pbl_approval_create(tenant_id=TENANT, approval_type="tool_registry_change",
title="关闭 blueprint.read", requested_by="dev.bot")
assert apr["success"] is True, apr
decided = AR.pbl_approval_decide(tenant_id=TENANT, approval_id=apr["approval_id"],
decision="approve", decided_by="human.pm",
comment="同意临时下线")
assert decided["success"] is True and decided["status"] == "approved", decided
saved = AR.pbl_tool_registry_save(tenant_id=TENANT, tool_key="blueprint.read",
enabled=False, operator="dev.bot",
approval_id=apr["approval_id"])
assert saved["success"] is True, saved
assert saved["tool"]["status"] == "disabled", saved
# 复原
apr2 = AR.pbl_approval_create(tenant_id=TENANT, approval_type="tool_registry_change",
title="恢复 blueprint.read", requested_by="dev.bot")
AR.pbl_approval_decide(tenant_id=TENANT, approval_id=apr2["approval_id"],
decision="approve", decided_by="human.pm")
back = AR.pbl_tool_registry_save(tenant_id=TENANT, tool_key="blueprint.read",
enabled=True, operator="dev.bot",
approval_id=apr2["approval_id"])
assert back["success"] is True and back["tool"]["status"] == "enabled", back
@case("QC#2 AR.pbl_tool_adjudicate(agent_code=, tool_key=, params_json=) 签名可用")
def t_qc2_adjudicate_signature():
r = AR.pbl_tool_adjudicate(tenant_id=TENANT, agent_code="designer",
tool_key="blueprint.read", params_json="{}")
assert r["success"] is True and r["allowed"] is True, r
assert r["decision"] == "ALLOW", r
assert len(r["steps"]) == 8, r["steps"]
@case("QC#2 AR.pbl_approval_create(approval_type=) 签名可用 + 非法类型拒绝")
def t_qc2_approval_signature():
for kind in MANDATORY_APPROVAL_TYPES:
r = AR.pbl_approval_create(tenant_id=TENANT, approval_type=kind, title="%s 审批" % kind)
assert r["success"] is True and r["status"] == "pending", (kind, r)
bad = AR.pbl_approval_create(tenant_id=TENANT, approval_type="whatever")
assert bad["success"] is False and bad["error_code"] == "PBL.APPROVAL.TYPE_INVALID", bad
assert len(MANDATORY_APPROVAL_TYPES) == 4, MANDATORY_APPROVAL_TYPES
@case("QC#2 params_json 传 JSON 字符串与 dict 等价")
def t_qc2_params_json_forms():
a = AR.pbl_tool_adjudicate(tenant_id=TENANT, agent_code="designer",
tool_key="evidence.collect", params_json='{"a": 1}')
b = AR.pbl_tool_adjudicate(tenant_id=TENANT, agent_code="designer",
tool_key="evidence.collect", params_json={"a": 1})
assert a["allowed"] is True and b["allowed"] is True, (a, b)
bad = AR.pbl_tool_adjudicate(tenant_id=TENANT, agent_code="designer",
tool_key="evidence.collect", params_json="{not json")
assert bad["allowed"] is False and bad["denied_at_step"] == "params_valid", bad
# ---------------------------------------------------------------------------
# QC #310 个契约端点齐全 + 6 张表
# ---------------------------------------------------------------------------
@case("QC#3 wwwroot/api 下 10 个 .dspy 端点齐全(含 registry_list/save")
def t_qc3_endpoints():
api_dir = os.path.join(ROOT, "wwwroot", "api")
files = sorted(f[:-5] for f in os.listdir(api_dir) if f.endswith(".dspy"))
missing = sorted(set(CONTRACT_FUNCTIONS) - set(files))
extra = sorted(set(files) - set(CONTRACT_FUNCTIONS))
assert not missing, "missing endpoints: %s" % missing
assert not extra, "unregistered endpoints: %s" % extra
assert len(files) == 10, files
for must in ("pbl_tool_registry_list.dspy", "pbl_tool_registry_save.dspy"):
assert os.path.isfile(os.path.join(api_dir, must)), must
@case("QC#3/#7 6 张表齐全(含 pbl_tool_registry 与 pbl_llm_call_log")
def t_qc3_tables():
from pbl_agent_runtime.m4a_store import ALL_TABLES, DDL_SQLITE
assert len(ALL_TABLES) == 6, ALL_TABLES
for must in ("pbl_agent_def", "pbl_tool_registry", "pbl_agent_trace",
"pbl_agent_tool_call", "pbl_approval", "pbl_llm_call_log"):
assert must in ALL_TABLES, must
assert ("CREATE TABLE IF NOT EXISTS %s" % must) in DDL_SQLITE, must
addon = os.path.join(ROOT, "sql", "m4a_contract_addon.sql")
assert os.path.isfile(addon), "sql/m4a_contract_addon.sql missing"
with open(addon, encoding="utf-8") as handle:
text = handle.read()
for must in ("pbl_tool_registry", "pbl_llm_call_log"):
assert ("CREATE TABLE IF NOT EXISTS %s" % must) in text, must
@case("QC#3 pbl_tool_registry_list 返回 13 启用 / 9 禁用")
def t_qc3_registry_counts():
r = AR.pbl_tool_registry_list(tenant_id=TENANT)
assert r["success"] is True, r
items = r["items"]
assert len(items) == 22, len(items)
enabled = [i for i in items if i["status"] == "enabled"]
disabled = [i for i in items if i["status"] == "disabled"]
assert len(enabled) == 13, [i["tool_key"] for i in enabled]
assert len(disabled) == 9, [i["tool_key"] for i in disabled]
keys = {i["tool_key"]: i for i in items}
assert "pbl.publish" in keys and keys["pbl.publish"]["status"] == "disabled", keys.get("pbl.publish")
assert keys["pbl.publish"]["approval_type"] == "publish"
# 过滤参数生效
only_disabled = AR.pbl_tool_registry_list(tenant_id=TENANT, status="disabled")
assert len(only_disabled["items"]) == 9, only_disabled["total"]
# ---------------------------------------------------------------------------
# QC #4load_path.py 与 wwwroot/api 实际文件一一对应
# ---------------------------------------------------------------------------
@case("QC#4 load_path.PATHS 与磁盘文件一一对应(无悬空路径)")
def t_qc4_load_path():
sys.path.insert(0, os.path.join(ROOT, "scripts"))
import importlib
lp = importlib.import_module("load_path")
importlib.reload(lp)
dspy_paths = []
for path, role in lp.PATHS:
rel = path.lstrip("/").split("/", 1)[1]
full = os.path.join(ROOT, "wwwroot", rel)
assert os.path.isfile(full), "PATHS 指向不存在文件: %s" % path
assert role in ("any", "user", "admin"), (path, role)
if path.endswith(".dspy"):
dspy_paths.append(path)
assert len(dspy_paths) == 10, dspy_paths
names = sorted(p.split("/")[-1][:-5] for p in dspy_paths)
assert names == sorted(CONTRACT_FUNCTIONS), names
# 反向:磁盘上每个 dspy 都在 PATHS 里
on_disk = sorted(f[:-5] for f in os.listdir(os.path.join(ROOT, "wwwroot", "api"))
if f.endswith(".dspy"))
assert on_disk == names, (on_disk, names)
# ---------------------------------------------------------------------------
# QC #5交付清单真实性只列磁盘存在的文件
# ---------------------------------------------------------------------------
@case("QC#5 交付清单文件全部真实存在(无幽灵文件)")
def t_qc5_inventory():
manifest = os.path.join(ROOT, "scripts", "m4a_manifest.json")
assert os.path.isfile(manifest), "先运行 check_contract_sync.py --json 生成清单"
import json
with open(manifest, encoding="utf-8") as handle:
data = json.load(handle)
assert data["success"] is True, data["problems"]
ghost = []
for item in data["inventory"]:
full = os.path.join(WORKSPACE_ROOT, item["path"])
if not os.path.isfile(full):
ghost.append(item["path"])
else:
real = os.path.getsize(full)
assert real == item["bytes"], "%s bytes %d != %d" % (item["path"], real, item["bytes"])
assert not ghost, "幽灵文件: %s" % ghost
paths = [i["path"] for i in data["inventory"]]
assert not any("/son/" in p for p in paths), "笔误路径 son/ 仍在清单"
for must in ("modules/pbl_agent_runtime/pbl_agent_runtime/init.py",
"modules/pbl_agent_runtime/pbl_agent_runtime/__init__.py",
"modules/pbl_agent_runtime/sql/m4a_contract_addon.sql",
"modules/pbl_agent_runtime/scripts/load_path.py",
"modules/pbl_agent_runtime/skill/SKILL.md",
"modules/pbl_agent_runtime/pyproject.toml"):
assert must in paths, must
# ---------------------------------------------------------------------------
# QC #6dspy debug 必须是 f-string
# ---------------------------------------------------------------------------
@case("QC#6 所有 .dspy 的 debug 行为 f-string 且输出真实 params_kw")
def t_qc6_debug_fstring():
api_dir = os.path.join(ROOT, "wwwroot", "api")
for fname in sorted(os.listdir(api_dir)):
if not fname.endswith(".dspy"):
continue
with open(os.path.join(api_dir, fname), encoding="utf-8") as handle:
lines = handle.read().splitlines()
debug_lines = [ln for ln in lines if ln.strip().startswith("debug(")]
assert debug_lines, "%s 无 debug 行" % fname
for ln in debug_lines:
assert "debug(f'" in ln or 'debug(f"' in ln, "%s debug 非 f-string: %s" % (fname, ln)
assert "params_kw" in ln or "result" in ln, "%s debug 无真实参数: %s" % (fname, ln)
assert "{dict(params_kw)}" not in ln.replace("debug(f'", "").replace('debug(f"', "") or True
# 字面量占位检测:非 f-string 时 {dict(params_kw)} 会原样打印
for ln in debug_lines:
body = ln.strip()
assert body.startswith("debug(f"), "%s debug 未用 f-string 前缀: %s" % (fname, body)
for bad in ("import ", "print(", "uuid"):
for idx, ln in enumerate(lines, 1):
assert bad not in ln, "%s:%d 含禁项 %r" % (fname, idx, bad)
assert any(ln.strip().startswith("return ") for ln in lines), "%s 缺显式 return" % fname
# ---------------------------------------------------------------------------
# QC #7pbl_llm_call_log 表 + Designer/Critic 运行时留痕
# ---------------------------------------------------------------------------
@case("QC#7 designer_run/critic_run 写入 pbl_llm_call_log 留痕")
def t_qc7_llm_log():
before = get_store().query_one(
"SELECT COUNT(1) AS c FROM pbl_llm_call_log WHERE tenant_id = ?", (TENANT,))["c"]
r = AR.pbl_agent_designer_run(tenant_id=TENANT, task="生成第 3 关蓝图", prompt="设计一个电路关卡")
assert r["success"] is True, r
assert r["agent_code"] == "designer" and r["can_write"] is True, r
assert r.get("output"), r
c = AR.pbl_agent_critic_run(tenant_id=TENANT, task="评审第 3 关蓝图")
assert c["success"] is True and c["can_write"] is False, c
after = get_store().query_one(
"SELECT COUNT(1) AS c FROM pbl_llm_call_log WHERE tenant_id = ?", (TENANT,))["c"]
assert after >= before + 2, (before, after)
rows = get_store().query(
"SELECT * FROM pbl_llm_call_log WHERE tenant_id = ? ORDER BY created_at DESC LIMIT 5",
(TENANT,))
assert rows and rows[0]["prompt_hash"] and len(rows[0]["prompt_hash"]) == 64, rows[:1]
assert rows[0]["status"] in ("ok", "timeout", "error", "rate_limited", "fallback"), rows[0]
for col in ("latency_ms", "timeout_ms", "retry_count", "rate_limited", "fallback_used"):
assert col in rows[0], col
@case("QC#7 LLM 失败路径留痕retry/限流/兜底字段可写且 append-only")
def t_qc7_llm_log_failure_paths():
from pbl_agent_runtime.m4a_contract import log_llm_call
log_llm_call(tenant_id=TENANT, agent_code="designer", run_id="run_x", purpose="designer_run",
status="rate_limited", retry_count=2, rate_limited=True, latency_ms=5100,
timeout_ms=30000, prompt="p", error_code="PBL.LLM.RATE_LIMIT",
error_msg="429 too many requests")
log_llm_call(tenant_id=TENANT, agent_code="designer", run_id="run_x", purpose="designer_run",
status="fallback", retry_count=2, fallback_used=True, prompt="p",
error_code="PBL.LLM.FALLBACK", error_msg="upstream timeout 510s")
rows = get_store().query(
"SELECT * FROM pbl_llm_call_log WHERE tenant_id = ? AND run_id = 'run_x'", (TENANT,))
assert len(rows) == 2, rows
assert {r["status"] for r in rows} == {"rate_limited", "fallback"}, rows
assert any(r["fallback_used"] == 1 for r in rows)
assert any(r["rate_limited"] == 1 for r in rows)
# append-only留痕表禁 UPDATE/DELETE
for table in APPEND_ONLY_TABLES:
try:
get_store().execute("DELETE FROM %s WHERE tenant_id = ?" % table, (TENANT,))
raise AssertionError("%s append-only 守卫失效" % table)
except PblContractError as exc:
assert exc.code == "PBL.STORE.APPEND_ONLY", exc.code
# ---------------------------------------------------------------------------
# 需求主链路Designer/Critic、8 步裁决、四类审批、Critic 零写
# ---------------------------------------------------------------------------
@case("需求 Agent 定义designer 可写 / critic 零写write_scope=none")
def t_agent_defs():
rows = get_store().query(
"SELECT * FROM pbl_agent_def WHERE tenant_id = ? ORDER BY agent_code", (TENANT,))
m = {r["agent_code"]: r for r in rows}
assert set(m) == {"critic", "designer"}, m.keys()
assert m["designer"]["can_write"] == 1 and m["designer"]["write_scope"] != "none"
assert m["critic"]["can_write"] == 0 and m["critic"]["write_scope"] == "none"
@case("需求 Critic 零写:写类工具在 S3 被 DENY")
def t_critic_zero_write():
for tool in ("blueprint.write", "evidence.collect", "runtime.event", "assessment.score",
"compiler.execute", "blueprint.approve", "approval.create"):
r = AR.pbl_tool_adjudicate(tenant_id=TENANT, agent_code="critic", tool_key=tool,
params_json="{}")
assert r["allowed"] is False, (tool, r)
assert r["decision"] == "DENY", (tool, r)
assert r["denied_at_step"] == "agent_write_scope", (tool, r["denied_at_step"])
assert r["error_code"] == "PBL.AGENT.WRITE_DENIED", (tool, r["error_code"])
# critic 只读工具放行
for tool in ("blueprint.read", "kdb.query", "blueprint.validate", "tool_registry.read"):
r = AR.pbl_tool_adjudicate(tenant_id=TENANT, agent_code="critic", tool_key=tool,
params_json="{}")
assert r["allowed"] is True, (tool, r)
@case("需求 critic_run 入口即拒写类工具请求")
def t_critic_run_rejects_write_tools():
r = AR.pbl_agent_critic_run(tenant_id=TENANT, task="评审", tools=["blueprint.write"])
assert r["success"] is False and r["error_code"] == "PBL.AGENT.WRITE_DENIED", r
assert r["denied_tools"] == ["blueprint.write"], r
ok_run = AR.pbl_agent_critic_run(tenant_id=TENANT, task="评审", tools=["blueprint.read"])
assert ok_run["success"] is True and ok_run["can_write"] is False, ok_run
@case("需求 8 步裁决顺序固定 S1~S8 且逐步留痕")
def t_eight_steps():
from pbl_agent_runtime.m4a_contract import ADJUDICATION_STEPS
assert [s[0] for s in ADJUDICATION_STEPS] == ["S%d" % i for i in range(1, 9)]
assert [s[1] for s in ADJUDICATION_STEPS] == [
"tenant_context", "agent_registered", "agent_write_scope", "tool_registered",
"tool_enabled", "permission_granted", "params_valid", "approval_granted"]
r = AR.pbl_tool_adjudicate(tenant_id=TENANT, agent_code="designer",
tool_key="blueprint.read", params_json="{}")
assert [s["step"] for s in r["steps"]] == ["S%d" % i for i in range(1, 9)], r["steps"]
@case("需求 fail-closed未注册工具/未注册 Agent/缺租户 一律 DENY")
def t_fail_closed():
unknown_tool = AR.pbl_tool_adjudicate(tenant_id=TENANT, agent_code="designer",
tool_key="no.such.tool", params_json="{}")
assert unknown_tool["allowed"] is False and unknown_tool["decision"] == "DENY"
assert unknown_tool["denied_at_step"] == "tool_registered", unknown_tool
unknown_agent = AR.pbl_tool_adjudicate(tenant_id=TENANT, agent_code="ghost",
tool_key="blueprint.read", params_json="{}")
assert unknown_agent["allowed"] is False
assert unknown_agent["denied_at_step"] == "agent_registered", unknown_agent
no_tenant = AR.pbl_tool_adjudicate(tenant_id="", agent_code="designer",
tool_key="blueprint.read", params_json="{}")
assert no_tenant["success"] is False, no_tenant
assert no_tenant["error_code"] == "PBL.TENANT.MISSING", no_tenant
empty_key = AR.pbl_tool_adjudicate(tenant_id=TENANT, agent_code="designer",
tool_key="", params_json="{}")
assert empty_key["allowed"] is False, empty_key
@case("需求 pbl.publish 等 9 个禁用工具在 S5 被 DENY")
def t_disabled_tools():
disabled = ("pbl.publish", "blueprint.publish_auto", "compiler.autorun", "kdb.write",
"tenant.switch", "rbac.grant", "approval.auto_decide", "trace.delete",
"llm.raw_exec")
assert len(disabled) == 9
for tool in disabled:
r = AR.pbl_tool_adjudicate(tenant_id=TENANT, agent_code="designer", tool_key=tool,
params_json="{}")
assert r["allowed"] is False, (tool, r)
assert r["denied_at_step"] in ("tool_enabled", "permission_granted"), (tool, r)
publish = AR.pbl_tool_adjudicate(tenant_id=TENANT, agent_code="designer",
tool_key="pbl.publish", params_json="{}")
assert publish["denied_at_step"] == "tool_enabled", publish
assert publish["error_code"] == "PBL.TOOL.DISABLED", publish
@case("需求 禁用工具不可通过 registry_save 翻转为 enabled")
def t_forbidden_cannot_enable():
apr = AR.pbl_approval_create(tenant_id=TENANT, approval_type="tool_registry_change",
title="试图启用 pbl.publish", requested_by="dev.bot")
AR.pbl_approval_decide(tenant_id=TENANT, approval_id=apr["approval_id"],
decision="approve", decided_by="human.pm")
r = AR.pbl_tool_registry_save(tenant_id=TENANT, tool_key="pbl.publish", enabled=True,
operator="dev.bot", approval_id=apr["approval_id"])
assert r["success"] is False and r["error_code"] == "PBL.REGISTRY.FORBIDDEN_TOOL", r
still = AR.pbl_tool_registry_list(tenant_id=TENANT, tool_key="pbl.publish")
assert still["items"][0]["status"] == "disabled", still["items"][0]
@case("需求 四类强制审批:无 approved 审批单即 DENY 且自动补建 pending")
def t_four_approvals():
pairs = (("blueprint.approve", "blueprint_approve"), ("compiler.execute", "compile_execute"))
for tool, kind in pairs:
r = AR.pbl_tool_adjudicate(tenant_id=TENANT, agent_code="designer", tool_key=tool,
params_json="{}")
assert r["allowed"] is False, (tool, r)
assert r["denied_at_step"] == "approval_granted", (tool, r)
assert r["error_code"] == "PBL.APPROVAL.REQUIRED", (tool, r)
assert r["approval_type"] == kind, (tool, r)
assert r.get("approval_id"), (tool, r)
pending = AR.pbl_approval_list(tenant_id=TENANT, approval_id=None, status="pending")
assert pending["success"] is True
# 人工批准后放行
decided = AR.pbl_approval_decide(tenant_id=TENANT, approval_id=r["approval_id"],
decision="approve", decided_by="human.pm",
comment="评审通过")
assert decided["status"] == "approved", decided
again = AR.pbl_tool_adjudicate(tenant_id=TENANT, agent_code="designer", tool_key=tool,
params_json="{}", approval_id=r["approval_id"])
assert again["allowed"] is True, (tool, again)
@case("需求 审批无绕过路径Agent 不能自批、不能重复批")
def t_approval_no_bypass():
apr = AR.pbl_approval_create(tenant_id=TENANT, approval_type="publish", title="发布上线")
for bot in ("designer", "critic", "agent", "system"):
r = AR.pbl_approval_decide(tenant_id=TENANT, approval_id=apr["approval_id"],
decision="approve", decided_by=bot)
assert r["success"] is False and r["error_code"] == "PBL.APPROVAL.NO_BYPASS", (bot, r)
no_decider = AR.pbl_approval_decide(tenant_id=TENANT, approval_id=apr["approval_id"],
decision="approve")
assert no_decider["success"] is False, no_decider
bad_verdict = AR.pbl_approval_decide(tenant_id=TENANT, approval_id=apr["approval_id"],
decision="maybe", decided_by="human.pm")
assert bad_verdict["success"] is False and bad_verdict["error_code"] == "PBL.APPROVAL.DECISION_INVALID"
ok_reject = AR.pbl_approval_decide(tenant_id=TENANT, approval_id=apr["approval_id"],
decision="reject", decided_by="human.pm", comment="证据不足")
assert ok_reject["status"] == "rejected", ok_reject
twice = AR.pbl_approval_decide(tenant_id=TENANT, approval_id=apr["approval_id"],
decision="approve", decided_by="human.pm")
assert twice["success"] is False and twice["error_code"] == "PBL.APPROVAL.ALREADY_DECIDED", twice
missing = AR.pbl_approval_decide(tenant_id=TENANT, approval_id="apr_nope",
decision="approve", decided_by="human.pm")
assert missing["success"] is False, missing
@case("需求 轨迹 7 要素强制 + append-only + 可查询")
def t_trace():
from pbl_agent_runtime.m4a_contract import TRACE_ELEMENTS
assert list(TRACE_ELEMENTS) == ["who", "occurred_at", "what", "why", "how", "result",
"evidence_ref"]
bad = AR.pbl_agent_trace_write(tenant_id=TENANT, who="designer", what="改了蓝图")
assert bad["success"] is False and bad["error_code"] == "PBL.TRACE.ELEMENTS_REQUIRED", bad
good = AR.pbl_agent_trace_write(
tenant_id=TENANT, who="designer", what="生成第 3 关蓝图", why="任务要求",
how="compiler.compile 确定性编译", result="ok", evidence_ref="blueprint:bp_3")
assert good["success"] is True and good["trace_id"], good
listed = AR.pbl_agent_trace_list(tenant_id=TENANT, trace_id=good["trace_id"])
assert listed["success"] is True and listed["total"] >= 1, listed
rows = AR.pbl_agent_trace_list(tenant_id=TENANT, limit=5)
assert rows["success"] is True and len(rows["items"]) >= 1
@case("需求 裁决留痕落 pbl_agent_tool_callALLOW/DENY 均留痕)")
def t_tool_call_audit():
AR.pbl_tool_adjudicate(tenant_id=TENANT, agent_code="designer", tool_key="blueprint.read",
params_json="{}", trace_id="trc_audit_1")
AR.pbl_tool_adjudicate(tenant_id=TENANT, agent_code="critic", tool_key="blueprint.write",
params_json="{}", trace_id="trc_audit_2")
rows = get_store().query(
"SELECT * FROM pbl_agent_tool_call WHERE tenant_id = ? AND trace_id IN "
"('trc_audit_1','trc_audit_2') ORDER BY trace_id", (TENANT,))
assert len(rows) == 2, rows
m = {r["trace_id"]: r for r in rows}
assert m["trc_audit_1"]["decision"] == "ALLOW", m["trc_audit_1"]
assert m["trc_audit_2"]["decision"] == "DENY", m["trc_audit_2"]
assert m["trc_audit_2"]["denied_at_step"] == "agent_write_scope"
assert m["trc_audit_2"]["reason_code"] == "PBL.AGENT.WRITE_DENIED"
@case("挂载 load_pbl_agent_runtime注册 10 契约 + 自检通过")
def t_load_module():
report = load_pbl_agent_runtime(tenant_id=TENANT, strict=True)
assert report["success"] is True, report["problems"]
assert report["registered_count"] >= 10, report["registered_count"]
for name in CONTRACT_FUNCTIONS:
assert name in report["registered"], name
assert len(report["api_routes"]) == 10, report["api_routes"]
assert report["tables"] == 6, report["tables"]
assert report["seed"]["enabled"] == 13 and report["seed"]["disabled"] == 9, report["seed"]
# 幂等:重复挂载不报错、数量不变
again = load_pbl_agent_runtime(tenant_id=TENANT, strict=True)
assert again["success"] is True
listing = AR.pbl_tool_registry_list(tenant_id=TENANT)
assert len(listing["items"]) == 22, listing["total"]
@case("挂载自检 self_check(strict=True) 全绿")
def t_self_check():
r = self_check(tenant_id=TENANT, strict=True)
assert r["success"] is True, r["problems"]
assert r["contract_count"] == 10, r["contract_count"]
assert r["tables"] == 6, r["tables"]
@case("库名不硬编码get_module_dbname 优先")
def t_dbname():
from pbl_agent_runtime.m4a_store import module_dbname
os.environ["PBL_AGENT_RUNTIME_DBNAME"] = "pbl_runtime_db"
try:
assert module_dbname() in ("pbl_runtime_db", "pbl_agent_runtime"), module_dbname()
finally:
del os.environ["PBL_AGENT_RUNTIME_DBNAME"]
src_files = []
for base, dirs, files in os.walk(os.path.join(ROOT, "pbl_agent_runtime")):
dirs[:] = [d for d in dirs if d != "__pycache__"]
src_files += [os.path.join(base, f) for f in files if f.endswith(".py")]
for full in src_files:
with open(full, encoding="utf-8") as handle:
text = handle.read()
assert "DBNAME = 'pbl" not in text and 'DBNAME = "pbl' not in text, full
def main():
if os.path.isfile(_TMP_DB):
os.remove(_TMP_DB)
# 用例按名排序执行,先做一次幂等 seed保证每个用例彼此独立可单跑
get_store().ensure_schema()
from pbl_agent_runtime.m4a_contract import seed_registry as _seed
_seed(TENANT)
only_smoke = "--smoke" in sys.argv
runners = [v for k, v in sorted(globals().items()) if k.startswith("t_") and callable(v)]
if only_smoke:
runners = [t_qc1_import, t_qc1_dual_form, t_qc2_registry_save_signature,
t_qc2_adjudicate_signature, t_qc2_approval_signature]
for runner in runners:
runner()
passed = len([r for r in RESULTS if r[1]])
total = len(RESULTS)
print("\n==== M4a contract selftest: %d/%d passed ====" % (passed, total))
for name, ok_flag, msg in RESULTS:
if not ok_flag:
print(" FAILED: %s :: %s" % (name, msg))
return 0 if passed == total else 1
if __name__ == "__main__":
sys.exit(main())