From a34c1919c6e84e313305ac06b71de6fce3c3da95 Mon Sep 17 00:00:00 2001 From: "agent.develop" Date: Fri, 18 Sep 2026 14:59:02 +0800 Subject: [PATCH] =?UTF-8?q?deliver:=20=E4=BA=A4=E4=BB=98=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=EF=BC=88=E5=BC=95=E6=93=8E=E4=BB=A3=E4=B8=BA=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pbl_agent_runtime/__init__.py | 195 ++-- pbl_agent_runtime/init.py | 670 +++++-------- pbl_agent_runtime/m4a_api.py | 101 ++ pbl_agent_runtime/m4a_contract.py | 1176 +++++++++++++++++++++++ pbl_agent_runtime/m4a_store.py | 544 +++++++++++ scripts/check_contract_sync.py | 218 +++++ scripts/gen_dspy.py | 52 + scripts/load_path.py | 89 +- scripts/m4a_contract_selftest.py | 591 ++++++++++++ scripts/m4a_manifest.json | 229 +++++ sql/m4a_contract_addon.sql | 167 ++++ wwwroot/api/pbl_agent_critic_run.dspy | 9 +- wwwroot/api/pbl_agent_designer_run.dspy | 9 +- wwwroot/api/pbl_agent_trace_list.dspy | 9 +- wwwroot/api/pbl_agent_trace_write.dspy | 9 +- wwwroot/api/pbl_approval_create.dspy | 9 +- wwwroot/api/pbl_approval_decide.dspy | 9 +- wwwroot/api/pbl_approval_list.dspy | 9 +- wwwroot/api/pbl_tool_adjudicate.dspy | 9 +- wwwroot/api/pbl_tool_registry_list.dspy | 5 + wwwroot/api/pbl_tool_registry_save.dspy | 5 + 21 files changed, 3539 insertions(+), 575 deletions(-) create mode 100644 pbl_agent_runtime/m4a_api.py create mode 100644 pbl_agent_runtime/m4a_contract.py create mode 100644 pbl_agent_runtime/m4a_store.py create mode 100644 scripts/check_contract_sync.py create mode 100644 scripts/gen_dspy.py create mode 100644 scripts/m4a_contract_selftest.py create mode 100644 scripts/m4a_manifest.json create mode 100644 sql/m4a_contract_addon.sql create mode 100644 wwwroot/api/pbl_tool_registry_list.dspy create mode 100644 wwwroot/api/pbl_tool_registry_save.dspy diff --git a/pbl_agent_runtime/__init__.py b/pbl_agent_runtime/__init__.py index 629546d..3e73d10 100644 --- a/pbl_agent_runtime/__init__.py +++ b/pbl_agent_runtime/__init__.py @@ -1,102 +1,135 @@ # -*- coding: utf-8 -*- """pbl_agent_runtime —— Designer/Critic Agent 运行时与 fail-closed 工具裁决(M4a)。 -M4a 交付范围: - * Agent 定义:designer(可写)/ critic(**零写权限**) - * 工具注册表:13 启用 + 9 禁用(含 blueprint.publish_auto 等自动发布类禁用) - * fail-closed 裁决:S1~S8 八步顺序,任一步不过即 DENY,默认裁决 = DENY - * 四类强制人工审批:publish / compile_execute / blueprint_approve / tool_registry_change - * 5 张表:pbl_agent_def / pbl_agent_tool / pbl_agent_trace / - pbl_agent_trace_stage / pbl_approval +M4a 交付范围 +------------ +* Agent 定义:``designer``(可写)/ ``critic``(**零写权限**,write_scope='none') +* 工具注册表:**13 启用 + 9 禁用**(含 ``pbl.publish`` 自动发布类禁用) +* fail-closed 裁决:S1~S8 八步固定顺序,任一步不过即 DENY,**默认裁决 = DENY** +* 四类强制人工审批:publish / compile_execute / blueprint_approve / tool_registry_change +* 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 注册): - 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 load_m4a # 租户级门面 M4aApi +包出口(三处同步:实现 + 本文件导出 + init.py 注册) +---------------------------------------------------- + from pbl_agent_runtime import api # 契约对象(集成冒烟测试用法) + 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 - load_pbl_agent_runtime_m4a, - bootstrap, - get_dbname, - ddl, - API_ROUTES, - MODULE_NAME, +from .m4a_store import ( # noqa: F401 + APPEND_ONLY_TABLES as STORE_APPEND_ONLY_TABLES, + PblContractError, + Result, + Store, + get_store, + set_store, + module_dbname, + resolve_tenant, ) -from .m4a import M4aApi, load_m4a, make_agent_ctx, make_user_ctx, make_admin_ctx # noqa: F401 -from .init import load_pbl_agent_runtime, self_check # noqa: F401 -from .m4a_registry import ( # noqa: F401 - 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, +from .m4a_contract import ( # noqa: F401 + ADJUDICATION_STEPS, + AGENT_DEFS, + CONTRACT_FUNCTIONS, + DISABLED_TOOLS, + ENABLED_TOOLS, + MANDATORY_APPROVAL_TYPES, + TRACE_ELEMENTS, + build_api, + log_llm_call, + seed_registry, + self_check, ) -from .m4a_tables import ( # noqa: F401 - ensure_tables, - TABLES, - APPEND_ONLY_TABLES, - all_sql, -) -from .m4a_kernel import ADJUDICATION_STEPS, TRACE_ELEMENTS, PblError # noqa: F401 -from .m4a_approval import MANDATORY_APPROVAL_TYPES # noqa: F401 +from .m4a_api import ContractApi, api, build_contract_api # noqa: F401 +from .init import load_pbl_agent_runtime # noqa: F401 + +# 历史 M4a 装配层(保留向后兼容:老代码 import 这些名字仍可用) +try: # pragma: no cover —— 兼容分支 + from .m4a_init import ( # noqa: F401 + load_pbl_agent_runtime_m4a, + 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__ = [ - # 挂载入口 + # 契约对象(对外唯一契约入口) + "api", + "ContractApi", + "build_contract_api", + "build_api", + "api_factory", + "CONTRACT_FUNCTIONS", + # 挂载 / 自检 "load_pbl_agent_runtime", "load_pbl_agent_runtime_m4a", - "load_m4a", - "bootstrap", "self_check", + "seed_registry", + "bootstrap", "get_dbname", + "module_dbname", + "resolve_tenant", "ddl", - # 门面 / 上下文 - "M4aApi", - "make_agent_ctx", - "make_user_ctx", - "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", + "API_ROUTES", + "MODULE_NAME", + # 运行时留痕 + "log_llm_call", + # 常量 "ADJUDICATION_STEPS", "TRACE_ELEMENTS", "MANDATORY_APPROVAL_TYPES", - "PblError", - "API_ROUTES", - "MODULE_NAME", - # 子模块(供高级用法直接 import) + "ENABLED_TOOLS", + "DISABLED_TOOLS", + "AGENT_DEFS", + "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", - "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" diff --git a/pbl_agent_runtime/init.py b/pbl_agent_runtime/init.py index c0787ac..a0a76b7 100644 --- a/pbl_agent_runtime/init.py +++ b/pbl_agent_runtime/init.py @@ -1,475 +1,297 @@ # -*- coding: utf-8 -*- -"""pbl_agent_runtime 挂载入口(M4a 唯一实现)。 +"""pbl_agent_runtime.init —— 模块挂载入口(ServerEnv 注册 + 挂载自检)。 -历史说明(QC 退回 #7 处置): - 旧「九关裁决 + 4 表」实现(tables.py / tool_registry.py / verdict.py / api.py) - 已**删除**,本文件不再引用;M4a(m4a_*.py,8 步裁决 + 5 表)是本模块唯一实现, - `load_pbl_agent_runtime()` 与 `load_pbl_agent_runtime_m4a()` 均委托 M4a 装配。 +三处同步注册(module-development-spec 铁律) +------------------------------------------ +① 实现:``m4a_contract.py``(10 个契约函数 + seed/self_check/log_llm_call) +② 导出:``__init__.py``(``from .init import load_pbl_agent_runtime``、``api``) +③ 注册:本文件 ``load_pbl_agent_runtime()`` 里 ``env. = `` +④ 路径:``scripts/load_path.py`` PATHS(10 个 .dspy 一一对应) -load_pbl_agent_runtime(env=None, tenant_id=None): - 1) ensure_tables —— 建 5 表(幂等;pbl_agent_trace / pbl_agent_trace_stage 为 append-only) - 2) seed_agents(designer / critic,critic write_allowed=0)+ seed_tools(13 启用 / 9 禁用) - 3) **挂载即 self_check()**:数量契约、Critic 零写、8 步裁决链、四类审批、默认裁决 DENY - 任一不过 → 抛 RuntimeError,宿主应用启动失败(fail-closed,绝不带病上线) - 4) 向 ServerEnv 注册 wwwroot/api/*.dspy 所需的 8 个处理函数 + 只读契约 - 5) 返回 default_verdict(= 'DENY') - -后端(sqlor/ahserver)未挂载时自动降级 MemoryStore:建表/seed/裁决/审批全链路可用, -数据仅存于进程内(重启即失),不抛错——便于自测与离线演示(US-05 兜底同源策略)。 +挂载语义(fail-closed) +---------------------- +* ``load_pbl_agent_runtime()`` 挂载后立即跑 ``self_check(strict=True)``,不过抛 RuntimeError, + 让宿主应用启动失败——绝不带病上线。 +* 建表 + seed(2 Agent / 13 启用 / 9 禁用工具)幂等,可重复挂载。 +* 库名一律 ``ServerEnv().get_module_dbname('pbl_agent_runtime')``,禁止硬编码 DBNAME。 """ -from .m4a_kernel import ( +from __future__ import annotations + +import os + +from .m4a_api import api as _contract_api +from .m4a_contract import ( ADJUDICATION_STEPS, + CONTRACT_FUNCTIONS, + MANDATORY_APPROVAL_TYPES, TRACE_ELEMENTS, - PblError, - TenantContext, - ACTOR_SYSTEM, + build_api, + log_llm_call, + seed_registry, + self_check as _self_check, +) +from .m4a_store import ( + APPEND_ONLY_TABLES, + PblContractError, + Result, + 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:任何异常/未知一律拒绝 -EXPECTED_ENABLED = 13 -EXPECTED_DISABLED = 9 -EXPECTED_STEPS = 8 -EXPECTED_APPROVAL_TYPES = 4 -EXPECTED_TABLES = 5 -POLICY_VERSION = "m4a-1.0.0" -AGENTS = ("designer", "critic") +#: 契约端点路由(与 wwwroot/api/ 实际文件、scripts/load_path.py 一一对应) +API_ROUTES = tuple("api/%s.dspy" % name for name in CONTRACT_FUNCTIONS) + +#: 挂载时注册到 ServerEnv 的附加(非契约端点)能力 +EXTRA_FUNCTIONS = ("seed_registry", "log_llm_call", "self_check", "get_store", "module_dbname") + +_LOADED = {"done": False, "env": None, "report": None} # --------------------------------------------------------------------------- -# 自检(挂载即执行,不过即抛) +# 对外契约对象 / 工厂(兼容两种历史写法) # --------------------------------------------------------------------------- -def self_check(tenant_id=None, store=None, run_probes=True): - """M4a 契约自检。返回 (all_ok, msgs)。 - 校验项: - C1 工具数量契约 22 = 13 启用 + 9 禁用 - C2 自动发布类工具必须禁用(blueprint.publish_auto / marketplace.create_listing) - C3 Critic 零写权限(write_allowed=0 且无任何写工具 allowed_agents 含 critic) - C4 裁决链恰为 8 步且顺序为 S1..S8 - C5 四类强制人工审批齐备 - C6 5 张表齐备且 append-only 表带标记 - C7 默认裁决 = DENY - C8 探针:禁用工具/未注册工具/Critic 写 三类调用必须被拒(run_probes=True 时) + +class _ApiAccessor: + """``api`` 双形态适配器:``api.fn(...)`` 与 ``api().fn(...)`` 同时成立。 + + 历史 init.py 里 ``api()`` 返回字典(工厂写法),项目冒烟测试用 ``api.fn(...)``(对象写法)。 + 本类把两种写法收敛到同一个契约对象,杜绝再次出现「注册三处同步断裂」。 """ - 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) - enabled = [t for t in tools if t.get("status") == "enabled"] - disabled = [t for t in tools if t.get("status") == "disabled"] + __slots__ = ("_obj",) - # C1 - if len(tools) != EXPECTED_ENABLED + EXPECTED_DISABLED: - 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))) + def __init__(self, obj=None): + object.__setattr__(self, "_obj", obj if obj is not None else _contract_api) - # C2 自动发布/自动改课必须禁用 - must_disabled = ("blueprint.publish_auto", "curriculum.modify_auto", - "marketplace.create_listing", "kdb.write", "billing.charge") - 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)) + # 工厂写法:api() -> 契约对象 + def __call__(self, *args, **kwargs): + return object.__getattribute__(self, "_obj") - # C3 Critic 零写 - agents = list_agents(ctx=admin, store=st) - by_code = {a.get("agent_code"): a for a in agents} - 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 必须为 0(14.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") + # 对象写法:api.pbl_tool_adjudicate(...) + def __getattr__(self, item): + return getattr(object.__getattribute__(self, "_obj"), item) - # C4 8 步裁决链 - steps = [s[0] for s in ADJUDICATION_STEPS] - if len(steps) != EXPECTED_STEPS or steps != ["S%d" % i for i in range(1, 9)]: - all_ok = False - msgs.append("FAIL C4 裁决链=%s 应为 S1..S8(8 步顺序固定)" % steps) - else: - msgs.append("C4 fail-closed 裁决链 8 步:%s" - % " → ".join("%s %s" % (s[0], s[1]) for s in ADJUDICATION_STEPS)) + # 下标写法:api['pbl_tool_adjudicate'](...) + def __getitem__(self, item): + return object.__getattribute__(self, "_obj")[item] - # C5 四类审批 - if len(MANDATORY_APPROVAL_TYPES) != EXPECTED_APPROVAL_TYPES: - 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))) + def __contains__(self, item): + return item in object.__getattribute__(self, "_obj") - # C6 5 表 + append-only - tbl_names = [t.get("tblname") for t in TABLES] - 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 张:%s(append-only:%s)" - % (len(tbl_names), ", ".join(tbl_names), ", ".join(APPEND_ONLY_TABLES))) + def __iter__(self): + return iter(object.__getattribute__(self, "_obj")) - # C7 默认裁决 - if DEFAULT_VERDICT != "DENY": - all_ok = False - msgs.append("FAIL C7 DEFAULT_VERDICT=%r 必须为 'DENY'" % DEFAULT_VERDICT) - else: - msgs.append("C7 默认裁决 = DENY(fail-closed)") + def keys(self): + return object.__getattribute__(self, "_obj").keys() - # C8 探针 - if run_probes: - 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)) + def items(self): + return object.__getattribute__(self, "_obj").items() - msgs.append("轨迹 7 要素:%s" % ", ".join(TRACE_ELEMENTS)) - if all_ok: - msgs.append("SELF_CHECK %s: PASS(%d 工具 / %d 表 / %d 步 / %d 类审批)" - % (MODULE_NAME, len(tools), len(tbl_names), - len(ADJUDICATION_STEPS), len(MANDATORY_APPROVAL_TYPES))) - return all_ok, msgs + def get(self, key, default=None): + return object.__getattribute__(self, "_obj").get(key, default) + + def names(self): + return list(CONTRACT_FUNCTIONS) + + def describe(self): + return object.__getattribute__(self, "_obj").describe() + + def __repr__(self): + return "" % len(CONTRACT_FUNCTIONS) -def _wrap(fn): - """dspy 处理函数统一包装:PblError → 结构化拒绝响应(fail-closed,不抛 500)。 +#: 模块级契约入口(init.api 与包级 pbl_agent_runtime.api 指向同一契约集合) +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 且发现问题 → 抛 RuntimeError(fail-closed,禁止带病挂载)。 """ - @functools.wraps(fn) - 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 + return _self_check(tenant_id=tenant_id, strict=strict) # --------------------------------------------------------------------------- -# 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): - return load_m4a(tenant_id=_tid(params_kw)) +def _resolve_env(): + """取 ServerEnv 实例;平台不可用时返回 None(离线自测走本地注册表)。""" + try: + from ahserver import ServerEnv # noqa: WPS433 + + return ServerEnv() + except Exception: # noqa: BLE001 + return None -@_wrap -async def pbl_agent_designer_run(**params_kw): - """US-01/03/04:Designer 生成 / 修改 / 澄清(action 分派,缺省 generate)。""" - api = _api(params_kw) - action = params_kw.get("action", "generate") - if action == "modify": - return api.designer_modify(params_kw.get("blueprint_id"), - params_kw.get("instruction"), - session_no=params_kw.get("session_no"), - version_no=params_kw.get("version_no")) - if action == "clarify": - return api.designer_clarify(params_kw.get("blueprint_id"), - params_kw.get("missing_fields") or [], - session_no=params_kw.get("session_no"), - round_no=params_kw.get("round_no")) - return api.designer_generate(params_kw.get("intent_text") or "", - owner_teacher_id=params_kw.get("owner_teacher_id"), - class_id=params_kw.get("class_id"), - session_no=params_kw.get("session_no")) +def _register(env, api_obj) -> list: + """把契约函数 + 附加能力注册到 ServerEnv,返回注册名清单。""" + registered = [] + for name in CONTRACT_FUNCTIONS: + fn = api_obj.get(name) + if fn is None: + raise RuntimeError("contract function missing in api object: %s" % name) + setattr(env, name, fn) + registered.append(name) + for name in EXTRA_FUNCTIONS: + target = { + "seed_registry": seed_registry, + "log_llm_call": log_llm_call, + "self_check": self_check, + "get_store": get_store, + "module_dbname": module_dbname, + }.get(name) + if target is not None: + 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 -async def pbl_agent_critic_run(**params_kw): - """US-18:Critic 只读评审(零写权限,写操作一律被 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")) +def load_pbl_agent_runtime(env=None, tenant_id=None, strict=True, reset_store=False): + """模块唯一集成点:建库表 → seed → 注册 ServerEnv → 自检。 - -@_wrap -async def pbl_tool_adjudicate(**params_kw): - """fail-closed 8 步裁决预检(只裁不执行)。tool_code 为空 → 返回裁决链说明。""" - api = _api(params_kw) - 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 非法:%r(append-only 仅支持 start / append)" % action) - - -@_wrap -async def pbl_approval_create(**params_kw): - """T10:Agent 发起人工审批提案(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')。自检不过抛 RuntimeError(fail-closed)。 + :param env: ServerEnv 实例(缺省自动获取;离线环境为 None 时用本地替身) + :param tenant_id: seed 的目标租户(缺省按 resolve_tenant 解析) + :param strict: 自检不过是否抛错(默认 True,fail-closed) + :param reset_store: 测试用,挂载前清空离线存储 + :return: 挂载报告 dict """ - srv = env - if srv is None: - try: - from ahserver.serverenv import ServerEnv - srv = ServerEnv() - except Exception: # noqa: BLE001 —— 无宿主环境时降级 MemoryStore - srv = None + store = get_store() + if reset_store: + store.reset() + store.ensure_schema() - st = get_store(force_memory=force_memory or srv is None) - ensure_tables(st) - 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)} + tenant = resolve_tenant(tenant_id) + seed_report = seed_registry(tenant) - if run_self_check: - ok, msgs = self_check(tenant_id=tid, store=st) - for m in msgs: - _log(srv, m) - if not ok: - raise RuntimeError("%s self_check FAILED(fail-closed,拒绝启动):%s" - % (MODULE_NAME, - "; ".join([m for m in msgs if m.startswith("FAIL")][:8]))) + env_obj = env if env is not None else _resolve_env() + offline = env_obj is None + if offline: + env_obj = _OfflineEnv() - if srv is not None: - setattr(srv, "pbl_agent_tools", list_tools(ctx=make_admin_ctx(tid), store=st)) - 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) + api_obj = build_api() + registered = _register(env_obj, api_obj) - 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): - logger = getattr(srv, "logger", None) if srv is not None else None - if logger is not None and hasattr(logger, "info"): - try: - logger.info("[%s] %s" % (MODULE_NAME, msg)) - return - except Exception: # noqa: BLE001 - pass - print("[%s] %s" % (MODULE_NAME, msg)) +def load_pbl_agent_runtime_m4a(env=None, tenant_id=None, strict=True): + """M4a 装配别名(历史入口,语义与 load_pbl_agent_runtime 一致)。""" + return load_pbl_agent_runtime(env=env, tenant_id=tenant_id, strict=strict) -if __name__ == "__main__": - _ok, _msgs = self_check(run_probes=True) - for _m in _msgs: - print(_m) - print("RESULT: %s" % ("PASS" if _ok else "FAIL")) - raise SystemExit(0 if _ok else 1) +def bootstrap(tenant_id=None): + """离线引导:只建表 + seed,不碰 ServerEnv(脚本/测试用)。""" + get_store().ensure_schema() + return Result(seed_registry(resolve_tenant(tenant_id))) + + +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", +] diff --git a/pbl_agent_runtime/m4a_api.py b/pbl_agent_runtime/m4a_api.py new file mode 100644 index 0000000..07b37a3 --- /dev/null +++ b/pbl_agent_runtime/m4a_api.py @@ -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/*.dspy、scripts/load_path.py、skill/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", +] diff --git a/pbl_agent_runtime/m4a_contract.py b/pbl_agent_runtime/m4a_contract.py new file mode 100644 index 0000000..413729b --- /dev/null +++ b/pbl_agent_runtime/m4a_contract.py @@ -0,0 +1,1176 @@ +# -*- coding: utf-8 -*- +"""pbl_agent_runtime.m4a_contract —— 10 个契约接口的**唯一实现**。 + +契约清单(与 skill/SKILL.md、wwwroot/api/*.dspy、scripts/load_path.py 四处同步): + 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 + +三条铁律 +-------- +* **fail-closed**:默认裁决 = DENY;任一步不过即 DENY;存储/参数异常一律 DENY,不放行。 +* **Critic 零写**:critic 的 write_scope='none'、can_write=0,任何 write_class=1 工具在 S3 直接 DENY。 +* **四类强制人工审批**:publish / compile_execute / blueprint_approve / tool_registry_change, + 无 approved 审批单即 DENY,且自动补建 pending 审批(无绕过路径)。 + +所有函数**同步返回 Result(dict)**,同时 ``await`` 亦可用(Result.__await__ 返回自身), +因此 .dspy(async 上下文)与 offline_contract_smoke.py(同步调用)共用同一实现。 +""" + +from __future__ import annotations + +import time + +from .m4a_store import ( + APPEND_ONLY_TABLES, + ERR_APPEND_ONLY, + ERR_NOT_FOUND, + ERR_STORE, + ERR_TENANT_MISSING, + PblContractError, + Result, + dumps, + fail, + get_store, + loads, + new_id, + now_ms, + now_str, + ok, + resolve_tenant, + sha256_text, +) + +# --------------------------------------------------------------------------- +# 契约常量 +# --------------------------------------------------------------------------- + +CONTRACT_FUNCTIONS = ( + "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", +) + +# 14.2 四类强制人工审批(无绕过路径) +MANDATORY_APPROVAL_TYPES = ( + "publish", + "compile_execute", + "blueprint_approve", + "tool_registry_change", +) + +# fail-closed 8 步裁决顺序(顺序不可调换:先身份、后授权、再参数、最后审批) +ADJUDICATION_STEPS = ( + ("S1", "tenant_context", "租户上下文必须存在"), + ("S2", "agent_registered", "Agent 必须已注册且 active"), + ("S3", "agent_write_scope", "Critic 零写:写类工具对 critic 一律 DENY"), + ("S4", "tool_registered", "工具必须在注册表中登记"), + ("S5", "tool_enabled", "工具必须处于 enabled(pbl.publish 等禁用工具 DENY)"), + ("S6", "permission_granted", "Agent 必须持有工具 required_perm"), + ("S7", "params_valid", "入参必须通过 params_schema 校验"), + ("S8", "approval_granted", "四类强制审批必须有 approved 审批单"), +) + +TRACE_ELEMENTS = ("who", "occurred_at", "what", "why", "how", "result", "evidence_ref") + +# 13 启用工具(write_class=1 为写类;audit_append=1 表示只追加留痕) +ENABLED_TOOLS = ( + ("blueprint.read", "蓝图读取", "blueprint", 0, "pbl.blueprint.read", None, "low"), + ("blueprint.write", "蓝图写入", "blueprint", 1, "pbl.blueprint.write", None, "high"), + ("blueprint.validate", "蓝图校验", "blueprint", 0, "pbl.blueprint.read", None, "low"), + ("blueprint.approve", "蓝图审批", "blueprint", 1, "pbl.blueprint.approve", "blueprint_approve", "high"), + ("compiler.compile", "确定性编译", "compiler", 0, "pbl.compiler.run", None, "medium"), + ("compiler.execute", "编译产物执行", "compiler", 1, "pbl.compiler.execute", "compile_execute", "high"), + ("evidence.collect", "证据采集", "evidence", 1, "pbl.evidence.write", None, "medium"), + ("assessment.score", "Rubric 评分", "assessment", 1, "pbl.assessment.write", None, "medium"), + ("kdb.query", "KDB 只读查询", "kdb", 0, "pbl.kdb.read", None, "low"), + ("runtime.event", "运行时事件写入", "runtime", 1, "pbl.runtime.write", None, "medium"), + ("agent.trace_write", "轨迹留痕", "agent", 1, "pbl.agent.trace", None, "low"), + ("approval.create", "发起人工审批", "approval", 1, "pbl.approval.create", None, "medium"), + ("tool_registry.read", "工具注册表查询", "registry", 0, "pbl.registry.read", None, "low"), +) + +# 9 禁用工具(含 pbl.publish 自动发布类,一律 status=disabled) +DISABLED_TOOLS = ( + ("pbl.publish", "自动发布上线", "publish", 1, "pbl.publish", "publish", "forbidden", "14.2 发布必须人工审批,禁止 Agent 自动发布"), + ("blueprint.publish_auto", "蓝图自动发布", "publish", 1, "pbl.publish", "publish", "forbidden", "自动发布类工具,fail-closed 默认禁用"), + ("compiler.autorun", "编译自动执行", "compiler", 1, "pbl.compiler.execute", "compile_execute", "forbidden", "执行必须人工审批"), + ("kdb.write", "KDB 写入", "kdb", 1, "pbl.kdb.write", None, "forbidden", "M7 KDB 只读桩,零写入"), + ("tenant.switch", "跨租户切换", "platform", 1, "pbl.platform.admin", None, "forbidden", "跨租户越权,禁止 Agent 调用"), + ("rbac.grant", "权限授予", "platform", 1, "pbl.platform.admin", None, "forbidden", "权限变更必须人工,禁止 Agent 自授权"), + ("approval.auto_decide", "审批自动通过", "approval", 1, "pbl.approval.decide", "blueprint_approve", "forbidden", "审批无绕过路径,禁止自动通过"), + ("trace.delete", "轨迹删除", "agent", 1, "pbl.platform.admin", None, "forbidden", "留痕 append-only,禁止删除"), + ("llm.raw_exec", "模型原始指令执行", "agent", 1, "pbl.platform.admin", None, "forbidden", "禁止把模型输出当指令直接执行"), +) + +# Agent 定义:designer 可写、critic 零写 +AGENT_DEFS = ( + { + "agent_code": "designer", + "agent_name": "Designer 设计者 Agent", + "role": "designer", + "write_scope": "blueprint,evidence,runtime,assessment", + "can_write": 1, + # 注意:持有权限 != 可自动执行。blueprint.approve / compiler.execute 虽在 perms 内, + # 仍必须在 S8 命中四类强制人工审批(blueprint_approve / compile_execute)才 ALLOW。 + "perms": [ + "pbl.blueprint.read", "pbl.blueprint.write", "pbl.blueprint.approve", + "pbl.compiler.run", "pbl.compiler.execute", + "pbl.evidence.write", "pbl.assessment.write", "pbl.runtime.write", + "pbl.kdb.read", "pbl.agent.trace", "pbl.approval.create", "pbl.registry.read", + ], + "model": "platform-default", + "system_prompt": "你是 PBL 蓝图 Designer:只做确定性设计产出,所有写操作必须经服务端裁决;" + "发布/执行/审批/注册表变更四类动作必须发起人工审批,禁止自行绕过。", + }, + { + "agent_code": "critic", + "agent_name": "Critic 评审 Agent", + "role": "critic", + "write_scope": "none", + "can_write": 0, + "perms": ["pbl.blueprint.read", "pbl.kdb.read", "pbl.agent.trace", "pbl.registry.read"], + "model": "platform-default", + "system_prompt": "你是 PBL Critic:只读评审,零写权限。任何写类工具调用都会被服务端在 S3 拒绝;" + "你只输出评审意见与证据引用,不得修改蓝图、不得发布、不得审批。", + }, +) + +DEFAULT_TIMEOUT_MS = 30000 +DEFAULT_MAX_RETRY = 2 + + +# --------------------------------------------------------------------------- +# 内部工具 +# --------------------------------------------------------------------------- + + +def _tenant_or_raise(tenant_id): + tenant = resolve_tenant(tenant_id) + if not tenant: + raise PblContractError(ERR_TENANT_MISSING, "tenant_id missing (fail-closed)") + return tenant + + +def _pick(kwargs: dict, names, default=None): + for name in names: + if name in kwargs and kwargs[name] is not None: + return kwargs[name] + return default + + +def _as_bool(value, default=False) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + text = str(value).strip().lower() + if text in ("1", "true", "yes", "y", "on", "enabled", "enable"): + return True + if text in ("0", "false", "no", "n", "off", "disabled", "disable"): + return False + return default + + +def _as_int(value, default=0) -> int: + try: + return int(value) + except Exception: + return default + + +def _guard(kwargs: dict): + """契约统一异常包装:把 PblContractError 转成 fail Result,其它异常转 ERR_STORE。""" + try: + return kwargs["fn"](**kwargs["kw"]) + except PblContractError as exc: + return fail(exc.code, exc.message, **exc.extra) + except Exception as exc: # noqa: BLE001 —— fail-closed:未知异常也拒绝 + return fail(ERR_STORE, "contract error: %s: %s" % (type(exc).__name__, exc)) + + +def _run(fn, **kw): + return _guard({"fn": fn, "kw": kw}) + + +# --------------------------------------------------------------------------- +# LLM 调用留痕(pbl_llm_call_log):超时 / 重试 / 限流 / 兜底 +# --------------------------------------------------------------------------- + + +def log_llm_call( + tenant_id=None, + agent_code=None, + run_id=None, + purpose=None, + model=None, + attempt_no=1, + status="ok", + latency_ms=0, + timeout_ms=DEFAULT_TIMEOUT_MS, + retry_count=0, + rate_limited=False, + fallback_used=False, + prompt=None, + response=None, + tokens_in=0, + tokens_out=0, + error_code=None, + error_msg=None, +) -> dict: + """写一条 LLM 调用留痕(append-only)。Designer/Critic 运行时每次模型调用都必须落一条。""" + tenant = _tenant_or_raise(tenant_id) + prompt_text = prompt if isinstance(prompt, str) else dumps(prompt) + response_text = response if isinstance(response, str) else dumps(response) + row = get_store().insert( + "pbl_llm_call_log", + { + "tenant_id": tenant, + "agent_code": agent_code or "unknown", + "run_id": run_id, + "purpose": purpose or "chat", + "model": model or "platform-default", + "attempt_no": _as_int(attempt_no, 1), + "status": status or "ok", + "latency_ms": _as_int(latency_ms, 0), + "timeout_ms": _as_int(timeout_ms, DEFAULT_TIMEOUT_MS), + "retry_count": _as_int(retry_count, 0), + "rate_limited": 1 if _as_bool(rate_limited) else 0, + "fallback_used": 1 if _as_bool(fallback_used) else 0, + "prompt_chars": len(prompt_text or ""), + "prompt_hash": sha256_text(prompt_text or ""), + "response_chars": len(response_text or ""), + "tokens_in": _as_int(tokens_in, 0), + "tokens_out": _as_int(tokens_out, 0), + "error_code": error_code, + "error_msg": (error_msg or "")[:500] or None, + "created_at": now_str(), + }, + ) + return row + + +def _invoke_llm(tenant, agent_code, run_id, purpose, prompt, model=None, + timeout_ms=DEFAULT_TIMEOUT_MS, max_retry=DEFAULT_MAX_RETRY): + """离线确定性“模型调用”:不联网、不编造,返回可复现的确定性产出,并全程留痕。 + + 真实部署时把本函数体替换为平台模型调用即可——留痕/超时/重试/限流/兜底语义保持不变。 + """ + started = now_ms() + attempt = 0 + last_error = None + while attempt <= max_retry: + attempt += 1 + try: + text = _deterministic_completion(agent_code, purpose, prompt) + latency = now_ms() - started + log_llm_call( + tenant_id=tenant, agent_code=agent_code, run_id=run_id, purpose=purpose, + model=model or "platform-default", attempt_no=attempt, status="ok", + latency_ms=latency, timeout_ms=timeout_ms, retry_count=attempt - 1, + rate_limited=False, fallback_used=False, prompt=prompt, response=text, + tokens_in=max(1, len(str(prompt or "")) // 4), + tokens_out=max(1, len(text) // 4), + ) + return {"ok": True, "text": text, "attempt_no": attempt, "latency_ms": latency, + "fallback_used": False, "retry_count": attempt - 1} + except Exception as exc: # noqa: BLE001 + last_error = exc + rate_limited = "429" in str(exc) or "rate" in str(exc).lower() + log_llm_call( + tenant_id=tenant, agent_code=agent_code, run_id=run_id, purpose=purpose, + model=model or "platform-default", attempt_no=attempt, + status="rate_limited" if rate_limited else "error", + latency_ms=now_ms() - started, timeout_ms=timeout_ms, + retry_count=attempt - 1, rate_limited=rate_limited, fallback_used=False, + prompt=prompt, error_code="PBL.LLM.CALL_FAILED", + error_msg="%s: %s" % (type(exc).__name__, exc), + ) + # 兜底:全部重试失败 → 留痕 fallback_used=1,返回确定性兜底产出(绝不静默成功) + fallback_text = _fallback_completion(agent_code, purpose) + log_llm_call( + tenant_id=tenant, agent_code=agent_code, run_id=run_id, purpose=purpose, + model=model or "platform-default", attempt_no=attempt, status="fallback", + latency_ms=now_ms() - started, timeout_ms=timeout_ms, retry_count=max_retry, + rate_limited=False, fallback_used=True, prompt=prompt, response=fallback_text, + error_code="PBL.LLM.FALLBACK", error_msg=str(last_error)[:500], + ) + return {"ok": False, "text": fallback_text, "attempt_no": attempt, + "latency_ms": now_ms() - started, "fallback_used": True, "retry_count": max_retry, + "error_msg": str(last_error)[:500]} + + +def _deterministic_completion(agent_code, purpose, prompt) -> str: + digest = sha256_text("%s|%s|%s" % (agent_code, purpose, prompt))[:12] + if agent_code == "critic": + return ("CRITIC_REVIEW digest=%s verdict=needs_evidence " + "findings=[\"写操作零权限已核验\",\"证据引用需补全\"] " + "note=critic 只读,不产出任何写入指令" % digest) + return ("DESIGNER_OUTPUT digest=%s purpose=%s " + "steps=[\"读取蓝图\",\"确定性生成子对象\",\"提交校验\"] " + "guard=所有写操作经服务端 8 步裁决" % (digest, purpose)) + + +def _fallback_completion(agent_code, purpose) -> str: + return "FALLBACK agent=%s purpose=%s reason=llm_unavailable action=hold_and_report" % ( + agent_code, purpose) + + +# --------------------------------------------------------------------------- +# 注册表:seed / list / save +# --------------------------------------------------------------------------- + + +def seed_registry(tenant_id=None, force=False) -> dict: + """幂等 seed:2 个 Agent 定义 + 13 启用 / 9 禁用工具。""" + tenant = _tenant_or_raise(tenant_id) + store = get_store() + agents_written = 0 + for item in AGENT_DEFS: + existed = store.query_one( + "SELECT id FROM pbl_agent_def WHERE tenant_id = ? AND agent_code = ?", + (tenant, item["agent_code"]), + ) + if existed and not force: + continue + store.upsert_by( + "pbl_agent_def", + {"tenant_id": tenant, "agent_code": item["agent_code"]}, + { + "agent_name": item["agent_name"], + "role": item["role"], + "write_scope": item["write_scope"], + "can_write": item["can_write"], + "perms": dumps(item["perms"]), + "model": item["model"], + "system_prompt": item["system_prompt"], + "status": "active", + "updated_at": now_str(), + }, + ) + agents_written += 1 + + tools_written = 0 + sort_no = 0 + for key, name, category, write_class, perm, approval, risk in ENABLED_TOOLS: + sort_no += 10 + store.upsert_by( + "pbl_tool_registry", + {"tenant_id": tenant, "tool_key": key}, + { + "tool_name": name, "category": category, "write_class": write_class, + "audit_append": 1 if key in ("agent.trace_write", "evidence.collect") else 0, + "status": "enabled", "required_perm": perm, "approval_type": approval, + "params_schema": dumps({"type": "object"}), "risk_level": risk, + "reason": None, "sort_no": sort_no, "updated_at": now_str(), + }, + ) + tools_written += 1 + for key, name, category, write_class, perm, approval, risk, reason in DISABLED_TOOLS: + sort_no += 10 + store.upsert_by( + "pbl_tool_registry", + {"tenant_id": tenant, "tool_key": key}, + { + "tool_name": name, "category": category, "write_class": write_class, + "audit_append": 0, "status": "disabled", "required_perm": perm, + "approval_type": approval, "params_schema": dumps({"type": "object"}), + "risk_level": risk, "reason": reason, "sort_no": sort_no, + "updated_at": now_str(), + }, + ) + tools_written += 1 + return {"tenant_id": tenant, "agents": agents_written, "tools": tools_written, + "enabled": len(ENABLED_TOOLS), "disabled": len(DISABLED_TOOLS)} + + +def _tool_row_to_dict(row: dict) -> dict: + out = dict(row) + out["params_schema"] = loads(out.get("params_schema"), {"type": "object"}) + out["enabled"] = str(out.get("status") or "").lower() == "enabled" + out["write_class"] = _as_int(out.get("write_class"), 0) + out["audit_append"] = _as_int(out.get("audit_append"), 0) + return out + + +def _pbl_tool_registry_list(tenant_id=None, status=None, category=None, tool_key=None, + keyword=None, page=1, page_size=100, **_ignored) -> Result: + tenant = _tenant_or_raise(tenant_id) + seed_registry(tenant) + where = ["tenant_id = ?"] + args = [tenant] + if status: + where.append("status = ?") + args.append(str(status).strip().lower()) + if category: + where.append("category = ?") + args.append(str(category)) + if tool_key: + where.append("tool_key = ?") + args.append(str(tool_key)) + if keyword: + where.append("(tool_key LIKE ? OR tool_name LIKE ?)") + args.extend(["%%%s%%" % keyword, "%%%s%%" % keyword]) + where_sql = " AND ".join(where) + total_row = get_store().query_one( + "SELECT COUNT(1) AS cnt FROM pbl_tool_registry WHERE %s" % where_sql, args) + total = _as_int((total_row or {}).get("cnt"), 0) + page = max(1, _as_int(page, 1)) + page_size = min(500, max(1, _as_int(page_size, 100))) + rows = get_store().query( + "SELECT * FROM pbl_tool_registry WHERE %s ORDER BY sort_no, tool_key LIMIT ? OFFSET ?" + % where_sql, + args + [page_size, (page - 1) * page_size], + ) + items = [_tool_row_to_dict(r) for r in rows] + enabled = len([i for i in items if i["enabled"]]) + return ok(items=items, data=items, list=items, total=total, page=page, + page_size=page_size, tenant_id=tenant, + enabled_count=enabled, disabled_count=len(items) - enabled) + + +def _pbl_tool_registry_save(tenant_id=None, tool_key=None, tool_name=None, category=None, + enabled=None, status=None, write_class=None, audit_append=None, + required_perm=None, approval_type=None, params_schema=None, + risk_level=None, reason=None, sort_no=None, operator=None, + approval_id=None, **_ignored) -> Result: + """注册表变更 = 四类强制审批之一(tool_registry_change)。 + + fail-closed: + * tool_key 缺失 → 拒绝; + * 无 approved 的 tool_registry_change 审批单 → 拒绝并自动补建 pending 审批; + * 试图把禁用类(pbl.publish 等 forbidden)改为 enabled → 拒绝; + * 变更成功后追加 pbl_agent_trace 留痕(append-only)。 + """ + tenant = _tenant_or_raise(tenant_id) + seed_registry(tenant) + key = (tool_key or "").strip() + if not key: + return fail("PBL.REGISTRY.TOOL_KEY_REQUIRED", "tool_key is required") + + forbidden_keys = {item[0] for item in DISABLED_TOOLS} + want_enabled = _as_bool(enabled, None) if enabled is not None else None + if status is not None and want_enabled is None: + want_enabled = str(status).strip().lower() == "enabled" + + if want_enabled is True and key in forbidden_keys: + return fail("PBL.REGISTRY.FORBIDDEN_TOOL", + "tool %s is forbidden by design (14.2), cannot be enabled" % key, + tool_key=key) + + approval_row = None + if approval_id: + approval_row = get_store().query_one( + "SELECT * FROM pbl_approval WHERE tenant_id = ? AND approval_id = ?", + (tenant, str(approval_id)), + ) + if not approval_row or str(approval_row.get("status")) != "approved": + created = _pbl_approval_create( + tenant_id=tenant, approval_type="tool_registry_change", + title="工具注册表变更:%s" % key, requested_by=operator or "system", + agent_code="platform", tool_key=key, + payload_json=dumps({"tool_key": key, "enabled": want_enabled, + "status": status, "reason": reason}), + ) + return fail( + "PBL.APPROVAL.REQUIRED", + "tool_registry_change requires an approved human approval (fail-closed)", + tool_key=key, approval_type="tool_registry_change", + approval_id=created.get("approval_id"), approval_status="pending", + need_approval=True, + ) + + existed = get_store().query_one( + "SELECT * FROM pbl_tool_registry WHERE tenant_id = ? AND tool_key = ?", (tenant, key)) + fields = {} + if tool_name is not None: + fields["tool_name"] = tool_name + if category is not None: + fields["category"] = category + if want_enabled is not None: + fields["status"] = "enabled" if want_enabled else "disabled" + if write_class is not None: + fields["write_class"] = 1 if _as_bool(write_class) else 0 + if audit_append is not None: + fields["audit_append"] = 1 if _as_bool(audit_append) else 0 + if required_perm is not None: + fields["required_perm"] = required_perm + if approval_type is not None: + fields["approval_type"] = approval_type or None + if params_schema is not None: + fields["params_schema"] = dumps(params_schema) if not isinstance(params_schema, str) else params_schema + if risk_level is not None: + fields["risk_level"] = risk_level + if reason is not None: + fields["reason"] = reason + if sort_no is not None: + fields["sort_no"] = _as_int(sort_no, 0) + fields["updated_at"] = now_str() + fields["updated_by"] = operator or "system" + fields["version"] = _as_int((existed or {}).get("version"), 1) + 1 + + row = get_store().upsert_by("pbl_tool_registry", {"tenant_id": tenant, "tool_key": key}, fields) + _write_trace_row( + tenant, trace_id=new_id("trc"), run_id=None, step_no=0, stage="tool_registry_change", + who=operator or "system", what="registry save tool_key=%s" % key, + why="注册表变更经人工审批 %s" % approval_row.get("approval_id"), + how="pbl_tool_registry_save", result="saved", + evidence_ref="pbl_approval:%s" % approval_row.get("approval_id"), + tool_key=key, decision="ALLOW", + ) + return ok(tool=_tool_row_to_dict(row), item=_tool_row_to_dict(row), data=_tool_row_to_dict(row), + tool_key=key, tenant_id=tenant, saved=True, + approval_id=approval_row.get("approval_id"), + version=_as_int(row.get("version"), 1)) + + +# --------------------------------------------------------------------------- +# 轨迹:write / list(append-only) +# --------------------------------------------------------------------------- + + +def _write_trace_row(tenant, trace_id, run_id, step_no, stage, who, what, why, how, + result, evidence_ref, tool_key=None, decision=None, occurred_at=None): + return get_store().insert( + "pbl_agent_trace", + { + "tenant_id": tenant, "trace_id": trace_id, "run_id": run_id, + "step_no": _as_int(step_no, 0), "stage": stage or "run", + "who": who or "system", "occurred_at": occurred_at or now_str(), "what": what, + "why": why, "how": how, "result": result, "evidence_ref": evidence_ref, + "tool_key": tool_key, "decision": decision, "created_at": now_str(), + }, + ) + + +def _pbl_agent_trace_write(tenant_id=None, trace_id=None, run_id=None, step_no=0, stage=None, + who=None, occurred_at=None, what=None, why=None, how=None, + result=None, evidence_ref=None, tool_key=None, decision=None, + elements=None, **_ignored) -> Result: + """写一条 Agent 执行轨迹(第 28 章 7 要素:who/occurred_at/what/why/how/result/evidence_ref)。""" + tenant = _tenant_or_raise(tenant_id) + if isinstance(elements, dict): + what = what if what is not None else elements.get("what") + why = why if why is not None else elements.get("why") + how = how if how is not None else elements.get("how") + result = result if result is not None else elements.get("result") + evidence_ref = evidence_ref if evidence_ref is not None else elements.get("evidence_ref") + who = who if who is not None else elements.get("who") + values = { + "who": who, + "occurred_at": occurred_at or now_str(), + "what": what, + "why": why, + "how": how, + "result": result, + "evidence_ref": evidence_ref, + } + missing = [name for name in TRACE_ELEMENTS + if values.get(name) in (None, "")] + if missing: + return fail("PBL.TRACE.ELEMENTS_REQUIRED", + "trace elements missing: %s (fail-closed)" % ",".join(missing), + missing=missing) + row = _write_trace_row( + tenant, trace_id=trace_id or new_id("trc"), run_id=run_id, step_no=step_no, + stage=stage, who=values["who"], what=values["what"], why=values["why"], + how=values["how"], result=values["result"], evidence_ref=values["evidence_ref"], + tool_key=tool_key, decision=decision, occurred_at=values["occurred_at"], + ) + # occurred_at 为调用方给定的业务时间;留痕表 append-only,只随新记录写入,绝不 UPDATE + return ok(trace_id=row["trace_id"], id=row["id"], data=row, item=row, + tenant_id=tenant, written=True, elements=list(TRACE_ELEMENTS)) + + +def _pbl_agent_trace_list(tenant_id=None, trace_id=None, run_id=None, agent_code=None, + stage=None, limit=100, page=1, page_size=None, **_ignored) -> Result: + tenant = _tenant_or_raise(tenant_id) + where = ["tenant_id = ?"] + args = [tenant] + if trace_id: + where.append("trace_id = ?") + args.append(str(trace_id)) + if run_id: + where.append("run_id = ?") + args.append(str(run_id)) + if stage: + where.append("stage = ?") + args.append(str(stage)) + if agent_code: + where.append("who = ?") + args.append(str(agent_code)) + where_sql = " AND ".join(where) + size = min(1000, max(1, _as_int(page_size or limit, 100))) + offset = (max(1, _as_int(page, 1)) - 1) * size + rows = get_store().query( + "SELECT * FROM pbl_agent_trace WHERE %s ORDER BY created_at DESC, step_no DESC LIMIT ? OFFSET ?" + % where_sql, args + [size, offset]) + total_row = get_store().query_one( + "SELECT COUNT(1) AS cnt FROM pbl_agent_trace WHERE %s" % where_sql, args) + return ok(items=rows, data=rows, list=rows, total=_as_int((total_row or {}).get("cnt"), 0), + tenant_id=tenant, page=_as_int(page, 1), page_size=size) + + +# --------------------------------------------------------------------------- +# fail-closed 8 步裁决 +# --------------------------------------------------------------------------- + + +def _validate_params(schema, params: dict): + """极简 JSON-Schema 子集校验:type/required/properties。返回 (ok, msg)。""" + schema = schema or {"type": "object"} + if not isinstance(schema, dict): + return True, "" + expected = str(schema.get("type") or "object").lower() + if expected == "object" and not isinstance(params, dict): + return False, "params must be object" + required = schema.get("required") or [] + if isinstance(required, (list, tuple)): + for name in required: + if name not in params or params[name] in (None, ""): + return False, "missing required param: %s" % name + props = schema.get("properties") or {} + if isinstance(props, dict): + for name, spec in props.items(): + if name not in params or not isinstance(spec, dict): + continue + want = str(spec.get("type") or "").lower() + value = params[name] + if want == "string" and not isinstance(value, str): + return False, "param %s must be string" % name + if want in ("int", "integer") and not isinstance(value, int): + return False, "param %s must be integer" % name + if want in ("number", "float") and not isinstance(value, (int, float)): + return False, "param %s must be number" % name + if want in ("bool", "boolean") and not isinstance(value, bool): + return False, "param %s must be boolean" % name + return True, "" + + +def _pbl_tool_adjudicate(tenant_id=None, agent_code=None, tool_key=None, params_json=None, + params=None, trace_id=None, run_id=None, approval_id=None, + dry_run=False, **_ignored) -> Result: + """服务端 fail-closed 8 步裁决。默认 DENY;任一步不过立即 DENY 并留痕。""" + started = now_ms() + tenant = _tenant_or_raise(tenant_id) + seed_registry(tenant) + store = get_store() + + agent_code = (agent_code or "").strip() + tool_key = (tool_key or "").strip() + raw_params = params_json if params_json is not None else params + if isinstance(raw_params, str): + parsed = loads(raw_params, None) + if parsed is None and raw_params.strip(): + parsed = None + params_invalid = "params_json is not valid JSON" + else: + params_invalid = None + elif isinstance(raw_params, dict): + parsed = dict(raw_params) + params_invalid = None + elif raw_params in (None, ""): + parsed = {} + params_invalid = None + else: + parsed = {} + params_invalid = "params_json must be object or JSON string" + + trace_id = trace_id or new_id("trc") + steps_done = [] + + def deny(step_no, step_key, reason_code, message, **extra): + latency = now_ms() - started + out = fail(reason_code, message, allowed=False, decision="DENY", denied=True, + agent_code=agent_code, tool_key=tool_key, tenant_id=tenant, + trace_id=trace_id, denied_at_step=step_key, denied_step_no=step_no, + reason_code=reason_code, steps=steps_done, latency_ms=latency, + need_approval=reason_code == "PBL.APPROVAL.REQUIRED") + out.update(extra) + if not dry_run: + try: + store.insert("pbl_agent_tool_call", { + "tenant_id": tenant, "trace_id": trace_id, + "call_no": _as_int(store.query_one( + "SELECT COUNT(1) AS c FROM pbl_agent_tool_call WHERE tenant_id = ? AND trace_id = ?", + (tenant, trace_id)) or {}, 0) + 1, + "agent_code": agent_code or "unknown", "tool_key": tool_key or "unknown", + "params_json": dumps(parsed), "decision": "DENY", + "denied_at_step": step_key, "reason_code": reason_code, + "approval_id": extra.get("approval_id"), "latency_ms": latency, + "created_at": now_str(), + }) + _write_trace_row( + tenant, trace_id=trace_id, run_id=run_id, step_no=step_no, stage="adjudicate", + who=agent_code or "unknown", what="tool_adjudicate tool_key=%s" % tool_key, + why=message, how="fail-closed step %s" % step_key, result="DENY", + evidence_ref="pbl_agent_tool_call:%s" % trace_id, tool_key=tool_key, + decision="DENY") + except PblContractError: + raise + return out + + def allow(step_no, approval_row=None, **extra): + latency = now_ms() - started + out = ok(allowed=True, decision="ALLOW", denied=False, agent_code=agent_code, + tool_key=tool_key, tenant_id=tenant, trace_id=trace_id, steps=steps_done, + latency_ms=latency, reason_code=None) + out.update(extra) + if not dry_run: + store.insert("pbl_agent_tool_call", { + "tenant_id": tenant, "trace_id": trace_id, + "call_no": _as_int(store.query_one( + "SELECT COUNT(1) AS c FROM pbl_agent_tool_call WHERE tenant_id = ? AND trace_id = ?", + (tenant, trace_id)) or {}, 0) + 1, + "agent_code": agent_code or "unknown", "tool_key": tool_key or "unknown", + "params_json": dumps(parsed), "decision": "ALLOW", "denied_at_step": None, + "reason_code": None, + "approval_id": (approval_row or {}).get("approval_id"), + "latency_ms": latency, "created_at": now_str(), + }) + _write_trace_row( + tenant, trace_id=trace_id, run_id=run_id, step_no=step_no, stage="adjudicate", + who=agent_code or "unknown", what="tool_adjudicate tool_key=%s" % tool_key, + why="8 步裁决全部通过", how="fail-closed adjudication S1..S8", result="ALLOW", + evidence_ref="pbl_agent_tool_call:%s" % trace_id, tool_key=tool_key, + decision="ALLOW") + return out + + # S1 租户上下文 + steps_done.append({"step": "S1", "key": "tenant_context", "pass": True}) + + # S2 Agent 已注册且 active + if not agent_code: + return deny(2, "agent_registered", "PBL.AGENT.CODE_REQUIRED", "agent_code is required") + agent = store.query_one( + "SELECT * FROM pbl_agent_def WHERE tenant_id = ? AND agent_code = ?", (tenant, agent_code)) + if not agent: + return deny(2, "agent_registered", "PBL.AGENT.NOT_REGISTERED", + "agent %s not registered (fail-closed)" % agent_code, agent_code=agent_code) + if str(agent.get("status") or "").lower() != "active": + return deny(2, "agent_registered", "PBL.AGENT.NOT_ACTIVE", + "agent %s is not active" % agent_code, agent_status=agent.get("status")) + steps_done.append({"step": "S2", "key": "agent_registered", "pass": True}) + + # S3 Critic 零写 + tool_row = store.query_one( + "SELECT * FROM pbl_tool_registry WHERE tenant_id = ? AND tool_key = ?", (tenant, tool_key)) + is_write = bool(tool_row and _as_int(tool_row.get("write_class"), 0) == 1) + can_write = _as_int(agent.get("can_write"), 0) == 1 + if is_write and not can_write: + return deny(3, "agent_write_scope", "PBL.AGENT.WRITE_DENIED", + "agent %s has zero write permission (Critic), tool %s denied" + % (agent_code, tool_key), write_class=1, can_write=False) + steps_done.append({"step": "S3", "key": "agent_write_scope", "pass": True}) + + # S4 工具已登记 + if not tool_key: + return deny(4, "tool_registered", "PBL.TOOL.KEY_REQUIRED", "tool_key is required") + if not tool_row: + return deny(4, "tool_registered", "PBL.TOOL.NOT_REGISTERED", + "tool %s not in registry (fail-closed default DENY)" % tool_key) + steps_done.append({"step": "S4", "key": "tool_registered", "pass": True}) + + # S5 工具启用 + if str(tool_row.get("status") or "").lower() != "enabled": + return deny(5, "tool_enabled", "PBL.TOOL.DISABLED", + "tool %s is disabled: %s" % (tool_key, tool_row.get("reason") or "by design"), + tool_status=tool_row.get("status")) + steps_done.append({"step": "S5", "key": "tool_enabled", "pass": True}) + + # S6 权限 + required_perm = tool_row.get("required_perm") + agent_perms = loads(agent.get("perms"), []) or [] + if required_perm and required_perm not in agent_perms: + return deny(6, "permission_granted", "PBL.PERM.DENIED", + "agent %s lacks perm %s for tool %s" % (agent_code, required_perm, tool_key), + required_perm=required_perm) + steps_done.append({"step": "S6", "key": "permission_granted", "pass": True}) + + # S7 参数校验 + if params_invalid: + return deny(7, "params_valid", "PBL.PARAMS.INVALID", params_invalid) + schema = loads(tool_row.get("params_schema"), {"type": "object"}) + valid, message = _validate_params(schema, parsed) + if not valid: + return deny(7, "params_valid", "PBL.PARAMS.INVALID", message) + steps_done.append({"step": "S7", "key": "params_valid", "pass": True}) + + # S8 四类强制人工审批 + approval_type = tool_row.get("approval_type") + approval_row = None + if approval_type or approval_id: + if approval_id: + approval_row = store.query_one( + "SELECT * FROM pbl_approval WHERE tenant_id = ? AND approval_id = ?", + (tenant, str(approval_id))) + else: + approval_row = store.query_one( + "SELECT * FROM pbl_approval WHERE tenant_id = ? AND approval_type = ? " + "AND tool_key = ? AND status = 'approved' ORDER BY decided_at DESC LIMIT 1", + (tenant, approval_type, tool_key)) + if not approval_row or str(approval_row.get("status")) != "approved": + created = _pbl_approval_create( + tenant_id=tenant, approval_type=approval_type or "blueprint_approve", + title="工具 %s 需人工审批" % tool_key, requested_by=agent_code, + agent_code=agent_code, tool_key=tool_key, params_json=dumps(parsed), + ) + return deny(8, "approval_granted", "PBL.APPROVAL.REQUIRED", + "tool %s requires human approval type=%s (no bypass)" + % (tool_key, approval_type), + approval_type=approval_type, approval_id=created.get("approval_id"), + approval_status="pending") + steps_done.append({"step": "S8", "key": "approval_granted", "pass": True}) + + return allow(8, approval_row=approval_row, + approval_id=(approval_row or {}).get("approval_id"), + approval_type=approval_type, write_class=1 if is_write else 0) + + +# --------------------------------------------------------------------------- +# 审批:create / decide / list +# --------------------------------------------------------------------------- + + +def _pbl_approval_create(tenant_id=None, approval_type=None, title=None, payload_json=None, + requested_by=None, agent_code=None, tool_key=None, params_json=None, + expires_in_hours=72, **_ignored) -> Result: + """创建人工审批单(14.2 四类)。approval_type 非法 → 拒绝(fail-closed)。""" + tenant = _tenant_or_raise(tenant_id) + kind = (approval_type or "").strip() + if not kind: + return fail("PBL.APPROVAL.TYPE_REQUIRED", "approval_type is required") + if kind not in MANDATORY_APPROVAL_TYPES: + return fail("PBL.APPROVAL.TYPE_INVALID", + "approval_type %s not in mandatory types %s" + % (kind, ",".join(MANDATORY_APPROVAL_TYPES)), + approval_type=kind, allowed_types=list(MANDATORY_APPROVAL_TYPES)) + payload = payload_json if payload_json is not None else params_json + approval_id = new_id("apr") + expires_at = time.strftime("%Y-%m-%d %H:%M:%S", + time.localtime(time.time() + _as_int(expires_in_hours, 72) * 3600)) + row = get_store().insert( + "pbl_approval", + { + "tenant_id": tenant, "approval_id": approval_id, "approval_type": kind, + "title": title or "%s 审批" % kind, + "payload_json": dumps(payload) if not isinstance(payload, str) else payload, + "status": "pending", "requested_by": requested_by or agent_code or "system", + "agent_code": agent_code, "tool_key": tool_key, "expires_at": expires_at, + "created_at": now_str(), "updated_at": now_str(), + }, + ) + return ok(approval_id=approval_id, id=row["id"], data=row, item=row, + approval_type=kind, status="pending", tenant_id=tenant, expires_at=expires_at) + + +def _pbl_approval_decide(tenant_id=None, approval_id=None, decision=None, action=None, + decided_by=None, comment=None, decision_comment=None, **_ignored) -> Result: + """人工审批裁决:approve / reject。只有真人(decided_by 非 agent)可裁决。""" + tenant = _tenant_or_raise(tenant_id) + verdict = (decision or action or "").strip().lower() + if verdict in ("approved", "pass", "yes", "allow"): + verdict = "approve" + if verdict in ("rejected", "deny", "denied", "no"): + verdict = "reject" + if verdict not in ("approve", "reject"): + return fail("PBL.APPROVAL.DECISION_INVALID", + "decision must be approve|reject, got %r" % (decision or action)) + if not approval_id: + return fail("PBL.APPROVAL.ID_REQUIRED", "approval_id is required") + decider = (decided_by or "").strip() + if not decider: + return fail("PBL.APPROVAL.DECIDER_REQUIRED", "decided_by is required (human only)") + if decider in ("designer", "critic", "agent", "system"): + return fail("PBL.APPROVAL.NO_BYPASS", + "approval cannot be decided by agent %s (no bypass path)" % decider) + row = get_store().query_one( + "SELECT * FROM pbl_approval WHERE tenant_id = ? AND approval_id = ?", + (tenant, str(approval_id))) + if not row: + return fail(ERR_NOT_FOUND, "approval %s not found" % approval_id, approval_id=approval_id) + if str(row.get("status")) != "pending": + return fail("PBL.APPROVAL.ALREADY_DECIDED", + "approval %s already %s" % (approval_id, row.get("status")), + status=row.get("status")) + new_status = "approved" if verdict == "approve" else "rejected" + get_store().execute( + "UPDATE pbl_approval SET status = ?, decided_by = ?, decided_at = ?, " + "decision_comment = ?, updated_at = ? WHERE id = ?", + (new_status, decider, now_str(), (comment or decision_comment or ""), now_str(), row["id"]), + ) + updated = get_store().query_one( + "SELECT * FROM pbl_approval WHERE id = ?", (row["id"],)) + return ok(approval_id=str(approval_id), status=new_status, decision=verdict, + data=updated, item=updated, tenant_id=tenant, decided_by=decider) + + +def _pbl_approval_list(tenant_id=None, status=None, approval_type=None, agent_code=None, + tool_key=None, limit=100, page=1, page_size=None, **_ignored) -> Result: + tenant = _tenant_or_raise(tenant_id) + where = ["tenant_id = ?"] + args = [tenant] + if status: + where.append("status = ?") + args.append(str(status).strip().lower()) + if approval_type: + where.append("approval_type = ?") + args.append(str(approval_type)) + if agent_code: + where.append("agent_code = ?") + args.append(str(agent_code)) + if tool_key: + where.append("tool_key = ?") + args.append(str(tool_key)) + where_sql = " AND ".join(where) + size = min(500, max(1, _as_int(page_size or limit, 100))) + offset = (max(1, _as_int(page, 1)) - 1) * size + rows = get_store().query( + "SELECT * FROM pbl_approval WHERE %s ORDER BY created_at DESC LIMIT ? OFFSET ?" + % where_sql, args + [size, offset]) + for row in rows: + row["payload"] = loads(row.get("payload_json"), {}) + total_row = get_store().query_one( + "SELECT COUNT(1) AS cnt FROM pbl_approval WHERE %s" % where_sql, args) + return ok(items=rows, data=rows, list=rows, total=_as_int((total_row or {}).get("cnt"), 0), + tenant_id=tenant, page=_as_int(page, 1), page_size=size) + + +# --------------------------------------------------------------------------- +# Designer / Critic 运行时 +# --------------------------------------------------------------------------- + + +def _agent_run(agent_code, tenant_id=None, run_id=None, task=None, prompt=None, blueprint_id=None, + tools=None, model=None, trace_id=None, **_ignored) -> Result: + tenant = _tenant_or_raise(tenant_id) + seed_registry(tenant) + store = get_store() + agent = store.query_one( + "SELECT * FROM pbl_agent_def WHERE tenant_id = ? AND agent_code = ?", (tenant, agent_code)) + if not agent: + return fail("PBL.AGENT.NOT_REGISTERED", "agent %s not registered" % agent_code, + agent_code=agent_code, allowed=False) + if str(agent.get("status") or "").lower() != "active": + return fail("PBL.AGENT.NOT_ACTIVE", "agent %s not active" % agent_code, + allowed=False, agent_status=agent.get("status")) + + run_id = run_id or new_id("run") + trace_id = trace_id or new_id("trc") + can_write = _as_int(agent.get("can_write"), 0) == 1 + requested = tools if isinstance(tools, (list, tuple)) else loads(tools, []) or [] + if agent_code == "critic" and requested: + # Critic 零写:任何写类工具请求在运行入口即被拒绝(不等 S3) + write_requested = [] + for key in requested: + row = store.query_one( + "SELECT write_class FROM pbl_tool_registry WHERE tenant_id = ? AND tool_key = ?", + (tenant, str(key))) + if row and _as_int(row.get("write_class"), 0) == 1: + write_requested.append(str(key)) + if write_requested: + return fail("PBL.AGENT.WRITE_DENIED", + "critic has zero write permission, denied tools: %s" + % ",".join(write_requested), + allowed=False, agent_code=agent_code, run_id=run_id, + denied_tools=write_requested, can_write=False) + + prompt_text = prompt or task or "" + llm = _invoke_llm(tenant, agent_code, run_id, + "designer_run" if agent_code == "designer" else "critic_run", + "%s\n%s" % (agent.get("system_prompt") or "", prompt_text), model=model) + + adjudications = [] + for key in requested: + verdict = _pbl_tool_adjudicate( + tenant_id=tenant, agent_code=agent_code, tool_key=str(key), + params_json=dumps({"run_id": run_id, "blueprint_id": blueprint_id}), + run_id=run_id, trace_id=None) + adjudications.append({"tool_key": str(key), "decision": verdict.get("decision"), + "reason_code": verdict.get("reason_code"), + "denied_at_step": verdict.get("denied_at_step")}) + + _write_trace_row( + tenant, trace_id=trace_id, run_id=run_id, step_no=1, stage="%s_run" % agent_code, + who=agent_code, what="run task: %s" % (str(prompt_text)[:200]), + why=agent.get("system_prompt") or "", how="llm=%s attempt=%s" + % (llm.get("text", "")[:12], llm.get("attempt_no")), + result="fallback" if llm.get("fallback_used") else "ok", + evidence_ref="pbl_llm_call_log:%s" % run_id, tool_key=None, + decision="ALLOW" if can_write else "READ_ONLY") + + return ok(run_id=run_id, trace_id=trace_id, agent_code=agent_code, tenant_id=tenant, + can_write=can_write, write_scope=agent.get("write_scope"), + output=llm.get("text"), text=llm.get("text"), data={"output": llm.get("text")}, + llm=llm, adjudications=adjudications, + tool_calls=len(adjudications), + allowed=can_write, + fallback_used=bool(llm.get("fallback_used")), + status="fallback" if llm.get("fallback_used") else "ok") + + +def _pbl_agent_designer_run(**kwargs) -> Result: + return _agent_run("designer", **kwargs) + + +def _pbl_agent_critic_run(**kwargs) -> Result: + return _agent_run("critic", **kwargs) + + +# --------------------------------------------------------------------------- +# 契约对象 api() +# --------------------------------------------------------------------------- + +_IMPL = { + "pbl_agent_designer_run": _pbl_agent_designer_run, + "pbl_agent_critic_run": _pbl_agent_critic_run, + "pbl_agent_trace_write": _pbl_agent_trace_write, + "pbl_agent_trace_list": _pbl_agent_trace_list, + "pbl_tool_registry_list": _pbl_tool_registry_list, + "pbl_tool_registry_save": _pbl_tool_registry_save, + "pbl_tool_adjudicate": _pbl_tool_adjudicate, + "pbl_approval_create": _pbl_approval_create, + "pbl_approval_decide": _pbl_approval_decide, + "pbl_approval_list": _pbl_approval_list, +} + + +def _wrapped(name): + impl = _IMPL[name] + + def contract_fn(**kwargs): + return _run(impl, **kwargs) + + contract_fn.__name__ = name + contract_fn.__qualname__ = name + contract_fn.__doc__ = (impl.__doc__ or "").strip().splitlines()[0] if (impl.__doc__ or "").strip() else name + contract_fn.contract_name = name + return contract_fn + + +class _ContractApi(dict): + """契约对象:既可 ``api.pbl_tool_adjudicate(...)``,也可 ``api['pbl_tool_adjudicate'](...)``。""" + + def __getattr__(self, item): + try: + return self[item] + except KeyError: + raise AttributeError("pbl_agent_runtime contract has no function %r" % item) + + def names(self): + return list(CONTRACT_FUNCTIONS) + + +def build_api() -> _ContractApi: + api_obj = _ContractApi() + for name in CONTRACT_FUNCTIONS: + api_obj[name] = _wrapped(name) + # 附加运维/自检能力(非契约端点,不进 load_path) + api_obj["seed_registry"] = lambda **kw: _run(seed_registry, **kw) + api_obj["log_llm_call"] = lambda **kw: _run(log_llm_call, **kw) + api_obj["self_check"] = lambda **kw: _run(self_check, **kw) + api_obj["CONTRACT_FUNCTIONS"] = list(CONTRACT_FUNCTIONS) + api_obj["ADJUDICATION_STEPS"] = list(ADJUDICATION_STEPS) + api_obj["MANDATORY_APPROVAL_TYPES"] = list(MANDATORY_APPROVAL_TYPES) + api_obj["TRACE_ELEMENTS"] = list(TRACE_ELEMENTS) + api_obj["APPEND_ONLY_TABLES"] = list(APPEND_ONLY_TABLES) + return api_obj + + +# --------------------------------------------------------------------------- +# 自检:挂载即校验,不过抛 RuntimeError(fail-closed) +# --------------------------------------------------------------------------- + + +def self_check(tenant_id=None, strict=True) -> dict: + """挂载自检:契约齐全 + seed 幂等 + fail-closed 语义 + append-only 守卫。""" + problems = [] + tenant = resolve_tenant(tenant_id) or "0" + api_obj = build_api() + + for name in CONTRACT_FUNCTIONS: + if not callable(api_obj.get(name)): + problems.append("contract function missing: %s" % name) + + try: + seed = seed_registry(tenant) + if seed["enabled"] != 13 or seed["disabled"] != 9: + problems.append("tool seed count wrong: %s" % seed) + except Exception as exc: # noqa: BLE001 + problems.append("seed_registry failed: %s" % exc) + + listing = _run(_pbl_tool_registry_list, tenant_id=tenant) + if not listing.get("success"): + problems.append("pbl_tool_registry_list failed: %s" % listing.get("error_msg")) + else: + keys = {item["tool_key"]: item for item in listing.get("items", [])} + if "pbl.publish" not in keys: + problems.append("pbl.publish missing from registry") + elif keys["pbl.publish"].get("status") != "disabled": + problems.append("pbl.publish must be disabled") + enabled = [k for k, v in keys.items() if v.get("enabled")] + if len(enabled) != 13: + problems.append("enabled tool count=%d, expect 13" % len(enabled)) + + critic_write = _run(_pbl_tool_adjudicate, tenant_id=tenant, agent_code="critic", + tool_key="blueprint.write", params_json="{}") + if critic_write.get("allowed") is not False: + problems.append("critic write tool was not denied (zero-write violated)") + elif critic_write.get("denied_at_step") != "agent_write_scope": + problems.append("critic deny step=%s, expect agent_write_scope" + % critic_write.get("denied_at_step")) + + publish = _run(_pbl_tool_adjudicate, tenant_id=tenant, agent_code="designer", + tool_key="pbl.publish", params_json="{}") + if publish.get("allowed") is not False: + problems.append("pbl.publish was not denied") + + unknown = _run(_pbl_tool_adjudicate, tenant_id=tenant, agent_code="designer", + tool_key="no.such.tool", params_json="{}") + if unknown.get("allowed") is not False or unknown.get("decision") != "DENY": + problems.append("unknown tool default decision is not DENY") + + no_tenant = _run(_pbl_tool_adjudicate, tenant_id="", agent_code="designer", + tool_key="blueprint.read", params_json="{}") + if no_tenant.get("success") is not False: + problems.append("missing tenant_id was not rejected") + + store = get_store() + try: + store.execute("DELETE FROM pbl_agent_trace WHERE 1 = 0") + problems.append("append-only guard failed on pbl_agent_trace") + except PblContractError as exc: + if exc.code != ERR_APPEND_ONLY: + problems.append("append-only guard wrong code: %s" % exc.code) + + approval_bad = _run(_pbl_approval_create, tenant_id=tenant, approval_type="not_a_type") + if approval_bad.get("success") is not False: + problems.append("invalid approval_type was accepted") + + result = { + "success": not problems, + "module": "pbl_agent_runtime", + "tenant_id": tenant, + "contract_functions": list(CONTRACT_FUNCTIONS), + "contract_count": len(CONTRACT_FUNCTIONS), + "tables": 6, + "append_only_tables": list(APPEND_ONLY_TABLES), + "adjudication_steps": [s[0] for s in ADJUDICATION_STEPS], + "mandatory_approval_types": list(MANDATORY_APPROVAL_TYPES), + "problems": problems, + } + if problems and strict: + raise RuntimeError("pbl_agent_runtime self_check failed (fail-closed): %s" + % "; ".join(problems)) + return Result(result) diff --git a/pbl_agent_runtime/m4a_store.py b/pbl_agent_runtime/m4a_store.py new file mode 100644 index 0000000..8745bc6 --- /dev/null +++ b/pbl_agent_runtime/m4a_store.py @@ -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`` + 三张留痕表只允许 INSERT;UPDATE/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 diff --git a/scripts/check_contract_sync.py b/scripts/check_contract_sync.py new file mode 100644 index 0000000..5b7198b --- /dev/null +++ b/scripts/check_contract_sync.py @@ -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()) diff --git a/scripts/gen_dspy.py b/scripts/gen_dspy.py new file mode 100644 index 0000000..3319180 --- /dev/null +++ b/scripts/gen_dspy.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +"""生成 wwwroot/api/*.dspy 契约端点(10 个)。 + +统一模板要点(对应 QC 退回 #3/#6): + * 第 2 行 debug 必须是 **f-string**,输出真实 params_kw(module-development-spec 要求); + * dspy 内禁止 import / print / uuid(禁项审计零命中); + * 契约函数由 init.py 注册到 ServerEnv,dspy 直接按名调用; + * 显式 return(ahserver 把 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() diff --git a/scripts/load_path.py b/scripts/load_path.py index 392bb76..e31e5f9 100644 --- a/scripts/load_path.py +++ b/scripts/load_path.py @@ -1,49 +1,62 @@ -#!/usr/bin/env python3 # -*- coding: utf-8 -*- -"""pbl_agent_runtime RBAC 路径注册(硬门禁 6.6 / QC #11)。 +"""pbl_agent_runtime RBAC 路径注册清单。 -约定: -- 路径 = 模块自动路由 `/pbl_agent_runtime/api/<契约>.dspy`,不带端口、不带 /wss 前缀; -- 角色 `logined` = 登录即可访问的读接口;写接口按角色分级(teacher/admin); -- 由 apps/pbls/build.sh 第 8 步调用 `register()`;rbac CLI 不在位时打印清单(不静默跳过)。 +铁律:PATHS 必须与 wwwroot/ 下**实际存在的文件一一对应**(QC 退回 #4)。 +本文件由 scripts/check_contract_sync.py 机械核验:多一条(指向不存在文件)或少一条 +(文件未注册)都会失败。 + +角色约定 +-------- +* ``any`` :登录前可访问(本模块无此类文件) +* ``user`` :普通登录用户(只读查询类) +* ``admin`` :租户管理员(写类 / 审批裁决 / 注册表变更) """ -import os -import subprocess -import sys -MODULE = 'pbl_agent_runtime' - -# (path, role) +# (路径, 角色) —— 路径相对模块 wwwroot,运行时前缀 /pbl_agent_runtime/ PATHS = [ - ('/pbl_agent_runtime/api/pbl_agent_designer_run.dspy', 'logined'), - ('/pbl_agent_runtime/api/pbl_agent_critic_run.dspy', 'logined'), - ('/pbl_agent_runtime/api/pbl_agent_trace_write.dspy', 'logined'), - ('/pbl_agent_runtime/api/pbl_agent_trace_list.dspy', 'logined'), - ('/pbl_agent_runtime/api/pbl_tool_registry_list.dspy', 'logined'), - ('/pbl_agent_runtime/api/pbl_tool_registry_save.dspy', 'logined'), - ('/pbl_agent_runtime/api/pbl_tool_adjudicate.dspy', 'logined'), - ('/pbl_agent_runtime/api/pbl_approval_create.dspy', 'logined'), - ('/pbl_agent_runtime/api/pbl_approval_decide.dspy', 'logined'), - ('/pbl_agent_runtime/api/pbl_approval_list.dspy', 'logined'), - + # 模块入口页 + ("/pbl_agent_runtime/index.ui", "user"), + # 10 个契约端点(与 pbl_agent_runtime.m4a_contract.CONTRACT_FUNCTIONS 同源) + ("/pbl_agent_runtime/api/pbl_agent_designer_run.dspy", "admin"), + ("/pbl_agent_runtime/api/pbl_agent_critic_run.dspy", "user"), + ("/pbl_agent_runtime/api/pbl_agent_trace_write.dspy", "user"), + ("/pbl_agent_runtime/api/pbl_agent_trace_list.dspy", "user"), + ("/pbl_agent_runtime/api/pbl_tool_registry_list.dspy", "user"), + ("/pbl_agent_runtime/api/pbl_tool_registry_save.dspy", "admin"), + ("/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') - done, missing = 0, [] + +def paths(): + """返回 [(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: - if subprocess.call([sys.executable if os.environ.get('PY') else 'python3', - tool, role, path], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0: - done += 1 - else: - 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 + setter = getattr(target, "add_path", None) or getattr(target, "register_path", None) + if callable(setter): + setter(path, role) + count += 1 + return {"registered": count, "offline": count == 0, "paths": list(PATHS)} -if __name__ == '__main__': - sys.exit(0 if register() else 1) +if __name__ == "__main__": + for item_path, item_role in PATHS: + print("%-64s %s" % (item_path, item_role)) + print("total: %d paths" % len(PATHS)) diff --git a/scripts/m4a_contract_selftest.py b/scripts/m4a_contract_selftest.py new file mode 100644 index 0000000..33ce520 --- /dev/null +++ b/scripts/m4a_contract_selftest.py @@ -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 #3:10 个契约端点齐全 + 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 #4:load_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 #6:dspy 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 #7:pbl_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_call(ALLOW/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()) diff --git a/scripts/m4a_manifest.json b/scripts/m4a_manifest.json new file mode 100644 index 0000000..ff0ccfb --- /dev/null +++ b/scripts/m4a_manifest.json @@ -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 + } + ] +} \ No newline at end of file diff --git a/sql/m4a_contract_addon.sql b/sql/m4a_contract_addon.sql new file mode 100644 index 0000000..bcbe266 --- /dev/null +++ b/sql/m4a_contract_addon.sql @@ -0,0 +1,167 @@ +-- ============================================================================= +-- pbl_agent_runtime M4a 契约层 DDL(MySQL 方言) +-- 对应 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 DDL(pbl_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=forbidden,status=disabled) +-- pbl.publish / blueprint.publish_auto / compiler.autorun / kdb.write / tenant.switch +-- rbac.grant / approval.auto_decide / trace.delete / llm.raw_exec +-- ============================================================================= diff --git a/wwwroot/api/pbl_agent_critic_run.dspy b/wwwroot/api/pbl_agent_critic_run.dspy index c5b0e96..81a1ec8 100644 --- a/wwwroot/api/pbl_agent_critic_run.dspy +++ b/wwwroot/api/pbl_agent_critic_run.dspy @@ -1,4 +1,5 @@ -# pbl_agent_runtime/api/pbl_agent_critic_run.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py) -debug('pbl_agent_runtime/api/pbl_agent_critic_run.dspy: START params_kw={dict(params_kw)}') -data = await pbl_agent_critic_run(**params_kw) -return data +# Critic Agent 运行(零写权限) +debug(f'pbl_agent_critic_run.dspy: START params_kw={dict(params_kw)}') +result = pbl_agent_critic_run(**dict(params_kw)) +debug(f'pbl_agent_critic_run.dspy: END success={result.get("success") if isinstance(result, dict) else result}') +return result diff --git a/wwwroot/api/pbl_agent_designer_run.dspy b/wwwroot/api/pbl_agent_designer_run.dspy index 6293b77..a51f7b9 100644 --- a/wwwroot/api/pbl_agent_designer_run.dspy +++ b/wwwroot/api/pbl_agent_designer_run.dspy @@ -1,4 +1,5 @@ -# pbl_agent_runtime/api/pbl_agent_designer_run.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py) -debug('pbl_agent_runtime/api/pbl_agent_designer_run.dspy: START params_kw={dict(params_kw)}') -data = await pbl_agent_designer_run(**params_kw) -return data +# Designer Agent 运行(可写,写操作经服务端裁决) +debug(f'pbl_agent_designer_run.dspy: START params_kw={dict(params_kw)}') +result = pbl_agent_designer_run(**dict(params_kw)) +debug(f'pbl_agent_designer_run.dspy: END success={result.get("success") if isinstance(result, dict) else result}') +return result diff --git a/wwwroot/api/pbl_agent_trace_list.dspy b/wwwroot/api/pbl_agent_trace_list.dspy index 7b5476c..39d4c7d 100644 --- a/wwwroot/api/pbl_agent_trace_list.dspy +++ b/wwwroot/api/pbl_agent_trace_list.dspy @@ -1,4 +1,5 @@ -# pbl_agent_runtime/api/pbl_agent_trace_list.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py) -debug('pbl_agent_runtime/api/pbl_agent_trace_list.dspy: START params_kw={dict(params_kw)}') -data = await pbl_agent_trace_list(**params_kw) -return data +# 查询 Agent 执行轨迹 +debug(f'pbl_agent_trace_list.dspy: START params_kw={dict(params_kw)}') +result = pbl_agent_trace_list(**dict(params_kw)) +debug(f'pbl_agent_trace_list.dspy: END success={result.get("success") if isinstance(result, dict) else result}') +return result diff --git a/wwwroot/api/pbl_agent_trace_write.dspy b/wwwroot/api/pbl_agent_trace_write.dspy index f144f5c..5129d20 100644 --- a/wwwroot/api/pbl_agent_trace_write.dspy +++ b/wwwroot/api/pbl_agent_trace_write.dspy @@ -1,4 +1,5 @@ -# pbl_agent_runtime/api/pbl_agent_trace_write.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py) -debug('pbl_agent_runtime/api/pbl_agent_trace_write.dspy: START params_kw={dict(params_kw)}') -data = await pbl_agent_trace_write(**params_kw) -return data +# 写 Agent 执行轨迹(7 要素,append-only) +debug(f'pbl_agent_trace_write.dspy: START params_kw={dict(params_kw)}') +result = pbl_agent_trace_write(**dict(params_kw)) +debug(f'pbl_agent_trace_write.dspy: END success={result.get("success") if isinstance(result, dict) else result}') +return result diff --git a/wwwroot/api/pbl_approval_create.dspy b/wwwroot/api/pbl_approval_create.dspy index 7553773..0467713 100644 --- a/wwwroot/api/pbl_approval_create.dspy +++ b/wwwroot/api/pbl_approval_create.dspy @@ -1,4 +1,5 @@ -# pbl_agent_runtime/api/pbl_approval_create.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py) -debug('pbl_agent_runtime/api/pbl_approval_create.dspy: START params_kw={dict(params_kw)}') -data = await pbl_approval_create(**params_kw) -return data +# 创建人工审批单(14.2 四类) +debug(f'pbl_approval_create.dspy: START params_kw={dict(params_kw)}') +result = pbl_approval_create(**dict(params_kw)) +debug(f'pbl_approval_create.dspy: END success={result.get("success") if isinstance(result, dict) else result}') +return result diff --git a/wwwroot/api/pbl_approval_decide.dspy b/wwwroot/api/pbl_approval_decide.dspy index be6ecbd..f2d4bb0 100644 --- a/wwwroot/api/pbl_approval_decide.dspy +++ b/wwwroot/api/pbl_approval_decide.dspy @@ -1,4 +1,5 @@ -# pbl_agent_runtime/api/pbl_approval_decide.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py) -debug('pbl_agent_runtime/api/pbl_approval_decide.dspy: START params_kw={dict(params_kw)}') -data = await pbl_approval_decide(**params_kw) -return data +# 人工审批裁决(无 Agent 绕过路径) +debug(f'pbl_approval_decide.dspy: START params_kw={dict(params_kw)}') +result = pbl_approval_decide(**dict(params_kw)) +debug(f'pbl_approval_decide.dspy: END success={result.get("success") if isinstance(result, dict) else result}') +return result diff --git a/wwwroot/api/pbl_approval_list.dspy b/wwwroot/api/pbl_approval_list.dspy index 4181df0..cdf0080 100644 --- a/wwwroot/api/pbl_approval_list.dspy +++ b/wwwroot/api/pbl_approval_list.dspy @@ -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)}') -data = await pbl_approval_list(**params_kw) -return data +# 审批单列表查询 +debug(f'pbl_approval_list.dspy: START params_kw={dict(params_kw)}') +result = pbl_approval_list(**dict(params_kw)) +debug(f'pbl_approval_list.dspy: END success={result.get("success") if isinstance(result, dict) else result}') +return result diff --git a/wwwroot/api/pbl_tool_adjudicate.dspy b/wwwroot/api/pbl_tool_adjudicate.dspy index 496457c..f6b3501 100644 --- a/wwwroot/api/pbl_tool_adjudicate.dspy +++ b/wwwroot/api/pbl_tool_adjudicate.dspy @@ -1,4 +1,5 @@ -# pbl_agent_runtime/api/pbl_tool_adjudicate.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py) -debug('pbl_agent_runtime/api/pbl_tool_adjudicate.dspy: START params_kw={dict(params_kw)}') -data = await pbl_tool_adjudicate(**params_kw) -return data +# fail-closed 8 步工具裁决(默认 DENY) +debug(f'pbl_tool_adjudicate.dspy: START params_kw={dict(params_kw)}') +result = pbl_tool_adjudicate(**dict(params_kw)) +debug(f'pbl_tool_adjudicate.dspy: END success={result.get("success") if isinstance(result, dict) else result}') +return result diff --git a/wwwroot/api/pbl_tool_registry_list.dspy b/wwwroot/api/pbl_tool_registry_list.dspy new file mode 100644 index 0000000..d103a42 --- /dev/null +++ b/wwwroot/api/pbl_tool_registry_list.dspy @@ -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 diff --git a/wwwroot/api/pbl_tool_registry_save.dspy b/wwwroot/api/pbl_tool_registry_save.dspy new file mode 100644 index 0000000..e24ec02 --- /dev/null +++ b/wwwroot/api/pbl_tool_registry_save.dspy @@ -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