From b21668b25d9addc1f3bccf4cddd666613460a9f4 Mon Sep 17 00:00:00 2001 From: "agent.develop" Date: Fri, 18 Sep 2026 16:07:34 +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/m4a_api.py | 74 +- pbl_agent_runtime/m4a_smoke_compat.py | 1206 +++++++++++++++++++++++++ pbl_agent_runtime/m4a_toolset.py | 329 +++++++ var/m4a_contract.sqlite3 | Bin 0 -> 110592 bytes 4 files changed, 1588 insertions(+), 21 deletions(-) create mode 100644 pbl_agent_runtime/m4a_smoke_compat.py create mode 100644 pbl_agent_runtime/m4a_toolset.py create mode 100644 var/m4a_contract.sqlite3 diff --git a/pbl_agent_runtime/m4a_api.py b/pbl_agent_runtime/m4a_api.py index 07b37a3..0160a16 100644 --- a/pbl_agent_runtime/m4a_api.py +++ b/pbl_agent_runtime/m4a_api.py @@ -6,27 +6,41 @@ 项目集成契约(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=...) + await AR.pbl_tool_registry_save(tool_key=..., title=..., enabled=1, agent_scope=...) + await AR.pbl_tool_registry_list() # -> enabled_keys[13] / disabled_keys[9] + await AR.pbl_tool_adjudicate(agent_code=..., tool_key=..., params_json=...) + # -> allowed / error_code=AD0x / steps[8] + await AR.pbl_approval_create(approval_type=..., target_type=..., target_id=...) + await AR.pbl_approval_decide(id=..., status='approved', comment_txt=...) + await AR.pbl_agent_critic_run(blueprint_id=...) # -> writable=False + 四要素 + await AR.pbl_agent_designer_run(input_txt=..., template_code='TPL-NOT-EXIST') + await AR.pbl_agent_trace_list() + 非法审批类型 / G6 禁用工具启用 -> raise pbl_common.PblError(e.code 断言) -即 ``api`` 必须是**带契约函数属性的对象**;而历史 init.py 里 ``api()`` 是返回字典的工厂函数, -两种写法互不兼容。本模块给出同时满足两者的实现: +即 ``api`` 必须是**带契约函数属性的对象**,且函数签名/返回字段/错误码要与集成契约逐字对齐。 +历史 init.py 里 ``api()`` 是返回字典的工厂函数,两种写法互不兼容;本模块给出同时满足的实现: api.pbl_tool_adjudicate(...) # 属性直调(冒烟测试写法) api().pbl_tool_adjudicate(...) # 先调用再属性(工厂写法) api['pbl_tool_adjudicate'](...) # 下标写法 - await api.pbl_tool_adjudicate(...)# async 上下文(.dspy 端点写法) + await api.pbl_tool_adjudicate(...)# async 上下文(.dspy 端点写法,Result.__await__ 返回自身) -契约函数清单以 m4a_contract.CONTRACT_FUNCTIONS 为唯一真源(10 个), -与 wwwroot/api/*.dspy、scripts/load_path.py、skill/SKILL.md 四处同步。 +实现分层(避免"改一处坏一处") +------------------------------ +* ``m4a_toolset`` :13 启用 / 9 禁用工具、四类审批、AD0x 错误码的**唯一真源** +* ``m4a_smoke_compat`` :集成契约口径实现(对外唯一口径,复用 m4a_contract 核心逻辑) +* ``m4a_contract`` :内部核心(轨迹 7 要素 / LLM 留痕 / append-only 守卫 / self_check) + +契约函数清单以 ``CONTRACT_FUNCTIONS``(10 个)为唯一真源,与 wwwroot/api/*.dspy、 +scripts/load_path.py、skill/SKILL.md 四处同步(scripts/check_contract_sync.py 机械核验)。 """ from __future__ import annotations +from . import m4a_smoke_compat as COMPAT from .m4a_contract import ( ADJUDICATION_STEPS, - CONTRACT_FUNCTIONS, + CONTRACT_FUNCTIONS as CORE_CONTRACT_FUNCTIONS, MANDATORY_APPROVAL_TYPES, TRACE_ELEMENTS, build_api, @@ -34,6 +48,14 @@ from .m4a_contract import ( seed_registry, ) from .m4a_store import APPEND_ONLY_TABLES, PblContractError, Result +from .m4a_toolset import ( + DISABLED_TOOLS, + ENABLED_TOOLS, + STEP_ERROR_CODES, +) + +#: 对外契约函数清单(10 个)—— 与 .dspy / load_path / SKILL.md 同源 +CONTRACT_FUNCTIONS = COMPAT.CONTRACT_FUNCTIONS class ContractApi(dict): @@ -62,23 +84,29 @@ class ContractApi(dict): 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, - } + return COMPAT.describe() def build_contract_api() -> ContractApi: - """构建契约对象(每次调用返回独立实例,便于测试隔离)。""" + """构建契约对象(每次调用返回独立实例,便于测试隔离)。 + + 装配顺序:内部核心(m4a_contract.build_api)打底 → 集成契约实现覆盖同名函数。 + 覆盖后对外口径唯一(AD0x 错误码 + ok/success 双布尔 + 冒烟测试字段名), + 内部核心函数以 ``core_`` 保留,供自测与排障对照。 + """ base = build_api() - obj = ContractApi(base) + merged = {} + for name, func in base.items(): + merged["core_%s" % name] = func + merged[name] = func + merged.update(COMPAT.build_integration_api()) + obj = ContractApi(merged) obj["describe"] = obj.describe + obj["names"] = obj.names + obj["seed_tools"] = COMPAT.seed_tools + obj["seed_registry"] = seed_registry + obj["self_check"] = self_check + obj["adjudication_steps"] = COMPAT.adjudication_steps return obj @@ -92,9 +120,13 @@ __all__ = [ "self_check", "seed_registry", "CONTRACT_FUNCTIONS", + "CORE_CONTRACT_FUNCTIONS", "ADJUDICATION_STEPS", + "STEP_ERROR_CODES", "MANDATORY_APPROVAL_TYPES", "TRACE_ELEMENTS", + "ENABLED_TOOLS", + "DISABLED_TOOLS", "APPEND_ONLY_TABLES", "PblContractError", "Result", diff --git a/pbl_agent_runtime/m4a_smoke_compat.py b/pbl_agent_runtime/m4a_smoke_compat.py new file mode 100644 index 0000000..a667924 --- /dev/null +++ b/pbl_agent_runtime/m4a_smoke_compat.py @@ -0,0 +1,1206 @@ +# -*- coding: utf-8 -*- +"""pbl_agent_runtime.m4a_smoke_compat —— 项目集成契约适配层(QC 退回 #2 根治)。 + +背景 +---- +``apps/pbls/scripts/tests/offline_contract_smoke.py``(与 modules/pbls 同源副本)是本项目 +**唯一可执行的跨模块集成契约**,它对 pbl_agent_runtime 的断言口径是: + + from pbl_agent_runtime import api as AR # :267 + await AR.pbl_tool_registry_save(tool_key=..., title=..., enabled=1, agent_scope=...) + await AR.pbl_tool_registry_list() -> {'enabled_keys':[13], 'disabled_keys':[9], 'counts':{}} + await AR.pbl_tool_adjudicate(agent_code=..., tool_key=..., params_json={...}) + -> {'allowed':bool, 'error_code':'AD0x_...', 'steps':[...]} + await AR.pbl_approval_create(approval_type=..., target_type=..., target_id=...) + -> {'ok':True, 'id':..., 'approval_uid':...} + await AR.pbl_approval_decide(id=..., status='approved', comment_txt=...) + await AR.pbl_agent_critic_run(blueprint_id=...) -> {'writable':False, recommendation/reason/evidence/confidence} + await AR.pbl_agent_designer_run(input_txt=..., template_code='TPL-NOT-EXIST') + -> {'ok':True, 'generation_source':'template_fallback', llm_error, latency_ms} + await AR.pbl_agent_trace_list() -> {'ok':True, 'total':>=2} + 非法审批类型 / G6 禁用工具启用 -> **raise pbl_common.PblError**(e.code 断言) + +而模块内部核心(m4a_contract)用的是 ``success`` + ``PBL.*`` 点分错误码 + 设计文档键名 +(blueprint.create 等)。两套口径都合法,但**对外只能有一套**。本文件的职责: + +1. 以集成契约口径为**对外唯一口径**(api 对象的 10 个契约函数由本文件实现); +2. 内部仍复用 m4a_contract 的核心逻辑(轨迹/LLM 留痕/append-only 守卫不重写); +3. 返回体**同时携带**内部码(``pbl_code``/``reason_code``)与集成码(``error_code``=AD0x), + 两套自测都能断言,不存在"改一处坏一处"。 + +fail-closed 铁律不变:默认 DENY;S1~S8 固定顺序;Critic 零写;pbl.publish 恒拒(G6); +四类审批无绕过;每次裁决(含 DENY)落 pbl_agent_tool_call 留痕。 +""" + +from __future__ import annotations + +import time +import traceback + +from . import m4a_toolset as TS +from .m4a_store import ( + Result, + get_store, + loads, + dumps, + module_dbname, + new_id, + now_ms, + now_str, + ok as _ok, + resolve_tenant, +) + +# --------------------------------------------------------------------------- +# PblError:集成契约要求"非法审批类型 / G6 禁用工具启用"抛异常(e.code 可断言) +# --------------------------------------------------------------------------- +PblError = None +for _mod, _name in ( + ("pbl_common.api", "PblError"), + ("pbl_common.errors", "PblError"), + ("pbl_common", "PblError"), +): + try: + _m = __import__(_mod, fromlist=[_name]) + _cand = getattr(_m, _name, None) + if isinstance(_cand, type) and issubclass(_cand, Exception): + PblError = _cand + break + except Exception: # noqa: BLE001 + continue + + +class ContractPblError(RuntimeError): + """pbl_common 不可用时的等价异常(离线自测/独立部署场景)。""" + + def __init__(self, code, message="", **extra): + super().__init__(message or code) + self.code = code + self.message = message or code + self.extra = dict(extra) + + def to_dict(self): + out = {"ok": False, "success": False, "error_code": self.code, + "error_msg": self.message} + out.update(self.extra) + return out + + +def _raise(code, message="", **extra): + """按集成契约抛 PblError(拿不到 pbl_common 时抛等价异常)。""" + if PblError is not None: + for ctor in ( + lambda: PblError(code, message), + lambda: PblError(message, code=code), + lambda: PblError(message), + ): + try: + err = ctor() + except Exception: # noqa: BLE001 + continue + try: + if getattr(err, "code", None) != code: + err.code = code + except Exception: # noqa: BLE001 + pass + for key, value in extra.items(): + try: + setattr(err, key, value) + except Exception: # noqa: BLE001 + pass + raise err + raise ContractPblError(code, message, **extra) + + +# --------------------------------------------------------------------------- +# 表列内省 + 行过滤(不同部署下表可能有增列,写入只落存在的列,其余进 payload_json) +# --------------------------------------------------------------------------- +_COL_CACHE = {} + + +def _columns(table): + cached = _COL_CACHE.get(table) + if cached is not None: + return cached + try: + rows = get_store().query("PRAGMA table_info(%s)" % table) + cols = tuple(str(r["name"]) for r in rows) + except Exception: # noqa: BLE001 + cols = () + _COL_CACHE[table] = cols + return cols + + +def _fit(table, row): + """把 row 裁剪到表实际列;被裁掉的键塞进 payload_json/extra_json(若存在)。""" + cols = _columns(table) + if not cols: + return dict(row) + keep, dropped = {}, {} + for key, value in row.items(): + (keep if key in cols else dropped)[key] = value + if dropped: + for blob_col in ("payload_json", "extra_json", "params_json", "detail_json"): + if blob_col in cols and blob_col not in keep: + keep[blob_col] = dumps(dropped) + break + return keep + + +def _insert(table, row): + store = get_store() + try: + return store.insert(table, _fit(table, row)) + except Exception: # noqa: BLE001 + # 极端兜底:手写 INSERT,只落已知列 + fitted = _fit(table, row) + cols = [k for k in fitted if k in (_columns(table) or fitted)] + if not cols: + raise + sql = "INSERT INTO %s (%s) VALUES (%s)" % ( + table, ",".join("`%s`" % c for c in cols), + ",".join("?" for _ in cols)) + store.execute(sql, tuple(fitted[c] for c in cols)) + return fitted + + +def _update(table, where, row): + store = get_store() + fitted = _fit(table, row) + sets = [k for k in fitted if not _columns(table) or k in _columns(table)] + if not sets: + return 0 + sql = "UPDATE %s SET %s WHERE %s" % ( + table, ",".join("`%s`=?" % c for c in sets), where) + return store.execute(sql, tuple(fitted[c] for c in sets)) + + +def _q(sql, args=()): + try: + return get_store().query(sql, args) + except Exception: # noqa: BLE001 + return [] + + +def _q1(sql, args=()): + try: + return get_store().query_one(sql, args) + except Exception: # noqa: BLE001 + return None + + +# --------------------------------------------------------------------------- +# 统一返回体:同时带 ok / success 两套布尔 + error_code(AD0x) / pbl_code(PBL.*) +# --------------------------------------------------------------------------- +def _result(success=True, code=None, msg="", **fields): + out = Result() + out["ok"] = bool(success) + out["success"] = bool(success) + if code: + out["error_code"] = code + out["reason_code"] = code + out["pbl_code"] = fields.pop("pbl_code", code) + out["error_msg"] = msg + out["message"] = msg + else: + out["error_code"] = None + out["reason_code"] = None + out["error_msg"] = None + for key, value in fields.items(): + out[key] = value + return out + + +# =========================================================================== +# 注册表:seed / list / save +# =========================================================================== +def seed_tools(tenant_id=None, force=False): + """幂等注入 13 启用 + 9 禁用(build.sh / 挂载自检调用)。""" + tenant = _tenant(tenant_id) + store = get_store() + inserted = skipped = 0 + for row in TS.seed_rows(): + exists = _q1("SELECT id FROM pbl_tool_registry WHERE tenant_id=? AND tool_key=?", + (tenant, row["tool_key"])) + payload = _registry_row(tenant, row) + if exists: + if force: + _update("pbl_tool_registry", + "tenant_id='%s' AND tool_key='%s'" % (tenant, row["tool_key"]), + payload) + skipped += 1 + continue + _insert("pbl_tool_registry", payload) + inserted += 1 + _seed_agents(tenant) + return {"ok": True, "success": True, "inserted": inserted, "skipped": skipped, + "enabled": len(TS.ENABLED_TOOLS), "disabled": len(TS.DISABLED_TOOLS), + "tenant_id": tenant, "dbname": _dbname()} + + +def _dbname(): + try: + return module_dbname() + except Exception: # noqa: BLE001 + return "pbl_agent_runtime" + + +def _tenant(tenant_id=None): + try: + return resolve_tenant(tenant_id) if tenant_id is None else str(tenant_id) + except Exception: # noqa: BLE001 + return str(tenant_id or "0") + + +def _registry_row(tenant, row): + return { + "tenant_id": tenant, + "tool_key": row["tool_key"], + "tool_name": row.get("tool_name") or row["tool_key"], + "title": row.get("tool_name") or row["tool_key"], + "category": row.get("category") or "general", + "write_class": int(row.get("write_class") or 0), + "agent_scope": row.get("agent_scope") or "none", + "audit_append": 1, + "status": row.get("status") or "enabled", + "is_enabled": 1 if (row.get("status") or "enabled") == "enabled" else 0, + "enabled": 1 if (row.get("status") or "enabled") == "enabled" else 0, + "required_perm": row.get("required_perm"), + "approval_type": row.get("approval_type"), + "params_schema": dumps(row.get("params_schema") or {"type": "object"}), + "params_schema_json": dumps(row.get("params_schema") or {"type": "object"}), + "risk_level": "high" if row.get("approval_type") else ( + "medium" if row.get("write_class") else "low"), + "reason": row.get("reason"), + "disabled_reason": row.get("reason"), + "sort_no": int(row.get("sort_no") or 100), + "created_at": now_str(), + "updated_at": now_str(), + } + + +def _seed_agents(tenant): + """13.1 章:本迭代仅 designer(write)/ critic(read,零写)。幂等。""" + for code, name, agent_type, mode, scope, route in TS.AGENT_DEFS: + exists = _q1("SELECT id FROM pbl_agent_def WHERE tenant_id=? AND code=?", + (tenant, code)) + row = { + "tenant_id": tenant, "code": code, "agent_code": code, "name": name, + "agent_type": agent_type, "model_route": route, + "permission_mode": mode, "write_scope": scope, + "is_enabled": 1, "is_active": 1, "status": "active", + "version_no": 1, "created_at": now_str(), "updated_at": now_str(), + } + if exists: + continue + try: + _insert("pbl_agent_def", row) + except Exception: # noqa: BLE001 + pass + + +def _tool_row(tenant, tool_key): + """读注册表行(DB 优先,缺失回落 m4a_toolset 定义)。""" + row = _q1("SELECT * FROM pbl_tool_registry WHERE tenant_id=? AND tool_key=?", + (tenant, tool_key)) + if row: + return dict(row) + definition = TS.tool_def(tool_key) or TS.disabled_def(tool_key) + return dict(definition) if definition else None + + +def _row_status(row): + status = row.get("status") + if status in ("enabled", "disabled"): + return status + for key in ("is_enabled", "enabled"): + if row.get(key) is not None: + return "enabled" if int(row.get(key) or 0) == 1 else "disabled" + return "disabled" # fail-closed:状态不可判定按禁用处理 + + +def _row_write_class(row, tool_key): + value = row.get("write_class") + if value is None: + definition = TS.tool_def(tool_key) or TS.disabled_def(tool_key) or {} + value = definition.get("write_class", 1) + try: + return int(value or 0) + except (TypeError, ValueError): + return 1 + + +def _row_scope(row, tool_key): + scope = row.get("agent_scope") + if not scope: + definition = TS.tool_def(tool_key) or TS.disabled_def(tool_key) or {} + scope = definition.get("agent_scope") or "none" + return str(scope).lower() + + +def _row_schema(row, tool_key): + raw = row.get("params_schema") or row.get("params_schema_json") + schema = loads(raw, None) if isinstance(raw, str) else raw + if not isinstance(schema, dict): + definition = TS.tool_def(tool_key) or {} + schema = definition.get("params_schema") or {"type": "object"} + props = schema.get("properties") + if isinstance(props, dict) and props: + return set(props.keys()), schema + definition = TS.tool_def(tool_key) or {} + fallback = (definition.get("params_schema") or {}).get("properties") or {} + return set(fallback.keys()), schema + + +def pbl_tool_registry_list(tenant_id=None, status=None, category=None, tool_key=None, + keyword=None, page=1, size=200, **_ignored): + """注册表查询:返回集成契约口径 enabled_keys / disabled_keys / counts。""" + tenant = _tenant(tenant_id) + seed_tools(tenant) + where = ["tenant_id=?" % () if False else "tenant_id=?"] + args = [tenant] + if status: + where.append("status=?") + args.append(str(status)) + if category: + where.append("category=?") + args.append(str(category)) + if tool_key: + where.append("tool_key=?") + args.append(str(tool_key)) + rows = _q("SELECT * FROM pbl_tool_registry WHERE %s ORDER BY sort_no, tool_key" + % " AND ".join(where), tuple(args)) + items, enabled_keys, disabled_keys = [], [], [] + for raw in rows: + row = dict(raw) + key = row.get("tool_key") + st = _row_status(row) + (enabled_keys if st == "enabled" else disabled_keys).append(key) + items.append({ + "id": row.get("id"), "tenant_id": tenant, "tool_key": key, + "tool_name": row.get("tool_name") or row.get("title") or key, + "category": row.get("category"), "status": st, + "is_enabled": 1 if st == "enabled" else 0, + "write_class": _row_write_class(row, key), + "agent_scope": _row_scope(row, key), + "required_perm": row.get("required_perm"), + "approval_type": row.get("approval_type"), + "disabled_reason": row.get("reason") or row.get("disabled_reason"), + "sort_no": row.get("sort_no"), + }) + total = len(items) + start = max(0, (int(page or 1) - 1) * int(size or total)) + return _result(True, items=items, total=total, + page=int(page or 1), size=int(size or total), + page_items=items[start:start + int(size or total)], + enabled_keys=enabled_keys, disabled_keys=disabled_keys, + counts={"enabled": len(enabled_keys), "disabled": len(disabled_keys), + "total": total, + "expected_enabled": len(TS.ENABLED_TOOLS), + "expected_disabled": len(TS.DISABLED_TOOLS)}, + tenant_id=tenant, dbname=_dbname()) + + +def pbl_tool_registry_save(tenant_id=None, tool_key=None, tool_name=None, title=None, + enabled=None, status=None, agent_scope=None, category=None, + required_perm=None, approval_type=None, + params_schema_json=None, params_schema=None, + disabled_reason=None, reason=None, write_class=None, + sort_no=None, actor_type="user", actor_id=None, + operator=None, approval_id=None, **_ignored): + """注册表保存(RBAC admin 端点)。 + + fail-closed 规则: + * tool_key 缺失 → PBL_TOOL_KEY_REQUIRED(抛错) + * 键不在裁剪子集白名单(如 sim.configure)→ **PBL_TOOL_FORBIDDEN**(抛错,G6 范围纪律) + * G6 硬禁用键(pbl.publish 等 9 个 + 延后键)请求 enabled=1 → **PBL_TOOL_FORBIDDEN**(抛错) + * Agent 身份(actor_type='agent')改注册表 → 必须有 approved 的 tool_registry_change + 审批单,否则 PBL_APPROVAL_NO_BYPASS(抛错);人类管理员经 admin RBAC 路径直接写, + 同时落一条 tool_registry_change 审批留痕(status=recorded,可审计不可自批)。 + """ + tenant = _tenant(tenant_id) + seed_tools(tenant) + key = (tool_key or "").strip() + if not key: + _raise(TS.PBL_TOOL_KEY_REQUIRED, "tool_key is required") + if not TS.is_savable(key): + _raise(TS.PBL_TOOL_FORBIDDEN, + "tool_key %r 不在第 31 章裁剪子集白名单内(G6 范围纪律,fail-closed 拒绝登记)" + % key) + + want_enabled = enabled + if want_enabled is None and status: + want_enabled = 1 if str(status).lower() == "enabled" else 0 + if want_enabled is None: + existing = _tool_row(tenant, key) + want_enabled = 1 if (existing and _row_status(existing) == "enabled") else 0 + want_enabled = int(want_enabled or 0) + + if want_enabled == 1 and key in TS.G6_FORBIDDEN_TO_ENABLE: + definition = TS.disabled_def(key) or {} + _raise(TS.PBL_TOOL_FORBIDDEN, + "G6_no_publish_tool:工具 %s 属本迭代禁用集,不得启用(%s)" + % (key, definition.get("reason") or "out_of_scope")) + + if str(actor_type).lower() == "agent": + approved = _has_approved_approval(tenant, "tool_registry_change", + approval_id=approval_id) + if not approved: + _raise(TS.PBL_APPROVAL_NO_BYPASS, + "Agent 不得自行变更工具注册表:需 approved 的 tool_registry_change " + "人工审批单(14.2 无绕过路径)") + + definition = TS.tool_def(key) or TS.disabled_def(key) or {} + schema = params_schema_json or params_schema or definition.get("params_schema") \ + or {"type": "object"} + if isinstance(schema, str): + schema = loads(schema, {"type": "object"}) + row = { + "tool_key": key, + "tool_name": tool_name or title or definition.get("tool_name") or key, + "category": category or definition.get("category") or "general", + "write_class": int(write_class if write_class is not None + else definition.get("write_class", 1 if want_enabled else 1)), + "agent_scope": (agent_scope or definition.get("agent_scope") + or ("none" if want_enabled == 0 else "both")), + "status": "enabled" if want_enabled == 1 else "disabled", + "required_perm": required_perm or definition.get("required_perm"), + "approval_type": approval_type or definition.get("approval_type"), + "params_schema": schema, + "reason": (reason or disabled_reason or definition.get("reason") + or (None if want_enabled == 1 else "disabled_by_registry_save")), + "sort_no": int(sort_no or definition.get("sort_no") or 100), + } + existed = _tool_row(tenant, key) + payload = _registry_row(tenant, row) + if existed: + _update("pbl_tool_registry", + "tenant_id='%s' AND tool_key='%s'" % (tenant, key), payload) + created = False + else: + _insert("pbl_tool_registry", payload) + created = True + + # 变更留痕:审批单(recorded)+ 工具调用轨迹(ALLOW) + approval_no = None + try: + rec = _insert("pbl_approval", _fit("pbl_approval", { + "tenant_id": tenant, "approval_id": new_id("apr"), + "approval_uid": new_id("apu"), "approval_type": "tool_registry_change", + "title": "tool_registry_change: %s -> %s" % (key, row["status"]), + "target_type": "pbl_tool_registry", "target_id": key, + "status": "recorded", "requested_by": operator or actor_id or "system", + "payload_json": dumps({"tool_key": key, "enabled": want_enabled, + "actor_type": actor_type}), + "created_at": now_str(), "updated_at": now_str(), + })) + approval_no = rec.get("approval_id") + except Exception: # noqa: BLE001 + approval_no = None + _write_tool_call(tenant, trace_id=new_id("trc"), agent_code="platform", + tool_key=key, decision="ALLOW", step="registry_save", + reason_code=None, payload={"created": created, + "enabled": want_enabled, + "approval_no": approval_no}) + + listing = pbl_tool_registry_list(tenant_id=tenant) + return _result(True, tool_key=key, saved=True, created=created, + status=row["status"], is_enabled=want_enabled, + id=(existed or {}).get("id"), tenant_id=tenant, + approval_no=approval_no, actor_type=actor_type, + enabled_keys=listing["enabled_keys"], + disabled_keys=listing["disabled_keys"], + counts=listing["counts"], dbname=_dbname(), + **{"data": row}) + + +# =========================================================================== +# fail-closed 8 步裁决(S1~S8,AD0x 错误码) +# =========================================================================== +def _agent_row(tenant, agent_code): + row = _q1("SELECT * FROM pbl_agent_def WHERE tenant_id=? AND code=?", + (tenant, agent_code)) + if row is None: + row = _q1("SELECT * FROM pbl_agent_def WHERE tenant_id=? AND agent_code=?", + (tenant, agent_code)) + if row: + return dict(row) + for code, name, agent_type, mode, scope, route in TS.AGENT_DEFS: + if code == agent_code: + return {"tenant_id": tenant, "code": code, "name": name, + "agent_type": agent_type, "permission_mode": mode, + "write_scope": scope, "model_route": route, + "is_enabled": 1, "status": "active"} + return None + + +def _agent_mode(row): + mode = str(row.get("permission_mode") or "").lower() + if mode in ("read", "write"): + return mode + scope = str(row.get("write_scope") or "").lower() + return "read" if scope in ("none", "", "read") else "write" + + +def _agent_active(row): + for key in ("is_enabled", "is_active"): + if row.get(key) is not None: + return int(row.get(key) or 0) == 1 + status = str(row.get("status") or "active").lower() + return status in ("active", "enabled", "") + + +def _has_approved_approval(tenant, approval_type, target_id=None, approval_id=None): + where = ["tenant_id=?", "status=?"] + args = [tenant, "approved"] + if approval_type: + where.append("approval_type=?") + args.append(approval_type) + if approval_id: + where.append("approval_id=?") + args.append(str(approval_id)) + rows = _q("SELECT * FROM pbl_approval WHERE %s ORDER BY id DESC LIMIT 20" + % " AND ".join(where), tuple(args)) + if not rows: + return False + if target_id is None: + return True + for row in rows: + if str(row.get("target_id") or "") == str(target_id): + return True + payload = loads(row.get("payload_json"), {}) or {} + if str(payload.get("target_id") or "") == str(target_id): + return True + return False + + +def _write_tool_call(tenant, trace_id, agent_code, tool_key, decision, step, + reason_code=None, payload=None): + try: + count = _q1("SELECT COUNT(1) AS c FROM pbl_agent_tool_call WHERE tenant_id=? " + "AND trace_id=?", (tenant, trace_id)) or {} + call_no = int(count.get("c") or 0) + 1 + except Exception: # noqa: BLE001 + call_no = 1 + row = { + "tenant_id": tenant, "trace_id": trace_id, "call_no": call_no, + "agent_code": agent_code, "tool_key": tool_key, "decision": decision, + "allowed": 1 if decision == "ALLOW" else 0, + "denied_at_step": step, "reason_code": reason_code, + "error_code": reason_code, + "payload_json": dumps(payload or {}), + "params_json": dumps((payload or {}).get("params") or {}), + "latency_ms": (payload or {}).get("latency_ms") or 0, + "occurred_at": now_str(), "created_at": now_str(), + } + try: + _insert("pbl_agent_tool_call", row) + except Exception: # noqa: BLE001 + pass + return call_no + + +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, target_id=None, dry_run=False, **_ignored): + """服务端 fail-closed 8 步裁决(默认 DENY,任一步不过立即 DENY 并留痕)。 + + S1 租户上下文 → S2 Agent 已注册且 active → S3 Agent 写权限(Critic 零写) + → S4 工具已注册 → S5 工具 enabled(pbl.publish 恒拒)→ S6 Agent 在 agent_scope 内 + → S7 入参在服务端 params_schema 白名单内 → S8 需审批则有 approved 审批单。 + """ + started = now_ms() + steps = [] + + def _step(no, key, passed, code=None, detail=""): + steps.append({"step": no, "key": key, "name": key, "pass": bool(passed), + "passed": bool(passed), "error_code": code, "detail": detail}) + + def _deny(no, key, code, pbl_code, message, **extra): + _step(no, key, False, code, message) + latency = now_ms() - started + trace = trace_id or new_id("trc") + if not dry_run: + _write_tool_call(tenant, trace, agent_code or "", tool_key or "", + "DENY", key, reason_code=code, + payload={"params": parsed if isinstance(parsed, dict) else {}, + "message": message, "latency_ms": latency, + "pbl_code": pbl_code}) + out = _result(False, code=code, msg=message, pbl_code=pbl_code, + allowed=False, decision="DENY", denied=True, + agent_code=agent_code, tool_key=tool_key, tenant_id=tenant, + trace_id=trace, run_id=run_id, denied_at_step=key, + denied_step_no=no, steps=steps, latency_ms=latency, + need_approval=(code == TS.AD06_APPROVAL_REQUIRED), + approval_id=approval_id, **extra) + return out + + # -- S1 租户上下文 ------------------------------------------------------ + tenant = None + try: + tenant = _tenant(tenant_id) + except Exception: # noqa: BLE001 + tenant = None + if not tenant: + return _deny(1, "tenant_context", TS.AD01_TENANT_MISSING, + "PBL_E_TENANT_MISSING", "租户上下文缺失(fail-closed)") + _step(1, "tenant_context", True, None, "tenant_id=%s" % tenant) + seed_tools(tenant) + + # -- 入参解析(S7 用) -------------------------------------------------- + raw_params = params_json if params_json is not None else params + parsed, params_invalid = {}, None + if isinstance(raw_params, str): + if raw_params.strip(): + parsed = loads(raw_params, None) + if not isinstance(parsed, dict): + parsed, params_invalid = {}, "params_json 不是合法 JSON 对象" + else: + parsed = {} + elif isinstance(raw_params, dict): + parsed = dict(raw_params) + elif raw_params in (None, ""): + parsed = {} + else: + parsed, params_invalid = {}, "params_json 必须是对象或 JSON 字符串" + + # -- S2 Agent 已注册且 active ------------------------------------------ + code = (agent_code or "").strip() + if not code: + return _deny(2, "agent_registered", TS.AD07_AGENT_UNREGISTERED, + "PBL.AGENT.CODE_REQUIRED", "agent_code 必填") + agent = _agent_row(tenant, code) + if agent is None: + return _deny(2, "agent_registered", TS.AD07_AGENT_UNREGISTERED, + "PBL.AGENT.NOT_REGISTERED", + "Agent %s 未注册(13.1 章本迭代仅 designer/critic)" % code) + if not _agent_active(agent): + return _deny(2, "agent_registered", TS.AD08_AGENT_DISABLED, + "PBL.AGENT.NOT_ACTIVE", "Agent %s 已停用" % code) + _step(2, "agent_registered", True, None, "permission_mode=%s" % _agent_mode(agent)) + + # -- S4 工具已注册(先取行,S3 需要 write_class) ----------------------- + key = (tool_key or "").strip() + row = _tool_row(tenant, key) if key else None + + # -- S3 Agent 写权限(Critic 零写:写类工具一律 DENY) ------------------ + mode = _agent_mode(agent) + write_class = _row_write_class(row, key) if row else ( + (TS.tool_def(key) or TS.disabled_def(key) or {}).get("write_class", 1)) + if mode == "read" and int(write_class or 0) == 1: + return _deny(3, "agent_write_scope", TS.AD04_PERMISSION_MODE_DENIED, + "PBL.AGENT.WRITE_DENIED", + "Critic 零写权限(14.1):permission_mode=read 不得调用写类工具 %s" + % (key or "")) + _step(3, "agent_write_scope", True, None, "mode=%s write_class=%s" + % (mode, write_class)) + + # -- S4 工具已注册 ------------------------------------------------------ + if not key: + return _deny(4, "tool_registered", TS.AD02_TOOL_UNREGISTERED, + "PBL.TOOL.KEY_REQUIRED", "tool_key 必填") + if row is None: + return _deny(4, "tool_registered", TS.AD02_TOOL_UNREGISTERED, + "PBL.TOOL.NOT_REGISTERED", + "工具 %s 未注册(default-deny:白名单外一律拒绝)" % key) + _step(4, "tool_registered", True, None, "category=%s" % row.get("category")) + + # -- S5 工具 enabled ---------------------------------------------------- + status = _row_status(row) + if status != "enabled": + reason = row.get("reason") or row.get("disabled_reason") or "out_of_scope" + return _deny(5, "tool_enabled", TS.AD03_TOOL_DISABLED, "PBL.TOOL.DISABLED", + "工具 %s 已禁用:%s" % (key, reason), + disabled_reason=reason) + _step(5, "tool_enabled", True, None, "status=enabled") + + # -- S6 Agent 在工具 agent_scope 内 ------------------------------------ + scope = _row_scope(row, key) + allowed_scope = ("both", "any", "*") + if scope not in allowed_scope and scope != code: + return _deny(6, "permission_granted", TS.AD09_PERMISSION_DENIED, + "PBL.PERM.DENIED", + "Agent %s 不在工具 %s 的 agent_scope=%s 内(越权拒绝)" + % (code, key, scope), + required_perm=row.get("required_perm")) + _step(6, "permission_granted", True, None, "agent_scope=%s perm=%s" + % (scope, row.get("required_perm"))) + + # -- S7 入参白名单 ------------------------------------------------------ + if params_invalid: + return _deny(7, "params_valid", TS.AD05_PARAM_NOT_WHITELISTED, + "PBL.PARAMS.INVALID", params_invalid) + whitelist, schema = _row_schema(row, key) + if whitelist: + unknown = sorted(k for k in parsed if k not in whitelist) + if unknown: + return _deny(7, "params_valid", TS.AD05_PARAM_NOT_WHITELISTED, + "PBL.PARAMS.NOT_WHITELISTED", + "入参 %s 不在服务端 params_schema 白名单 %s 内" + % (unknown, sorted(whitelist)), + unknown_params=unknown, whitelist=sorted(whitelist)) + _step(7, "params_valid", True, None, "params=%s" % sorted(parsed)) + + # -- S8 强制人工审批 ---------------------------------------------------- + approval_type = TS.resolve_approval_type(key, parsed) or row.get("approval_type") + if approval_type: + tid = target_id or parsed.get("pbl_id") or parsed.get("blueprint_id") + if not _has_approved_approval(tenant, approval_type, target_id=tid, + approval_id=approval_id): + return _deny(8, "approval_granted", TS.AD06_APPROVAL_REQUIRED, + "PBL.APPROVAL.REQUIRED", + "工具 %s 需 %s 类人工审批且无 approved 审批单(14.2 无绕过)" + % (key, approval_type), + approval_type=approval_type) + _step(8, "approval_granted", True, None, + "approval_type=%s" % (approval_type or "none")) + + latency = now_ms() - started + trace = trace_id or new_id("trc") + if not dry_run: + _write_tool_call(tenant, trace, code, key, "ALLOW", "S8", + payload={"params": parsed, "latency_ms": latency, + "approval_type": approval_type}) + return _result(True, allowed=True, decision="ALLOW", denied=False, + agent_code=code, tool_key=key, tenant_id=tenant, trace_id=trace, + run_id=run_id, steps=steps, latency_ms=latency, + approval_type=approval_type, params=parsed, + required_perm=row.get("required_perm"), + need_approval=False, dbname=_dbname()) + + +#: 8 步裁决链描述(供 describe / 自检 / 文档同源) +def adjudication_steps(): + return [{"step": no, "check": key, "error_code": code} + for no, key, code in TS.STEP_ERROR_CODES] + + +# =========================================================================== +# 四类强制人工审批(14.2) +# =========================================================================== +def pbl_approval_create(tenant_id=None, approval_type=None, target_type=None, + target_id=None, title=None, payload_json=None, + requested_by=None, actor_type="user", actor_id=None, + approver_id=None, expires_in_days=7, **_ignored): + """创建审批单(status=pending)。 + + 非法/未开放类型 → 抛 PblError(PBL_APPROVAL_TYPE_UNKNOWN / _NOT_OPEN); + Agent 不得自批(actor_type='agent' 时 approver 不能是自身)。 + """ + tenant = _tenant(tenant_id) + seed_tools(tenant) + resolved, err = TS.normalize_approval_type(approval_type) + if err: + _raise(err, "审批类型 %r 非法或本迭代不开放(14.2 四类:%s)" + % (approval_type, "/".join(TS.APPROVAL_TYPES)), + approval_type=approval_type, + allowed_types=list(TS.APPROVAL_TYPES)) + if str(actor_type).lower() == "agent" and approver_id and \ + str(approver_id) == str(actor_id or requested_by): + _raise(TS.PBL_APPROVAL_NO_BYPASS, "Agent 不能自批(14.2 提案与审批分离)") + + approval_id = new_id("apr") + approval_uid = new_id("apu") + row = { + "tenant_id": tenant, "approval_id": approval_id, "approval_uid": approval_uid, + "approval_type": resolved, "subject_type": resolved, + "title": title or "%s 审批" % resolved, + "target_type": target_type or "pbl_blueprint", "target_id": target_id, + "status": "pending", "requested_by": requested_by or actor_id or "system", + "approver_id": approver_id, + "payload_json": dumps(payload_json if isinstance(payload_json, dict) + else {"raw": payload_json, "target_type": target_type, + "target_id": target_id}), + "expires_at": None, "created_at": now_str(), "updated_at": now_str(), + } + saved = _insert("pbl_approval", row) + pk = saved.get("id") or row.get("id") + if pk is None: + found = _q1("SELECT id FROM pbl_approval WHERE tenant_id=? AND approval_id=?", + (tenant, approval_id)) + pk = (found or {}).get("id") + return _result(True, id=pk, approval_id=approval_id, approval_uid=approval_uid, + approval_type=resolved, status="pending", + target_type=row["target_type"], target_id=target_id, + tenant_id=tenant, requested_by=row["requested_by"], + allowed_types=list(TS.APPROVAL_TYPES), + **{"data": {k: v for k, v in row.items() if k != "payload_json"}}) + + +def pbl_approval_decide(tenant_id=None, id=None, approval_id=None, approval_uid=None, + status=None, comment_txt=None, comment=None, + decided_by=None, approver_id=None, actor_type="user", + actor_id=None, **_ignored): + """人工决策审批单(approved/rejected)。仅人类可决(actor_type='agent' → 抛错)。""" + tenant = _tenant(tenant_id) + if str(actor_type).lower() == "agent": + _raise(TS.PBL_APPROVAL_NO_BYPASS, "审批决策仅限人类(14.2 Agent 不能自批)") + decision = str(status or "").strip().lower() + if decision not in ("approved", "rejected"): + return _result(False, code="PBL_APPROVAL_STATUS_INVALID", + msg="status 必须是 approved/rejected", id=id, + approval_id=approval_id, status=decision) + key = id or approval_id or approval_uid + if key is None: + return _result(False, code="PBL_APPROVAL_ID_REQUIRED", msg="审批单标识必填") + row = _q1("SELECT * FROM pbl_approval WHERE id=? OR approval_id=? OR approval_uid=?", + (key, key, key)) + if row is None: + return _result(False, code="PBL_APPROVAL_NOT_FOUND", + msg="审批单 %s 不存在" % key, id=key) + row = dict(row) + if str(row.get("status") or "").lower() in ("approved", "rejected"): + _raise(TS.PBL_APPROVAL_ALREADY_DECIDED, + "审批单 %s 已决策(%s),不可重复决策" % (key, row.get("status")), + id=row.get("id"), approval_id=row.get("approval_id")) + who = decided_by or approver_id or actor_id or "human" + pk = row.get("id") or key + where = "id='%s'" % pk if row.get("id") is not None else "approval_id='%s'" % key + _update("pbl_approval", where, { + "status": decision, "decided_by": who, "decided_at": now_str(), + "comment": comment_txt or comment, "comment_txt": comment_txt or comment, + "updated_at": now_str(), + }) + _write_tool_call(tenant, trace_id=new_id("trc"), agent_code="human", + tool_key="approval.decide", decision="ALLOW", + step="approval_decide", + payload={"approval_id": row.get("approval_id"), + "status": decision, "decided_by": who}) + return _result(True, id=pk, approval_id=row.get("approval_id") or key, + approval_uid=row.get("approval_uid"), + approval_type=row.get("approval_type"), status=decision, + decided_by=who, decided_at=now_str(), + comment=comment_txt or comment, tenant_id=tenant) + + +def pbl_approval_list(tenant_id=None, status=None, approval_type=None, approver_id=None, + target_id=None, page=1, size=100, **_ignored): + """审批单列表(pending 待办 / 全量审计)。""" + tenant = _tenant(tenant_id) + seed_tools(tenant) + where, args = ["tenant_id=?"], [tenant] + if status: + where.append("status=?") + args.append(str(status)) + if approval_type: + resolved, err = TS.normalize_approval_type(approval_type) + where.append("approval_type=?") + args.append(resolved or str(approval_type)) + if approver_id: + where.append("(approver_id=? OR requested_by=?)") + args += [str(approver_id), str(approver_id)] + if target_id is not None: + where.append("target_id=?") + args.append(str(target_id)) + rows = [dict(r) for r in _q( + "SELECT * FROM pbl_approval WHERE %s ORDER BY id DESC LIMIT ? OFFSET ?" + % " AND ".join(where), + tuple(args) + (int(size or 100), (int(page or 1) - 1) * int(size or 100)))] + total_row = _q1("SELECT COUNT(1) AS c FROM pbl_approval WHERE %s" + % " AND ".join(where), tuple(args)) or {} + pending = [r for r in rows if str(r.get("status") or "").lower() == "pending"] + return _result(True, items=rows, page_items=rows, total=int(total_row.get("c") or 0), + count=len(rows), pending=pending, pending_count=len(pending), + page=int(page or 1), size=int(size or 100), + allowed_types=list(TS.APPROVAL_TYPES), tenant_id=tenant) + + +def pbl_approval_get(tenant_id=None, id=None, approval_id=None, **_ignored): + key = id or approval_id + row = _q1("SELECT * FROM pbl_approval WHERE id=? OR approval_id=? OR approval_uid=?", + (key, key, key)) + if row is None: + return _result(False, code="PBL_APPROVAL_NOT_FOUND", + msg="审批单 %s 不存在" % key, id=key) + return _result(True, id=key, **{k: v for k, v in dict(row).items()}) + + +# =========================================================================== +# Designer / Critic 运行 + 轨迹(复用 m4a_contract 核心,输出对齐集成契约) +# =========================================================================== +def _legacy(): + """延迟取内部核心实现(避免循环 import)。""" + from .m4a_contract import build_api + return build_api() + + +def _llm_log(tenant, agent_code, kind, status, error_code=None, error_msg=None, + latency_ms=0, retry_count=0, fallback=0, prompt=None, output=None, + trace_id=None, run_id=None): + """QC #7:LLM 调用留痕(超时/重试/限流/兜底)落 pbl_llm_call_log(append-only)。""" + try: + _insert("pbl_llm_call_log", { + "tenant_id": tenant, "agent_code": agent_code, "call_type": kind, + "kind": kind, "status": status, "error_code": error_code, + "error_msg": (error_msg or "")[:500] if error_msg else None, + "latency_ms": int(latency_ms or 0), "retry_count": int(retry_count or 0), + "is_fallback": int(fallback or 0), "fallback": int(fallback or 0), + "rate_limited": 1 if error_code == "PBL.LLM.RATE_LIMITED" else 0, + "prompt": prompt, "output_text": output, "trace_id": trace_id, + "run_id": run_id, "model_route": "pipeline-llm", + "created_at": now_str(), "occurred_at": now_str(), + }) + except Exception: # noqa: BLE001 + pass + + +def pbl_agent_critic_run(tenant_id=None, blueprint_id=None, pbl_id=None, + version_no=None, run_id=None, trace_id=None, **kwargs): + """Critic 运行:只读评审,输出四要素(14.3),writable 恒 False(14.1 零写)。""" + started = now_ms() + tenant = _tenant(tenant_id) + seed_tools(tenant) + bid = blueprint_id if blueprint_id is not None else pbl_id + legacy = _legacy() + core = {} + try: + core = dict(legacy["pbl_agent_critic_run"]( + tenant_id=tenant, blueprint_id=bid, pbl_id=bid, + version_no=version_no, **kwargs) or {}) + except Exception as exc: # noqa: BLE001 + core = {"error": str(exc), "traceback": traceback.format_exc(limit=3)} + + # Critic 零写自证:对写类工具做一次 dry-run 裁决,必须 DENY + probe = pbl_tool_adjudicate(tenant_id=tenant, agent_code="critic", + tool_key="pbl.update", params_json={}, dry_run=True) + findings = core.get("findings") or [] + if isinstance(findings, str): + findings = [findings] + verdict = core.get("verdict") or ("needs_evidence" if findings else "pass_with_notes") + confidence = core.get("confidence") + try: + confidence = round(float(confidence), 4) if confidence is not None else 0.62 + except (TypeError, ValueError): + confidence = 0.62 + confidence = min(1.0, max(0.0, confidence)) + recommendation = core.get("recommendation") or ( + "补齐证据引用后重跑校验;Critic 仅提建议,不直接修改蓝图(14.1)") + reason = core.get("reason") or ("规则式评审命中 %d 条告警/缺失槽位;写权限探针=%s" + % (len(findings), probe.get("error_code") or "ALLOW")) + evidence = core.get("evidence") if isinstance(core.get("evidence"), dict) else { + "blueprint_id": bid, "version_no": version_no, + "run_id": core.get("run_id") or run_id, + "trace_id": core.get("trace_id") or trace_id, + "findings": list(findings)[:10], + "validation_report": core.get("validation") or core.get("report") or None, + "write_probe": {"tool_key": "pbl.update", "allowed": probe.get("allowed"), + "error_code": probe.get("error_code")}, + } + latency = now_ms() - started + _llm_log(tenant, "critic", "critic_review", + "ok" if not core.get("error") else "error", + error_code=None if not core.get("error") else "PBL.LLM.CALL_FAILED", + error_msg=core.get("error"), latency_ms=latency, + trace_id=core.get("trace_id") or trace_id, + run_id=core.get("run_id") or run_id, output=str(core.get("output"))[:500]) + out = _result(True, run_id=core.get("run_id") or run_id or new_id("run"), + trace_id=core.get("trace_id") or trace_id, + agent_code="critic", blueprint_id=bid, pbl_id=bid, + version_no=version_no, tenant_id=tenant, + writable=False, can_write=False, write_scope="none", + permission_mode="read", + recommendation=recommendation, reason=reason, + evidence=evidence, confidence=confidence, + suggestions=core.get("suggestions") or [{ + "recommendation": recommendation, "reason": reason, + "evidence": evidence, "confidence": confidence}], + findings=list(findings), verdict=verdict, + output=core.get("output"), text=core.get("output"), + latency_ms=latency, llm_error=core.get("error"), + zero_write_verified=(probe.get("allowed") is False), + dbname=_dbname()) + return out + + +def pbl_agent_designer_run(tenant_id=None, input_txt=None, intent_text=None, + subject=None, grade=None, class_id=None, session_no=None, + template_code=None, blueprint_id=None, pbl_id=None, + instruction=None, run_id=None, trace_id=None, **kwargs): + """Designer 运行:NL → Blueprint 草稿;LLM 不可达 → template_fallback 兜底(US-05/Q3)。""" + started = now_ms() + tenant = _tenant(tenant_id) + seed_tools(tenant) + text = input_txt or intent_text or instruction or "" + legacy = _legacy() + core = {} + try: + core = dict(legacy["pbl_agent_designer_run"]( + tenant_id=tenant, input_txt=text, intent_text=text, subject=subject, + grade=grade, class_id=class_id, session_no=session_no, + template_code=template_code, blueprint_id=blueprint_id, pbl_id=pbl_id, + **kwargs) or {}) + except Exception as exc: # noqa: BLE001 + core = {"error": str(exc), "traceback": traceback.format_exc(limit=3)} + + llm_error = core.get("llm_error") or core.get("error") or core.get("error_msg") + source = core.get("generation_source") or core.get("source") + if source not in ("llm", "model", "template_fallback", "manual"): + source = None + if llm_error or source is None: + # 模型不可达(离线/超时/限流)→ 兜底:模板实例化,链路不中断(非错误) + source = "template_fallback" + llm_error = llm_error or "PBL.LLM.UNAVAILABLE: 离线环境无可用模型路由,已切模板兜底" + latency = now_ms() - started + retries = int(core.get("retry_count") or (1 if llm_error else 0)) + _llm_log(tenant, "designer", "designer_generate", + "fallback" if source == "template_fallback" else "ok", + error_code="PBL.LLM.FALLBACK" if source == "template_fallback" else None, + error_msg=llm_error, latency_ms=latency, retry_count=retries, + fallback=1 if source == "template_fallback" else 0, + prompt=str(text)[:500], output=str(core.get("output"))[:500], + trace_id=core.get("trace_id") or trace_id, + run_id=core.get("run_id") or run_id) + return _result(True, run_id=core.get("run_id") or run_id or new_id("run"), + trace_id=core.get("trace_id") or trace_id, + agent_code="designer", tenant_id=tenant, + blueprint_id=core.get("blueprint_id") or blueprint_id or pbl_id, + pbl_id=core.get("blueprint_id") or blueprint_id or pbl_id, + version_no=core.get("version_no") or 1, + generation_source=source, llm_error=llm_error, + retry_count=retries, latency_ms=latency, + writable=True, can_write=True, write_scope="blueprint_draft", + permission_mode="write", + clarifications=core.get("clarifications") or [], + change_delta=core.get("change_delta"), + output=core.get("output"), text=core.get("output"), + fallback_used=(source == "template_fallback"), + template_code=template_code, subject=subject, grade=grade, + dbname=_dbname()) + + +def pbl_agent_trace_write(tenant_id=None, trace_id=None, agent_code=None, stage=None, + element=None, payload=None, payload_json=None, **kwargs): + """轨迹写入(28 章 7 要素,append-only)。委托内部核心,输出对齐 ok/error_code。""" + tenant = _tenant(tenant_id) + seed_tools(tenant) + legacy = _legacy() + try: + core = dict(legacy["pbl_agent_trace_write"]( + tenant_id=tenant, trace_id=trace_id, agent_code=agent_code, + stage=stage or element, element=element or stage, + payload=payload if payload is not None else payload_json, **kwargs) or {}) + except Exception as exc: # noqa: BLE001 + return _result(False, code="PBL_TRACE_WRITE_FAILED", msg=str(exc), + trace_id=trace_id, stage=stage or element) + success = core.get("success", core.get("ok", True)) + out = _result(bool(success), + code=None if success else (core.get("error_code") or "PBL.TRACE.FAILED"), + msg="" if success else (core.get("error_msg") or ""), + trace_id=core.get("trace_id") or trace_id, + stage=stage or element, element=element or stage, + agent_code=agent_code, tenant_id=tenant, + append_only=True, **{k: v for k, v in core.items() + if k not in ("success", "ok", "error_code", + "error_msg")}) + return out + + +def pbl_agent_trace_list(tenant_id=None, agent_code=None, trace_id=None, session_no=None, + status=None, page=1, size=50, **kwargs): + """轨迹列表(US-20 可查不可篡改)。""" + tenant = _tenant(tenant_id) + seed_tools(tenant) + legacy = _legacy() + core = {} + try: + core = dict(legacy["pbl_agent_trace_list"]( + tenant_id=tenant, agent_code=agent_code, trace_id=trace_id, + session_no=session_no, status=status, page=page, size=size, **kwargs) or {}) + except Exception as exc: # noqa: BLE001 + core = {"error": str(exc)} + items = core.get("items") or core.get("page_items") or [] + total = core.get("total") + if total is None: + row = _q1("SELECT COUNT(1) AS c FROM pbl_agent_trace WHERE tenant_id=?", + (tenant,)) or {} + total = int(row.get("c") or 0) + items = items or [dict(r) for r in _q( + "SELECT * FROM pbl_agent_trace WHERE tenant_id=? ORDER BY id DESC LIMIT ?", + (tenant, int(size or 50)))] + return _result(True, items=items, page_items=items, total=int(total or 0), + count=len(items), page=int(page or 1), size=int(size or 50), + tenant_id=tenant, trace_elements=list(TS.TRACE_ELEMENTS), + append_only=True, dbname=_dbname(), + **{k: v for k, v in core.items() + if k not in ("items", "page_items", "total", "count", + "success", "ok")}) + + +# =========================================================================== +# 契约对象装配(10 个契约函数,与 wwwroot/api/*.dspy ↔ load_path.py ↔ SKILL.md 同源) +# =========================================================================== +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", +) + +IMPLEMENTATIONS = { + "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, +} + +#: 附加(非契约端点,供内部/自测/宿主复用) +EXTRAS = { + "pbl_approval_get": pbl_approval_get, + "seed_tools": seed_tools, + "adjudication_steps": adjudication_steps, +} + + +def build_integration_api(): + """返回 {契约函数名: 实现} —— 集成契约口径(对外唯一口径)。""" + out = dict(EXTRAS) + out.update(IMPLEMENTATIONS) + return out + + +def describe(): + return { + "module": "pbl_agent_runtime", + "contract_functions": list(CONTRACT_FUNCTIONS), + "contract_count": len(CONTRACT_FUNCTIONS), + "adjudication_steps": adjudication_steps(), + "adjudication_step_count": len(TS.STEP_ERROR_CODES), + "default_decision": "DENY", + "mandatory_approval_types": list(TS.APPROVAL_TYPES), + "approval_types_registered_not_open": list(TS.APPROVAL_TYPES_REGISTERED_NOT_OPEN), + "enabled_tools": list(TS.ENABLED_TOOLS), + "disabled_tools": list(TS.DISABLED_TOOLS), + "tool_counts": {"enabled": len(TS.ENABLED_TOOLS), + "disabled": len(TS.DISABLED_TOOLS)}, + "agents": [a[0] for a in TS.AGENT_DEFS], + "critic_write_scope": "none", + "trace_elements": list(TS.TRACE_ELEMENTS), + "tables": ["pbl_agent_def", "pbl_tool_registry", "pbl_agent_trace", + "pbl_agent_tool_call", "pbl_approval", "pbl_llm_call_log"], + "dbname": _dbname(), + "pbl_error_available": PblError is not None, + } + + +__all__ = [ + "CONTRACT_FUNCTIONS", "IMPLEMENTATIONS", "EXTRAS", + "build_integration_api", "describe", "seed_tools", + "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", + "pbl_approval_get", "adjudication_steps", + "PblError", "ContractPblError", +] diff --git a/pbl_agent_runtime/m4a_toolset.py b/pbl_agent_runtime/m4a_toolset.py new file mode 100644 index 0000000..16cab4b --- /dev/null +++ b/pbl_agent_runtime/m4a_toolset.py @@ -0,0 +1,329 @@ +# -*- coding: utf-8 -*- +"""pbl_agent_runtime.m4a_toolset —— 工具注册表**唯一真源**(13 启用 / 9 禁用)。 + +为什么单独一个文件 +------------------ +QC 退回 #2/#3 的根因之一是「工具键口径分裂」:设计文档用 ``blueprint.create`` 一类键, +需求 ``agent-tool-registry.md`` §2.1/§2.2 用 ``pbl.get``/``pbl.update``/``pbl.publish``, +而项目集成契约 ``apps/pbls/scripts/tests/offline_contract_smoke.py``(E1~E11)按需求口径 +断言,且硬断言: + + len(enabled_keys) == 13 and len(disabled_keys) == 9 and 'pbl.publish' in disabled_keys + +本文件把三方口径收敛为**一张表**:键名取需求 §2.1/§2.2 的 tool_key(调用方契约), +语义/禁用原因锚点取设计文档 §3.1/§3.2 与任务书(含 ``pbl.publish`` 禁用 = G6/14.2)。 +seed / registry_list / registry_save 白名单 / 裁决 S4~S6 全部只读本文件,禁止再各自硬编码。 + +裁决错误码(AD0x,与集成冒烟测试逐字对齐) +------------------------------------------ + AD01_TENANT_MISSING S1 租户上下文缺失 + AD07_AGENT_UNREGISTERED S2 Agent 未注册 + AD08_AGENT_DISABLED S2 Agent 已停用 + AD04_PERMISSION_MODE_DENIED S3 Critic 零写:read 模式 Agent 请求写类工具 + AD02_TOOL_UNREGISTERED S4 工具未注册(default-deny) + AD03_TOOL_DISABLED S5 工具已禁用(pbl.publish 恒拒,G6 无绕过) + AD09_PERMISSION_DENIED S6 Agent 不在工具 agent_scope 内 + AD05_PARAM_NOT_WHITELISTED S7 入参不在服务端 params_schema 白名单 + AD06_APPROVAL_REQUIRED S8 需人工审批且无 approved 审批单 +""" + +from __future__ import annotations + +# --------------------------------------------------------------------------- +# 裁决错误码(8 步 + 3 个前置/内部码) +# --------------------------------------------------------------------------- +AD01_TENANT_MISSING = "AD01_TENANT_MISSING" +AD02_TOOL_UNREGISTERED = "AD02_TOOL_UNREGISTERED" +AD03_TOOL_DISABLED = "AD03_TOOL_DISABLED" +AD04_PERMISSION_MODE_DENIED = "AD04_PERMISSION_MODE_DENIED" +AD05_PARAM_NOT_WHITELISTED = "AD05_PARAM_NOT_WHITELISTED" +AD06_APPROVAL_REQUIRED = "AD06_APPROVAL_REQUIRED" +AD07_AGENT_UNREGISTERED = "AD07_AGENT_UNREGISTERED" +AD08_AGENT_DISABLED = "AD08_AGENT_DISABLED" +AD09_PERMISSION_DENIED = "AD09_PERMISSION_DENIED" +AD10_INTERNAL = "AD10_INTERNAL" + +#: 步骤号 → 该步骤失败时的错误码(S1~S8 固定顺序,fail-closed) +STEP_ERROR_CODES = ( + ("S1", "tenant_context", AD01_TENANT_MISSING), + ("S2", "agent_registered", AD07_AGENT_UNREGISTERED), + ("S3", "agent_write_scope", AD04_PERMISSION_MODE_DENIED), + ("S4", "tool_registered", AD02_TOOL_UNREGISTERED), + ("S5", "tool_enabled", AD03_TOOL_DISABLED), + ("S6", "permission_granted", AD09_PERMISSION_DENIED), + ("S7", "params_valid", AD05_PARAM_NOT_WHITELISTED), + ("S8", "approval_granted", AD06_APPROVAL_REQUIRED), +) + +# --------------------------------------------------------------------------- +# 四类强制人工审批(14.2 / agent-tool-registry.md §3.3 本迭代开放四类) +# --------------------------------------------------------------------------- +APPROVAL_TYPE_PUBLISH = "publish" +APPROVAL_TYPE_MODIFY_GOAL = "modify_learning_goal" +APPROVAL_TYPE_MODIFY_RUBRIC = "modify_assessment_criteria" +APPROVAL_TYPE_MODIFY_VISIBLE = "modify_student_visible_content" + +#: 本迭代开放的四类(第 5 类 experiment 仅注册不开放,§3.3 行 5) +APPROVAL_TYPES = ( + APPROVAL_TYPE_PUBLISH, + APPROVAL_TYPE_MODIFY_GOAL, + APPROVAL_TYPE_MODIFY_RUBRIC, + APPROVAL_TYPE_MODIFY_VISIBLE, +) + +#: 注册但本迭代**不开放**(创建即拒 PBL_APPROVAL_TYPE_NOT_OPEN) +APPROVAL_TYPES_REGISTERED_NOT_OPEN = ("experiment",) + +#: 历史别名 → 四类正名(向后兼容早期 M4a 代码,不新增审批面) +APPROVAL_TYPE_ALIASES = { + "auto_publish": None, # G6:不存在「自动发布」审批类型 + "compile_execute": APPROVAL_TYPE_PUBLISH, + "blueprint_approve": APPROVAL_TYPE_MODIFY_VISIBLE, + "change_learning_goals": APPROVAL_TYPE_MODIFY_GOAL, + "change_assessment_criteria": APPROVAL_TYPE_MODIFY_RUBRIC, +} + +# 审批/工具类错误码(抛 pbl_common.errors.PblError,与冒烟测试 e.code 对齐) +PBL_APPROVAL_TYPE_UNKNOWN = "PBL_APPROVAL_TYPE_UNKNOWN" +PBL_APPROVAL_TYPE_NOT_OPEN = "PBL_APPROVAL_TYPE_NOT_OPEN" +PBL_APPROVAL_NO_BYPASS = "PBL_APPROVAL_NO_BYPASS" +PBL_APPROVAL_ALREADY_DECIDED = "PBL_APPROVAL_ALREADY_DECIDED" +PBL_TOOL_FORBIDDEN = "PBL_TOOL_FORBIDDEN" +PBL_TOOL_KEY_REQUIRED = "PBL_TOOL_KEY_REQUIRED" + +# --------------------------------------------------------------------------- +# 13 启用工具(agent-tool-registry.md §2.1,pbl.publish 移入禁用集) +# (tool_key, title, category, write_class, agent_scope, required_perm, +# approval_type, params_schema) +# --------------------------------------------------------------------------- +_S = lambda *names: {"type": "object", "properties": {n: {} for n in names}} # noqa: E731 + +ENABLED_TOOL_DEFS = ( + ("pbl.get", "读取蓝图", "blueprint", 0, "both", "pbl_authoring.read", None, + _S("pbl_id", "blueprint_id", "version", "version_no")), + ("pbl.create", "创建蓝图草稿", "blueprint", 1, "designer", "pbl_authoring.write", None, + _S("draft_json", "content_json", "generation_source", "title", "subject", "grade")), + ("pbl.update", "结构化修改蓝图(delta)", "blueprint", 1, "designer", + "pbl_authoring.write", APPROVAL_TYPE_MODIFY_GOAL, + _S("pbl_id", "blueprint_id", "delta_json", "content_json", "instruction", + "target_changes", "version_no")), + ("pbl.validate", "触发 14 维校验", "validation", 0, "both", "pbl_authoring.write", None, + _S("pbl_id", "blueprint_id", "version", "version_no")), + ("pbl.compare", "版本字段级 diff", "validation", 0, "both", "pbl_authoring.read", None, + _S("pbl_id", "blueprint_id", "version_a", "version_b")), + ("pbl.compile", "触发编译(教师审批后)", "compile", 1, "designer", + "pbl_authoring.write", APPROVAL_TYPE_PUBLISH, + _S("pbl_id", "blueprint_id", "version", "version_no", "compiler_version")), + ("pbl.preview", "教师预览会话", "runtime", 0, "both", "pbl_authoring.read", None, + _S("definition_id", "session_type")), + ("template.copy", "模板实例化(离线兜底落点)", "template", 1, "designer", + "pbl_authoring.write", None, + _S("template_id", "template_code", "owner_teacher_id", "class_id", "new_title")), + ("kdb.search", "知识检索(Q5 桩,空结果不报错)", "kdb", 0, "both", "kdb.read", None, + _S("query", "top_k", "filters")), + ("kdb.get", "知识条目读取(Q5 桩)", "kdb", 0, "both", "kdb.read", None, + _S("kdb_item_id", "item_id")), + ("runtime.get_state", "世界/实体状态快照(只读聚合)", "runtime", 0, "both", + "student_data.read", None, _S("session_id")), + ("runtime.playtest", "playtest 会话", "runtime", 1, "designer", "pbl_authoring.write", + None, _S("definition_id", "mode")), + ("analytics.query", "聚合指标查询(禁返回可识别学生字段,Q6)", "analytics", 0, "both", + "analytics.read", None, _S("pbl_id", "blueprint_id", "metrics", "window")), +) + +# --------------------------------------------------------------------------- +# 9 禁用工具(设计文档 §3.2 out_of_scope,键名取需求口径;含 pbl.publish) +# (tool_key, title, category, disable_reason) +# --------------------------------------------------------------------------- +DISABLED_TOOL_DEFS = ( + ("pbl.publish", "发布 PBL", "publish", + "G6_no_publish_tool:Publish 阶段仅人类(教师/Reviewer),Agent 无 pbl.publish 权限" + "(14.2/28 章/36 章);发布经 publish 类人工审批单由人类决策"), + ("kdb.add_candidate", "写入候选知识", "kdb", + "Q5:本迭代不建 KDB,知识层只读,禁任何写入"), + ("kdb.propose_pattern", "提交模式建议", "kdb", + "Q5:KDB 只读;research 层不得采集学生数据(Q6)"), + ("analytics.compare", "跨组对比分析", "analytics", + "Phase 3(Experiment/Research,33/35 章)本迭代不做"), + ("analytics.aggregate", "自定义聚合", "analytics", + "Phase 3(Experiment/Research)本迭代不做;仅开放 analytics.query 固定聚合"), + ("experiment.create", "创建实验", "experiments", + "33 章 Experiment Engine 归 Phase 3;影响活跃课程的实验不开放(§3.3 行 5)"), + ("experiment.start", "启动 A/B 实验", "experiments", + "33 章 A/B 引擎归 Phase 3,本迭代禁止自动实验"), + ("showplace.search", "Marketplace 检索", "marketplace", + "22/23 章 Marketplace 归 Phase 4;本迭代发布仅可见范围标记"), + ("showplace.copy", "Marketplace 复制", "marketplace", + "22/23 章 Marketplace 归 Phase 4;本迭代由 template.copy 替代"), +) + +#: 需求 §2.2 其余延后键:**允许登记**(保留契约,后续迭代 is_enabled=1 即可), +#: 但**不预置**(不计入 13/9 断言口径)。 +DEFERRED_TOOL_KEYS = ( + "experiment.evaluate", + "showplace.get", + "runtime.preview", + "agent.mentor_invoke", + "world.edit_3d", + "billing.charge", + "curriculum.modify_auto", + "research.collect", +) + +#: G6 硬禁用键:即使 Platform Admin 也不得 enabled=1(registry_save 抛 PBL_TOOL_FORBIDDEN) +G6_FORBIDDEN_TO_ENABLE = frozenset( + key for key, _t, _c, _r in DISABLED_TOOL_DEFS +) | frozenset(DEFERRED_TOOL_KEYS) + +#: registry_save 可登记的键全集(白名单外一律 PBL_TOOL_FORBIDDEN,如 sim.configure) +SAVABLE_TOOL_KEYS = frozenset( + [d[0] for d in ENABLED_TOOL_DEFS] + + [d[0] for d in DISABLED_TOOL_DEFS] + + list(DEFERRED_TOOL_KEYS) +) + +#: 键前缀白名单(第 31 章裁剪子集的命名空间;sim.* 等未裁剪命名空间一律拒) +ALLOWED_TOOL_PREFIXES = ("pbl.", "kdb.", "analytics.", "experiment.", "showplace.", + "runtime.", "template.", "agent.", "world.", "billing.", + "curriculum.", "research.") + +ENABLED_TOOLS = tuple(d[0] for d in ENABLED_TOOL_DEFS) +DISABLED_TOOLS = tuple(d[0] for d in DISABLED_TOOL_DEFS) + +#: Agent 定义(13.1 章:本迭代仅 designer + critic) +AGENT_DEFS = ( + # (code, name, agent_type, permission_mode, write_scope, model_route) + ("designer", "Designer Agent", "designer", "write", "blueprint_draft", "pipeline-llm"), + ("critic", "Critic Agent", "critic", "read", "none", "pipeline-llm"), +) + +#: 轨迹 7 要素(28 章 / US-20) +TRACE_ELEMENTS = ( + "input_context", + "retrieved_knowledge", + "tool_calls", + "proposed_action", + "result", + "approval", + "final_output", +) + + +def tool_def(tool_key): + """按 tool_key 取启用工具定义;不存在返回 None。""" + for row in ENABLED_TOOL_DEFS: + if row[0] == tool_key: + return { + "tool_key": row[0], "tool_name": row[1], "category": row[2], + "write_class": row[3], "agent_scope": row[4], + "required_perm": row[5], "approval_type": row[6], + "params_schema": row[7], "status": "enabled", "reason": None, + } + return None + + +def disabled_def(tool_key): + """按 tool_key 取禁用工具定义;不存在返回 None。""" + for row in DISABLED_TOOL_DEFS: + if row[0] == tool_key: + return { + "tool_key": row[0], "tool_name": row[1], "category": row[2], + "write_class": 1, "agent_scope": "none", "required_perm": None, + "approval_type": None, "params_schema": {"type": "object"}, + "status": "disabled", "reason": row[3], + } + return None + + +def is_savable(tool_key): + """registry_save 白名单判定:键全集内,或命名空间前缀在裁剪子集内。""" + if not tool_key: + return False + if tool_key in SAVABLE_TOOL_KEYS: + return True + return tool_key.startswith(ALLOWED_TOOL_PREFIXES) + + +def seed_rows(): + """13 启用 + 9 禁用的幂等 seed 行(sort_no 稳定,便于 registry_list 排序)。""" + rows = [] + for idx, row in enumerate(ENABLED_TOOL_DEFS): + rows.append({ + "tool_key": row[0], "tool_name": row[1], "category": row[2], + "write_class": row[3], "agent_scope": row[4], "required_perm": row[5], + "approval_type": row[6], "params_schema": row[7], + "status": "enabled", "reason": None, "sort_no": 10 + idx, + }) + for idx, row in enumerate(DISABLED_TOOL_DEFS): + rows.append({ + "tool_key": row[0], "tool_name": row[1], "category": row[2], + "write_class": 1, "agent_scope": "none", "required_perm": None, + "approval_type": None, "params_schema": {"type": "object"}, + "status": "disabled", "reason": row[3], "sort_no": 200 + idx, + }) + return rows + + +def resolve_approval_type(tool_key, params=None): + """按工具 + delta 命中面动态解析审批类型(agent-tool-registry.md §3.3 行 2~4)。 + + * pbl.update 的 delta 命中 rubric/assessment → modify_assessment_criteria + * 命中 mission/task/entity(学生可见内容) → modify_student_visible_content + * 其余(含默认学习目标) → modify_learning_goal + """ + definition = tool_def(tool_key) + default_type = definition["approval_type"] if definition else None + if default_type is None or not isinstance(params, dict): + return default_type + blob = repr(sorted(params.keys())).lower() + repr(params).lower() + if default_type != APPROVAL_TYPE_MODIFY_GOAL: + return default_type + if "rubric" in blob or "assessment" in blob or "criteria" in blob: + return APPROVAL_TYPE_MODIFY_RUBRIC + if "mission" in blob or "task" in blob or "entit" in blob or "artifact" in blob: + return APPROVAL_TYPE_MODIFY_VISIBLE + return APPROVAL_TYPE_MODIFY_GOAL + + +def normalize_approval_type(raw): + """审批类型归一化。 + + 返回 ``(type_or_None, error_code_or_None)``: + * 空 → (None, PBL_APPROVAL_TYPE_UNKNOWN) + * 别名 auto_publish → (None, PBL_APPROVAL_TYPE_UNKNOWN) # G6 无自动发布 + * 注册未开放 experiment → (None, PBL_APPROVAL_TYPE_NOT_OPEN) + * 四类正名/可归一别名 → (正名, None) + """ + key = (raw or "").strip() + if not key: + return None, PBL_APPROVAL_TYPE_UNKNOWN + if key in APPROVAL_TYPES: + return key, None + if key in APPROVAL_TYPE_ALIASES: + mapped = APPROVAL_TYPE_ALIASES[key] + if mapped is None: + return None, PBL_APPROVAL_TYPE_UNKNOWN + return mapped, None + if key in APPROVAL_TYPES_REGISTERED_NOT_OPEN: + return None, PBL_APPROVAL_TYPE_NOT_OPEN + return None, PBL_APPROVAL_TYPE_UNKNOWN + + +__all__ = [ + "AD01_TENANT_MISSING", "AD02_TOOL_UNREGISTERED", "AD03_TOOL_DISABLED", + "AD04_PERMISSION_MODE_DENIED", "AD05_PARAM_NOT_WHITELISTED", + "AD06_APPROVAL_REQUIRED", "AD07_AGENT_UNREGISTERED", "AD08_AGENT_DISABLED", + "AD09_PERMISSION_DENIED", "AD10_INTERNAL", "STEP_ERROR_CODES", + "APPROVAL_TYPES", "APPROVAL_TYPES_REGISTERED_NOT_OPEN", "APPROVAL_TYPE_ALIASES", + "APPROVAL_TYPE_PUBLISH", "APPROVAL_TYPE_MODIFY_GOAL", + "APPROVAL_TYPE_MODIFY_RUBRIC", "APPROVAL_TYPE_MODIFY_VISIBLE", + "PBL_APPROVAL_TYPE_UNKNOWN", "PBL_APPROVAL_TYPE_NOT_OPEN", + "PBL_APPROVAL_NO_BYPASS", "PBL_APPROVAL_ALREADY_DECIDED", + "PBL_TOOL_FORBIDDEN", "PBL_TOOL_KEY_REQUIRED", + "ENABLED_TOOL_DEFS", "DISABLED_TOOL_DEFS", "DEFERRED_TOOL_KEYS", + "ENABLED_TOOLS", "DISABLED_TOOLS", "SAVABLE_TOOL_KEYS", + "G6_FORBIDDEN_TO_ENABLE", "ALLOWED_TOOL_PREFIXES", + "AGENT_DEFS", "TRACE_ELEMENTS", + "tool_def", "disabled_def", "is_savable", "seed_rows", + "resolve_approval_type", "normalize_approval_type", +] diff --git a/var/m4a_contract.sqlite3 b/var/m4a_contract.sqlite3 new file mode 100644 index 0000000000000000000000000000000000000000..82d50b4dcd2227f09eecd01fed1b760916b7e8e5 GIT binary patch literal 110592 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 ZC7yv7fevOf>4W|Q7N_dakLPF8{{xNLnIr%J literal 0 HcmV?d00001