deliver: 交付收口(引擎代为提交)
This commit is contained in:
parent
b8a727b375
commit
a34c1919c6
@ -1,102 +1,135 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""pbl_agent_runtime —— Designer/Critic Agent 运行时与 fail-closed 工具裁决(M4a)。
|
"""pbl_agent_runtime —— Designer/Critic Agent 运行时与 fail-closed 工具裁决(M4a)。
|
||||||
|
|
||||||
M4a 交付范围:
|
M4a 交付范围
|
||||||
* Agent 定义:designer(可写)/ critic(**零写权限**)
|
------------
|
||||||
* 工具注册表:13 启用 + 9 禁用(含 blueprint.publish_auto 等自动发布类禁用)
|
* Agent 定义:``designer``(可写)/ ``critic``(**零写权限**,write_scope='none')
|
||||||
* fail-closed 裁决:S1~S8 八步顺序,任一步不过即 DENY,默认裁决 = DENY
|
* 工具注册表:**13 启用 + 9 禁用**(含 ``pbl.publish`` 自动发布类禁用)
|
||||||
* 四类强制人工审批:publish / compile_execute / blueprint_approve / tool_registry_change
|
* fail-closed 裁决:S1~S8 八步固定顺序,任一步不过即 DENY,**默认裁决 = DENY**
|
||||||
* 5 张表:pbl_agent_def / pbl_agent_tool / pbl_agent_trace /
|
* 四类强制人工审批:publish / compile_execute / blueprint_approve / tool_registry_change
|
||||||
pbl_agent_trace_stage / pbl_approval
|
* 6 张表:pbl_agent_def / pbl_tool_registry / pbl_agent_trace / pbl_agent_tool_call /
|
||||||
|
pbl_approval / **pbl_llm_call_log**(LLM 超时·重试·限流·兜底留痕)
|
||||||
|
* 10 个契约端点(wwwroot/api/*.dspy ↔ scripts/load_path.py ↔ skill/SKILL.md 四处同步)
|
||||||
|
|
||||||
包出口(三处同步:定义 + 本文件导出 + init.py 注册):
|
包出口(三处同步:实现 + 本文件导出 + init.py 注册)
|
||||||
from pbl_agent_runtime import load_pbl_agent_runtime # 宿主应用标准入口
|
----------------------------------------------------
|
||||||
from pbl_agent_runtime import load_pbl_agent_runtime_m4a # M4a 装配(建表+seed)
|
from pbl_agent_runtime import api # 契约对象(集成冒烟测试用法)
|
||||||
from pbl_agent_runtime import load_m4a # 租户级门面 M4aApi
|
from pbl_agent_runtime import load_pbl_agent_runtime # 宿主应用标准挂载入口
|
||||||
|
from pbl_agent_runtime import self_check # 挂载自检(不过抛 RuntimeError)
|
||||||
|
|
||||||
|
``api`` 同时支持四种调用写法(见 m4a_api.ContractApi):
|
||||||
|
api.pbl_tool_adjudicate(agent_code=..., tool_key=..., params_json=...)
|
||||||
|
api().pbl_tool_adjudicate(...) # 兼容历史 api() 工厂写法
|
||||||
|
api['pbl_tool_adjudicate'](...)
|
||||||
|
await api.pbl_tool_adjudicate(...) # .dspy async 上下文
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .m4a_init import ( # noqa: F401
|
from .m4a_store import ( # noqa: F401
|
||||||
load_pbl_agent_runtime_m4a,
|
APPEND_ONLY_TABLES as STORE_APPEND_ONLY_TABLES,
|
||||||
bootstrap,
|
PblContractError,
|
||||||
get_dbname,
|
Result,
|
||||||
ddl,
|
Store,
|
||||||
API_ROUTES,
|
get_store,
|
||||||
MODULE_NAME,
|
set_store,
|
||||||
|
module_dbname,
|
||||||
|
resolve_tenant,
|
||||||
)
|
)
|
||||||
from .m4a import M4aApi, load_m4a, make_agent_ctx, make_user_ctx, make_admin_ctx # noqa: F401
|
from .m4a_contract import ( # noqa: F401
|
||||||
from .init import load_pbl_agent_runtime, self_check # noqa: F401
|
ADJUDICATION_STEPS,
|
||||||
from .m4a_registry import ( # noqa: F401
|
AGENT_DEFS,
|
||||||
seed_agents,
|
CONTRACT_FUNCTIONS,
|
||||||
seed_tools,
|
DISABLED_TOOLS,
|
||||||
list_agents,
|
ENABLED_TOOLS,
|
||||||
list_tools,
|
MANDATORY_APPROVAL_TYPES,
|
||||||
get_tool,
|
TRACE_ELEMENTS,
|
||||||
register_tool,
|
build_api,
|
||||||
set_tool_status,
|
log_llm_call,
|
||||||
ENABLED_TOOL_CODES,
|
seed_registry,
|
||||||
DISABLED_TOOL_CODES,
|
self_check,
|
||||||
APPROVAL_REQUIRED_TOOLS,
|
|
||||||
PERM_PLATFORM_ADMIN,
|
|
||||||
)
|
)
|
||||||
from .m4a_tables import ( # noqa: F401
|
from .m4a_api import ContractApi, api, build_contract_api # noqa: F401
|
||||||
ensure_tables,
|
from .init import load_pbl_agent_runtime # noqa: F401
|
||||||
TABLES,
|
|
||||||
APPEND_ONLY_TABLES,
|
# 历史 M4a 装配层(保留向后兼容:老代码 import 这些名字仍可用)
|
||||||
all_sql,
|
try: # pragma: no cover —— 兼容分支
|
||||||
)
|
from .m4a_init import ( # noqa: F401
|
||||||
from .m4a_kernel import ADJUDICATION_STEPS, TRACE_ELEMENTS, PblError # noqa: F401
|
load_pbl_agent_runtime_m4a,
|
||||||
from .m4a_approval import MANDATORY_APPROVAL_TYPES # noqa: F401
|
bootstrap,
|
||||||
|
get_dbname,
|
||||||
|
ddl,
|
||||||
|
API_ROUTES,
|
||||||
|
MODULE_NAME,
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
load_pbl_agent_runtime_m4a = None
|
||||||
|
bootstrap = None
|
||||||
|
get_dbname = module_dbname
|
||||||
|
ddl = None
|
||||||
|
API_ROUTES = tuple("api/%s.dspy" % name for name in CONTRACT_FUNCTIONS)
|
||||||
|
MODULE_NAME = "pbl_agent_runtime"
|
||||||
|
|
||||||
|
try: # pragma: no cover —— 兼容分支
|
||||||
|
from .m4a import M4aApi, load_m4a, make_agent_ctx, make_user_ctx, make_admin_ctx # noqa: F401
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
M4aApi = None
|
||||||
|
load_m4a = None
|
||||||
|
make_agent_ctx = None
|
||||||
|
make_user_ctx = None
|
||||||
|
make_admin_ctx = None
|
||||||
|
|
||||||
|
|
||||||
|
def api_factory():
|
||||||
|
"""兼容旧签名 ``api()``:返回契约对象本身。"""
|
||||||
|
return api
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# 挂载入口
|
# 契约对象(对外唯一契约入口)
|
||||||
|
"api",
|
||||||
|
"ContractApi",
|
||||||
|
"build_contract_api",
|
||||||
|
"build_api",
|
||||||
|
"api_factory",
|
||||||
|
"CONTRACT_FUNCTIONS",
|
||||||
|
# 挂载 / 自检
|
||||||
"load_pbl_agent_runtime",
|
"load_pbl_agent_runtime",
|
||||||
"load_pbl_agent_runtime_m4a",
|
"load_pbl_agent_runtime_m4a",
|
||||||
"load_m4a",
|
|
||||||
"bootstrap",
|
|
||||||
"self_check",
|
"self_check",
|
||||||
|
"seed_registry",
|
||||||
|
"bootstrap",
|
||||||
"get_dbname",
|
"get_dbname",
|
||||||
|
"module_dbname",
|
||||||
|
"resolve_tenant",
|
||||||
"ddl",
|
"ddl",
|
||||||
# 门面 / 上下文
|
"API_ROUTES",
|
||||||
"M4aApi",
|
"MODULE_NAME",
|
||||||
"make_agent_ctx",
|
# 运行时留痕
|
||||||
"make_user_ctx",
|
"log_llm_call",
|
||||||
"make_admin_ctx",
|
# 常量
|
||||||
# 注册表
|
|
||||||
"seed_agents",
|
|
||||||
"seed_tools",
|
|
||||||
"list_agents",
|
|
||||||
"list_tools",
|
|
||||||
"get_tool",
|
|
||||||
"register_tool",
|
|
||||||
"set_tool_status",
|
|
||||||
"ENABLED_TOOL_CODES",
|
|
||||||
"DISABLED_TOOL_CODES",
|
|
||||||
"APPROVAL_REQUIRED_TOOLS",
|
|
||||||
"PERM_PLATFORM_ADMIN",
|
|
||||||
# 表 / 裁决常量
|
|
||||||
"ensure_tables",
|
|
||||||
"TABLES",
|
|
||||||
"APPEND_ONLY_TABLES",
|
|
||||||
"all_sql",
|
|
||||||
"ADJUDICATION_STEPS",
|
"ADJUDICATION_STEPS",
|
||||||
"TRACE_ELEMENTS",
|
"TRACE_ELEMENTS",
|
||||||
"MANDATORY_APPROVAL_TYPES",
|
"MANDATORY_APPROVAL_TYPES",
|
||||||
"PblError",
|
"ENABLED_TOOLS",
|
||||||
"API_ROUTES",
|
"DISABLED_TOOLS",
|
||||||
"MODULE_NAME",
|
"AGENT_DEFS",
|
||||||
# 子模块(供高级用法直接 import)
|
"STORE_APPEND_ONLY_TABLES",
|
||||||
|
# 存储 / 异常
|
||||||
|
"Store",
|
||||||
|
"get_store",
|
||||||
|
"set_store",
|
||||||
|
"Result",
|
||||||
|
"PblContractError",
|
||||||
|
# 兼容门面
|
||||||
|
"M4aApi",
|
||||||
|
"load_m4a",
|
||||||
|
"make_agent_ctx",
|
||||||
|
"make_user_ctx",
|
||||||
|
"make_admin_ctx",
|
||||||
|
# 子模块
|
||||||
|
"m4a_api",
|
||||||
|
"m4a_contract",
|
||||||
|
"m4a_store",
|
||||||
"init",
|
"init",
|
||||||
"m4a",
|
|
||||||
"m4a_init",
|
|
||||||
"m4a_kernel",
|
|
||||||
"m4a_tables",
|
|
||||||
"m4a_registry",
|
|
||||||
"m4a_adjudicate",
|
|
||||||
"m4a_approval",
|
|
||||||
"m4a_trace",
|
|
||||||
"m4a_designer",
|
|
||||||
"m4a_critic",
|
|
||||||
"m4a_backend",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
__version__ = "1.1.0"
|
||||||
|
|||||||
@ -1,475 +1,297 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""pbl_agent_runtime 挂载入口(M4a 唯一实现)。
|
"""pbl_agent_runtime.init —— 模块挂载入口(ServerEnv 注册 + 挂载自检)。
|
||||||
|
|
||||||
历史说明(QC 退回 #7 处置):
|
三处同步注册(module-development-spec 铁律)
|
||||||
旧「九关裁决 + 4 表」实现(tables.py / tool_registry.py / verdict.py / api.py)
|
------------------------------------------
|
||||||
已**删除**,本文件不再引用;M4a(m4a_*.py,8 步裁决 + 5 表)是本模块唯一实现,
|
① 实现:``m4a_contract.py``(10 个契约函数 + seed/self_check/log_llm_call)
|
||||||
`load_pbl_agent_runtime()` 与 `load_pbl_agent_runtime_m4a()` 均委托 M4a 装配。
|
② 导出:``__init__.py``(``from .init import load_pbl_agent_runtime``、``api``)
|
||||||
|
③ 注册:本文件 ``load_pbl_agent_runtime()`` 里 ``env.<name> = <fn>``
|
||||||
|
④ 路径:``scripts/load_path.py`` PATHS(10 个 .dspy 一一对应)
|
||||||
|
|
||||||
load_pbl_agent_runtime(env=None, tenant_id=None):
|
挂载语义(fail-closed)
|
||||||
1) ensure_tables —— 建 5 表(幂等;pbl_agent_trace / pbl_agent_trace_stage 为 append-only)
|
----------------------
|
||||||
2) seed_agents(designer / critic,critic write_allowed=0)+ seed_tools(13 启用 / 9 禁用)
|
* ``load_pbl_agent_runtime()`` 挂载后立即跑 ``self_check(strict=True)``,不过抛 RuntimeError,
|
||||||
3) **挂载即 self_check()**:数量契约、Critic 零写、8 步裁决链、四类审批、默认裁决 DENY
|
让宿主应用启动失败——绝不带病上线。
|
||||||
任一不过 → 抛 RuntimeError,宿主应用启动失败(fail-closed,绝不带病上线)
|
* 建表 + seed(2 Agent / 13 启用 / 9 禁用工具)幂等,可重复挂载。
|
||||||
4) 向 ServerEnv 注册 wwwroot/api/*.dspy 所需的 8 个处理函数 + 只读契约
|
* 库名一律 ``ServerEnv().get_module_dbname('pbl_agent_runtime')``,禁止硬编码 DBNAME。
|
||||||
5) 返回 default_verdict(= 'DENY')
|
|
||||||
|
|
||||||
后端(sqlor/ahserver)未挂载时自动降级 MemoryStore:建表/seed/裁决/审批全链路可用,
|
|
||||||
数据仅存于进程内(重启即失),不抛错——便于自测与离线演示(US-05 兜底同源策略)。
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .m4a_kernel import (
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from .m4a_api import api as _contract_api
|
||||||
|
from .m4a_contract import (
|
||||||
ADJUDICATION_STEPS,
|
ADJUDICATION_STEPS,
|
||||||
|
CONTRACT_FUNCTIONS,
|
||||||
|
MANDATORY_APPROVAL_TYPES,
|
||||||
TRACE_ELEMENTS,
|
TRACE_ELEMENTS,
|
||||||
PblError,
|
build_api,
|
||||||
TenantContext,
|
log_llm_call,
|
||||||
ACTOR_SYSTEM,
|
seed_registry,
|
||||||
|
self_check as _self_check,
|
||||||
|
)
|
||||||
|
from .m4a_store import (
|
||||||
|
APPEND_ONLY_TABLES,
|
||||||
|
PblContractError,
|
||||||
|
Result,
|
||||||
|
Store,
|
||||||
get_store,
|
get_store,
|
||||||
|
module_dbname,
|
||||||
|
resolve_tenant,
|
||||||
|
set_store,
|
||||||
)
|
)
|
||||||
from .m4a_tables import TABLES, APPEND_ONLY_TABLES, ensure_tables
|
|
||||||
from .m4a_registry import (
|
|
||||||
ENABLED_TOOL_CODES,
|
|
||||||
DISABLED_TOOL_CODES,
|
|
||||||
APPROVAL_REQUIRED_TOOLS,
|
|
||||||
PERM_PLATFORM_ADMIN,
|
|
||||||
list_agents,
|
|
||||||
list_tools,
|
|
||||||
get_tool,
|
|
||||||
register_tool,
|
|
||||||
set_tool_status,
|
|
||||||
seed_agents,
|
|
||||||
seed_tools,
|
|
||||||
)
|
|
||||||
from .m4a_adjudicate import adjudicate, adjudicate_report, explain_chain
|
|
||||||
from .m4a_approval import MANDATORY_APPROVAL_TYPES
|
|
||||||
from .m4a_trace import get_trace, list_traces
|
|
||||||
from .m4a import M4aApi, load_m4a, make_admin_ctx
|
|
||||||
from .m4a_init import MODULE_NAME, get_dbname
|
|
||||||
|
|
||||||
import functools
|
MODULE_NAME = "pbl_agent_runtime"
|
||||||
|
|
||||||
DEFAULT_VERDICT = "DENY" # fail-closed:任何异常/未知一律拒绝
|
#: 契约端点路由(与 wwwroot/api/ 实际文件、scripts/load_path.py 一一对应)
|
||||||
EXPECTED_ENABLED = 13
|
API_ROUTES = tuple("api/%s.dspy" % name for name in CONTRACT_FUNCTIONS)
|
||||||
EXPECTED_DISABLED = 9
|
|
||||||
EXPECTED_STEPS = 8
|
#: 挂载时注册到 ServerEnv 的附加(非契约端点)能力
|
||||||
EXPECTED_APPROVAL_TYPES = 4
|
EXTRA_FUNCTIONS = ("seed_registry", "log_llm_call", "self_check", "get_store", "module_dbname")
|
||||||
EXPECTED_TABLES = 5
|
|
||||||
POLICY_VERSION = "m4a-1.0.0"
|
_LOADED = {"done": False, "env": None, "report": None}
|
||||||
AGENTS = ("designer", "critic")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 自检(挂载即执行,不过即抛)
|
# 对外契约对象 / 工厂(兼容两种历史写法)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
def self_check(tenant_id=None, store=None, run_probes=True):
|
|
||||||
"""M4a 契约自检。返回 (all_ok, msgs)。
|
|
||||||
|
|
||||||
校验项:
|
|
||||||
C1 工具数量契约 22 = 13 启用 + 9 禁用
|
class _ApiAccessor:
|
||||||
C2 自动发布类工具必须禁用(blueprint.publish_auto / marketplace.create_listing)
|
"""``api`` 双形态适配器:``api.fn(...)`` 与 ``api().fn(...)`` 同时成立。
|
||||||
C3 Critic 零写权限(write_allowed=0 且无任何写工具 allowed_agents 含 critic)
|
|
||||||
C4 裁决链恰为 8 步且顺序为 S1..S8
|
历史 init.py 里 ``api()`` 返回字典(工厂写法),项目冒烟测试用 ``api.fn(...)``(对象写法)。
|
||||||
C5 四类强制人工审批齐备
|
本类把两种写法收敛到同一个契约对象,杜绝再次出现「注册三处同步断裂」。
|
||||||
C6 5 张表齐备且 append-only 表带标记
|
|
||||||
C7 默认裁决 = DENY
|
|
||||||
C8 探针:禁用工具/未注册工具/Critic 写 三类调用必须被拒(run_probes=True 时)
|
|
||||||
"""
|
"""
|
||||||
msgs = []
|
|
||||||
all_ok = True
|
|
||||||
st = store or get_store()
|
|
||||||
tid = tenant_id or "__platform__"
|
|
||||||
admin = make_admin_ctx(tid)
|
|
||||||
|
|
||||||
tools = list_tools(ctx=admin, store=st)
|
__slots__ = ("_obj",)
|
||||||
enabled = [t for t in tools if t.get("status") == "enabled"]
|
|
||||||
disabled = [t for t in tools if t.get("status") == "disabled"]
|
|
||||||
|
|
||||||
# C1
|
def __init__(self, obj=None):
|
||||||
if len(tools) != EXPECTED_ENABLED + EXPECTED_DISABLED:
|
object.__setattr__(self, "_obj", obj if obj is not None else _contract_api)
|
||||||
all_ok = False
|
|
||||||
msgs.append("FAIL C1 工具总数=%d 应为 %d"
|
|
||||||
% (len(tools), EXPECTED_ENABLED + EXPECTED_DISABLED))
|
|
||||||
if len(enabled) != EXPECTED_ENABLED:
|
|
||||||
all_ok = False
|
|
||||||
msgs.append("FAIL C1 启用工具=%d 应为 %d" % (len(enabled), EXPECTED_ENABLED))
|
|
||||||
if len(disabled) != EXPECTED_DISABLED:
|
|
||||||
all_ok = False
|
|
||||||
msgs.append("FAIL C1 禁用工具=%d 应为 %d" % (len(disabled), EXPECTED_DISABLED))
|
|
||||||
msgs.append("C1 工具契约:%d = %d 启用 + %d 禁用"
|
|
||||||
% (len(tools), len(enabled), len(disabled)))
|
|
||||||
|
|
||||||
# C2 自动发布/自动改课必须禁用
|
# 工厂写法:api() -> 契约对象
|
||||||
must_disabled = ("blueprint.publish_auto", "curriculum.modify_auto",
|
def __call__(self, *args, **kwargs):
|
||||||
"marketplace.create_listing", "kdb.write", "billing.charge")
|
return object.__getattribute__(self, "_obj")
|
||||||
codes_disabled = set(DISABLED_TOOL_CODES)
|
|
||||||
for code in must_disabled:
|
|
||||||
if code not in codes_disabled:
|
|
||||||
all_ok = False
|
|
||||||
msgs.append("FAIL C2 %s 必须处于禁用清单" % code)
|
|
||||||
msgs.append("C2 高危工具禁用:%s" % ", ".join(must_disabled))
|
|
||||||
|
|
||||||
# C3 Critic 零写
|
# 对象写法:api.pbl_tool_adjudicate(...)
|
||||||
agents = list_agents(ctx=admin, store=st)
|
def __getattr__(self, item):
|
||||||
by_code = {a.get("agent_code"): a for a in agents}
|
return getattr(object.__getattribute__(self, "_obj"), item)
|
||||||
if set(by_code) != set(AGENTS):
|
|
||||||
all_ok = False
|
|
||||||
msgs.append("FAIL C3 Agent 定义=%s 应为 %s" % (sorted(by_code), list(AGENTS)))
|
|
||||||
critic = by_code.get("critic") or {}
|
|
||||||
if critic and int(critic.get("write_allowed") or 0) != 0:
|
|
||||||
all_ok = False
|
|
||||||
msgs.append("FAIL C3 critic.write_allowed=%r 必须为 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")
|
|
||||||
|
|
||||||
# C4 8 步裁决链
|
# 下标写法:api['pbl_tool_adjudicate'](...)
|
||||||
steps = [s[0] for s in ADJUDICATION_STEPS]
|
def __getitem__(self, item):
|
||||||
if len(steps) != EXPECTED_STEPS or steps != ["S%d" % i for i in range(1, 9)]:
|
return object.__getattribute__(self, "_obj")[item]
|
||||||
all_ok = False
|
|
||||||
msgs.append("FAIL C4 裁决链=%s 应为 S1..S8(8 步顺序固定)" % steps)
|
|
||||||
else:
|
|
||||||
msgs.append("C4 fail-closed 裁决链 8 步:%s"
|
|
||||||
% " → ".join("%s %s" % (s[0], s[1]) for s in ADJUDICATION_STEPS))
|
|
||||||
|
|
||||||
# C5 四类审批
|
def __contains__(self, item):
|
||||||
if len(MANDATORY_APPROVAL_TYPES) != EXPECTED_APPROVAL_TYPES:
|
return item in object.__getattribute__(self, "_obj")
|
||||||
all_ok = False
|
|
||||||
msgs.append("FAIL C5 强制审批类型=%d 应为 %d"
|
|
||||||
% (len(MANDATORY_APPROVAL_TYPES), EXPECTED_APPROVAL_TYPES))
|
|
||||||
appr_tools = [t.get("tool_code") for t in enabled
|
|
||||||
if int(t.get("require_approval") or 0) == 1]
|
|
||||||
if set(appr_tools) != set(APPROVAL_REQUIRED_TOOLS):
|
|
||||||
all_ok = False
|
|
||||||
msgs.append("FAIL C5 require_approval 工具=%s 应为 %s"
|
|
||||||
% (appr_tools, list(APPROVAL_REQUIRED_TOOLS)))
|
|
||||||
msgs.append("C5 四类强制人工审批:%s;需审批工具=%s"
|
|
||||||
% (", ".join(MANDATORY_APPROVAL_TYPES), ", ".join(appr_tools)))
|
|
||||||
|
|
||||||
# C6 5 表 + append-only
|
def __iter__(self):
|
||||||
tbl_names = [t.get("tblname") for t in TABLES]
|
return iter(object.__getattribute__(self, "_obj"))
|
||||||
if len(tbl_names) != EXPECTED_TABLES:
|
|
||||||
all_ok = False
|
|
||||||
msgs.append("FAIL C6 表数=%d 应为 %d(%s)"
|
|
||||||
% (len(tbl_names), EXPECTED_TABLES, tbl_names))
|
|
||||||
for name in ("pbl_agent_trace", "pbl_agent_trace_stage"):
|
|
||||||
if name not in APPEND_ONLY_TABLES:
|
|
||||||
all_ok = False
|
|
||||||
msgs.append("FAIL C6 %s 必须标记 append-only" % name)
|
|
||||||
msgs.append("C6 数据表 %d 张:%s(append-only:%s)"
|
|
||||||
% (len(tbl_names), ", ".join(tbl_names), ", ".join(APPEND_ONLY_TABLES)))
|
|
||||||
|
|
||||||
# C7 默认裁决
|
def keys(self):
|
||||||
if DEFAULT_VERDICT != "DENY":
|
return object.__getattribute__(self, "_obj").keys()
|
||||||
all_ok = False
|
|
||||||
msgs.append("FAIL C7 DEFAULT_VERDICT=%r 必须为 'DENY'" % DEFAULT_VERDICT)
|
|
||||||
else:
|
|
||||||
msgs.append("C7 默认裁决 = DENY(fail-closed)")
|
|
||||||
|
|
||||||
# C8 探针
|
def items(self):
|
||||||
if run_probes:
|
return object.__getattribute__(self, "_obj").items()
|
||||||
probes = [
|
|
||||||
("designer", "blueprint.publish_auto", "禁用工具(自动发布)"),
|
|
||||||
("designer", "pbl.publish", "未注册工具(白名单外 default-deny)"),
|
|
||||||
("critic", "blueprint.update", "Critic 写蓝图(14.1)"),
|
|
||||||
("critic", "publish.request", "Critic 发布(14.1)"),
|
|
||||||
("designer", "compile.trigger", "未审批编译执行(S7 审批门)"),
|
|
||||||
]
|
|
||||||
for agent_code, tool_code, why in probes:
|
|
||||||
step = code = None
|
|
||||||
try:
|
|
||||||
rep = adjudicate_report(admin, agent_code, tool_code, {}, store=st)
|
|
||||||
# Verdict.to_dict():allowed=False 即拒绝(fail-closed 语义)
|
|
||||||
denied = rep.get("allowed") is False
|
|
||||||
step = rep.get("step_reached")
|
|
||||||
code = rep.get("reason_code")
|
|
||||||
except PblError as e:
|
|
||||||
denied, code = True, e.code
|
|
||||||
except Exception: # noqa: BLE001 —— 任何异常都视为拒绝(fail-closed)
|
|
||||||
denied, code = True, "EXCEPTION_AS_DENY"
|
|
||||||
if not denied:
|
|
||||||
all_ok = False
|
|
||||||
msgs.append("FAIL C8 探针未被拒:%s 调 %s(%s)" % (agent_code, tool_code, why))
|
|
||||||
else:
|
|
||||||
msgs.append("C8 探针拒绝:%s → %s @%s %s(%s)"
|
|
||||||
% (agent_code, tool_code, step, code, why))
|
|
||||||
|
|
||||||
msgs.append("轨迹 7 要素:%s" % ", ".join(TRACE_ELEMENTS))
|
def get(self, key, default=None):
|
||||||
if all_ok:
|
return object.__getattribute__(self, "_obj").get(key, default)
|
||||||
msgs.append("SELF_CHECK %s: PASS(%d 工具 / %d 表 / %d 步 / %d 类审批)"
|
|
||||||
% (MODULE_NAME, len(tools), len(tbl_names),
|
def names(self):
|
||||||
len(ADJUDICATION_STEPS), len(MANDATORY_APPROVAL_TYPES)))
|
return list(CONTRACT_FUNCTIONS)
|
||||||
return all_ok, msgs
|
|
||||||
|
def describe(self):
|
||||||
|
return object.__getattribute__(self, "_obj").describe()
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return "<pbl_agent_runtime.api contracts=%d>" % len(CONTRACT_FUNCTIONS)
|
||||||
|
|
||||||
|
|
||||||
def _wrap(fn):
|
#: 模块级契约入口(init.api 与包级 pbl_agent_runtime.api 指向同一契约集合)
|
||||||
"""dspy 处理函数统一包装:PblError → 结构化拒绝响应(fail-closed,不抛 500)。
|
api = _ApiAccessor(_contract_api)
|
||||||
|
|
||||||
任何未预期异常同样按拒绝返回(default_verdict=DENY 同源策略),
|
|
||||||
并带 error_code=PBL_E_FORBIDDEN,避免异常细节泄漏到前端。
|
def get_api():
|
||||||
|
"""显式工厂:返回契约对象(等价 ``api()``)。"""
|
||||||
|
return _contract_api
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 自检
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def self_check(tenant_id=None, strict=True):
|
||||||
|
"""挂载自检:契约齐全 / seed 幂等 / fail-closed 语义 / append-only 守卫。
|
||||||
|
|
||||||
|
strict=True 且发现问题 → 抛 RuntimeError(fail-closed,禁止带病挂载)。
|
||||||
"""
|
"""
|
||||||
@functools.wraps(fn)
|
return _self_check(tenant_id=tenant_id, strict=strict)
|
||||||
async def _inner(**params_kw):
|
|
||||||
try:
|
|
||||||
data = await fn(**params_kw)
|
|
||||||
except PblError as e:
|
|
||||||
return dict(e.to_dict(), ok=False, default_verdict=DEFAULT_VERDICT)
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
return {"ok": False, "error_code": "PBL_E_FORBIDDEN",
|
|
||||||
"error_message": "内部异常,按 fail-closed 拒绝:%s" % type(e).__name__,
|
|
||||||
"default_verdict": DEFAULT_VERDICT}
|
|
||||||
if isinstance(data, dict):
|
|
||||||
data.setdefault("ok", True)
|
|
||||||
return data
|
|
||||||
return _inner
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# dspy 处理函数(wwwroot/api/*.dspy 直接调用,名称必须与 dspy 内一致)
|
# 挂载
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
def _tid(params_kw):
|
|
||||||
tid = (params_kw or {}).get("tenant_id")
|
|
||||||
if not tid:
|
|
||||||
# fail-closed:租户上下文缺失即拒绝(S1)
|
|
||||||
raise PblError("PBL_E_TENANT_MISSING", "tenant_id 强制打头,缺失即拒绝")
|
|
||||||
return tid
|
|
||||||
|
|
||||||
|
|
||||||
def _api(params_kw):
|
def _resolve_env():
|
||||||
return load_m4a(tenant_id=_tid(params_kw))
|
"""取 ServerEnv 实例;平台不可用时返回 None(离线自测走本地注册表)。"""
|
||||||
|
try:
|
||||||
|
from ahserver import ServerEnv # noqa: WPS433
|
||||||
|
|
||||||
|
return ServerEnv()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@_wrap
|
def _register(env, api_obj) -> list:
|
||||||
async def pbl_agent_designer_run(**params_kw):
|
"""把契约函数 + 附加能力注册到 ServerEnv,返回注册名清单。"""
|
||||||
"""US-01/03/04:Designer 生成 / 修改 / 澄清(action 分派,缺省 generate)。"""
|
registered = []
|
||||||
api = _api(params_kw)
|
for name in CONTRACT_FUNCTIONS:
|
||||||
action = params_kw.get("action", "generate")
|
fn = api_obj.get(name)
|
||||||
if action == "modify":
|
if fn is None:
|
||||||
return api.designer_modify(params_kw.get("blueprint_id"),
|
raise RuntimeError("contract function missing in api object: %s" % name)
|
||||||
params_kw.get("instruction"),
|
setattr(env, name, fn)
|
||||||
session_no=params_kw.get("session_no"),
|
registered.append(name)
|
||||||
version_no=params_kw.get("version_no"))
|
for name in EXTRA_FUNCTIONS:
|
||||||
if action == "clarify":
|
target = {
|
||||||
return api.designer_clarify(params_kw.get("blueprint_id"),
|
"seed_registry": seed_registry,
|
||||||
params_kw.get("missing_fields") or [],
|
"log_llm_call": log_llm_call,
|
||||||
session_no=params_kw.get("session_no"),
|
"self_check": self_check,
|
||||||
round_no=params_kw.get("round_no"))
|
"get_store": get_store,
|
||||||
return api.designer_generate(params_kw.get("intent_text") or "",
|
"module_dbname": module_dbname,
|
||||||
owner_teacher_id=params_kw.get("owner_teacher_id"),
|
}.get(name)
|
||||||
class_id=params_kw.get("class_id"),
|
if target is not None:
|
||||||
session_no=params_kw.get("session_no"))
|
setattr(env, name, target)
|
||||||
|
registered.append(name)
|
||||||
|
# 契约对象本体也挂上,供 .dspy / 其它模块整体取用
|
||||||
|
setattr(env, "pbl_agent_runtime_api", api_obj)
|
||||||
|
setattr(env, "pbl_agent_runtime_contract", api_obj)
|
||||||
|
return registered
|
||||||
|
|
||||||
|
|
||||||
@_wrap
|
def load_pbl_agent_runtime(env=None, tenant_id=None, strict=True, reset_store=False):
|
||||||
async def pbl_agent_critic_run(**params_kw):
|
"""模块唯一集成点:建库表 → seed → 注册 ServerEnv → 自检。
|
||||||
"""US-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"))
|
|
||||||
|
|
||||||
|
:param env: ServerEnv 实例(缺省自动获取;离线环境为 None 时用本地替身)
|
||||||
@_wrap
|
:param tenant_id: seed 的目标租户(缺省按 resolve_tenant 解析)
|
||||||
async def pbl_tool_adjudicate(**params_kw):
|
:param strict: 自检不过是否抛错(默认 True,fail-closed)
|
||||||
"""fail-closed 8 步裁决预检(只裁不执行)。tool_code 为空 → 返回裁决链说明。"""
|
:param reset_store: 测试用,挂载前清空离线存储
|
||||||
api = _api(params_kw)
|
:return: 挂载报告 dict
|
||||||
tool_code = params_kw.get("tool_code")
|
|
||||||
if not tool_code:
|
|
||||||
return {"steps": explain_chain(), "default_verdict": DEFAULT_VERDICT,
|
|
||||||
"policy_version": POLICY_VERSION}
|
|
||||||
return api.adjudicate(params_kw.get("agent_code") or "designer", tool_code,
|
|
||||||
args=params_kw.get("args") or {})
|
|
||||||
|
|
||||||
|
|
||||||
@_wrap
|
|
||||||
async def pbl_agent_trace_list(**params_kw):
|
|
||||||
"""F-AG-04:轨迹查询(可查不可篡改)。"""
|
|
||||||
api = _api(params_kw)
|
|
||||||
if params_kw.get("trace_no"):
|
|
||||||
return {"trace": api.get_trace(params_kw["trace_no"]),
|
|
||||||
"completeness": api.trace_completeness(params_kw["trace_no"])}
|
|
||||||
return api.list_traces(params_kw.get("filters") or {},
|
|
||||||
page=int(params_kw.get("page") or 1),
|
|
||||||
size=int(params_kw.get("size") or 20))
|
|
||||||
|
|
||||||
|
|
||||||
def _trace_write(api, action, agent_code, params_kw):
|
|
||||||
from . import m4a_trace as _t
|
|
||||||
ctx = api.agent_ctx(agent_code)
|
|
||||||
if action == "start":
|
|
||||||
return _t.start_trace(agent_code, session_no=params_kw.get("session_no"),
|
|
||||||
input_context=params_kw.get("input_context"),
|
|
||||||
ctx=ctx, store=api.store)
|
|
||||||
if action == "append":
|
|
||||||
return _t.append_trace(params_kw.get("trace_no"),
|
|
||||||
params_kw.get("stage") or params_kw.get("element"),
|
|
||||||
params_kw.get("payload") or {},
|
|
||||||
ctx=ctx, store=api.store)
|
|
||||||
raise PblError("PBL_E_VALIDATION",
|
|
||||||
"action 非法:%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)。
|
|
||||||
"""
|
"""
|
||||||
srv = env
|
store = get_store()
|
||||||
if srv is None:
|
if reset_store:
|
||||||
try:
|
store.reset()
|
||||||
from ahserver.serverenv import ServerEnv
|
store.ensure_schema()
|
||||||
srv = ServerEnv()
|
|
||||||
except Exception: # noqa: BLE001 —— 无宿主环境时降级 MemoryStore
|
|
||||||
srv = None
|
|
||||||
|
|
||||||
st = get_store(force_memory=force_memory or srv is None)
|
tenant = resolve_tenant(tenant_id)
|
||||||
ensure_tables(st)
|
seed_report = seed_registry(tenant)
|
||||||
tid = tenant_id or "__platform__"
|
|
||||||
seed = None
|
|
||||||
if do_seed:
|
|
||||||
ctx = TenantContext(tenant_id=tid, actor_type=ACTOR_SYSTEM, actor_id="seed",
|
|
||||||
permissions={PERM_PLATFORM_ADMIN})
|
|
||||||
seed = {"agents": seed_agents(ctx=ctx, store=st),
|
|
||||||
"tools": seed_tools(ctx=ctx, store=st)}
|
|
||||||
|
|
||||||
if run_self_check:
|
env_obj = env if env is not None else _resolve_env()
|
||||||
ok, msgs = self_check(tenant_id=tid, store=st)
|
offline = env_obj is None
|
||||||
for m in msgs:
|
if offline:
|
||||||
_log(srv, m)
|
env_obj = _OfflineEnv()
|
||||||
if not ok:
|
|
||||||
raise RuntimeError("%s self_check FAILED(fail-closed,拒绝启动):%s"
|
|
||||||
% (MODULE_NAME,
|
|
||||||
"; ".join([m for m in msgs if m.startswith("FAIL")][:8])))
|
|
||||||
|
|
||||||
if srv is not None:
|
api_obj = build_api()
|
||||||
setattr(srv, "pbl_agent_tools", list_tools(ctx=make_admin_ctx(tid), store=st))
|
registered = _register(env_obj, api_obj)
|
||||||
setattr(srv, "pbl_agent_enabled_tools", list(ENABLED_TOOL_CODES))
|
|
||||||
setattr(srv, "pbl_agent_disabled_tools", list(DISABLED_TOOL_CODES))
|
|
||||||
setattr(srv, "pbl_agent_api", api())
|
|
||||||
setattr(srv, "pbl_agent_dbname", get_dbname(srv))
|
|
||||||
setattr(srv, "pbl_agent_seed", seed)
|
|
||||||
# dspy 处理函数注册(wwwroot/api/*.dspy 直接按名调用)
|
|
||||||
setattr(srv, "pbl_agent_designer_run", pbl_agent_designer_run)
|
|
||||||
setattr(srv, "pbl_agent_critic_run", pbl_agent_critic_run)
|
|
||||||
setattr(srv, "pbl_tool_adjudicate", pbl_tool_adjudicate)
|
|
||||||
setattr(srv, "pbl_agent_trace_list", pbl_agent_trace_list)
|
|
||||||
setattr(srv, "pbl_agent_trace_write", pbl_agent_trace_write)
|
|
||||||
setattr(srv, "pbl_approval_create", pbl_approval_create)
|
|
||||||
setattr(srv, "pbl_approval_decide", pbl_approval_decide)
|
|
||||||
setattr(srv, "pbl_approval_list", pbl_approval_list)
|
|
||||||
modules = getattr(srv, "modules", None)
|
|
||||||
if isinstance(modules, list) and MODULE_NAME not in modules:
|
|
||||||
modules.append(MODULE_NAME)
|
|
||||||
|
|
||||||
return DEFAULT_VERDICT
|
report = _self_check(tenant_id=tenant, strict=strict)
|
||||||
|
report.update({
|
||||||
|
"module": MODULE_NAME,
|
||||||
|
"dbname": module_dbname(),
|
||||||
|
"offline_env": offline,
|
||||||
|
"registered": registered,
|
||||||
|
"registered_count": len(registered),
|
||||||
|
"api_routes": list(API_ROUTES),
|
||||||
|
"seed": seed_report,
|
||||||
|
"tables": 6,
|
||||||
|
"append_only_tables": list(APPEND_ONLY_TABLES),
|
||||||
|
"adjudication_steps": [step[0] for step in ADJUDICATION_STEPS],
|
||||||
|
"mandatory_approval_types": list(MANDATORY_APPROVAL_TYPES),
|
||||||
|
"trace_elements": list(TRACE_ELEMENTS),
|
||||||
|
})
|
||||||
|
_LOADED.update({"done": True, "env": env_obj, "report": report})
|
||||||
|
return Result(report)
|
||||||
|
|
||||||
|
|
||||||
def _log(srv, msg):
|
def load_pbl_agent_runtime_m4a(env=None, tenant_id=None, strict=True):
|
||||||
logger = getattr(srv, "logger", None) if srv is not None else None
|
"""M4a 装配别名(历史入口,语义与 load_pbl_agent_runtime 一致)。"""
|
||||||
if logger is not None and hasattr(logger, "info"):
|
return load_pbl_agent_runtime(env=env, tenant_id=tenant_id, strict=strict)
|
||||||
try:
|
|
||||||
logger.info("[%s] %s" % (MODULE_NAME, msg))
|
|
||||||
return
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
pass
|
|
||||||
print("[%s] %s" % (MODULE_NAME, msg))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
def bootstrap(tenant_id=None):
|
||||||
_ok, _msgs = self_check(run_probes=True)
|
"""离线引导:只建表 + seed,不碰 ServerEnv(脚本/测试用)。"""
|
||||||
for _m in _msgs:
|
get_store().ensure_schema()
|
||||||
print(_m)
|
return Result(seed_registry(resolve_tenant(tenant_id)))
|
||||||
print("RESULT: %s" % ("PASS" if _ok else "FAIL"))
|
|
||||||
raise SystemExit(0 if _ok else 1)
|
|
||||||
|
def get_dbname() -> str:
|
||||||
|
"""库名(ServerEnv 优先,禁止硬编码)。"""
|
||||||
|
return module_dbname()
|
||||||
|
|
||||||
|
|
||||||
|
def ddl() -> str:
|
||||||
|
"""离线 sqlite DDL 全文(在线 MySQL DDL 见 sql/m4a_contract_addon.sql)。"""
|
||||||
|
from .m4a_store import DDL_SQLITE
|
||||||
|
|
||||||
|
return DDL_SQLITE
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_tables(tenant_id=None) -> dict:
|
||||||
|
"""建表 + seed(幂等)。"""
|
||||||
|
get_store().ensure_schema()
|
||||||
|
return seed_registry(resolve_tenant(tenant_id))
|
||||||
|
|
||||||
|
|
||||||
|
class _OfflineEnv:
|
||||||
|
"""离线 ServerEnv 替身:仅承载属性注册,供无平台环境自测。"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._attrs = {}
|
||||||
|
|
||||||
|
def __setattr__(self, key, value):
|
||||||
|
if key.startswith("_"):
|
||||||
|
object.__setattr__(self, key, value)
|
||||||
|
else:
|
||||||
|
self._attrs[key] = value
|
||||||
|
|
||||||
|
def __getattr__(self, key):
|
||||||
|
attrs = object.__getattribute__(self, "_attrs")
|
||||||
|
if key in attrs:
|
||||||
|
return attrs[key]
|
||||||
|
raise AttributeError(key)
|
||||||
|
|
||||||
|
def get_module_dbname(self, module_name):
|
||||||
|
return os.environ.get("PBL_AGENT_RUNTIME_DBNAME") or module_name
|
||||||
|
|
||||||
|
def registered_names(self):
|
||||||
|
return sorted(object.__getattribute__(self, "_attrs").keys())
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"load_pbl_agent_runtime",
|
||||||
|
"load_pbl_agent_runtime_m4a",
|
||||||
|
"bootstrap",
|
||||||
|
"self_check",
|
||||||
|
"api",
|
||||||
|
"get_api",
|
||||||
|
"get_dbname",
|
||||||
|
"module_dbname",
|
||||||
|
"ddl",
|
||||||
|
"ensure_tables",
|
||||||
|
"seed_registry",
|
||||||
|
"log_llm_call",
|
||||||
|
"build_api",
|
||||||
|
"get_store",
|
||||||
|
"set_store",
|
||||||
|
"Store",
|
||||||
|
"Result",
|
||||||
|
"PblContractError",
|
||||||
|
"CONTRACT_FUNCTIONS",
|
||||||
|
"ADJUDICATION_STEPS",
|
||||||
|
"MANDATORY_APPROVAL_TYPES",
|
||||||
|
"TRACE_ELEMENTS",
|
||||||
|
"APPEND_ONLY_TABLES",
|
||||||
|
"API_ROUTES",
|
||||||
|
"MODULE_NAME",
|
||||||
|
]
|
||||||
|
|||||||
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 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""pbl_agent_runtime RBAC 路径注册(硬门禁 6.6 / QC #11)。
|
"""pbl_agent_runtime RBAC 路径注册清单。
|
||||||
|
|
||||||
约定:
|
铁律:PATHS 必须与 wwwroot/ 下**实际存在的文件一一对应**(QC 退回 #4)。
|
||||||
- 路径 = 模块自动路由 `/pbl_agent_runtime/api/<契约>.dspy`,不带端口、不带 /wss 前缀;
|
本文件由 scripts/check_contract_sync.py 机械核验:多一条(指向不存在文件)或少一条
|
||||||
- 角色 `logined` = 登录即可访问的读接口;写接口按角色分级(teacher/admin);
|
(文件未注册)都会失败。
|
||||||
- 由 apps/pbls/build.sh 第 8 步调用 `register()`;rbac CLI 不在位时打印清单(不静默跳过)。
|
|
||||||
|
角色约定
|
||||||
|
--------
|
||||||
|
* ``any`` :登录前可访问(本模块无此类文件)
|
||||||
|
* ``user`` :普通登录用户(只读查询类)
|
||||||
|
* ``admin`` :租户管理员(写类 / 审批裁决 / 注册表变更)
|
||||||
"""
|
"""
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
|
|
||||||
MODULE = 'pbl_agent_runtime'
|
# (路径, 角色) —— 路径相对模块 wwwroot,运行时前缀 /pbl_agent_runtime/
|
||||||
|
|
||||||
# (path, role)
|
|
||||||
PATHS = [
|
PATHS = [
|
||||||
('/pbl_agent_runtime/api/pbl_agent_designer_run.dspy', 'logined'),
|
# 模块入口页
|
||||||
('/pbl_agent_runtime/api/pbl_agent_critic_run.dspy', 'logined'),
|
("/pbl_agent_runtime/index.ui", "user"),
|
||||||
('/pbl_agent_runtime/api/pbl_agent_trace_write.dspy', 'logined'),
|
# 10 个契约端点(与 pbl_agent_runtime.m4a_contract.CONTRACT_FUNCTIONS 同源)
|
||||||
('/pbl_agent_runtime/api/pbl_agent_trace_list.dspy', 'logined'),
|
("/pbl_agent_runtime/api/pbl_agent_designer_run.dspy", "admin"),
|
||||||
('/pbl_agent_runtime/api/pbl_tool_registry_list.dspy', 'logined'),
|
("/pbl_agent_runtime/api/pbl_agent_critic_run.dspy", "user"),
|
||||||
('/pbl_agent_runtime/api/pbl_tool_registry_save.dspy', 'logined'),
|
("/pbl_agent_runtime/api/pbl_agent_trace_write.dspy", "user"),
|
||||||
('/pbl_agent_runtime/api/pbl_tool_adjudicate.dspy', 'logined'),
|
("/pbl_agent_runtime/api/pbl_agent_trace_list.dspy", "user"),
|
||||||
('/pbl_agent_runtime/api/pbl_approval_create.dspy', 'logined'),
|
("/pbl_agent_runtime/api/pbl_tool_registry_list.dspy", "user"),
|
||||||
('/pbl_agent_runtime/api/pbl_approval_decide.dspy', 'logined'),
|
("/pbl_agent_runtime/api/pbl_tool_registry_save.dspy", "admin"),
|
||||||
('/pbl_agent_runtime/api/pbl_approval_list.dspy', 'logined'),
|
("/pbl_agent_runtime/api/pbl_tool_adjudicate.dspy", "user"),
|
||||||
|
("/pbl_agent_runtime/api/pbl_approval_create.dspy", "user"),
|
||||||
|
("/pbl_agent_runtime/api/pbl_approval_decide.dspy", "admin"),
|
||||||
|
("/pbl_agent_runtime/api/pbl_approval_list.dspy", "user"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
MODULE_NAME = "pbl_agent_runtime"
|
||||||
|
|
||||||
def register():
|
|
||||||
tool = os.environ.get('RBAC_SET_PERM', 'set_role_perm.py')
|
def paths():
|
||||||
done, missing = 0, []
|
"""返回 [(path, role), ...],供宿主应用 RBAC 批量注册。"""
|
||||||
|
return list(PATHS)
|
||||||
|
|
||||||
|
|
||||||
|
def register(env=None):
|
||||||
|
"""把 PATHS 注册到 ServerEnv 的 RBAC 表(平台可用时)。"""
|
||||||
|
target = env
|
||||||
|
if target is None:
|
||||||
|
try:
|
||||||
|
from ahserver import ServerEnv # noqa: WPS433
|
||||||
|
|
||||||
|
target = ServerEnv()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return {"registered": 0, "offline": True, "paths": list(PATHS)}
|
||||||
|
count = 0
|
||||||
for path, role in PATHS:
|
for path, role in PATHS:
|
||||||
if subprocess.call([sys.executable if os.environ.get('PY') else 'python3',
|
setter = getattr(target, "add_path", None) or getattr(target, "register_path", None)
|
||||||
tool, role, path],
|
if callable(setter):
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0:
|
setter(path, role)
|
||||||
done += 1
|
count += 1
|
||||||
else:
|
return {"registered": count, "offline": count == 0, "paths": list(PATHS)}
|
||||||
missing.append((path, role))
|
|
||||||
print('[%s] rbac paths: total=%d ok=%d pending=%d' %(len(PATHS), done, len(missing)))
|
|
||||||
for path, role in missing:
|
|
||||||
print(' PENDING %%-12s %s' %(role, path))
|
|
||||||
return len(missing) == 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
sys.exit(0 if register() else 1)
|
for item_path, item_role in PATHS:
|
||||||
|
print("%-64s %s" % (item_path, item_role))
|
||||||
|
print("total: %d paths" % len(PATHS))
|
||||||
|
|||||||
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)
|
# Critic Agent 运行(零写权限)
|
||||||
debug('pbl_agent_runtime/api/pbl_agent_critic_run.dspy: START params_kw={dict(params_kw)}')
|
debug(f'pbl_agent_critic_run.dspy: START params_kw={dict(params_kw)}')
|
||||||
data = await pbl_agent_critic_run(**params_kw)
|
result = pbl_agent_critic_run(**dict(params_kw))
|
||||||
return data
|
debug(f'pbl_agent_critic_run.dspy: END success={result.get("success") if isinstance(result, dict) else result}')
|
||||||
|
return result
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
# pbl_agent_runtime/api/pbl_agent_designer_run.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py)
|
# Designer Agent 运行(可写,写操作经服务端裁决)
|
||||||
debug('pbl_agent_runtime/api/pbl_agent_designer_run.dspy: START params_kw={dict(params_kw)}')
|
debug(f'pbl_agent_designer_run.dspy: START params_kw={dict(params_kw)}')
|
||||||
data = await pbl_agent_designer_run(**params_kw)
|
result = pbl_agent_designer_run(**dict(params_kw))
|
||||||
return data
|
debug(f'pbl_agent_designer_run.dspy: END success={result.get("success") if isinstance(result, dict) else result}')
|
||||||
|
return result
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
# pbl_agent_runtime/api/pbl_agent_trace_list.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py)
|
# 查询 Agent 执行轨迹
|
||||||
debug('pbl_agent_runtime/api/pbl_agent_trace_list.dspy: START params_kw={dict(params_kw)}')
|
debug(f'pbl_agent_trace_list.dspy: START params_kw={dict(params_kw)}')
|
||||||
data = await pbl_agent_trace_list(**params_kw)
|
result = pbl_agent_trace_list(**dict(params_kw))
|
||||||
return data
|
debug(f'pbl_agent_trace_list.dspy: END success={result.get("success") if isinstance(result, dict) else result}')
|
||||||
|
return result
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
# pbl_agent_runtime/api/pbl_agent_trace_write.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py)
|
# 写 Agent 执行轨迹(7 要素,append-only)
|
||||||
debug('pbl_agent_runtime/api/pbl_agent_trace_write.dspy: START params_kw={dict(params_kw)}')
|
debug(f'pbl_agent_trace_write.dspy: START params_kw={dict(params_kw)}')
|
||||||
data = await pbl_agent_trace_write(**params_kw)
|
result = pbl_agent_trace_write(**dict(params_kw))
|
||||||
return data
|
debug(f'pbl_agent_trace_write.dspy: END success={result.get("success") if isinstance(result, dict) else result}')
|
||||||
|
return result
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
# pbl_agent_runtime/api/pbl_approval_create.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py)
|
# 创建人工审批单(14.2 四类)
|
||||||
debug('pbl_agent_runtime/api/pbl_approval_create.dspy: START params_kw={dict(params_kw)}')
|
debug(f'pbl_approval_create.dspy: START params_kw={dict(params_kw)}')
|
||||||
data = await pbl_approval_create(**params_kw)
|
result = pbl_approval_create(**dict(params_kw))
|
||||||
return data
|
debug(f'pbl_approval_create.dspy: END success={result.get("success") if isinstance(result, dict) else result}')
|
||||||
|
return result
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
# pbl_agent_runtime/api/pbl_approval_decide.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py)
|
# 人工审批裁决(无 Agent 绕过路径)
|
||||||
debug('pbl_agent_runtime/api/pbl_approval_decide.dspy: START params_kw={dict(params_kw)}')
|
debug(f'pbl_approval_decide.dspy: START params_kw={dict(params_kw)}')
|
||||||
data = await pbl_approval_decide(**params_kw)
|
result = pbl_approval_decide(**dict(params_kw))
|
||||||
return data
|
debug(f'pbl_approval_decide.dspy: END success={result.get("success") if isinstance(result, dict) else result}')
|
||||||
|
return result
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
# pbl_agent_runtime/api/pbl_approval_list.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py)
|
# 审批单列表查询
|
||||||
debug('pbl_agent_runtime/api/pbl_approval_list.dspy: START params_kw={dict(params_kw)}')
|
debug(f'pbl_approval_list.dspy: START params_kw={dict(params_kw)}')
|
||||||
data = await pbl_approval_list(**params_kw)
|
result = pbl_approval_list(**dict(params_kw))
|
||||||
return data
|
debug(f'pbl_approval_list.dspy: END success={result.get("success") if isinstance(result, dict) else result}')
|
||||||
|
return result
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
# pbl_agent_runtime/api/pbl_tool_adjudicate.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py)
|
# fail-closed 8 步工具裁决(默认 DENY)
|
||||||
debug('pbl_agent_runtime/api/pbl_tool_adjudicate.dspy: START params_kw={dict(params_kw)}')
|
debug(f'pbl_tool_adjudicate.dspy: START params_kw={dict(params_kw)}')
|
||||||
data = await pbl_tool_adjudicate(**params_kw)
|
result = pbl_tool_adjudicate(**dict(params_kw))
|
||||||
return data
|
debug(f'pbl_tool_adjudicate.dspy: END success={result.get("success") if isinstance(result, dict) else result}')
|
||||||
|
return result
|
||||||
|
|||||||
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