deliver: 交付收口(引擎代为提交)
This commit is contained in:
parent
b8a727b375
commit
a34c1919c6
@ -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"
|
||||
|
||||
@ -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.<name> = <fn>``
|
||||
④ 路径:``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 "<pbl_agent_runtime.api contracts=%d>" % 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",
|
||||
]
|
||||
|
||||
101
pbl_agent_runtime/m4a_api.py
Normal file
101
pbl_agent_runtime/m4a_api.py
Normal file
@ -0,0 +1,101 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""pbl_agent_runtime.m4a_api —— 对外契约对象 ``api``。
|
||||
|
||||
为什么需要本文件(QC 退回 #1/#2 的根治)
|
||||
--------------------------------------
|
||||
项目集成契约(apps/pbls/scripts/tests/offline_contract_smoke.py:267)写法是:
|
||||
|
||||
from pbl_agent_runtime import api as AR
|
||||
AR.pbl_tool_registry_save(tool_key=..., enabled=...)
|
||||
AR.pbl_tool_adjudicate(agent_code=..., tool_key=..., params_json=...)
|
||||
AR.pbl_approval_create(approval_type=...)
|
||||
|
||||
即 ``api`` 必须是**带契约函数属性的对象**;而历史 init.py 里 ``api()`` 是返回字典的工厂函数,
|
||||
两种写法互不兼容。本模块给出同时满足两者的实现:
|
||||
|
||||
api.pbl_tool_adjudicate(...) # 属性直调(冒烟测试写法)
|
||||
api().pbl_tool_adjudicate(...) # 先调用再属性(工厂写法)
|
||||
api['pbl_tool_adjudicate'](...) # 下标写法
|
||||
await api.pbl_tool_adjudicate(...)# async 上下文(.dspy 端点写法)
|
||||
|
||||
契约函数清单以 m4a_contract.CONTRACT_FUNCTIONS 为唯一真源(10 个),
|
||||
与 wwwroot/api/*.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",
|
||||
]
|
||||
1176
pbl_agent_runtime/m4a_contract.py
Normal file
1176
pbl_agent_runtime/m4a_contract.py
Normal file
File diff suppressed because it is too large
Load Diff
544
pbl_agent_runtime/m4a_store.py
Normal file
544
pbl_agent_runtime/m4a_store.py
Normal file
@ -0,0 +1,544 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""pbl_agent_runtime.m4a_store —— M4a 契约层存储内核(离线可跑 / 在线可切)。
|
||||
|
||||
设计要点
|
||||
--------
|
||||
1. **fail-closed 存储**:任何存储异常统一抛 ``PblContractError``,由契约层转成 DENY,
|
||||
绝不静默放行、绝不吞异常返回“看起来成功”的结果。
|
||||
2. **append-only 守卫**:``pbl_agent_trace`` / ``pbl_agent_tool_call`` / ``pbl_llm_call_log``
|
||||
三张留痕表只允许 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
|
||||
218
scripts/check_contract_sync.py
Normal file
218
scripts/check_contract_sync.py
Normal file
@ -0,0 +1,218 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""契约四处同步机械核验(QC 退回 #1/#3/#4/#5 的防复发闸门)。
|
||||
|
||||
核验项
|
||||
------
|
||||
1. 契约函数 10 个:m4a_contract.CONTRACT_FUNCTIONS ↔ __init__.py 导出 ↔ init.py 注册
|
||||
↔ wwwroot/api/*.dspy 文件 ↔ scripts/load_path.py PATHS,五处集合完全相等;
|
||||
2. wwwroot/api/ 下不存在未注册端点,PATHS 不指向不存在文件;
|
||||
3. 每个 .dspy 的 debug 行必须是 f-string(禁普通字符串占位);
|
||||
4. .dspy 禁项审计:无 import / print / uuid;
|
||||
5. 所有 .py py_compile 通过;
|
||||
6. 交付文件清单(deliver 用)与磁盘实际存在文件一致——本脚本直接产出真实清单。
|
||||
|
||||
用法:python3 scripts/check_contract_sync.py [--json out.json]
|
||||
"""
|
||||
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
WORKSPACE_ROOT = os.path.dirname(os.path.dirname(ROOT)) # 机构工作空间根(清单路径基准)
|
||||
sys.path.insert(0, ROOT)
|
||||
sys.path.insert(0, os.path.dirname(ROOT))
|
||||
|
||||
API_DIR = os.path.join(ROOT, "wwwroot", "api")
|
||||
PKG_DIR = os.path.join(ROOT, "pbl_agent_runtime")
|
||||
|
||||
FORBIDDEN_IN_DSPY = ("import ", "print(", "uuid")
|
||||
|
||||
|
||||
def _contract_names():
|
||||
from pbl_agent_runtime.m4a_contract import CONTRACT_FUNCTIONS
|
||||
|
||||
return list(CONTRACT_FUNCTIONS)
|
||||
|
||||
|
||||
def _dspy_files():
|
||||
if not os.path.isdir(API_DIR):
|
||||
return []
|
||||
return sorted(f[:-5] for f in os.listdir(API_DIR) if f.endswith(".dspy"))
|
||||
|
||||
|
||||
def _load_path_entries():
|
||||
path = os.path.join(ROOT, "scripts", "load_path.py")
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
tree = ast.parse(handle.read())
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Assign) and getattr(node.targets[0], "id", "") == "PATHS":
|
||||
out = []
|
||||
for elt in node.value.elts:
|
||||
out.append((elt.elts[0].value, elt.elts[1].value))
|
||||
return out
|
||||
return []
|
||||
|
||||
|
||||
def _init_exports():
|
||||
path = os.path.join(PKG_DIR, "__init__.py")
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
tree = ast.parse(handle.read())
|
||||
exported = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom):
|
||||
for alias in node.names:
|
||||
exported.add(alias.asname or alias.name)
|
||||
all_list = set()
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Assign) and getattr(node.targets[0], "id", "") == "__all__":
|
||||
all_list = {elt.value for elt in node.value.elts}
|
||||
return exported, all_list
|
||||
|
||||
|
||||
def _init_registers():
|
||||
"""从 init.py 静态解析注册名(CONTRACT_FUNCTIONS 循环 + EXTRA_FUNCTIONS)。"""
|
||||
path = os.path.join(PKG_DIR, "init.py")
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
source = handle.read()
|
||||
names = set()
|
||||
match = re.search(r"EXTRA_FUNCTIONS\s*=\s*\(([^)]*)\)", source, re.S)
|
||||
if match:
|
||||
names |= set(re.findall(r"'([^']+)'|\"([^\"]+)\"", match.group(1)) and
|
||||
[a or b for a, b in re.findall(r"'([^']*)'|\"([^\"]*)\"", match.group(1))])
|
||||
return names, source
|
||||
|
||||
|
||||
def main():
|
||||
problems = []
|
||||
contracts = _contract_names()
|
||||
dspy = _dspy_files()
|
||||
entries = _load_path_entries()
|
||||
entry_paths = [p for p, _ in entries]
|
||||
entry_dspy = sorted(p.split("/")[-1][:-5] for p in entry_paths if p.endswith(".dspy"))
|
||||
|
||||
# 1) 集合一致性
|
||||
if sorted(contracts) != sorted(dspy):
|
||||
problems.append("contract vs dspy mismatch: only_contract=%s only_dspy=%s"
|
||||
% (sorted(set(contracts) - set(dspy)), sorted(set(dspy) - set(contracts))))
|
||||
if sorted(contracts) != sorted(entry_dspy):
|
||||
problems.append("contract vs load_path mismatch: only_contract=%s only_path=%s"
|
||||
% (sorted(set(contracts) - set(entry_dspy)),
|
||||
sorted(set(entry_dspy) - set(contracts))))
|
||||
|
||||
# 2) PATHS 指向的文件必须真实存在
|
||||
for path, role in entries:
|
||||
# PATHS 是运行时 URL 路径 /{module}/wwwroot相对路径,磁盘落点在 ROOT/wwwroot/
|
||||
rel = path.lstrip("/")
|
||||
parts = rel.split("/", 1)
|
||||
rel_in_module = parts[1] if len(parts) == 2 and parts[0] == "pbl_agent_runtime" else rel
|
||||
full = os.path.join(ROOT, "wwwroot", rel_in_module)
|
||||
if not os.path.isfile(full):
|
||||
problems.append("load_path points to missing file: %s" % path)
|
||||
if role not in ("any", "user", "admin"):
|
||||
problems.append("load_path bad role %r for %s" % (role, path))
|
||||
|
||||
# 3)+4) dspy 内容审计
|
||||
for name in dspy:
|
||||
full = os.path.join(API_DIR, "%s.dspy" % name)
|
||||
with open(full, encoding="utf-8") as handle:
|
||||
text = handle.read()
|
||||
lines = text.splitlines()
|
||||
debug_lines = [ln for ln in lines if ln.strip().startswith("debug(")]
|
||||
if not debug_lines:
|
||||
problems.append("%s.dspy: no debug line" % name)
|
||||
for ln in debug_lines:
|
||||
if "debug(f'" not in ln and 'debug(f"' not in ln:
|
||||
problems.append("%s.dspy: debug is not f-string -> %s" % (name, ln.strip()))
|
||||
if "params_kw" not in ln and "result" not in ln:
|
||||
problems.append("%s.dspy: debug missing real params -> %s" % (name, ln.strip()))
|
||||
for bad in FORBIDDEN_IN_DSPY:
|
||||
for idx, ln in enumerate(lines, 1):
|
||||
if bad in ln:
|
||||
problems.append("%s.dspy:%d forbidden token %r" % (name, idx, bad))
|
||||
if not any(ln.strip().startswith("return ") for ln in lines):
|
||||
problems.append("%s.dspy: missing explicit return" % name)
|
||||
if name not in text:
|
||||
problems.append("%s.dspy: does not call contract function %s" % (name, name))
|
||||
|
||||
# 5) __init__ 导出 api + 契约对象
|
||||
exported, all_list = _init_exports()
|
||||
if "api" not in exported or "api" not in all_list:
|
||||
problems.append("__init__.py must export `api` (QC #1)")
|
||||
for name in ("load_pbl_agent_runtime", "self_check"):
|
||||
if name not in exported:
|
||||
problems.append("__init__.py missing export: %s" % name)
|
||||
_, init_source = _init_registers()
|
||||
if "def api" in init_source and "_ApiAccessor" not in init_source:
|
||||
problems.append("init.py api must stay compatible with attribute access")
|
||||
|
||||
# 6) py_compile 全部 .py
|
||||
compiled = 0
|
||||
for base, dirs, files in os.walk(ROOT):
|
||||
dirs[:] = [d for d in dirs if d not in ("__pycache__", ".git", "var")]
|
||||
for fname in files:
|
||||
if not fname.endswith(".py"):
|
||||
continue
|
||||
full = os.path.join(base, fname)
|
||||
try:
|
||||
with open(full, encoding="utf-8") as handle:
|
||||
tree = ast.parse(handle.read(), filename=full)
|
||||
compile(tree, full, "exec")
|
||||
if not [n for n in tree.body
|
||||
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef,
|
||||
ast.Assign, ast.Import, ast.ImportFrom, ast.If,
|
||||
ast.Try, ast.With, ast.For, ast.While,
|
||||
ast.Expr, ast.AnnAssign))]:
|
||||
problems.append("empty module (no effective statement): %s"
|
||||
% os.path.relpath(full, ROOT))
|
||||
compiled += 1
|
||||
except SyntaxError as exc:
|
||||
problems.append("syntax error %s: %s" % (os.path.relpath(full, ROOT), exc))
|
||||
except Exception as exc:
|
||||
problems.append("compile failed %s: %s" % (os.path.relpath(full, ROOT), exc))
|
||||
|
||||
# 7) 真实交付文件清单(存在性过滤,QC #5)
|
||||
# 清单文件自身不计入清单(否则字节数自引用抖动,QC#5 核验永远对不上)
|
||||
manifest_rel = os.path.abspath(os.path.join(ROOT, "scripts", "m4a_manifest.json"))
|
||||
inventory = []
|
||||
for base, dirs, files in os.walk(ROOT):
|
||||
dirs[:] = [d for d in dirs if d not in ("__pycache__", ".git", "var")]
|
||||
for fname in sorted(files):
|
||||
full = os.path.join(base, fname)
|
||||
if os.path.abspath(full) == manifest_rel:
|
||||
continue
|
||||
if not os.path.isfile(full):
|
||||
continue
|
||||
rel = os.path.relpath(full, WORKSPACE_ROOT)
|
||||
with open(full, encoding="utf-8", errors="ignore") as handle:
|
||||
line_count = sum(1 for _ in handle)
|
||||
inventory.append({"path": rel.replace(os.sep, "/"),
|
||||
"bytes": os.path.getsize(full),
|
||||
"lines": line_count})
|
||||
inventory.sort(key=lambda item: item["path"])
|
||||
|
||||
report = {
|
||||
"success": not problems,
|
||||
"contract_count": len(contracts),
|
||||
"contracts": contracts,
|
||||
"dspy_endpoints": dspy,
|
||||
"load_path_entries": len(entries),
|
||||
"py_compiled": compiled,
|
||||
"problems": problems,
|
||||
"inventory_count": len(inventory),
|
||||
"inventory": inventory,
|
||||
}
|
||||
print(json.dumps({k: v for k, v in report.items() if k != "inventory"},
|
||||
ensure_ascii=False, indent=2))
|
||||
if "--json" in sys.argv:
|
||||
out = sys.argv[sys.argv.index("--json") + 1]
|
||||
with open(out, "w", encoding="utf-8") as handle:
|
||||
json.dump(report, handle, ensure_ascii=False, indent=2)
|
||||
print("inventory written ->", out)
|
||||
return 0 if not problems else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
52
scripts/gen_dspy.py
Normal file
52
scripts/gen_dspy.py
Normal file
@ -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()
|
||||
@ -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))
|
||||
|
||||
591
scripts/m4a_contract_selftest.py
Normal file
591
scripts/m4a_contract_selftest.py
Normal file
@ -0,0 +1,591 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""M4a 契约自测(离线可跑,逐条覆盖 QC 退回 #1~#7)。
|
||||
|
||||
用法:
|
||||
python3 scripts/m4a_contract_selftest.py # 全量自测
|
||||
python3 scripts/m4a_contract_selftest.py --smoke # 只跑冒烟测试兼容签名核验
|
||||
|
||||
退出码 0 = 全部通过;非 0 = 有失败项(fail-closed,不允许带病交付)。
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import traceback
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
WORKSPACE_ROOT = os.path.dirname(os.path.dirname(ROOT)) # 与 check_contract_sync 同基准
|
||||
sys.path.insert(0, ROOT)
|
||||
sys.path.insert(0, os.path.dirname(ROOT))
|
||||
|
||||
# 独立临时库,避免污染工作空间
|
||||
_TMP_DB = os.path.join(tempfile.gettempdir(), "pbl_m4a_selftest.sqlite3")
|
||||
os.environ["PBL_AGENT_RUNTIME_SQLITE"] = _TMP_DB
|
||||
os.environ.setdefault("PBL_TENANT_ID", "t_selftest")
|
||||
|
||||
RESULTS = []
|
||||
|
||||
|
||||
def case(name):
|
||||
def deco(fn):
|
||||
def runner():
|
||||
try:
|
||||
fn()
|
||||
RESULTS.append((name, True, ""))
|
||||
print("PASS %s" % name)
|
||||
except AssertionError as exc:
|
||||
RESULTS.append((name, False, str(exc)))
|
||||
print("FAIL %s :: %s" % (name, exc))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
RESULTS.append((name, False, "%s: %s" % (type(exc).__name__, exc)))
|
||||
print("ERROR %s :: %s" % (name, exc))
|
||||
traceback.print_exc()
|
||||
runner.__name__ = fn.__name__
|
||||
return runner
|
||||
return deco
|
||||
|
||||
|
||||
from pbl_agent_runtime import api as AR # noqa: E402 —— QC #1:这行必须不 ImportError
|
||||
from pbl_agent_runtime import ( # noqa: E402
|
||||
CONTRACT_FUNCTIONS,
|
||||
MANDATORY_APPROVAL_TYPES,
|
||||
PblContractError,
|
||||
load_pbl_agent_runtime,
|
||||
self_check,
|
||||
)
|
||||
from pbl_agent_runtime.m4a_store import APPEND_ONLY_TABLES, get_store # noqa: E402
|
||||
|
||||
TENANT = "t_selftest"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# QC #1:__init__ 导出 api;引擎 import 闭包核验(冒烟测试第 267 行写法)
|
||||
# ---------------------------------------------------------------------------
|
||||
@case("QC#1 from pbl_agent_runtime import api as AR 不 ImportError")
|
||||
def t_qc1_import():
|
||||
assert AR is not None
|
||||
for name in CONTRACT_FUNCTIONS:
|
||||
assert callable(getattr(AR, name)), "api.%s not callable" % name
|
||||
|
||||
|
||||
@case("QC#1 api() 工厂写法与属性写法指向同一契约集合")
|
||||
def t_qc1_dual_form():
|
||||
obj = AR()
|
||||
assert callable(obj.pbl_tool_adjudicate)
|
||||
assert obj["pbl_tool_adjudicate"] is not None
|
||||
assert sorted(obj.names()) == sorted(CONTRACT_FUNCTIONS)
|
||||
assert len(CONTRACT_FUNCTIONS) == 10, CONTRACT_FUNCTIONS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# QC #2:冒烟测试签名兼容(tool_key / params_json / approval_type / enabled)
|
||||
# ---------------------------------------------------------------------------
|
||||
@case("QC#2 AR.pbl_tool_registry_save(tool_key=, enabled=) 签名可用")
|
||||
def t_qc2_registry_save_signature():
|
||||
# 无审批 → fail-closed 拒绝并自动补建 pending 审批
|
||||
r = AR.pbl_tool_registry_save(tenant_id=TENANT, tool_key="blueprint.read",
|
||||
enabled=False, operator="dev.bot")
|
||||
assert r["success"] is False, r
|
||||
assert r["error_code"] == "PBL.APPROVAL.REQUIRED", r
|
||||
assert r.get("approval_id"), r
|
||||
assert r.get("need_approval") is True, r
|
||||
|
||||
# 人工审批通过后 → 保存成功
|
||||
apr = AR.pbl_approval_create(tenant_id=TENANT, approval_type="tool_registry_change",
|
||||
title="关闭 blueprint.read", requested_by="dev.bot")
|
||||
assert apr["success"] is True, apr
|
||||
decided = AR.pbl_approval_decide(tenant_id=TENANT, approval_id=apr["approval_id"],
|
||||
decision="approve", decided_by="human.pm",
|
||||
comment="同意临时下线")
|
||||
assert decided["success"] is True and decided["status"] == "approved", decided
|
||||
saved = AR.pbl_tool_registry_save(tenant_id=TENANT, tool_key="blueprint.read",
|
||||
enabled=False, operator="dev.bot",
|
||||
approval_id=apr["approval_id"])
|
||||
assert saved["success"] is True, saved
|
||||
assert saved["tool"]["status"] == "disabled", saved
|
||||
# 复原
|
||||
apr2 = AR.pbl_approval_create(tenant_id=TENANT, approval_type="tool_registry_change",
|
||||
title="恢复 blueprint.read", requested_by="dev.bot")
|
||||
AR.pbl_approval_decide(tenant_id=TENANT, approval_id=apr2["approval_id"],
|
||||
decision="approve", decided_by="human.pm")
|
||||
back = AR.pbl_tool_registry_save(tenant_id=TENANT, tool_key="blueprint.read",
|
||||
enabled=True, operator="dev.bot",
|
||||
approval_id=apr2["approval_id"])
|
||||
assert back["success"] is True and back["tool"]["status"] == "enabled", back
|
||||
|
||||
|
||||
@case("QC#2 AR.pbl_tool_adjudicate(agent_code=, tool_key=, params_json=) 签名可用")
|
||||
def t_qc2_adjudicate_signature():
|
||||
r = AR.pbl_tool_adjudicate(tenant_id=TENANT, agent_code="designer",
|
||||
tool_key="blueprint.read", params_json="{}")
|
||||
assert r["success"] is True and r["allowed"] is True, r
|
||||
assert r["decision"] == "ALLOW", r
|
||||
assert len(r["steps"]) == 8, r["steps"]
|
||||
|
||||
|
||||
@case("QC#2 AR.pbl_approval_create(approval_type=) 签名可用 + 非法类型拒绝")
|
||||
def t_qc2_approval_signature():
|
||||
for kind in MANDATORY_APPROVAL_TYPES:
|
||||
r = AR.pbl_approval_create(tenant_id=TENANT, approval_type=kind, title="%s 审批" % kind)
|
||||
assert r["success"] is True and r["status"] == "pending", (kind, r)
|
||||
bad = AR.pbl_approval_create(tenant_id=TENANT, approval_type="whatever")
|
||||
assert bad["success"] is False and bad["error_code"] == "PBL.APPROVAL.TYPE_INVALID", bad
|
||||
assert len(MANDATORY_APPROVAL_TYPES) == 4, MANDATORY_APPROVAL_TYPES
|
||||
|
||||
|
||||
@case("QC#2 params_json 传 JSON 字符串与 dict 等价")
|
||||
def t_qc2_params_json_forms():
|
||||
a = AR.pbl_tool_adjudicate(tenant_id=TENANT, agent_code="designer",
|
||||
tool_key="evidence.collect", params_json='{"a": 1}')
|
||||
b = AR.pbl_tool_adjudicate(tenant_id=TENANT, agent_code="designer",
|
||||
tool_key="evidence.collect", params_json={"a": 1})
|
||||
assert a["allowed"] is True and b["allowed"] is True, (a, b)
|
||||
bad = AR.pbl_tool_adjudicate(tenant_id=TENANT, agent_code="designer",
|
||||
tool_key="evidence.collect", params_json="{not json")
|
||||
assert bad["allowed"] is False and bad["denied_at_step"] == "params_valid", bad
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# QC #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())
|
||||
229
scripts/m4a_manifest.json
Normal file
229
scripts/m4a_manifest.json
Normal file
@ -0,0 +1,229 @@
|
||||
{
|
||||
"success": true,
|
||||
"contract_count": 10,
|
||||
"contracts": [
|
||||
"pbl_agent_designer_run",
|
||||
"pbl_agent_critic_run",
|
||||
"pbl_agent_trace_write",
|
||||
"pbl_agent_trace_list",
|
||||
"pbl_tool_registry_list",
|
||||
"pbl_tool_registry_save",
|
||||
"pbl_tool_adjudicate",
|
||||
"pbl_approval_create",
|
||||
"pbl_approval_decide",
|
||||
"pbl_approval_list"
|
||||
],
|
||||
"dspy_endpoints": [
|
||||
"pbl_agent_critic_run",
|
||||
"pbl_agent_designer_run",
|
||||
"pbl_agent_trace_list",
|
||||
"pbl_agent_trace_write",
|
||||
"pbl_approval_create",
|
||||
"pbl_approval_decide",
|
||||
"pbl_approval_list",
|
||||
"pbl_tool_adjudicate",
|
||||
"pbl_tool_registry_list",
|
||||
"pbl_tool_registry_save"
|
||||
],
|
||||
"load_path_entries": 11,
|
||||
"py_compiled": 22,
|
||||
"problems": [],
|
||||
"inventory_count": 39,
|
||||
"inventory": [
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/.gitignore",
|
||||
"bytes": 106,
|
||||
"lines": 12
|
||||
},
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/README.md",
|
||||
"bytes": 298,
|
||||
"lines": 1
|
||||
},
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/pbl_agent_runtime/__init__.py",
|
||||
"bytes": 4120,
|
||||
"lines": 135
|
||||
},
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/pbl_agent_runtime/init.py",
|
||||
"bytes": 9592,
|
||||
"lines": 297
|
||||
},
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/pbl_agent_runtime/m4a.py",
|
||||
"bytes": 12920,
|
||||
"lines": 265
|
||||
},
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/pbl_agent_runtime/m4a_adjudicate.py",
|
||||
"bytes": 17151,
|
||||
"lines": 371
|
||||
},
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/pbl_agent_runtime/m4a_api.py",
|
||||
"bytes": 3342,
|
||||
"lines": 101
|
||||
},
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/pbl_agent_runtime/m4a_approval.py",
|
||||
"bytes": 13953,
|
||||
"lines": 305
|
||||
},
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/pbl_agent_runtime/m4a_backend.py",
|
||||
"bytes": 16774,
|
||||
"lines": 380
|
||||
},
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/pbl_agent_runtime/m4a_contract.py",
|
||||
"bytes": 55217,
|
||||
"lines": 1176
|
||||
},
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/pbl_agent_runtime/m4a_critic.py",
|
||||
"bytes": 12984,
|
||||
"lines": 296
|
||||
},
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/pbl_agent_runtime/m4a_designer.py",
|
||||
"bytes": 27272,
|
||||
"lines": 620
|
||||
},
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/pbl_agent_runtime/m4a_init.py",
|
||||
"bytes": 5092,
|
||||
"lines": 106
|
||||
},
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/pbl_agent_runtime/m4a_kernel.py",
|
||||
"bytes": 13381,
|
||||
"lines": 389
|
||||
},
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/pbl_agent_runtime/m4a_registry.py",
|
||||
"bytes": 31817,
|
||||
"lines": 638
|
||||
},
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/pbl_agent_runtime/m4a_store.py",
|
||||
"bytes": 18821,
|
||||
"lines": 544
|
||||
},
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/pbl_agent_runtime/m4a_tables.py",
|
||||
"bytes": 15694,
|
||||
"lines": 321
|
||||
},
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/pbl_agent_runtime/m4a_trace.py",
|
||||
"bytes": 11178,
|
||||
"lines": 267
|
||||
},
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/pyproject.toml",
|
||||
"bytes": 1008,
|
||||
"lines": 28
|
||||
},
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/scripts/check_contract_sync.py",
|
||||
"bytes": 9418,
|
||||
"lines": 218
|
||||
},
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/scripts/gen_dspy.py",
|
||||
"bytes": 2140,
|
||||
"lines": 52
|
||||
},
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/scripts/load_path.py",
|
||||
"bytes": 2445,
|
||||
"lines": 62
|
||||
},
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/scripts/m4a_contract_selftest.py",
|
||||
"bytes": 30530,
|
||||
"lines": 591
|
||||
},
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/scripts/m4a_selftest.py",
|
||||
"bytes": 24715,
|
||||
"lines": 447
|
||||
},
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/scripts/seed_m4a.py",
|
||||
"bytes": 4086,
|
||||
"lines": 89
|
||||
},
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/skill/SKILL.md",
|
||||
"bytes": 1501,
|
||||
"lines": 33
|
||||
},
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/sql/m4a_contract_addon.sql",
|
||||
"bytes": 11069,
|
||||
"lines": 167
|
||||
},
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/sql/m4a_ddl.sql",
|
||||
"bytes": 8965,
|
||||
"lines": 123
|
||||
},
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/wwwroot/api/pbl_agent_critic_run.dspy",
|
||||
"bytes": 287,
|
||||
"lines": 5
|
||||
},
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/wwwroot/api/pbl_agent_designer_run.dspy",
|
||||
"bytes": 319,
|
||||
"lines": 5
|
||||
},
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/wwwroot/api/pbl_agent_trace_list.dspy",
|
||||
"bytes": 275,
|
||||
"lines": 5
|
||||
},
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/wwwroot/api/pbl_agent_trace_write.dspy",
|
||||
"bytes": 303,
|
||||
"lines": 5
|
||||
},
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/wwwroot/api/pbl_approval_create.dspy",
|
||||
"bytes": 285,
|
||||
"lines": 5
|
||||
},
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/wwwroot/api/pbl_approval_decide.dspy",
|
||||
"bytes": 293,
|
||||
"lines": 5
|
||||
},
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/wwwroot/api/pbl_approval_list.dspy",
|
||||
"bytes": 262,
|
||||
"lines": 5
|
||||
},
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/wwwroot/api/pbl_tool_adjudicate.dspy",
|
||||
"bytes": 293,
|
||||
"lines": 5
|
||||
},
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/wwwroot/api/pbl_tool_registry_list.dspy",
|
||||
"bytes": 303,
|
||||
"lines": 5
|
||||
},
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/wwwroot/api/pbl_tool_registry_save.dspy",
|
||||
"bytes": 307,
|
||||
"lines": 5
|
||||
},
|
||||
{
|
||||
"path": "modules/pbl_agent_runtime/wwwroot/index.ui",
|
||||
"bytes": 8236,
|
||||
"lines": 315
|
||||
}
|
||||
]
|
||||
}
|
||||
167
sql/m4a_contract_addon.sql
Normal file
167
sql/m4a_contract_addon.sql
Normal file
@ -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
|
||||
-- =============================================================================
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
5
wwwroot/api/pbl_tool_registry_list.dspy
Normal file
5
wwwroot/api/pbl_tool_registry_list.dspy
Normal file
@ -0,0 +1,5 @@
|
||||
# 工具注册表查询(13 启用 / 9 禁用)
|
||||
debug(f'pbl_tool_registry_list.dspy: START params_kw={dict(params_kw)}')
|
||||
result = pbl_tool_registry_list(**dict(params_kw))
|
||||
debug(f'pbl_tool_registry_list.dspy: END success={result.get("success") if isinstance(result, dict) else result}')
|
||||
return result
|
||||
5
wwwroot/api/pbl_tool_registry_save.dspy
Normal file
5
wwwroot/api/pbl_tool_registry_save.dspy
Normal file
@ -0,0 +1,5 @@
|
||||
# 工具注册表变更(四类强制审批之一)
|
||||
debug(f'pbl_tool_registry_save.dspy: START params_kw={dict(params_kw)}')
|
||||
result = pbl_tool_registry_save(**dict(params_kw))
|
||||
debug(f'pbl_tool_registry_save.dspy: END success={result.get("success") if isinstance(result, dict) else result}')
|
||||
return result
|
||||
Loading…
x
Reference in New Issue
Block a user