deliver: 交付收口(引擎代为提交)

This commit is contained in:
agent.develop 2026-09-18 14:59:02 +08:00
parent b8a727b375
commit a34c1919c6
21 changed files with 3539 additions and 575 deletions

View File

@ -1,102 +1,135 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""pbl_agent_runtime —— Designer/Critic Agent 运行时与 fail-closed 工具裁决M4a """pbl_agent_runtime —— Designer/Critic Agent 运行时与 fail-closed 工具裁决M4a
M4a 交付范围 M4a 交付范围
* Agent 定义designer可写/ critic**零写权限** ------------
* 工具注册表13 启用 + 9 禁用 blueprint.publish_auto 等自动发布类禁用 * Agent 定义``designer``可写/ ``critic``**零写权限**write_scope='none'
* fail-closed 裁决S1~S8 八步顺序任一步不过即 DENY默认裁决 = DENY * 工具注册表**13 启用 + 9 禁用** ``pbl.publish`` 自动发布类禁用
* 四类强制人工审批publish / compile_execute / blueprint_approve / tool_registry_change * fail-closed 裁决S1~S8 八步固定顺序任一步不过即 DENY**默认裁决 = DENY**
* 5 张表pbl_agent_def / pbl_agent_tool / pbl_agent_trace / * 四类强制人工审批publish / compile_execute / blueprint_approve / tool_registry_change
pbl_agent_trace_stage / pbl_approval * 6 张表pbl_agent_def / pbl_tool_registry / pbl_agent_trace / pbl_agent_tool_call /
pbl_approval / **pbl_llm_call_log**LLM 超时·重试·限流·兜底留痕
* 10 个契约端点wwwroot/api/*.dspy scripts/load_path.py skill/SKILL.md 四处同步
包出口三处同步定义 + 本文件导出 + init.py 注册 包出口三处同步实现 + 本文件导出 + init.py 注册
from pbl_agent_runtime import load_pbl_agent_runtime # 宿主应用标准入口 ----------------------------------------------------
from pbl_agent_runtime import load_pbl_agent_runtime_m4a # M4a 装配(建表+seed from pbl_agent_runtime import api # 契约对象(集成冒烟测试用法)
from pbl_agent_runtime import load_m4a # 租户级门面 M4aApi from pbl_agent_runtime import load_pbl_agent_runtime # 宿主应用标准挂载入口
from pbl_agent_runtime import self_check # 挂载自检(不过抛 RuntimeError
``api`` 同时支持四种调用写法 m4a_api.ContractApi
api.pbl_tool_adjudicate(agent_code=..., tool_key=..., params_json=...)
api().pbl_tool_adjudicate(...) # 兼容历史 api() 工厂写法
api['pbl_tool_adjudicate'](...)
await api.pbl_tool_adjudicate(...) # .dspy async 上下文
""" """
from .m4a_init import ( # noqa: F401 from .m4a_store import ( # noqa: F401
load_pbl_agent_runtime_m4a, APPEND_ONLY_TABLES as STORE_APPEND_ONLY_TABLES,
bootstrap, PblContractError,
get_dbname, Result,
ddl, Store,
API_ROUTES, get_store,
MODULE_NAME, set_store,
module_dbname,
resolve_tenant,
) )
from .m4a import M4aApi, load_m4a, make_agent_ctx, make_user_ctx, make_admin_ctx # noqa: F401 from .m4a_contract import ( # noqa: F401
from .init import load_pbl_agent_runtime, self_check # noqa: F401 ADJUDICATION_STEPS,
from .m4a_registry import ( # noqa: F401 AGENT_DEFS,
seed_agents, CONTRACT_FUNCTIONS,
seed_tools, DISABLED_TOOLS,
list_agents, ENABLED_TOOLS,
list_tools, MANDATORY_APPROVAL_TYPES,
get_tool, TRACE_ELEMENTS,
register_tool, build_api,
set_tool_status, log_llm_call,
ENABLED_TOOL_CODES, seed_registry,
DISABLED_TOOL_CODES, self_check,
APPROVAL_REQUIRED_TOOLS,
PERM_PLATFORM_ADMIN,
) )
from .m4a_tables import ( # noqa: F401 from .m4a_api import ContractApi, api, build_contract_api # noqa: F401
ensure_tables, from .init import load_pbl_agent_runtime # noqa: F401
TABLES,
APPEND_ONLY_TABLES, # 历史 M4a 装配层(保留向后兼容:老代码 import 这些名字仍可用)
all_sql, try: # pragma: no cover —— 兼容分支
) from .m4a_init import ( # noqa: F401
from .m4a_kernel import ADJUDICATION_STEPS, TRACE_ELEMENTS, PblError # noqa: F401 load_pbl_agent_runtime_m4a,
from .m4a_approval import MANDATORY_APPROVAL_TYPES # noqa: F401 bootstrap,
get_dbname,
ddl,
API_ROUTES,
MODULE_NAME,
)
except Exception: # noqa: BLE001
load_pbl_agent_runtime_m4a = None
bootstrap = None
get_dbname = module_dbname
ddl = None
API_ROUTES = tuple("api/%s.dspy" % name for name in CONTRACT_FUNCTIONS)
MODULE_NAME = "pbl_agent_runtime"
try: # pragma: no cover —— 兼容分支
from .m4a import M4aApi, load_m4a, make_agent_ctx, make_user_ctx, make_admin_ctx # noqa: F401
except Exception: # noqa: BLE001
M4aApi = None
load_m4a = None
make_agent_ctx = None
make_user_ctx = None
make_admin_ctx = None
def api_factory():
"""兼容旧签名 ``api()``:返回契约对象本身。"""
return api
__all__ = [ __all__ = [
# 挂载入口 # 契约对象(对外唯一契约入口)
"api",
"ContractApi",
"build_contract_api",
"build_api",
"api_factory",
"CONTRACT_FUNCTIONS",
# 挂载 / 自检
"load_pbl_agent_runtime", "load_pbl_agent_runtime",
"load_pbl_agent_runtime_m4a", "load_pbl_agent_runtime_m4a",
"load_m4a",
"bootstrap",
"self_check", "self_check",
"seed_registry",
"bootstrap",
"get_dbname", "get_dbname",
"module_dbname",
"resolve_tenant",
"ddl", "ddl",
# 门面 / 上下文 "API_ROUTES",
"M4aApi", "MODULE_NAME",
"make_agent_ctx", # 运行时留痕
"make_user_ctx", "log_llm_call",
"make_admin_ctx", # 常量
# 注册表
"seed_agents",
"seed_tools",
"list_agents",
"list_tools",
"get_tool",
"register_tool",
"set_tool_status",
"ENABLED_TOOL_CODES",
"DISABLED_TOOL_CODES",
"APPROVAL_REQUIRED_TOOLS",
"PERM_PLATFORM_ADMIN",
# 表 / 裁决常量
"ensure_tables",
"TABLES",
"APPEND_ONLY_TABLES",
"all_sql",
"ADJUDICATION_STEPS", "ADJUDICATION_STEPS",
"TRACE_ELEMENTS", "TRACE_ELEMENTS",
"MANDATORY_APPROVAL_TYPES", "MANDATORY_APPROVAL_TYPES",
"PblError", "ENABLED_TOOLS",
"API_ROUTES", "DISABLED_TOOLS",
"MODULE_NAME", "AGENT_DEFS",
# 子模块(供高级用法直接 import "STORE_APPEND_ONLY_TABLES",
# 存储 / 异常
"Store",
"get_store",
"set_store",
"Result",
"PblContractError",
# 兼容门面
"M4aApi",
"load_m4a",
"make_agent_ctx",
"make_user_ctx",
"make_admin_ctx",
# 子模块
"m4a_api",
"m4a_contract",
"m4a_store",
"init", "init",
"m4a",
"m4a_init",
"m4a_kernel",
"m4a_tables",
"m4a_registry",
"m4a_adjudicate",
"m4a_approval",
"m4a_trace",
"m4a_designer",
"m4a_critic",
"m4a_backend",
] ]
__version__ = "1.0.0" __version__ = "1.1.0"

View File

@ -1,475 +1,297 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""pbl_agent_runtime 挂载入口M4a 唯一实现)。 """pbl_agent_runtime.init —— 模块挂载入口ServerEnv 注册 + 挂载自检)。
历史说明QC 退回 #7 处置): 三处同步注册module-development-spec 铁律
九关裁决 + 4 实现tables.py / tool_registry.py / verdict.py / api.py ------------------------------------------
**删除**本文件不再引用M4am4a_*.py8 步裁决 + 5 是本模块唯一实现 实现``m4a_contract.py``10 个契约函数 + seed/self_check/log_llm_call
`load_pbl_agent_runtime()` `load_pbl_agent_runtime_m4a()` 均委托 M4a 装配 导出``__init__.py````from .init import load_pbl_agent_runtime````api``
注册本文件 ``load_pbl_agent_runtime()`` ``env.<name> = <fn>``
路径``scripts/load_path.py`` PATHS10 .dspy 一一对应
load_pbl_agent_runtime(env=None, tenant_id=None) 挂载语义fail-closed
1) ensure_tables 5 幂等pbl_agent_trace / pbl_agent_trace_stage append-only ----------------------
2) seed_agentsdesigner / criticcritic write_allowed=0+ seed_tools13 启用 / 9 禁用 * ``load_pbl_agent_runtime()`` 挂载后立即跑 ``self_check(strict=True)``不过抛 RuntimeError
3) **挂载即 self_check()**数量契约Critic 零写8 步裁决链四类审批默认裁决 DENY 让宿主应用启动失败绝不带病上线
任一不过 RuntimeError宿主应用启动失败fail-closed绝不带病上线 * 建表 + seed2 Agent / 13 启用 / 9 禁用工具幂等可重复挂载
4) ServerEnv 注册 wwwroot/api/*.dspy 所需的 8 个处理函数 + 只读契约 * 库名一律 ``ServerEnv().get_module_dbname('pbl_agent_runtime')``禁止硬编码 DBNAME
5) 返回 default_verdict= 'DENY'
后端sqlor/ahserver未挂载时自动降级 MemoryStore建表/seed/裁决/审批全链路可用
数据仅存于进程内重启即失不抛错便于自测与离线演示US-05 兜底同源策略
""" """
from .m4a_kernel import ( from __future__ import annotations
import os
from .m4a_api import api as _contract_api
from .m4a_contract import (
ADJUDICATION_STEPS, ADJUDICATION_STEPS,
CONTRACT_FUNCTIONS,
MANDATORY_APPROVAL_TYPES,
TRACE_ELEMENTS, TRACE_ELEMENTS,
PblError, build_api,
TenantContext, log_llm_call,
ACTOR_SYSTEM, seed_registry,
self_check as _self_check,
)
from .m4a_store import (
APPEND_ONLY_TABLES,
PblContractError,
Result,
Store,
get_store, get_store,
module_dbname,
resolve_tenant,
set_store,
) )
from .m4a_tables import TABLES, APPEND_ONLY_TABLES, ensure_tables
from .m4a_registry import (
ENABLED_TOOL_CODES,
DISABLED_TOOL_CODES,
APPROVAL_REQUIRED_TOOLS,
PERM_PLATFORM_ADMIN,
list_agents,
list_tools,
get_tool,
register_tool,
set_tool_status,
seed_agents,
seed_tools,
)
from .m4a_adjudicate import adjudicate, adjudicate_report, explain_chain
from .m4a_approval import MANDATORY_APPROVAL_TYPES
from .m4a_trace import get_trace, list_traces
from .m4a import M4aApi, load_m4a, make_admin_ctx
from .m4a_init import MODULE_NAME, get_dbname
import functools MODULE_NAME = "pbl_agent_runtime"
DEFAULT_VERDICT = "DENY" # fail-closed任何异常/未知一律拒绝 #: 契约端点路由(与 wwwroot/api/ 实际文件、scripts/load_path.py 一一对应)
EXPECTED_ENABLED = 13 API_ROUTES = tuple("api/%s.dspy" % name for name in CONTRACT_FUNCTIONS)
EXPECTED_DISABLED = 9
EXPECTED_STEPS = 8 #: 挂载时注册到 ServerEnv 的附加(非契约端点)能力
EXPECTED_APPROVAL_TYPES = 4 EXTRA_FUNCTIONS = ("seed_registry", "log_llm_call", "self_check", "get_store", "module_dbname")
EXPECTED_TABLES = 5
POLICY_VERSION = "m4a-1.0.0" _LOADED = {"done": False, "env": None, "report": None}
AGENTS = ("designer", "critic")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 自检(挂载即执行,不过即抛 # 对外契约对象 / 工厂(兼容两种历史写法)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def self_check(tenant_id=None, store=None, run_probes=True):
"""M4a 契约自检。返回 (all_ok, msgs)。
校验项
C1 工具数量契约 22 = 13 启用 + 9 禁用 class _ApiAccessor:
C2 自动发布类工具必须禁用blueprint.publish_auto / marketplace.create_listing """``api`` 双形态适配器:``api.fn(...)`` 与 ``api().fn(...)`` 同时成立。
C3 Critic 零写权限write_allowed=0 且无任何写工具 allowed_agents critic
C4 裁决链恰为 8 步且顺序为 S1..S8 历史 init.py ``api()`` 返回字典工厂写法项目冒烟测试用 ``api.fn(...)``对象写法
C5 四类强制人工审批齐备 本类把两种写法收敛到同一个契约对象杜绝再次出现注册三处同步断裂
C6 5 张表齐备且 append-only 表带标记
C7 默认裁决 = DENY
C8 探针禁用工具/未注册工具/Critic 三类调用必须被拒run_probes=True
""" """
msgs = []
all_ok = True
st = store or get_store()
tid = tenant_id or "__platform__"
admin = make_admin_ctx(tid)
tools = list_tools(ctx=admin, store=st) __slots__ = ("_obj",)
enabled = [t for t in tools if t.get("status") == "enabled"]
disabled = [t for t in tools if t.get("status") == "disabled"]
# C1 def __init__(self, obj=None):
if len(tools) != EXPECTED_ENABLED + EXPECTED_DISABLED: object.__setattr__(self, "_obj", obj if obj is not None else _contract_api)
all_ok = False
msgs.append("FAIL C1 工具总数=%d 应为 %d"
% (len(tools), EXPECTED_ENABLED + EXPECTED_DISABLED))
if len(enabled) != EXPECTED_ENABLED:
all_ok = False
msgs.append("FAIL C1 启用工具=%d 应为 %d" % (len(enabled), EXPECTED_ENABLED))
if len(disabled) != EXPECTED_DISABLED:
all_ok = False
msgs.append("FAIL C1 禁用工具=%d 应为 %d" % (len(disabled), EXPECTED_DISABLED))
msgs.append("C1 工具契约:%d = %d 启用 + %d 禁用"
% (len(tools), len(enabled), len(disabled)))
# C2 自动发布/自动改课必须禁用 # 工厂写法api() -> 契约对象
must_disabled = ("blueprint.publish_auto", "curriculum.modify_auto", def __call__(self, *args, **kwargs):
"marketplace.create_listing", "kdb.write", "billing.charge") return object.__getattribute__(self, "_obj")
codes_disabled = set(DISABLED_TOOL_CODES)
for code in must_disabled:
if code not in codes_disabled:
all_ok = False
msgs.append("FAIL C2 %s 必须处于禁用清单" % code)
msgs.append("C2 高危工具禁用:%s" % ", ".join(must_disabled))
# C3 Critic 零写 # 对象写法api.pbl_tool_adjudicate(...)
agents = list_agents(ctx=admin, store=st) def __getattr__(self, item):
by_code = {a.get("agent_code"): a for a in agents} return getattr(object.__getattribute__(self, "_obj"), item)
if set(by_code) != set(AGENTS):
all_ok = False
msgs.append("FAIL C3 Agent 定义=%s 应为 %s" % (sorted(by_code), list(AGENTS)))
critic = by_code.get("critic") or {}
if critic and int(critic.get("write_allowed") or 0) != 0:
all_ok = False
msgs.append("FAIL C3 critic.write_allowed=%r 必须为 014.1 零写权限)"
% critic.get("write_allowed"))
write_tools_for_critic = [
t.get("tool_code") for t in enabled
if int(t.get("write_operation") or 0) == 1
and "critic" in (t.get("allowed_agents") or [])
]
if write_tools_for_critic:
all_ok = False
msgs.append("FAIL C3 critic 持有写工具:%s" % write_tools_for_critic)
msgs.append("C3 Critic 零写权限write_allowed=0写工具授权数=0")
# C4 8 步裁决链 # 下标写法api['pbl_tool_adjudicate'](...)
steps = [s[0] for s in ADJUDICATION_STEPS] def __getitem__(self, item):
if len(steps) != EXPECTED_STEPS or steps != ["S%d" % i for i in range(1, 9)]: return object.__getattribute__(self, "_obj")[item]
all_ok = False
msgs.append("FAIL C4 裁决链=%s 应为 S1..S88 步顺序固定)" % steps)
else:
msgs.append("C4 fail-closed 裁决链 8 步:%s"
% "".join("%s %s" % (s[0], s[1]) for s in ADJUDICATION_STEPS))
# C5 四类审批 def __contains__(self, item):
if len(MANDATORY_APPROVAL_TYPES) != EXPECTED_APPROVAL_TYPES: return item in object.__getattribute__(self, "_obj")
all_ok = False
msgs.append("FAIL C5 强制审批类型=%d 应为 %d"
% (len(MANDATORY_APPROVAL_TYPES), EXPECTED_APPROVAL_TYPES))
appr_tools = [t.get("tool_code") for t in enabled
if int(t.get("require_approval") or 0) == 1]
if set(appr_tools) != set(APPROVAL_REQUIRED_TOOLS):
all_ok = False
msgs.append("FAIL C5 require_approval 工具=%s 应为 %s"
% (appr_tools, list(APPROVAL_REQUIRED_TOOLS)))
msgs.append("C5 四类强制人工审批:%s;需审批工具=%s"
% (", ".join(MANDATORY_APPROVAL_TYPES), ", ".join(appr_tools)))
# C6 5 表 + append-only def __iter__(self):
tbl_names = [t.get("tblname") for t in TABLES] return iter(object.__getattribute__(self, "_obj"))
if len(tbl_names) != EXPECTED_TABLES:
all_ok = False
msgs.append("FAIL C6 表数=%d 应为 %d%s"
% (len(tbl_names), EXPECTED_TABLES, tbl_names))
for name in ("pbl_agent_trace", "pbl_agent_trace_stage"):
if name not in APPEND_ONLY_TABLES:
all_ok = False
msgs.append("FAIL C6 %s 必须标记 append-only" % name)
msgs.append("C6 数据表 %d 张:%sappend-only%s"
% (len(tbl_names), ", ".join(tbl_names), ", ".join(APPEND_ONLY_TABLES)))
# C7 默认裁决 def keys(self):
if DEFAULT_VERDICT != "DENY": return object.__getattribute__(self, "_obj").keys()
all_ok = False
msgs.append("FAIL C7 DEFAULT_VERDICT=%r 必须为 'DENY'" % DEFAULT_VERDICT)
else:
msgs.append("C7 默认裁决 = DENYfail-closed")
# C8 探针 def items(self):
if run_probes: return object.__getattribute__(self, "_obj").items()
probes = [
("designer", "blueprint.publish_auto", "禁用工具(自动发布)"),
("designer", "pbl.publish", "未注册工具(白名单外 default-deny"),
("critic", "blueprint.update", "Critic 写蓝图14.1"),
("critic", "publish.request", "Critic 发布14.1"),
("designer", "compile.trigger", "未审批编译执行S7 审批门)"),
]
for agent_code, tool_code, why in probes:
step = code = None
try:
rep = adjudicate_report(admin, agent_code, tool_code, {}, store=st)
# Verdict.to_dict()allowed=False 即拒绝fail-closed 语义)
denied = rep.get("allowed") is False
step = rep.get("step_reached")
code = rep.get("reason_code")
except PblError as e:
denied, code = True, e.code
except Exception: # noqa: BLE001 —— 任何异常都视为拒绝fail-closed
denied, code = True, "EXCEPTION_AS_DENY"
if not denied:
all_ok = False
msgs.append("FAIL C8 探针未被拒:%s%s%s" % (agent_code, tool_code, why))
else:
msgs.append("C8 探针拒绝:%s%s @%s %s%s"
% (agent_code, tool_code, step, code, why))
msgs.append("轨迹 7 要素:%s" % ", ".join(TRACE_ELEMENTS)) def get(self, key, default=None):
if all_ok: return object.__getattribute__(self, "_obj").get(key, default)
msgs.append("SELF_CHECK %s: PASS%d 工具 / %d 表 / %d 步 / %d 类审批)"
% (MODULE_NAME, len(tools), len(tbl_names), def names(self):
len(ADJUDICATION_STEPS), len(MANDATORY_APPROVAL_TYPES))) return list(CONTRACT_FUNCTIONS)
return all_ok, msgs
def describe(self):
return object.__getattribute__(self, "_obj").describe()
def __repr__(self):
return "<pbl_agent_runtime.api contracts=%d>" % len(CONTRACT_FUNCTIONS)
def _wrap(fn): #: 模块级契约入口init.api 与包级 pbl_agent_runtime.api 指向同一契约集合)
"""dspy 处理函数统一包装PblError → 结构化拒绝响应fail-closed不抛 500 api = _ApiAccessor(_contract_api)
任何未预期异常同样按拒绝返回default_verdict=DENY 同源策略
并带 error_code=PBL_E_FORBIDDEN避免异常细节泄漏到前端 def get_api():
"""显式工厂:返回契约对象(等价 ``api()``)。"""
return _contract_api
# ---------------------------------------------------------------------------
# 自检
# ---------------------------------------------------------------------------
def self_check(tenant_id=None, strict=True):
"""挂载自检:契约齐全 / seed 幂等 / fail-closed 语义 / append-only 守卫。
strict=True 且发现问题 RuntimeErrorfail-closed禁止带病挂载
""" """
@functools.wraps(fn) return _self_check(tenant_id=tenant_id, strict=strict)
async def _inner(**params_kw):
try:
data = await fn(**params_kw)
except PblError as e:
return dict(e.to_dict(), ok=False, default_verdict=DEFAULT_VERDICT)
except Exception as e: # noqa: BLE001
return {"ok": False, "error_code": "PBL_E_FORBIDDEN",
"error_message": "内部异常,按 fail-closed 拒绝:%s" % type(e).__name__,
"default_verdict": DEFAULT_VERDICT}
if isinstance(data, dict):
data.setdefault("ok", True)
return data
return _inner
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# dspy 处理函数wwwroot/api/*.dspy 直接调用,名称必须与 dspy 内一致) # 挂载
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _tid(params_kw):
tid = (params_kw or {}).get("tenant_id")
if not tid:
# fail-closed租户上下文缺失即拒绝S1
raise PblError("PBL_E_TENANT_MISSING", "tenant_id 强制打头,缺失即拒绝")
return tid
def _api(params_kw): def _resolve_env():
return load_m4a(tenant_id=_tid(params_kw)) """取 ServerEnv 实例;平台不可用时返回 None离线自测走本地注册表"""
try:
from ahserver import ServerEnv # noqa: WPS433
return ServerEnv()
except Exception: # noqa: BLE001
return None
@_wrap def _register(env, api_obj) -> list:
async def pbl_agent_designer_run(**params_kw): """把契约函数 + 附加能力注册到 ServerEnv返回注册名清单。"""
"""US-01/03/04Designer 生成 / 修改 / 澄清action 分派,缺省 generate""" registered = []
api = _api(params_kw) for name in CONTRACT_FUNCTIONS:
action = params_kw.get("action", "generate") fn = api_obj.get(name)
if action == "modify": if fn is None:
return api.designer_modify(params_kw.get("blueprint_id"), raise RuntimeError("contract function missing in api object: %s" % name)
params_kw.get("instruction"), setattr(env, name, fn)
session_no=params_kw.get("session_no"), registered.append(name)
version_no=params_kw.get("version_no")) for name in EXTRA_FUNCTIONS:
if action == "clarify": target = {
return api.designer_clarify(params_kw.get("blueprint_id"), "seed_registry": seed_registry,
params_kw.get("missing_fields") or [], "log_llm_call": log_llm_call,
session_no=params_kw.get("session_no"), "self_check": self_check,
round_no=params_kw.get("round_no")) "get_store": get_store,
return api.designer_generate(params_kw.get("intent_text") or "", "module_dbname": module_dbname,
owner_teacher_id=params_kw.get("owner_teacher_id"), }.get(name)
class_id=params_kw.get("class_id"), if target is not None:
session_no=params_kw.get("session_no")) setattr(env, name, target)
registered.append(name)
# 契约对象本体也挂上,供 .dspy / 其它模块整体取用
setattr(env, "pbl_agent_runtime_api", api_obj)
setattr(env, "pbl_agent_runtime_contract", api_obj)
return registered
@_wrap def load_pbl_agent_runtime(env=None, tenant_id=None, strict=True, reset_store=False):
async def pbl_agent_critic_run(**params_kw): """模块唯一集成点:建库表 → seed → 注册 ServerEnv → 自检。
"""US-18Critic 只读评审(零写权限,写操作一律被 S6 拒)。"""
api = _api(params_kw)
if params_kw.get("trace_no"):
return api.get_critic_report(params_kw["trace_no"])
blueprint_id = params_kw.get("blueprint_id")
if not blueprint_id:
# 后端pbl_blueprint 权威服务)未挂载 / Designer 兜底提案尚未落库时,
# Critic 无可评审对象:返回结构化降级说明(不抛 500不伪造评审结论
return {"ok": False, "degraded": True,
"error_code": "PBL_E_BACKEND_UNAVAILABLE",
"error_message": "blueprint_id 缺失或蓝图后端未挂载Critic 无可评审对象;"
"Designer 兜底提案落 pending_backend待后端挂载后重试",
"agent_code": "critic", "write_allowed": 0}
return api.critic_review(blueprint_id,
version_no=params_kw.get("version_no"),
session_no=params_kw.get("session_no"))
:param env: ServerEnv 实例缺省自动获取离线环境为 None 时用本地替身
@_wrap :param tenant_id: seed 的目标租户缺省按 resolve_tenant 解析
async def pbl_tool_adjudicate(**params_kw): :param strict: 自检不过是否抛错默认 Truefail-closed
"""fail-closed 8 步裁决预检只裁不执行。tool_code 为空 → 返回裁决链说明。""" :param reset_store: 测试用挂载前清空离线存储
api = _api(params_kw) :return: 挂载报告 dict
tool_code = params_kw.get("tool_code")
if not tool_code:
return {"steps": explain_chain(), "default_verdict": DEFAULT_VERDICT,
"policy_version": POLICY_VERSION}
return api.adjudicate(params_kw.get("agent_code") or "designer", tool_code,
args=params_kw.get("args") or {})
@_wrap
async def pbl_agent_trace_list(**params_kw):
"""F-AG-04轨迹查询可查不可篡改"""
api = _api(params_kw)
if params_kw.get("trace_no"):
return {"trace": api.get_trace(params_kw["trace_no"]),
"completeness": api.trace_completeness(params_kw["trace_no"])}
return api.list_traces(params_kw.get("filters") or {},
page=int(params_kw.get("page") or 1),
size=int(params_kw.get("size") or 20))
def _trace_write(api, action, agent_code, params_kw):
from . import m4a_trace as _t
ctx = api.agent_ctx(agent_code)
if action == "start":
return _t.start_trace(agent_code, session_no=params_kw.get("session_no"),
input_context=params_kw.get("input_context"),
ctx=ctx, store=api.store)
if action == "append":
return _t.append_trace(params_kw.get("trace_no"),
params_kw.get("stage") or params_kw.get("element"),
params_kw.get("payload") or {},
ctx=ctx, store=api.store)
raise PblError("PBL_E_VALIDATION",
"action 非法:%rappend-only 仅支持 start / append" % action)
@_wrap
async def pbl_approval_create(**params_kw):
"""T10Agent 发起人工审批提案Agent 不可自批)。"""
api = _api(params_kw)
return api.request_approval(
action_type=params_kw.get("action_type"),
object_id=params_kw.get("object_id"),
agent_code=params_kw.get("agent_code") or "designer",
tool_code=params_kw.get("tool_code"),
object_type=params_kw.get("object_type"),
action_payload=params_kw.get("action_payload") or {},
approver_id=params_kw.get("approver_id"),
trace_no=params_kw.get("trace_no"),
)
@_wrap
async def pbl_approval_decide(**params_kw):
"""14.2:审批裁决 —— 仅 actor_type=user人类可调Agent 调用被拒。"""
api = _api(params_kw)
return api.decide_approval(params_kw.get("approval_no"),
params_kw.get("status"),
params_kw.get("user_id"),
comment=params_kw.get("comment"))
@_wrap
async def pbl_approval_list(**params_kw):
"""待办审批工作台approver_id 必填)。"""
api = _api(params_kw)
approver_id = params_kw.get("approver_id")
if not approver_id:
raise PblError("PBL_E_VALIDATION", "approver_id 必填")
return api.list_pending_approvals(approver_id)
# 修正 pbl_agent_trace_write 的 start 分支(避免占位返回)
@_wrap
async def pbl_agent_trace_write(**params_kw):
"""轨迹写入append-only。action=start 开轨迹action=append 追加 7 要素之一。"""
api = _api(params_kw)
return _trace_write(api, params_kw.get("action", "append"),
params_kw.get("agent_code") or "designer", params_kw)
# ---------------------------------------------------------------------------
# 对外契约
# ---------------------------------------------------------------------------
def api():
"""模块对外 API 契约(供宿主应用 / 其它模块经 ServerEnv 调用)。"""
return {
"adjudicate": adjudicate,
"adjudicate_report": adjudicate_report,
"adjudication_steps": explain_chain,
"list_tools": list_tools,
"get_tool": get_tool,
"register_tool": register_tool,
"set_tool_status": set_tool_status,
"list_agents": list_agents,
"get_trace": get_trace,
"list_traces": list_traces,
"self_check": self_check,
"load_m4a": load_m4a,
"M4aApi": M4aApi,
"PblError": PblError,
"DEFAULT_VERDICT": DEFAULT_VERDICT,
"POLICY_VERSION": POLICY_VERSION,
"ADJUDICATION_STEPS": ADJUDICATION_STEPS,
"MANDATORY_APPROVAL_TYPES": MANDATORY_APPROVAL_TYPES,
"ENABLED_TOOL_CODES": ENABLED_TOOL_CODES,
"DISABLED_TOOL_CODES": DISABLED_TOOL_CODES,
}
def load_pbl_agent_runtime(env=None, tenant_id=None, force_memory=False,
do_seed=True, run_self_check=True):
"""宿主应用标准挂载入口(委托 M4a 装配)。
返回 default_verdict'DENY'自检不过抛 RuntimeErrorfail-closed
""" """
srv = env store = get_store()
if srv is None: if reset_store:
try: store.reset()
from ahserver.serverenv import ServerEnv store.ensure_schema()
srv = ServerEnv()
except Exception: # noqa: BLE001 —— 无宿主环境时降级 MemoryStore
srv = None
st = get_store(force_memory=force_memory or srv is None) tenant = resolve_tenant(tenant_id)
ensure_tables(st) seed_report = seed_registry(tenant)
tid = tenant_id or "__platform__"
seed = None
if do_seed:
ctx = TenantContext(tenant_id=tid, actor_type=ACTOR_SYSTEM, actor_id="seed",
permissions={PERM_PLATFORM_ADMIN})
seed = {"agents": seed_agents(ctx=ctx, store=st),
"tools": seed_tools(ctx=ctx, store=st)}
if run_self_check: env_obj = env if env is not None else _resolve_env()
ok, msgs = self_check(tenant_id=tid, store=st) offline = env_obj is None
for m in msgs: if offline:
_log(srv, m) env_obj = _OfflineEnv()
if not ok:
raise RuntimeError("%s self_check FAILEDfail-closed拒绝启动%s"
% (MODULE_NAME,
"; ".join([m for m in msgs if m.startswith("FAIL")][:8])))
if srv is not None: api_obj = build_api()
setattr(srv, "pbl_agent_tools", list_tools(ctx=make_admin_ctx(tid), store=st)) registered = _register(env_obj, api_obj)
setattr(srv, "pbl_agent_enabled_tools", list(ENABLED_TOOL_CODES))
setattr(srv, "pbl_agent_disabled_tools", list(DISABLED_TOOL_CODES))
setattr(srv, "pbl_agent_api", api())
setattr(srv, "pbl_agent_dbname", get_dbname(srv))
setattr(srv, "pbl_agent_seed", seed)
# dspy 处理函数注册wwwroot/api/*.dspy 直接按名调用)
setattr(srv, "pbl_agent_designer_run", pbl_agent_designer_run)
setattr(srv, "pbl_agent_critic_run", pbl_agent_critic_run)
setattr(srv, "pbl_tool_adjudicate", pbl_tool_adjudicate)
setattr(srv, "pbl_agent_trace_list", pbl_agent_trace_list)
setattr(srv, "pbl_agent_trace_write", pbl_agent_trace_write)
setattr(srv, "pbl_approval_create", pbl_approval_create)
setattr(srv, "pbl_approval_decide", pbl_approval_decide)
setattr(srv, "pbl_approval_list", pbl_approval_list)
modules = getattr(srv, "modules", None)
if isinstance(modules, list) and MODULE_NAME not in modules:
modules.append(MODULE_NAME)
return DEFAULT_VERDICT report = _self_check(tenant_id=tenant, strict=strict)
report.update({
"module": MODULE_NAME,
"dbname": module_dbname(),
"offline_env": offline,
"registered": registered,
"registered_count": len(registered),
"api_routes": list(API_ROUTES),
"seed": seed_report,
"tables": 6,
"append_only_tables": list(APPEND_ONLY_TABLES),
"adjudication_steps": [step[0] for step in ADJUDICATION_STEPS],
"mandatory_approval_types": list(MANDATORY_APPROVAL_TYPES),
"trace_elements": list(TRACE_ELEMENTS),
})
_LOADED.update({"done": True, "env": env_obj, "report": report})
return Result(report)
def _log(srv, msg): def load_pbl_agent_runtime_m4a(env=None, tenant_id=None, strict=True):
logger = getattr(srv, "logger", None) if srv is not None else None """M4a 装配别名(历史入口,语义与 load_pbl_agent_runtime 一致)。"""
if logger is not None and hasattr(logger, "info"): return load_pbl_agent_runtime(env=env, tenant_id=tenant_id, strict=strict)
try:
logger.info("[%s] %s" % (MODULE_NAME, msg))
return
except Exception: # noqa: BLE001
pass
print("[%s] %s" % (MODULE_NAME, msg))
if __name__ == "__main__": def bootstrap(tenant_id=None):
_ok, _msgs = self_check(run_probes=True) """离线引导:只建表 + seed不碰 ServerEnv脚本/测试用)。"""
for _m in _msgs: get_store().ensure_schema()
print(_m) return Result(seed_registry(resolve_tenant(tenant_id)))
print("RESULT: %s" % ("PASS" if _ok else "FAIL"))
raise SystemExit(0 if _ok else 1)
def get_dbname() -> str:
"""库名ServerEnv 优先,禁止硬编码)。"""
return module_dbname()
def ddl() -> str:
"""离线 sqlite DDL 全文(在线 MySQL DDL 见 sql/m4a_contract_addon.sql"""
from .m4a_store import DDL_SQLITE
return DDL_SQLITE
def ensure_tables(tenant_id=None) -> dict:
"""建表 + seed幂等"""
get_store().ensure_schema()
return seed_registry(resolve_tenant(tenant_id))
class _OfflineEnv:
"""离线 ServerEnv 替身:仅承载属性注册,供无平台环境自测。"""
def __init__(self):
self._attrs = {}
def __setattr__(self, key, value):
if key.startswith("_"):
object.__setattr__(self, key, value)
else:
self._attrs[key] = value
def __getattr__(self, key):
attrs = object.__getattribute__(self, "_attrs")
if key in attrs:
return attrs[key]
raise AttributeError(key)
def get_module_dbname(self, module_name):
return os.environ.get("PBL_AGENT_RUNTIME_DBNAME") or module_name
def registered_names(self):
return sorted(object.__getattribute__(self, "_attrs").keys())
__all__ = [
"load_pbl_agent_runtime",
"load_pbl_agent_runtime_m4a",
"bootstrap",
"self_check",
"api",
"get_api",
"get_dbname",
"module_dbname",
"ddl",
"ensure_tables",
"seed_registry",
"log_llm_call",
"build_api",
"get_store",
"set_store",
"Store",
"Result",
"PblContractError",
"CONTRACT_FUNCTIONS",
"ADJUDICATION_STEPS",
"MANDATORY_APPROVAL_TYPES",
"TRACE_ELEMENTS",
"APPEND_ONLY_TABLES",
"API_ROUTES",
"MODULE_NAME",
]

View File

@ -0,0 +1,101 @@
# -*- coding: utf-8 -*-
"""pbl_agent_runtime.m4a_api —— 对外契约对象 ``api``。
为什么需要本文件QC 退回 #1/#2 的根治)
--------------------------------------
项目集成契约apps/pbls/scripts/tests/offline_contract_smoke.py:267写法是
from pbl_agent_runtime import api as AR
AR.pbl_tool_registry_save(tool_key=..., enabled=...)
AR.pbl_tool_adjudicate(agent_code=..., tool_key=..., params_json=...)
AR.pbl_approval_create(approval_type=...)
``api`` 必须是**带契约函数属性的对象**而历史 init.py ``api()`` 是返回字典的工厂函数
两种写法互不兼容本模块给出同时满足两者的实现
api.pbl_tool_adjudicate(...) # 属性直调(冒烟测试写法)
api().pbl_tool_adjudicate(...) # 先调用再属性(工厂写法)
api['pbl_tool_adjudicate'](...) # 下标写法
await api.pbl_tool_adjudicate(...)# async 上下文(.dspy 端点写法)
契约函数清单以 m4a_contract.CONTRACT_FUNCTIONS 为唯一真源10
wwwroot/api/*.dspyscripts/load_path.pyskill/SKILL.md 四处同步
"""
from __future__ import annotations
from .m4a_contract import (
ADJUDICATION_STEPS,
CONTRACT_FUNCTIONS,
MANDATORY_APPROVAL_TYPES,
TRACE_ELEMENTS,
build_api,
self_check,
seed_registry,
)
from .m4a_store import APPEND_ONLY_TABLES, PblContractError, Result
class ContractApi(dict):
"""契约对象dict + 属性访问 + 可调用(返回自身)。"""
__slots__ = ()
def __call__(self, *args, **kwargs):
"""兼容 ``api()`` 工厂写法:返回自身,链式属性访问继续可用。"""
return self
def __getattr__(self, item):
try:
return self[item]
except KeyError:
raise AttributeError(
"pbl_agent_runtime.api has no contract function %r; available: %s"
% (item, ", ".join(CONTRACT_FUNCTIONS)))
def __await__(self):
return self
yield # pragma: no cover
# -- 内省 ---------------------------------------------------------------
def names(self):
return list(CONTRACT_FUNCTIONS)
def describe(self) -> dict:
return {
"module": "pbl_agent_runtime",
"contract_functions": list(CONTRACT_FUNCTIONS),
"contract_count": len(CONTRACT_FUNCTIONS),
"adjudication_steps": [step[0] for step in ADJUDICATION_STEPS],
"mandatory_approval_types": list(MANDATORY_APPROVAL_TYPES),
"trace_elements": list(TRACE_ELEMENTS),
"append_only_tables": list(APPEND_ONLY_TABLES),
"tables": 6,
}
def build_contract_api() -> ContractApi:
"""构建契约对象(每次调用返回独立实例,便于测试隔离)。"""
base = build_api()
obj = ContractApi(base)
obj["describe"] = obj.describe
return obj
#: 模块级单例——``from pbl_agent_runtime import api`` 拿到的就是它
api = build_contract_api()
__all__ = [
"api",
"ContractApi",
"build_contract_api",
"self_check",
"seed_registry",
"CONTRACT_FUNCTIONS",
"ADJUDICATION_STEPS",
"MANDATORY_APPROVAL_TYPES",
"TRACE_ELEMENTS",
"APPEND_ONLY_TABLES",
"PblContractError",
"Result",
]

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,544 @@
# -*- coding: utf-8 -*-
"""pbl_agent_runtime.m4a_store —— M4a 契约层存储内核(离线可跑 / 在线可切)。
设计要点
--------
1. **fail-closed 存储**任何存储异常统一抛 ``PblContractError``由契约层转成 DENY
绝不静默放行绝不吞异常返回看起来成功的结果
2. **append-only 守卫**``pbl_agent_trace`` / ``pbl_agent_tool_call`` / ``pbl_llm_call_log``
三张留痕表只允许 INSERTUPDATE/DELETE 直接抛错 28 留痕不可篡改
3. **离线自足**默认落 stdlib ``sqlite3`` 文件无需 DB 服务即可跑契约冒烟
在线部署使用同一套表结构MySQL 方言 DDL ``sql/m4a_contract_addon.sql``
4. **库名不硬编码**``module_dbname()`` 优先 ``ServerEnv().get_module_dbname('pbl_agent_runtime')``
取不到时退回环境变量 ``PBL_AGENT_RUNTIME_DBNAME``最后才用模块名作离线命名空间标签
标签只用于 sqlite 文件命名不作为任何连接串
5. **租户强制**``resolve_tenant()`` 解析顺序 = 显式入参 pbl_common 上下文 环境变量
平台默认租户 '0'显式传空串/解析结果为空 返回 None上层按 fail-closed 拒绝
"""
from __future__ import annotations
import hashlib
import json
import os
import sqlite3
import threading
import time
import uuid
MODULE_NAME = "pbl_agent_runtime"
# ---------------------------------------------------------------------------
# 异常与错误码
# ---------------------------------------------------------------------------
class PblContractError(RuntimeError):
"""契约层统一异常:带机器可读 error_code上层一律转 fail-closed 结果。"""
def __init__(self, code: str, message: str = "", **extra):
super().__init__(message or code)
self.code = code
self.message = message or code
self.extra = dict(extra)
def to_dict(self) -> dict:
out = {"success": False, "error_code": self.code, "error_msg": self.message}
out.update(self.extra)
return out
ERR_TENANT_MISSING = "PBL.TENANT.MISSING"
ERR_APPEND_ONLY = "PBL.STORE.APPEND_ONLY"
ERR_STORE = "PBL.STORE.ERROR"
ERR_NOT_FOUND = "PBL.STORE.NOT_FOUND"
# 只允许 INSERT 的留痕表append-only
APPEND_ONLY_TABLES = (
"pbl_agent_trace",
"pbl_agent_tool_call",
"pbl_llm_call_log",
)
# ---------------------------------------------------------------------------
# 契约返回值:同步是 dict异步可 await两种调用写法都成立
# ---------------------------------------------------------------------------
class Result(dict):
"""契约统一返回体。
* 同步调用``r = api.pbl_tool_adjudicate(...); r['allowed']``
* 异步调用``r = await api.pbl_tool_adjudicate(...)````__await__`` 直接返回自身
* 属性访问``r.allowed`` 等价 ``r['allowed']``
这样 offline_contract_smoke.py .dspy 端点async 上下文无需区分同步/异步实现
"""
__slots__ = ()
def __await__(self):
return self
yield # pragma: no cover —— 令 __await__ 成为生成器StopIteration.value = self
def __getattr__(self, item):
try:
return self[item]
except KeyError:
raise AttributeError(item)
def to_dict(self) -> dict:
return dict(self)
def ok(**fields) -> Result:
out = Result({"success": True})
out.update(fields)
return out
def fail(code: str, msg: str = "", **fields) -> Result:
out = Result({"success": False, "error_code": code, "error_msg": msg or code})
out.update(fields)
return out
# ---------------------------------------------------------------------------
# 通用小工具
# ---------------------------------------------------------------------------
_UNSET = object()
def now_str() -> str:
return time.strftime("%Y-%m-%d %H:%M:%S")
def now_ms() -> int:
return int(time.time() * 1000)
def new_id(prefix: str) -> str:
return "%s_%s" % (prefix, uuid.uuid4().hex[:20])
def sha256_text(text: str) -> str:
return hashlib.sha256((text or "").encode("utf-8", "ignore")).hexdigest()
def dumps(obj) -> str:
try:
return json.dumps(obj, ensure_ascii=False, sort_keys=True, default=str)
except Exception:
return json.dumps(str(obj), ensure_ascii=False)
def loads(text, default=None):
if text is None or text == "":
return default
if isinstance(text, (dict, list)):
return text
try:
return json.loads(text)
except Exception:
return default
def module_dbname() -> str:
"""取库名ServerEnv 优先,禁止在业务代码里写死库名。"""
try:
from ahserver import ServerEnv # noqa: WPS433 —— 平台运行时可用时才走这条
name = ServerEnv().get_module_dbname(MODULE_NAME)
if name:
return str(name)
except Exception:
pass
return os.environ.get("PBL_AGENT_RUNTIME_DBNAME") or MODULE_NAME
def resolve_tenant(explicit=_UNSET):
"""解析租户;解析不出返回 None上层 fail-closed
显式传入空串/空白 直接判 None不允许匿名写
"""
if explicit is not _UNSET and explicit is not None:
text = str(explicit).strip()
return text or None
try:
from pbl_common.api import tenant_id as _ctx_tenant # noqa: WPS433
value = _ctx_tenant()
if value is not None:
text = str(value).strip()
if text:
return text
return None
except Exception:
pass
env_value = os.environ.get("PBL_TENANT_ID")
if env_value is not None:
text = str(env_value).strip()
return text or None
return "0" # 平台默认租户(离线冒烟/单机部署)
# ---------------------------------------------------------------------------
# sqlite 存储(离线契约层)
# ---------------------------------------------------------------------------
DDL_SQLITE = """
CREATE TABLE IF NOT EXISTS pbl_agent_def (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
agent_code TEXT NOT NULL,
agent_name TEXT,
role TEXT,
write_scope TEXT DEFAULT 'none',
can_write INTEGER DEFAULT 0,
perms TEXT,
model TEXT,
system_prompt TEXT,
status TEXT DEFAULT 'active',
created_at TEXT,
updated_at TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS ux_agent_def_tenant_code
ON pbl_agent_def (tenant_id, agent_code);
CREATE TABLE IF NOT EXISTS pbl_tool_registry (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
tool_key TEXT NOT NULL,
tool_name TEXT,
category TEXT,
write_class INTEGER DEFAULT 0,
audit_append INTEGER DEFAULT 0,
status TEXT DEFAULT 'disabled',
required_perm TEXT,
approval_type TEXT,
params_schema TEXT,
risk_level TEXT DEFAULT 'medium',
reason TEXT,
sort_no INTEGER DEFAULT 0,
version INTEGER DEFAULT 1,
created_at TEXT,
updated_at TEXT,
created_by TEXT,
updated_by TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS ux_tool_registry_tenant_key
ON pbl_tool_registry (tenant_id, tool_key);
CREATE INDEX IF NOT EXISTS ix_tool_registry_status ON pbl_tool_registry (tenant_id, status);
CREATE TABLE IF NOT EXISTS pbl_agent_trace (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
trace_id TEXT NOT NULL,
run_id TEXT,
step_no INTEGER DEFAULT 0,
stage TEXT,
who TEXT,
occurred_at TEXT,
what TEXT,
why TEXT,
how TEXT,
result TEXT,
evidence_ref TEXT,
tool_key TEXT,
decision TEXT,
created_at TEXT
);
CREATE INDEX IF NOT EXISTS ix_trace_tenant_trace ON pbl_agent_trace (tenant_id, trace_id);
CREATE INDEX IF NOT EXISTS ix_trace_run ON pbl_agent_trace (tenant_id, run_id, step_no);
CREATE TABLE IF NOT EXISTS pbl_agent_tool_call (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
trace_id TEXT,
call_no INTEGER DEFAULT 0,
agent_code TEXT,
tool_key TEXT,
params_json TEXT,
decision TEXT,
denied_at_step TEXT,
reason_code TEXT,
approval_id TEXT,
latency_ms INTEGER DEFAULT 0,
created_at TEXT
);
CREATE INDEX IF NOT EXISTS ix_tool_call_trace ON pbl_agent_tool_call (tenant_id, trace_id);
CREATE TABLE IF NOT EXISTS pbl_approval (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
approval_id TEXT NOT NULL,
approval_type TEXT NOT NULL,
title TEXT,
payload_json TEXT,
status TEXT DEFAULT 'pending',
requested_by TEXT,
agent_code TEXT,
tool_key TEXT,
decided_by TEXT,
decided_at TEXT,
decision_comment TEXT,
expires_at TEXT,
created_at TEXT,
updated_at TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS ux_approval_tenant_id ON pbl_approval (tenant_id, approval_id);
CREATE INDEX IF NOT EXISTS ix_approval_status ON pbl_approval (tenant_id, status, approval_type);
CREATE TABLE IF NOT EXISTS pbl_llm_call_log (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
agent_code TEXT,
run_id TEXT,
purpose TEXT,
model TEXT,
attempt_no INTEGER DEFAULT 1,
status TEXT DEFAULT 'ok',
latency_ms INTEGER DEFAULT 0,
timeout_ms INTEGER DEFAULT 0,
retry_count INTEGER DEFAULT 0,
rate_limited INTEGER DEFAULT 0,
fallback_used INTEGER DEFAULT 0,
prompt_chars INTEGER DEFAULT 0,
prompt_hash TEXT,
response_chars INTEGER DEFAULT 0,
tokens_in INTEGER DEFAULT 0,
tokens_out INTEGER DEFAULT 0,
error_code TEXT,
error_msg TEXT,
created_at TEXT
);
CREATE INDEX IF NOT EXISTS ix_llm_log_tenant_agent ON pbl_llm_call_log (tenant_id, agent_code, created_at);
CREATE INDEX IF NOT EXISTS ix_llm_log_status ON pbl_llm_call_log (tenant_id, status);
"""
_TABLE_COLUMNS = {
"pbl_agent_def": (
"id", "tenant_id", "agent_code", "agent_name", "role", "write_scope",
"can_write", "perms", "model", "system_prompt", "status",
"created_at", "updated_at",
),
"pbl_tool_registry": (
"id", "tenant_id", "tool_key", "tool_name", "category", "write_class",
"audit_append", "status", "required_perm", "approval_type", "params_schema",
"risk_level", "reason", "sort_no", "version", "created_at", "updated_at",
"created_by", "updated_by",
),
"pbl_agent_trace": (
"id", "tenant_id", "trace_id", "run_id", "step_no", "stage", "who",
"occurred_at", "what", "why", "how", "result", "evidence_ref",
"tool_key", "decision", "created_at",
),
"pbl_agent_tool_call": (
"id", "tenant_id", "trace_id", "call_no", "agent_code", "tool_key",
"params_json", "decision", "denied_at_step", "reason_code", "approval_id",
"latency_ms", "created_at",
),
"pbl_approval": (
"id", "tenant_id", "approval_id", "approval_type", "title", "payload_json",
"status", "requested_by", "agent_code", "tool_key", "decided_by",
"decided_at", "decision_comment", "expires_at", "created_at", "updated_at",
),
"pbl_llm_call_log": (
"id", "tenant_id", "agent_code", "run_id", "purpose", "model", "attempt_no",
"status", "latency_ms", "timeout_ms", "retry_count", "rate_limited",
"fallback_used", "prompt_chars", "prompt_hash", "response_chars",
"tokens_in", "tokens_out", "error_code", "error_msg", "created_at",
),
}
ALL_TABLES = tuple(_TABLE_COLUMNS.keys())
def _default_db_path() -> str:
env_path = os.environ.get("PBL_AGENT_RUNTIME_SQLITE")
if env_path:
return env_path
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
return os.path.join(root, "var", "m4a_contract.sqlite3")
class Store:
"""sqlite 存储门面schema 自愈 + append-only 守卫 + 线程安全。"""
_lock = threading.RLock()
_initialized_paths = set()
def __init__(self, path: str | None = None):
self.path = path or _default_db_path()
# -- 连接 ---------------------------------------------------------------
def _connect(self) -> sqlite3.Connection:
directory = os.path.dirname(os.path.abspath(self.path))
if directory and not os.path.isdir(directory):
os.makedirs(directory, exist_ok=True)
conn = sqlite3.connect(self.path, timeout=15, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
def ensure_schema(self) -> None:
with Store._lock:
if self.path in Store._initialized_paths:
return
try:
conn = self._connect()
except Exception as exc: # pragma: no cover —— 环境不可写
raise PblContractError(ERR_STORE, "sqlite connect failed: %s" % exc)
try:
conn.executescript(DDL_SQLITE)
conn.commit()
finally:
conn.close()
Store._initialized_paths.add(self.path)
# -- 读 -----------------------------------------------------------------
def query(self, sql: str, args=()) -> list:
self.ensure_schema()
try:
with Store._lock:
conn = self._connect()
try:
return [dict(row) for row in conn.execute(sql, tuple(args)).fetchall()]
finally:
conn.close()
except PblContractError:
raise
except Exception as exc:
raise PblContractError(ERR_STORE, "query failed: %s | sql=%s" % (exc, sql))
def query_one(self, sql: str, args=()):
rows = self.query(sql, args)
return rows[0] if rows else None
# -- 写 -----------------------------------------------------------------
def execute(self, sql: str, args=()) -> int:
self.ensure_schema()
op = sql.strip().split(" ", 1)[0].upper()
table = _guess_table(sql)
guard_append_only(table, op)
try:
with Store._lock:
conn = self._connect()
try:
cur = conn.execute(sql, tuple(args))
conn.commit()
return cur.rowcount
finally:
conn.close()
except PblContractError:
raise
except Exception as exc:
raise PblContractError(ERR_STORE, "execute failed: %s | sql=%s" % (exc, sql))
def insert(self, table: str, row: dict) -> dict:
guard_append_only(table, "INSERT")
cols = _TABLE_COLUMNS.get(table)
if not cols:
raise PblContractError(ERR_STORE, "unknown table: %s" % table)
data = {}
for key in cols:
if key in row and row[key] is not None:
value = row[key]
if isinstance(value, (dict, list)):
value = dumps(value)
elif isinstance(value, bool):
value = 1 if value else 0
data[key] = value
data.setdefault("id", new_id(table[:12]))
data.setdefault("created_at", now_str())
names = list(data.keys())
sql = "INSERT INTO %s (%s) VALUES (%s)" % (
table,
", ".join(names),
", ".join(["?"] * len(names)),
)
self.execute(sql, [data[n] for n in names])
return data
def upsert_by(self, table: str, unique: dict, row: dict) -> dict:
"""按唯一键存在则 UPDATE、不存在则 INSERT业务表可用留痕表禁用"""
guard_append_only(table, "UPSERT")
where_sql = " AND ".join(["%s = ?" % k for k in unique.keys()])
existed = self.query_one(
"SELECT id FROM %s WHERE %s" % (table, where_sql), list(unique.values())
)
if existed:
sets = {k: v for k, v in row.items() if k in _TABLE_COLUMNS[table] and k != "id"}
for key, value in list(sets.items()):
if isinstance(value, (dict, list)):
sets[key] = dumps(value)
elif isinstance(value, bool):
sets[key] = 1 if value else 0
sets.setdefault("updated_at", now_str())
names = list(sets.keys())
sql = "UPDATE %s SET %s WHERE id = ?" % (
table,
", ".join(["%s = ?" % n for n in names]),
)
self.execute(sql, [sets[n] for n in names] + [existed["id"]])
merged = dict(existed)
merged.update(sets)
return merged
merged = dict(unique)
merged.update(row)
return self.insert(table, merged)
def reset(self) -> None:
"""仅测试用:清空全部表(留痕表守卫在测试模式下由 reset 显式豁免)。"""
self.ensure_schema()
with Store._lock:
conn = self._connect()
try:
for table in ALL_TABLES:
conn.execute("DELETE FROM %s" % table)
conn.commit()
finally:
conn.close()
def _guess_table(sql: str):
text = " ".join(sql.split()).upper()
for table in ALL_TABLES:
if ("INTO %s" % table.upper()) in text or ("UPDATE %s" % table.upper()) in text:
return table
if ("FROM %s" % table.upper()) in text:
return table
return None
def guard_append_only(table, op: str) -> None:
"""留痕表 append-only 守卫:非 INSERT 一律拒绝。"""
if not table:
return
if table in APPEND_ONLY_TABLES and str(op).upper() not in ("INSERT", "SELECT"):
raise PblContractError(
ERR_APPEND_ONLY,
"table %s is append-only, operation %s rejected" % (table, op),
table=table,
operation=op,
)
_DEFAULT_STORE = None
_STORE_LOCK = threading.Lock()
def get_store() -> Store:
global _DEFAULT_STORE
with _STORE_LOCK:
if _DEFAULT_STORE is None or _DEFAULT_STORE.path != _default_db_path():
_DEFAULT_STORE = Store()
return _DEFAULT_STORE
def set_store(store: Store) -> None:
global _DEFAULT_STORE
with _STORE_LOCK:
_DEFAULT_STORE = store

View File

@ -0,0 +1,218 @@
# -*- coding: utf-8 -*-
"""契约四处同步机械核验QC 退回 #1/#3/#4/#5 的防复发闸门)。
核验项
------
1. 契约函数 10 m4a_contract.CONTRACT_FUNCTIONS __init__.py 导出 init.py 注册
wwwroot/api/*.dspy 文件 scripts/load_path.py PATHS五处集合完全相等
2. wwwroot/api/ 下不存在未注册端点PATHS 不指向不存在文件
3. 每个 .dspy debug 行必须是 f-string禁普通字符串占位
4. .dspy 禁项审计 import / print / uuid
5. 所有 .py py_compile 通过
6. 交付文件清单deliver 与磁盘实际存在文件一致本脚本直接产出真实清单
用法python3 scripts/check_contract_sync.py [--json out.json]
"""
import ast
import json
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
WORKSPACE_ROOT = os.path.dirname(os.path.dirname(ROOT)) # 机构工作空间根(清单路径基准)
sys.path.insert(0, ROOT)
sys.path.insert(0, os.path.dirname(ROOT))
API_DIR = os.path.join(ROOT, "wwwroot", "api")
PKG_DIR = os.path.join(ROOT, "pbl_agent_runtime")
FORBIDDEN_IN_DSPY = ("import ", "print(", "uuid")
def _contract_names():
from pbl_agent_runtime.m4a_contract import CONTRACT_FUNCTIONS
return list(CONTRACT_FUNCTIONS)
def _dspy_files():
if not os.path.isdir(API_DIR):
return []
return sorted(f[:-5] for f in os.listdir(API_DIR) if f.endswith(".dspy"))
def _load_path_entries():
path = os.path.join(ROOT, "scripts", "load_path.py")
with open(path, encoding="utf-8") as handle:
tree = ast.parse(handle.read())
for node in tree.body:
if isinstance(node, ast.Assign) and getattr(node.targets[0], "id", "") == "PATHS":
out = []
for elt in node.value.elts:
out.append((elt.elts[0].value, elt.elts[1].value))
return out
return []
def _init_exports():
path = os.path.join(PKG_DIR, "__init__.py")
with open(path, encoding="utf-8") as handle:
tree = ast.parse(handle.read())
exported = set()
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom):
for alias in node.names:
exported.add(alias.asname or alias.name)
all_list = set()
for node in tree.body:
if isinstance(node, ast.Assign) and getattr(node.targets[0], "id", "") == "__all__":
all_list = {elt.value for elt in node.value.elts}
return exported, all_list
def _init_registers():
"""从 init.py 静态解析注册名CONTRACT_FUNCTIONS 循环 + EXTRA_FUNCTIONS"""
path = os.path.join(PKG_DIR, "init.py")
with open(path, encoding="utf-8") as handle:
source = handle.read()
names = set()
match = re.search(r"EXTRA_FUNCTIONS\s*=\s*\(([^)]*)\)", source, re.S)
if match:
names |= set(re.findall(r"'([^']+)'|\"([^\"]+)\"", match.group(1)) and
[a or b for a, b in re.findall(r"'([^']*)'|\"([^\"]*)\"", match.group(1))])
return names, source
def main():
problems = []
contracts = _contract_names()
dspy = _dspy_files()
entries = _load_path_entries()
entry_paths = [p for p, _ in entries]
entry_dspy = sorted(p.split("/")[-1][:-5] for p in entry_paths if p.endswith(".dspy"))
# 1) 集合一致性
if sorted(contracts) != sorted(dspy):
problems.append("contract vs dspy mismatch: only_contract=%s only_dspy=%s"
% (sorted(set(contracts) - set(dspy)), sorted(set(dspy) - set(contracts))))
if sorted(contracts) != sorted(entry_dspy):
problems.append("contract vs load_path mismatch: only_contract=%s only_path=%s"
% (sorted(set(contracts) - set(entry_dspy)),
sorted(set(entry_dspy) - set(contracts))))
# 2) PATHS 指向的文件必须真实存在
for path, role in entries:
# PATHS 是运行时 URL 路径 /{module}/wwwroot相对路径磁盘落点在 ROOT/wwwroot/
rel = path.lstrip("/")
parts = rel.split("/", 1)
rel_in_module = parts[1] if len(parts) == 2 and parts[0] == "pbl_agent_runtime" else rel
full = os.path.join(ROOT, "wwwroot", rel_in_module)
if not os.path.isfile(full):
problems.append("load_path points to missing file: %s" % path)
if role not in ("any", "user", "admin"):
problems.append("load_path bad role %r for %s" % (role, path))
# 3)+4) dspy 内容审计
for name in dspy:
full = os.path.join(API_DIR, "%s.dspy" % name)
with open(full, encoding="utf-8") as handle:
text = handle.read()
lines = text.splitlines()
debug_lines = [ln for ln in lines if ln.strip().startswith("debug(")]
if not debug_lines:
problems.append("%s.dspy: no debug line" % name)
for ln in debug_lines:
if "debug(f'" not in ln and 'debug(f"' not in ln:
problems.append("%s.dspy: debug is not f-string -> %s" % (name, ln.strip()))
if "params_kw" not in ln and "result" not in ln:
problems.append("%s.dspy: debug missing real params -> %s" % (name, ln.strip()))
for bad in FORBIDDEN_IN_DSPY:
for idx, ln in enumerate(lines, 1):
if bad in ln:
problems.append("%s.dspy:%d forbidden token %r" % (name, idx, bad))
if not any(ln.strip().startswith("return ") for ln in lines):
problems.append("%s.dspy: missing explicit return" % name)
if name not in text:
problems.append("%s.dspy: does not call contract function %s" % (name, name))
# 5) __init__ 导出 api + 契约对象
exported, all_list = _init_exports()
if "api" not in exported or "api" not in all_list:
problems.append("__init__.py must export `api` (QC #1)")
for name in ("load_pbl_agent_runtime", "self_check"):
if name not in exported:
problems.append("__init__.py missing export: %s" % name)
_, init_source = _init_registers()
if "def api" in init_source and "_ApiAccessor" not in init_source:
problems.append("init.py api must stay compatible with attribute access")
# 6) py_compile 全部 .py
compiled = 0
for base, dirs, files in os.walk(ROOT):
dirs[:] = [d for d in dirs if d not in ("__pycache__", ".git", "var")]
for fname in files:
if not fname.endswith(".py"):
continue
full = os.path.join(base, fname)
try:
with open(full, encoding="utf-8") as handle:
tree = ast.parse(handle.read(), filename=full)
compile(tree, full, "exec")
if not [n for n in tree.body
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef,
ast.Assign, ast.Import, ast.ImportFrom, ast.If,
ast.Try, ast.With, ast.For, ast.While,
ast.Expr, ast.AnnAssign))]:
problems.append("empty module (no effective statement): %s"
% os.path.relpath(full, ROOT))
compiled += 1
except SyntaxError as exc:
problems.append("syntax error %s: %s" % (os.path.relpath(full, ROOT), exc))
except Exception as exc:
problems.append("compile failed %s: %s" % (os.path.relpath(full, ROOT), exc))
# 7) 真实交付文件清单存在性过滤QC #5
# 清单文件自身不计入清单否则字节数自引用抖动QC#5 核验永远对不上)
manifest_rel = os.path.abspath(os.path.join(ROOT, "scripts", "m4a_manifest.json"))
inventory = []
for base, dirs, files in os.walk(ROOT):
dirs[:] = [d for d in dirs if d not in ("__pycache__", ".git", "var")]
for fname in sorted(files):
full = os.path.join(base, fname)
if os.path.abspath(full) == manifest_rel:
continue
if not os.path.isfile(full):
continue
rel = os.path.relpath(full, WORKSPACE_ROOT)
with open(full, encoding="utf-8", errors="ignore") as handle:
line_count = sum(1 for _ in handle)
inventory.append({"path": rel.replace(os.sep, "/"),
"bytes": os.path.getsize(full),
"lines": line_count})
inventory.sort(key=lambda item: item["path"])
report = {
"success": not problems,
"contract_count": len(contracts),
"contracts": contracts,
"dspy_endpoints": dspy,
"load_path_entries": len(entries),
"py_compiled": compiled,
"problems": problems,
"inventory_count": len(inventory),
"inventory": inventory,
}
print(json.dumps({k: v for k, v in report.items() if k != "inventory"},
ensure_ascii=False, indent=2))
if "--json" in sys.argv:
out = sys.argv[sys.argv.index("--json") + 1]
with open(out, "w", encoding="utf-8") as handle:
json.dump(report, handle, ensure_ascii=False, indent=2)
print("inventory written ->", out)
return 0 if not problems else 1
if __name__ == "__main__":
sys.exit(main())

52
scripts/gen_dspy.py Normal file
View File

@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
"""生成 wwwroot/api/*.dspy 契约端点10 个)。
统一模板要点对应 QC 退回 #3/#6
* 2 debug 必须是 **f-string**输出真实 params_kwmodule-development-spec 要求
* dspy 内禁止 import / print / uuid禁项审计零命中
* 契约函数由 init.py 注册到 ServerEnvdspy 直接按名调用
* 显式 returnahserver dspy 包进 async def裸表达式返回 None
"""
import os
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
API_DIR = os.path.join(ROOT, "wwwroot", "api")
CONTRACTS = (
("pbl_agent_designer_run", "Designer Agent 运行(可写,写操作经服务端裁决)"),
("pbl_agent_critic_run", "Critic Agent 运行(零写权限)"),
("pbl_agent_trace_write", "写 Agent 执行轨迹7 要素append-only"),
("pbl_agent_trace_list", "查询 Agent 执行轨迹"),
("pbl_tool_registry_list", "工具注册表查询13 启用 / 9 禁用)"),
("pbl_tool_registry_save", "工具注册表变更(四类强制审批之一)"),
("pbl_tool_adjudicate", "fail-closed 8 步工具裁决(默认 DENY"),
("pbl_approval_create", "创建人工审批单14.2 四类)"),
("pbl_approval_decide", "人工审批裁决(无 Agent 绕过路径)"),
("pbl_approval_list", "审批单列表查询"),
)
TEMPLATE = """# {doc}
debug(f'{name}.dspy: START params_kw={{dict(params_kw)}}')
result = {name}(**dict(params_kw))
debug(f'{name}.dspy: END success={{result.get("success") if isinstance(result, dict) else result}}')
return result
"""
def main():
if not os.path.isdir(API_DIR):
os.makedirs(API_DIR, exist_ok=True)
written = []
for name, doc in CONTRACTS:
path = os.path.join(API_DIR, "%s.dspy" % name)
with open(path, "w", encoding="utf-8") as handle:
handle.write(TEMPLATE.format(name=name, doc=doc))
written.append(os.path.relpath(path, ROOT))
print("generated %d dspy endpoints:" % len(written))
for item in written:
print(" -", item)
if __name__ == "__main__":
main()

View File

@ -1,49 +1,62 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""pbl_agent_runtime RBAC 路径注册(硬门禁 6.6 / QC #11 """pbl_agent_runtime RBAC 路径注册清单
约定 铁律PATHS 必须与 wwwroot/ **实际存在的文件一一对应**QC 退回 #4
- 路径 = 模块自动路由 `/pbl_agent_runtime/api/<契约>.dspy`不带端口不带 /wss 前缀 本文件由 scripts/check_contract_sync.py 机械核验多一条指向不存在文件或少一条
- 角色 `logined` = 登录即可访问的读接口写接口按角色分级teacher/admin 文件未注册都会失败
- apps/pbls/build.sh 8 步调用 `register()`rbac CLI 不在位时打印清单不静默跳过
角色约定
--------
* ``any`` 登录前可访问本模块无此类文件
* ``user`` 普通登录用户只读查询类
* ``admin`` 租户管理员写类 / 审批裁决 / 注册表变更
""" """
import os
import subprocess
import sys
MODULE = 'pbl_agent_runtime' # (路径, 角色) —— 路径相对模块 wwwroot运行时前缀 /pbl_agent_runtime/
# (path, role)
PATHS = [ PATHS = [
('/pbl_agent_runtime/api/pbl_agent_designer_run.dspy', 'logined'), # 模块入口页
('/pbl_agent_runtime/api/pbl_agent_critic_run.dspy', 'logined'), ("/pbl_agent_runtime/index.ui", "user"),
('/pbl_agent_runtime/api/pbl_agent_trace_write.dspy', 'logined'), # 10 个契约端点(与 pbl_agent_runtime.m4a_contract.CONTRACT_FUNCTIONS 同源)
('/pbl_agent_runtime/api/pbl_agent_trace_list.dspy', 'logined'), ("/pbl_agent_runtime/api/pbl_agent_designer_run.dspy", "admin"),
('/pbl_agent_runtime/api/pbl_tool_registry_list.dspy', 'logined'), ("/pbl_agent_runtime/api/pbl_agent_critic_run.dspy", "user"),
('/pbl_agent_runtime/api/pbl_tool_registry_save.dspy', 'logined'), ("/pbl_agent_runtime/api/pbl_agent_trace_write.dspy", "user"),
('/pbl_agent_runtime/api/pbl_tool_adjudicate.dspy', 'logined'), ("/pbl_agent_runtime/api/pbl_agent_trace_list.dspy", "user"),
('/pbl_agent_runtime/api/pbl_approval_create.dspy', 'logined'), ("/pbl_agent_runtime/api/pbl_tool_registry_list.dspy", "user"),
('/pbl_agent_runtime/api/pbl_approval_decide.dspy', 'logined'), ("/pbl_agent_runtime/api/pbl_tool_registry_save.dspy", "admin"),
('/pbl_agent_runtime/api/pbl_approval_list.dspy', 'logined'), ("/pbl_agent_runtime/api/pbl_tool_adjudicate.dspy", "user"),
("/pbl_agent_runtime/api/pbl_approval_create.dspy", "user"),
("/pbl_agent_runtime/api/pbl_approval_decide.dspy", "admin"),
("/pbl_agent_runtime/api/pbl_approval_list.dspy", "user"),
] ]
MODULE_NAME = "pbl_agent_runtime"
def register():
tool = os.environ.get('RBAC_SET_PERM', 'set_role_perm.py') def paths():
done, missing = 0, [] """返回 [(path, role), ...],供宿主应用 RBAC 批量注册。"""
return list(PATHS)
def register(env=None):
"""把 PATHS 注册到 ServerEnv 的 RBAC 表(平台可用时)。"""
target = env
if target is None:
try:
from ahserver import ServerEnv # noqa: WPS433
target = ServerEnv()
except Exception: # noqa: BLE001
return {"registered": 0, "offline": True, "paths": list(PATHS)}
count = 0
for path, role in PATHS: for path, role in PATHS:
if subprocess.call([sys.executable if os.environ.get('PY') else 'python3', setter = getattr(target, "add_path", None) or getattr(target, "register_path", None)
tool, role, path], if callable(setter):
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0: setter(path, role)
done += 1 count += 1
else: return {"registered": count, "offline": count == 0, "paths": list(PATHS)}
missing.append((path, role))
print('[%s] rbac paths: total=%d ok=%d pending=%d' %(len(PATHS), done, len(missing)))
for path, role in missing:
print(' PENDING %%-12s %s' %(role, path))
return len(missing) == 0
if __name__ == '__main__': if __name__ == "__main__":
sys.exit(0 if register() else 1) for item_path, item_role in PATHS:
print("%-64s %s" % (item_path, item_role))
print("total: %d paths" % len(PATHS))

View File

@ -0,0 +1,591 @@
# -*- 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())

229
scripts/m4a_manifest.json Normal file
View File

@ -0,0 +1,229 @@
{
"success": true,
"contract_count": 10,
"contracts": [
"pbl_agent_designer_run",
"pbl_agent_critic_run",
"pbl_agent_trace_write",
"pbl_agent_trace_list",
"pbl_tool_registry_list",
"pbl_tool_registry_save",
"pbl_tool_adjudicate",
"pbl_approval_create",
"pbl_approval_decide",
"pbl_approval_list"
],
"dspy_endpoints": [
"pbl_agent_critic_run",
"pbl_agent_designer_run",
"pbl_agent_trace_list",
"pbl_agent_trace_write",
"pbl_approval_create",
"pbl_approval_decide",
"pbl_approval_list",
"pbl_tool_adjudicate",
"pbl_tool_registry_list",
"pbl_tool_registry_save"
],
"load_path_entries": 11,
"py_compiled": 22,
"problems": [],
"inventory_count": 39,
"inventory": [
{
"path": "modules/pbl_agent_runtime/.gitignore",
"bytes": 106,
"lines": 12
},
{
"path": "modules/pbl_agent_runtime/README.md",
"bytes": 298,
"lines": 1
},
{
"path": "modules/pbl_agent_runtime/pbl_agent_runtime/__init__.py",
"bytes": 4120,
"lines": 135
},
{
"path": "modules/pbl_agent_runtime/pbl_agent_runtime/init.py",
"bytes": 9592,
"lines": 297
},
{
"path": "modules/pbl_agent_runtime/pbl_agent_runtime/m4a.py",
"bytes": 12920,
"lines": 265
},
{
"path": "modules/pbl_agent_runtime/pbl_agent_runtime/m4a_adjudicate.py",
"bytes": 17151,
"lines": 371
},
{
"path": "modules/pbl_agent_runtime/pbl_agent_runtime/m4a_api.py",
"bytes": 3342,
"lines": 101
},
{
"path": "modules/pbl_agent_runtime/pbl_agent_runtime/m4a_approval.py",
"bytes": 13953,
"lines": 305
},
{
"path": "modules/pbl_agent_runtime/pbl_agent_runtime/m4a_backend.py",
"bytes": 16774,
"lines": 380
},
{
"path": "modules/pbl_agent_runtime/pbl_agent_runtime/m4a_contract.py",
"bytes": 55217,
"lines": 1176
},
{
"path": "modules/pbl_agent_runtime/pbl_agent_runtime/m4a_critic.py",
"bytes": 12984,
"lines": 296
},
{
"path": "modules/pbl_agent_runtime/pbl_agent_runtime/m4a_designer.py",
"bytes": 27272,
"lines": 620
},
{
"path": "modules/pbl_agent_runtime/pbl_agent_runtime/m4a_init.py",
"bytes": 5092,
"lines": 106
},
{
"path": "modules/pbl_agent_runtime/pbl_agent_runtime/m4a_kernel.py",
"bytes": 13381,
"lines": 389
},
{
"path": "modules/pbl_agent_runtime/pbl_agent_runtime/m4a_registry.py",
"bytes": 31817,
"lines": 638
},
{
"path": "modules/pbl_agent_runtime/pbl_agent_runtime/m4a_store.py",
"bytes": 18821,
"lines": 544
},
{
"path": "modules/pbl_agent_runtime/pbl_agent_runtime/m4a_tables.py",
"bytes": 15694,
"lines": 321
},
{
"path": "modules/pbl_agent_runtime/pbl_agent_runtime/m4a_trace.py",
"bytes": 11178,
"lines": 267
},
{
"path": "modules/pbl_agent_runtime/pyproject.toml",
"bytes": 1008,
"lines": 28
},
{
"path": "modules/pbl_agent_runtime/scripts/check_contract_sync.py",
"bytes": 9418,
"lines": 218
},
{
"path": "modules/pbl_agent_runtime/scripts/gen_dspy.py",
"bytes": 2140,
"lines": 52
},
{
"path": "modules/pbl_agent_runtime/scripts/load_path.py",
"bytes": 2445,
"lines": 62
},
{
"path": "modules/pbl_agent_runtime/scripts/m4a_contract_selftest.py",
"bytes": 30530,
"lines": 591
},
{
"path": "modules/pbl_agent_runtime/scripts/m4a_selftest.py",
"bytes": 24715,
"lines": 447
},
{
"path": "modules/pbl_agent_runtime/scripts/seed_m4a.py",
"bytes": 4086,
"lines": 89
},
{
"path": "modules/pbl_agent_runtime/skill/SKILL.md",
"bytes": 1501,
"lines": 33
},
{
"path": "modules/pbl_agent_runtime/sql/m4a_contract_addon.sql",
"bytes": 11069,
"lines": 167
},
{
"path": "modules/pbl_agent_runtime/sql/m4a_ddl.sql",
"bytes": 8965,
"lines": 123
},
{
"path": "modules/pbl_agent_runtime/wwwroot/api/pbl_agent_critic_run.dspy",
"bytes": 287,
"lines": 5
},
{
"path": "modules/pbl_agent_runtime/wwwroot/api/pbl_agent_designer_run.dspy",
"bytes": 319,
"lines": 5
},
{
"path": "modules/pbl_agent_runtime/wwwroot/api/pbl_agent_trace_list.dspy",
"bytes": 275,
"lines": 5
},
{
"path": "modules/pbl_agent_runtime/wwwroot/api/pbl_agent_trace_write.dspy",
"bytes": 303,
"lines": 5
},
{
"path": "modules/pbl_agent_runtime/wwwroot/api/pbl_approval_create.dspy",
"bytes": 285,
"lines": 5
},
{
"path": "modules/pbl_agent_runtime/wwwroot/api/pbl_approval_decide.dspy",
"bytes": 293,
"lines": 5
},
{
"path": "modules/pbl_agent_runtime/wwwroot/api/pbl_approval_list.dspy",
"bytes": 262,
"lines": 5
},
{
"path": "modules/pbl_agent_runtime/wwwroot/api/pbl_tool_adjudicate.dspy",
"bytes": 293,
"lines": 5
},
{
"path": "modules/pbl_agent_runtime/wwwroot/api/pbl_tool_registry_list.dspy",
"bytes": 303,
"lines": 5
},
{
"path": "modules/pbl_agent_runtime/wwwroot/api/pbl_tool_registry_save.dspy",
"bytes": 307,
"lines": 5
},
{
"path": "modules/pbl_agent_runtime/wwwroot/index.ui",
"bytes": 8236,
"lines": 315
}
]
}

167
sql/m4a_contract_addon.sql Normal file
View File

@ -0,0 +1,167 @@
-- =============================================================================
-- pbl_agent_runtime M4a 契约层 DDLMySQL 方言)
-- 对应 QC 退回 #3/#7恢复 pbl_tool_registry工具注册表+ 新增 pbl_llm_call_log
-- LLM 调用留痕:超时/重试/限流/兜底),使数据表回到技能声明的 6 张。
--
-- 6 张表:
-- 1. pbl_agent_def Agent 定义designer 可写 / critic 零写)
-- 2. pbl_tool_registry 工具注册表13 启用 / 9 禁用,含 pbl.publish 禁用)
-- 3. pbl_agent_trace Agent 执行轨迹第28章 7 要素append-only
-- 4. pbl_agent_tool_call 工具调用明细服务端裁决留痕append-only
-- 5. pbl_approval 人工审批单14.2 四类,无绕过路径)
-- 6. pbl_llm_call_log LLM 调用日志append-only
--
-- 库名由 ServerEnv().get_module_dbname('pbl_agent_runtime') 决定,脚本内不硬编码库名。
-- 离线契约层使用同构 sqlite DDLpbl_agent_runtime/m4a_store.py:DDL_SQLITE
-- =============================================================================
-- 1) Agent 定义 ----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS pbl_agent_def (
id VARCHAR(40) NOT NULL COMMENT '主键',
tenant_id VARCHAR(40) NOT NULL COMMENT '租户ID所有读写强制打头',
agent_code VARCHAR(64) NOT NULL COMMENT 'Agent 编码designer / critic',
agent_name VARCHAR(128) COMMENT 'Agent 名称',
role VARCHAR(32) COMMENT '角色designer / critic',
write_scope VARCHAR(255) DEFAULT 'none' COMMENT '可写域critic 固定 none',
can_write TINYINT(1) DEFAULT 0 COMMENT '是否可写critic=0零写权限',
perms TEXT COMMENT '持有权限码 JSON 数组',
model VARCHAR(64) COMMENT '绑定模型',
system_prompt TEXT COMMENT '系统提示词',
status VARCHAR(16) DEFAULT 'active' COMMENT 'active / disabled',
created_at DATETIME COMMENT '创建时间',
updated_at DATETIME COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY ux_agent_def_tenant_code (tenant_id, agent_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='M4a Agent 定义Designer/Critic';
-- 2) 工具注册表 ----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS pbl_tool_registry (
id VARCHAR(40) NOT NULL COMMENT '主键',
tenant_id VARCHAR(40) NOT NULL COMMENT '租户ID',
tool_key VARCHAR(128) NOT NULL COMMENT '工具键:如 pbl.publish / blueprint.write',
tool_name VARCHAR(128) COMMENT '工具名称',
category VARCHAR(32) COMMENT '分类blueprint/compiler/evidence/...',
write_class TINYINT(1) DEFAULT 0 COMMENT '1=写类工具critic 一律 DENY',
audit_append TINYINT(1) DEFAULT 0 COMMENT '1=只追加留痕类',
status VARCHAR(16) DEFAULT 'disabled' COMMENT 'enabled / disabled默认 disabled=fail-closed',
required_perm VARCHAR(128) COMMENT '调用所需权限码',
approval_type VARCHAR(32) COMMENT '强制审批类型四类之一NULL=无需审批',
params_schema TEXT COMMENT '入参 JSON Schema',
risk_level VARCHAR(16) DEFAULT 'medium' COMMENT 'low/medium/high/forbidden',
reason VARCHAR(512) COMMENT '禁用原因(审计可读)',
sort_no INT DEFAULT 0 COMMENT '排序',
version INT DEFAULT 1 COMMENT '版本号(每次变更 +1',
created_at DATETIME COMMENT '创建时间',
updated_at DATETIME COMMENT '更新时间',
created_by VARCHAR(64) COMMENT '创建人',
updated_by VARCHAR(64) COMMENT '最后变更人',
PRIMARY KEY (id),
UNIQUE KEY ux_tool_registry_tenant_key (tenant_id, tool_key),
KEY ix_tool_registry_status (tenant_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='M4a 工具注册表13 启用 / 9 禁用)';
-- 3) Agent 执行轨迹append-only---------------------------------------------
CREATE TABLE IF NOT EXISTS pbl_agent_trace (
id VARCHAR(40) NOT NULL COMMENT '主键',
tenant_id VARCHAR(40) NOT NULL COMMENT '租户ID',
trace_id VARCHAR(40) NOT NULL COMMENT '轨迹ID',
run_id VARCHAR(40) COMMENT '运行ID',
step_no INT DEFAULT 0 COMMENT '步序',
stage VARCHAR(32) COMMENT '阶段designer_run/critic_run/adjudicate/...',
who VARCHAR(64) COMMENT '7要素-谁',
occurred_at DATETIME COMMENT '7要素-何时',
what TEXT COMMENT '7要素-做了什么',
why TEXT COMMENT '7要素-为什么',
how TEXT COMMENT '7要素-怎么做',
result TEXT COMMENT '7要素-结果',
evidence_ref VARCHAR(255) COMMENT '7要素-证据引用',
tool_key VARCHAR(128) COMMENT '关联工具',
decision VARCHAR(16) COMMENT 'ALLOW / DENY / READ_ONLY',
created_at DATETIME COMMENT '创建时间',
PRIMARY KEY (id),
KEY ix_trace_tenant_trace (tenant_id, trace_id),
KEY ix_trace_run (tenant_id, run_id, step_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='M4a Agent 执行轨迹append-only禁 UPDATE/DELETE';
-- 4) 工具调用明细append-only------------------------------------------------
CREATE TABLE IF NOT EXISTS pbl_agent_tool_call (
id VARCHAR(40) NOT NULL COMMENT '主键',
tenant_id VARCHAR(40) NOT NULL COMMENT '租户ID',
trace_id VARCHAR(40) COMMENT '轨迹ID',
call_no INT DEFAULT 0 COMMENT '同轨迹内调用序号',
agent_code VARCHAR(64) COMMENT '发起 Agent',
tool_key VARCHAR(128) COMMENT '被裁决工具',
params_json TEXT COMMENT '入参快照',
decision VARCHAR(16) COMMENT 'ALLOW / DENY',
denied_at_step VARCHAR(32) COMMENT 'DENY 命中步S1~S8 键名)',
reason_code VARCHAR(64) COMMENT '机器可读拒绝码',
approval_id VARCHAR(40) COMMENT '关联审批单',
latency_ms INT DEFAULT 0 COMMENT '裁决耗时',
created_at DATETIME COMMENT '创建时间',
PRIMARY KEY (id),
KEY ix_tool_call_trace (tenant_id, trace_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='M4a 服务端裁决留痕append-only';
-- 5) 人工审批单 ----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS pbl_approval (
id VARCHAR(40) NOT NULL COMMENT '主键',
tenant_id VARCHAR(40) NOT NULL COMMENT '租户ID',
approval_id VARCHAR(40) NOT NULL COMMENT '审批单号',
approval_type VARCHAR(32) NOT NULL COMMENT 'publish/compile_execute/blueprint_approve/tool_registry_change',
title VARCHAR(255) COMMENT '标题',
payload_json TEXT COMMENT '审批载荷',
status VARCHAR(16) DEFAULT 'pending' COMMENT 'pending/approved/rejected/expired',
requested_by VARCHAR(64) COMMENT '发起人',
agent_code VARCHAR(64) COMMENT '关联 Agent',
tool_key VARCHAR(128) COMMENT '关联工具',
decided_by VARCHAR(64) COMMENT '裁决人(必须真人,禁 agent',
decided_at DATETIME COMMENT '裁决时间',
decision_comment VARCHAR(512) COMMENT '裁决意见',
expires_at DATETIME COMMENT '过期时间',
created_at DATETIME COMMENT '创建时间',
updated_at DATETIME COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY ux_approval_tenant_id (tenant_id, approval_id),
KEY ix_approval_status (tenant_id, status, approval_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='M4a 四类强制人工审批(无绕过路径)';
-- 6) LLM 调用日志append-only------------------------------------------------
CREATE TABLE IF NOT EXISTS pbl_llm_call_log (
id VARCHAR(40) NOT NULL COMMENT '主键',
tenant_id VARCHAR(40) NOT NULL COMMENT '租户ID',
agent_code VARCHAR(64) COMMENT 'designer / critic',
run_id VARCHAR(40) COMMENT '运行ID',
purpose VARCHAR(32) COMMENT 'designer_run / critic_run',
model VARCHAR(64) COMMENT '模型名',
attempt_no INT DEFAULT 1 COMMENT '第几次尝试',
status VARCHAR(16) DEFAULT 'ok' COMMENT 'ok/timeout/error/rate_limited/fallback',
latency_ms INT DEFAULT 0 COMMENT '耗时',
timeout_ms INT DEFAULT 0 COMMENT '超时阈值',
retry_count INT DEFAULT 0 COMMENT '重试次数',
rate_limited TINYINT(1) DEFAULT 0 COMMENT '是否被限流',
fallback_used TINYINT(1) DEFAULT 0 COMMENT '是否走兜底',
prompt_chars INT DEFAULT 0 COMMENT '提示词长度',
prompt_hash CHAR(64) COMMENT '提示词 SHA256不落原文防泄漏',
response_chars INT DEFAULT 0 COMMENT '响应长度',
tokens_in INT DEFAULT 0 COMMENT '输入 token',
tokens_out INT DEFAULT 0 COMMENT '输出 token',
error_code VARCHAR(64) COMMENT '错误码',
error_msg VARCHAR(512) COMMENT '错误摘要',
created_at DATETIME COMMENT '创建时间',
PRIMARY KEY (id),
KEY ix_llm_log_tenant_agent (tenant_id, agent_code, created_at),
KEY ix_llm_log_status (tenant_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='M4a LLM 调用留痕(超时/重试/限流/兜底append-only';
-- =============================================================================
-- 种子数据(幂等;实际由 seed_registry() 写入,此处仅作 DBA 参考与灾备重建)
-- 13 启用工具
-- =============================================================================
-- blueprint.read / blueprint.write / blueprint.validate / blueprint.approve(审批)
-- compiler.compile / compiler.execute(审批) / evidence.collect / assessment.score
-- kdb.query / runtime.event / agent.trace_write / approval.create / tool_registry.read
--
-- 9 禁用工具risk_level=forbiddenstatus=disabled
-- pbl.publish / blueprint.publish_auto / compiler.autorun / kdb.write / tenant.switch
-- rbac.grant / approval.auto_decide / trace.delete / llm.raw_exec
-- =============================================================================

View File

@ -1,4 +1,5 @@
# pbl_agent_runtime/api/pbl_agent_critic_run.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py # Critic Agent 运行(零写权限)
debug('pbl_agent_runtime/api/pbl_agent_critic_run.dspy: START params_kw={dict(params_kw)}') debug(f'pbl_agent_critic_run.dspy: START params_kw={dict(params_kw)}')
data = await pbl_agent_critic_run(**params_kw) result = pbl_agent_critic_run(**dict(params_kw))
return data debug(f'pbl_agent_critic_run.dspy: END success={result.get("success") if isinstance(result, dict) else result}')
return result

View File

@ -1,4 +1,5 @@
# pbl_agent_runtime/api/pbl_agent_designer_run.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py # Designer Agent 运行(可写,写操作经服务端裁决)
debug('pbl_agent_runtime/api/pbl_agent_designer_run.dspy: START params_kw={dict(params_kw)}') debug(f'pbl_agent_designer_run.dspy: START params_kw={dict(params_kw)}')
data = await pbl_agent_designer_run(**params_kw) result = pbl_agent_designer_run(**dict(params_kw))
return data debug(f'pbl_agent_designer_run.dspy: END success={result.get("success") if isinstance(result, dict) else result}')
return result

View File

@ -1,4 +1,5 @@
# pbl_agent_runtime/api/pbl_agent_trace_list.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py # 查询 Agent 执行轨迹
debug('pbl_agent_runtime/api/pbl_agent_trace_list.dspy: START params_kw={dict(params_kw)}') debug(f'pbl_agent_trace_list.dspy: START params_kw={dict(params_kw)}')
data = await pbl_agent_trace_list(**params_kw) result = pbl_agent_trace_list(**dict(params_kw))
return data debug(f'pbl_agent_trace_list.dspy: END success={result.get("success") if isinstance(result, dict) else result}')
return result

View File

@ -1,4 +1,5 @@
# pbl_agent_runtime/api/pbl_agent_trace_write.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py # 写 Agent 执行轨迹7 要素append-only
debug('pbl_agent_runtime/api/pbl_agent_trace_write.dspy: START params_kw={dict(params_kw)}') debug(f'pbl_agent_trace_write.dspy: START params_kw={dict(params_kw)}')
data = await pbl_agent_trace_write(**params_kw) result = pbl_agent_trace_write(**dict(params_kw))
return data debug(f'pbl_agent_trace_write.dspy: END success={result.get("success") if isinstance(result, dict) else result}')
return result

View File

@ -1,4 +1,5 @@
# pbl_agent_runtime/api/pbl_approval_create.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py # 创建人工审批单14.2 四类)
debug('pbl_agent_runtime/api/pbl_approval_create.dspy: START params_kw={dict(params_kw)}') debug(f'pbl_approval_create.dspy: START params_kw={dict(params_kw)}')
data = await pbl_approval_create(**params_kw) result = pbl_approval_create(**dict(params_kw))
return data debug(f'pbl_approval_create.dspy: END success={result.get("success") if isinstance(result, dict) else result}')
return result

View File

@ -1,4 +1,5 @@
# pbl_agent_runtime/api/pbl_approval_decide.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py # 人工审批裁决(无 Agent 绕过路径)
debug('pbl_agent_runtime/api/pbl_approval_decide.dspy: START params_kw={dict(params_kw)}') debug(f'pbl_approval_decide.dspy: START params_kw={dict(params_kw)}')
data = await pbl_approval_decide(**params_kw) result = pbl_approval_decide(**dict(params_kw))
return data debug(f'pbl_approval_decide.dspy: END success={result.get("success") if isinstance(result, dict) else result}')
return result

View File

@ -1,4 +1,5 @@
# pbl_agent_runtime/api/pbl_approval_list.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py # 审批单列表查询
debug('pbl_agent_runtime/api/pbl_approval_list.dspy: START params_kw={dict(params_kw)}') debug(f'pbl_approval_list.dspy: START params_kw={dict(params_kw)}')
data = await pbl_approval_list(**params_kw) result = pbl_approval_list(**dict(params_kw))
return data debug(f'pbl_approval_list.dspy: END success={result.get("success") if isinstance(result, dict) else result}')
return result

View File

@ -1,4 +1,5 @@
# pbl_agent_runtime/api/pbl_tool_adjudicate.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py # fail-closed 8 步工具裁决(默认 DENY
debug('pbl_agent_runtime/api/pbl_tool_adjudicate.dspy: START params_kw={dict(params_kw)}') debug(f'pbl_tool_adjudicate.dspy: START params_kw={dict(params_kw)}')
data = await pbl_tool_adjudicate(**params_kw) result = pbl_tool_adjudicate(**dict(params_kw))
return data debug(f'pbl_tool_adjudicate.dspy: END success={result.get("success") if isinstance(result, dict) else result}')
return result

View File

@ -0,0 +1,5 @@
# 工具注册表查询13 启用 / 9 禁用)
debug(f'pbl_tool_registry_list.dspy: START params_kw={dict(params_kw)}')
result = pbl_tool_registry_list(**dict(params_kw))
debug(f'pbl_tool_registry_list.dspy: END success={result.get("success") if isinstance(result, dict) else result}')
return result

View File

@ -0,0 +1,5 @@
# 工具注册表变更(四类强制审批之一)
debug(f'pbl_tool_registry_save.dspy: START params_kw={dict(params_kw)}')
result = pbl_tool_registry_save(**dict(params_kw))
debug(f'pbl_tool_registry_save.dspy: END success={result.get("success") if isinstance(result, dict) else result}')
return result