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

This commit is contained in:
agent.develop 2026-09-17 23:44:00 +08:00
parent ef2c2a1816
commit c2f683690e
14 changed files with 4617 additions and 0 deletions

265
pbl_agent_runtime/m4a.py Normal file
View File

@ -0,0 +1,265 @@
# -*- coding: utf-8 -*-
"""
pbl_agent_runtime M4a 对外统一门面facade
装配
from pbl_agent_runtime.m4a import load_m4a, M4aApi
api = load_m4a(tenant_id="T001") # 幂等:建表 + seed_agents + seed_tools
对外契约modules/pbl_agent_runtime.md §5
Designer : designer_generate / designer_modify / designer_clarify
Critic : critic_review / get_critic_report
工具裁决 : invoke_tool / adjudicate / register_tool / list_tools /
set_tool_status / seed_tools
轨迹 : start_trace / append_trace / get_trace / list_traces /
trace_completeness
审批 : request_approval / decide_approval / get_approval /
list_pending_approvals / mandatory_approval_matrix
"""
from .m4a_kernel import (
PblError, TenantContext, get_store, set_store, reset_store, fail,
MemoryStore, AppendOnlyGuard, canonical_json, now_ts, gen_no,
ADJUDICATION_STEPS, TRACE_ELEMENTS,
E_TENANT_MISSING, E_FORBIDDEN, E_STATE_ILLEGAL, E_NOT_FOUND, E_VALIDATION,
E_DUPLICATE, E_APPEND_ONLY, E_MODEL_UNAVAILABLE, E_BACKEND_UNAVAILABLE,
E_TEMPLATE_NONE,
ACTOR_USER, ACTOR_AGENT, ACTOR_SYSTEM,
)
from .m4a_tables import TABLES, APPEND_ONLY_TABLES, to_sql, all_sql, ensure_tables
from .m4a_registry import (
AGENT_DEFS, TOOL_SEED, ENABLED_TOOL_CODES, DISABLED_TOOL_CODES,
APPROVAL_REQUIRED_TOOLS, APPROVAL_TYPES, DESIGNER, CRITIC,
PERM_AUTHORING, PERM_AGENT_TOOLS, PERM_PUBLISHING, PERM_KDB,
PERM_PLATFORM_ADMIN,
seed_agents, seed_tools, get_agent_def, list_agents,
register_tool, get_tool, list_tools, set_tool_status, registry_stats,
)
from .m4a_adjudicate import (
Verdict, adjudicate, invoke_tool, validate_args, adjudicate_report,
explain_chain,
)
from .m4a_trace import (
start_trace, append_trace, finish_trace, get_trace, list_traces,
list_trace_stages, trace_completeness, try_mutate_trace,
)
from .m4a_approval import (
MANDATORY_APPROVAL_TYPES, APPROVAL_PUBLISH, APPROVAL_COMPILE,
APPROVAL_BLUEPRINT, APPROVAL_REGISTRY,
request_approval, decide_approval, get_approval, list_pending_approvals,
list_approvals, find_approved, mandatory_approval_matrix, normalize_action,
)
from .m4a_designer import (
designer_generate, designer_modify, designer_clarify, parse_intent,
instruction_to_changes, build_clarifications, set_model_runner,
CLARIFY_FIELDS, MAX_CLARIFY_ROUNDS,
)
from .m4a_critic import (
critic_review, get_critic_report, assert_suggestion, critic_write_attempt,
set_critic_runner, CRITIC_READONLY_TOOLS, SUGGESTION_FIELDS,
)
from .m4a_backend import execute as execute_tool_backend, backend_availability
# ---------------------------------------------------------------------------
# 上下文构造辅助
# ---------------------------------------------------------------------------
def make_agent_ctx(tenant_id, agent_code=DESIGNER, permissions=None, org_id=None):
"""构造 Agent 调用上下文actor_type=agent不可审批"""
perms = set(permissions or ())
if not perms:
# Agent 默认权限域authoring + agent_tools + kdb只读+ publishing。
# 注意:持有 publishing 只代表"可提案"发布S5 通过),
# 真正执行仍被 S7 人工审批门禁拦住14.2 Publish 必须人工审批)。
perms = {PERM_AUTHORING, PERM_AGENT_TOOLS, PERM_KDB, PERM_PUBLISHING}
return TenantContext(tenant_id=tenant_id, actor_type=ACTOR_AGENT,
actor_id=agent_code, permissions=perms, org_id=org_id)
def make_user_ctx(tenant_id, user_id, permissions=None, org_id=None):
"""构造人类用户上下文actor_type=user可审批 / 可治理注册表)。"""
perms = set(permissions or ())
if not perms:
perms = {PERM_AUTHORING, PERM_PUBLISHING, PERM_AGENT_TOOLS}
return TenantContext(tenant_id=tenant_id, actor_type=ACTOR_USER,
actor_id=user_id, permissions=perms, org_id=org_id)
def make_admin_ctx(tenant_id, user_id="admin"):
"""Platform Admin工具注册表治理 + 全量轨迹可读)。"""
return TenantContext(tenant_id=tenant_id, actor_type=ACTOR_USER,
actor_id=user_id,
permissions={PERM_PLATFORM_ADMIN, PERM_AUTHORING,
PERM_PUBLISHING, PERM_AGENT_TOOLS,
PERM_KDB})
# ---------------------------------------------------------------------------
# 装配
# ---------------------------------------------------------------------------
def load_m4a(tenant_id=None, store=None, force_memory=False):
"""
幂等装配建表 seed_agentsdesigner/critic seed_tools13+9
返回 M4aApi 实例build.sh / 应用 init() 调用
"""
st = store or get_store(force_memory=force_memory)
ensure_tables(st)
ctx = TenantContext(tenant_id=tenant_id or "__platform__",
actor_type=ACTOR_SYSTEM, actor_id="seed",
permissions={PERM_PLATFORM_ADMIN})
agents = seed_agents(ctx=ctx, store=st)
tools = seed_tools(ctx=ctx, store=st)
return M4aApi(tenant_id=tenant_id or "__platform__", store=st,
seed_result={"agents": agents, "tools": tools})
class M4aApi(object):
"""按租户绑定的 M4a 门面(避免每个调用都传 store/tenant"""
def __init__(self, tenant_id, store=None, seed_result=None):
self.tenant_id = tenant_id
self.store = store or get_store()
self.seed_result = seed_result or {}
# --- 上下文 ---
def agent_ctx(self, agent_code=DESIGNER, permissions=None):
return make_agent_ctx(self.tenant_id, agent_code, permissions)
def user_ctx(self, user_id, permissions=None):
return make_user_ctx(self.tenant_id, user_id, permissions)
def admin_ctx(self, user_id="admin"):
return make_admin_ctx(self.tenant_id, user_id)
# --- Designer ---
def designer_generate(self, intent_text, owner_teacher_id=None, class_id=None,
session_no=None, model_runner=None):
return designer_generate(intent_text, owner_teacher_id=owner_teacher_id,
class_id=class_id, session_no=session_no,
ctx=self.agent_ctx(DESIGNER), store=self.store,
model_runner=model_runner)
def designer_modify(self, blueprint_id, instruction, session_no=None,
version_no=None):
return designer_modify(blueprint_id, instruction, session_no=session_no,
ctx=self.agent_ctx(DESIGNER), store=self.store,
version_no=version_no)
def designer_clarify(self, blueprint_id, missing_fields, session_no=None,
round_no=None):
return designer_clarify(blueprint_id, missing_fields,
ctx=self.agent_ctx(DESIGNER), store=self.store,
session_no=session_no, round_no=round_no)
# --- Critic ---
def critic_review(self, blueprint_id, version_no=None, session_no=None):
return critic_review(blueprint_id, version_no=version_no,
ctx=self.agent_ctx(CRITIC), store=self.store,
session_no=session_no)
def get_critic_report(self, trace_no):
return get_critic_report(trace_no, ctx=self.admin_ctx(), store=self.store)
# --- 工具裁决 ---
def invoke_tool(self, agent_code, tool_code, args=None, trace_no=None,
session_no=None, approval_no=None):
return invoke_tool(self.agent_ctx(agent_code), agent_code, tool_code,
args=args, trace_no=trace_no, session_no=session_no,
approval_no=approval_no, store=self.store)
def adjudicate(self, agent_code, tool_code, args=None):
return adjudicate_report(self.agent_ctx(agent_code), agent_code, tool_code,
args=args, store=self.store)
def list_tools(self, status=None, group=None):
return list_tools(ctx=self.admin_ctx(), status=status, group=group,
store=self.store)
def registry_stats(self):
return registry_stats(ctx=self.admin_ctx(), store=self.store)
def set_tool_status(self, tool_code, status, reason=None, admin_id="admin",
approval_no=None):
return set_tool_status(self.admin_ctx(admin_id), tool_code, status,
reason=reason or approval_no, store=self.store)
# --- 轨迹 ---
def get_trace(self, trace_no):
return get_trace(trace_no, ctx=self.admin_ctx(), store=self.store)
def list_traces(self, filters=None, page=1, size=20):
return list_traces(filters, page=page, size=size, ctx=self.admin_ctx(),
store=self.store)
def trace_completeness(self, trace_no):
return trace_completeness(trace_no, ctx=self.admin_ctx(), store=self.store)
# --- 审批 ---
def request_approval(self, action_type, object_id=None, agent_code=DESIGNER,
tool_code=None, object_type=None, action_payload=None,
approver_id=None, trace_no=None, trace_id=None):
return request_approval(self.agent_ctx(agent_code), trace_id=trace_id,
action_type=action_type, agent_code=agent_code,
tool_code=tool_code, object_type=object_type,
object_id=object_id,
action_payload=action_payload,
approver_id=approver_id, trace_no=trace_no,
store=self.store)
def decide_approval(self, approval_no, status, user_id, comment=None):
return decide_approval(self.user_ctx(user_id), approval_no, status,
comment=comment, store=self.store)
def list_pending_approvals(self, approver_id):
return list_pending_approvals(self.user_ctx(approver_id),
approver_id=approver_id, store=self.store)
# --- 自检 ---
def self_check(self):
"""M4a 装配自检Agent 定义 / 工具计数 / 审批矩阵 / 裁决链步数。"""
agents = list_agents(ctx=self.admin_ctx(), store=self.store)
stats = self.registry_stats()
return {
"tenant_id": self.tenant_id,
"agents": [{"agent_code": a["agent_code"],
"write_allowed": a.get("write_allowed"),
"status": a.get("status"),
"allowed_tools": a.get("allowed_tools")} for a in agents],
"agent_count": len(agents),
"critic_write_allowed": [a["agent_code"] for a in agents
if a["agent_code"] == CRITIC
and a.get("write_allowed") in (1, True)],
"tools": stats,
"adjudication_steps": [s[0] for s in ADJUDICATION_STEPS],
"adjudication_step_count": len(ADJUDICATION_STEPS),
"mandatory_approvals": [m["action_type"]
for m in mandatory_approval_matrix()],
"trace_elements": list(TRACE_ELEMENTS),
"append_only_tables": list(APPEND_ONLY_TABLES),
"backend": backend_availability(),
}
__all__ = [
"load_m4a", "M4aApi", "make_agent_ctx", "make_user_ctx", "make_admin_ctx",
"TenantContext", "PblError", "get_store", "set_store", "reset_store",
"MemoryStore", "AppendOnlyGuard", "ensure_tables", "TABLES", "to_sql",
"all_sql", "APPEND_ONLY_TABLES",
"seed_agents", "seed_tools", "get_agent_def", "list_agents",
"register_tool", "get_tool", "list_tools", "set_tool_status",
"registry_stats", "AGENT_DEFS", "TOOL_SEED", "ENABLED_TOOL_CODES",
"DISABLED_TOOL_CODES", "APPROVAL_REQUIRED_TOOLS", "APPROVAL_TYPES",
"adjudicate", "adjudicate_report", "invoke_tool", "validate_args",
"explain_chain", "Verdict", "ADJUDICATION_STEPS",
"start_trace", "append_trace", "finish_trace", "get_trace", "list_traces",
"list_trace_stages", "trace_completeness", "try_mutate_trace",
"TRACE_ELEMENTS",
"request_approval", "decide_approval", "get_approval",
"list_pending_approvals", "list_approvals", "find_approved",
"mandatory_approval_matrix", "MANDATORY_APPROVAL_TYPES",
"designer_generate", "designer_modify", "designer_clarify", "parse_intent",
"instruction_to_changes", "set_model_runner",
"critic_review", "get_critic_report", "assert_suggestion",
"critic_write_attempt", "set_critic_runner",
"backend_availability", "DESIGNER", "CRITIC",
]

View File

@ -0,0 +1,371 @@
# -*- coding: utf-8 -*-
"""
pbl_agent_runtime M4a fail-closed 工具裁决8 default-deny
裁决顺序modules/pbl_agent_runtime.md §4 六步 + 本任务要求的 8 步细化顺序不可变
S1 租户上下文有效 PBL_E_TENANT_MISSING
S2 Agent 已注册且 enabled PBL_E_FORBIDDEN
S3 工具已注册白名单外拒 PBL_E_FORBIDDEN
S4 工具 status=enabled PBL_E_FORBIDDEN返回 disable_reason
S5 required_permission PBL_E_FORBIDDEN越权拒绝
S6 allowed_agents + Critic 零写权限 PBL_E_FORBIDDEN
S7 require_approval=1 approved PBL_E_STATE_ILLEGAL
S8 input_schema 契约校验 PBL_E_VALIDATION
全通过 执行后端映射 轨迹落库7 要素
铁律
* default-deny 任何一步失败立即返回拒绝绝不"默认放行"
* 被拒调用同样写轨迹proposed_action + result=deny + 原因可审计US-20
* Agent 不能自批S7 只认 actor_type=user 决出的 approved 记录
"""
from .m4a_kernel import (
get_store, one, fail, now_ts, dumps, loads, write_audit, gen_no,
PblError, ADJUDICATION_STEPS,
E_FORBIDDEN, E_STATE_ILLEGAL, E_TENANT_MISSING, E_VALIDATION, E_NOT_FOUND,
TOOL_STATUS_ENABLED, TOOL_STATUS_DISABLED, AGENT_STATUS_ENABLED,
TRACE_STATUS_DENY, ACTOR_AGENT,
)
from .m4a_registry import get_tool, get_agent_def, CRITIC, DESIGNER
from . import m4a_trace as trace_mod
from . import m4a_approval as approval_mod
from . import m4a_backend as backend_mod
# ---------------------------------------------------------------------------
# 裁决结果对象
# ---------------------------------------------------------------------------
class Verdict(object):
"""一次裁决的完整结论allow / deny + 到达步 + 原因),可序列化入轨迹。"""
def __init__(self, allowed, step, check, reason_code, reason, tool=None,
agent=None, approval=None):
self.allowed = allowed
self.step = step # S1~S8 / EXECUTED
self.check = check # 检查项名
self.reason_code = reason_code
self.reason = reason
self.tool = tool or {}
self.agent = agent or {}
self.approval = approval
def to_dict(self):
return {
"allowed": self.allowed,
"step_reached": self.step,
"check": self.check,
"reason_code": self.reason_code,
"reason": self.reason,
"tool_code": self.tool.get("tool_code"),
"tool_status": self.tool.get("status"),
"disable_reason": self.tool.get("disable_reason"),
"agent_code": self.agent.get("agent_code"),
"approval_no": (self.approval or {}).get("approval_no"),
}
@staticmethod
def allow(step, tool, agent, approval=None):
return Verdict(True, step, "pass", None, "裁决通过", tool, agent, approval)
@staticmethod
def deny(step, check, code, reason, tool=None, agent=None):
return Verdict(False, step, check, code, reason, tool, agent)
def _deny(step_id, check, code, reason, tool=None, agent=None):
return Verdict.deny(step_id, check, code, reason, tool, agent)
# ---------------------------------------------------------------------------
# 8 步裁决链
# ---------------------------------------------------------------------------
def adjudicate(ctx, agent_code, tool_code, args=None, trace_no=None,
approval_no=None, store=None):
"""
执行 fail-closed 8 步裁决返回 Verdict不抛异常便于把 deny 落轨迹
args 仅用于 S8 契约校验不做任何副作用
"""
st = store or get_store()
args = args if isinstance(args, dict) else {}
# ---- S1 租户上下文 ----
if ctx is None or not getattr(ctx, "tenant_id", None) \
or not str(ctx.tenant_id).strip():
return _deny("S1", "tenant_context", E_TENANT_MISSING,
"租户上下文缺失或非法tenant_id 强制打头)",
tool={"tool_code": tool_code},
agent={"agent_code": agent_code})
tenant_id = str(ctx.tenant_id).strip()
# ---- S2 Agent 已注册且 enabled ----
try:
agent = get_agent_def(agent_code, ctx=ctx, store=st)
except PblError:
agent = None
if not agent:
return _deny("S2", "agent_registered", E_FORBIDDEN,
"Agent 未注册(本迭代仅 designer / critic 两个13.1 章)",
tool={"tool_code": tool_code},
agent={"agent_code": agent_code})
if agent.get("status") != AGENT_STATUS_ENABLED:
return _deny("S2", "agent_registered", E_FORBIDDEN,
"Agent 已停用: %s" % agent_code, tool={"tool_code": tool_code},
agent=agent)
# ---- S3 工具已注册default-deny白名单外一律拒 ----
tool = get_tool(tool_code, ctx=ctx, store=st, tenant_id=tenant_id)
if not tool:
return _deny("S3", "tool_registered", E_FORBIDDEN,
"工具未注册default-deny 拒绝(第 31 章裁剪白名单外不可用)",
tool={"tool_code": tool_code}, agent=agent)
# ---- S4 工具 enabled ----
if tool.get("status") != TOOL_STATUS_ENABLED:
return _deny("S4", "tool_enabled", E_FORBIDDEN,
"工具已禁用out_of_scope%s"
% (tool.get("disable_reason") or "无说明"),
tool=tool, agent=agent)
# ---- S5 权限域 ----
perm = tool.get("required_permission")
if perm and not ctx.has_perm(perm):
return _deny("S5", "permission_domain", E_FORBIDDEN,
"越权拒绝:调用方缺少权限域 %s" % perm, tool=tool, agent=agent)
# ---- S6 Agent 作用域 + Critic 零写权限 ----
allowed_agents = tool.get("allowed_agents") or []
denied_agents = agent.get("denied_tools") or []
if tool_code in denied_agents:
return _deny("S6", "agent_scope", E_FORBIDDEN,
"该 Agent 显式禁用此工具: %s%s" % (agent_code, tool_code),
tool=tool, agent=agent)
if allowed_agents and agent_code not in allowed_agents:
return _deny("S6", "agent_scope", E_FORBIDDEN,
"工具 %s 仅允许 %s 调用,%s 越界"
% (tool_code, "/".join(allowed_agents), agent_code),
tool=tool, agent=agent)
# Critic 零写权限14.1):双重防线——即使注册表误配也拒绝
if agent.get("write_allowed") in (0, False) and tool.get("write_operation") in (1, True):
return _deny("S6", "agent_scope", E_FORBIDDEN,
"Critic 零写权限14.1):不得调用写操作工具 %s" % tool_code,
tool=tool, agent=agent)
if agent_code == CRITIC and tool.get("write_operation") in (1, True):
return _deny("S6", "agent_scope", E_FORBIDDEN,
"Critic 零写权限14.1):不得调用写操作工具 %s" % tool_code,
tool=tool, agent=agent)
# ---- S7 强制人工审批 ----
approval = None
if tool.get("require_approval") in (1, True):
approval = approval_mod.find_approved(
tool.get("approval_action_type") or "execute",
args.get("blueprint_id") or args.get("object_id") or tool_code,
ctx=ctx, store=st, tenant_id=tenant_id, approval_no=approval_no,
trace_no=trace_no, tool_code=tool_code)
if approval is None:
return _deny("S7", "approval_gate", E_STATE_ILLEGAL,
"工具 %s 需人工审批(%s),无 approved 记录 → 拒绝执行14.2"
% (tool_code, tool.get("approval_action_type") or "execute"),
tool=tool, agent=agent)
# ---- S8 入参契约 ----
errs = validate_args(tool.get("input_schema") or {}, args)
if errs:
return _deny("S8", "input_contract", E_VALIDATION,
"入参不满足工具契约: %s" % "; ".join(errs), tool=tool, agent=agent)
return Verdict.allow("S8", tool, agent, approval)
# ---------------------------------------------------------------------------
# S8 入参契约校验(轻量 JSON Schema 子集required / type / enum / minItems
# ---------------------------------------------------------------------------
_TYPE_MAP = {
"string": str, "integer": int, "number": (int, float),
"boolean": bool, "object": dict, "array": (list, tuple),
}
def _type_ok(value, tname):
if tname not in _TYPE_MAP:
return True
expect = _TYPE_MAP[tname]
if tname == "integer" and isinstance(value, bool):
return False
if tname == "string":
return isinstance(value, str)
return isinstance(value, expect)
def validate_args(schema, args):
"""返回错误列表(空=通过。schema 为 input_schemaJSON Schema 子集)。"""
errs = []
if not isinstance(schema, dict) or not schema:
return errs
args = args if isinstance(args, dict) else {}
props = schema.get("properties") or {}
for name in schema.get("required") or []:
if name not in args or args[name] is None or args[name] == "":
errs.append("缺少必填参数 %s" % name)
for name, spec in props.items():
if name not in args or args[name] is None:
continue
val = args[name]
tname = spec.get("type")
if tname and not _type_ok(val, tname):
errs.append("参数 %s 类型应为 %s,实为 %s"
% (name, tname, type(val).__name__))
continue
if spec.get("enum") and val not in spec["enum"]:
errs.append("参数 %s 取值须在 %s 内,实为 %r"
% (name, spec["enum"], val))
if tname == "array":
if spec.get("minItems") and len(val) < spec["minItems"]:
errs.append("参数 %s 至少 %s" % (name, spec["minItems"]))
item_spec = spec.get("items") or {}
if isinstance(item_spec, dict) and item_spec.get("required"):
for i, item in enumerate(val):
if not isinstance(item, dict):
errs.append("参数 %s[%d] 应为对象" % (name, i))
continue
for rq in item_spec["required"]:
if rq not in item or item[rq] is None:
errs.append("参数 %s[%d] 缺少 %s" % (name, i, rq))
return errs
# ---------------------------------------------------------------------------
# 工具调用(裁决 + 执行 + 轨迹)
# ---------------------------------------------------------------------------
def invoke_tool(ctx, agent_code, tool_code, args=None, trace_no=None,
session_no=None, approval_no=None, store=None,
input_context=None):
"""
Agent 请求工具调用的唯一入口
流程开轨迹若未给 trace_no 8 步裁决 拒绝则落 deny 轨迹并抛 PblError
通过则执行后端映射 tool_calls/result/final_output 落轨迹 审计
"""
st = store or get_store()
args = args if isinstance(args, dict) else {}
own_trace = trace_no is None
if not trace_no:
trace_no = trace_mod.start_trace(
agent_code, session_no=session_no,
input_context=input_context or {"tool_code": tool_code, "args": args},
ctx=ctx, store=st)
tr = trace_mod.get_trace(trace_no, ctx=ctx, store=st, internal=True)
verdict = adjudicate(ctx, agent_code, tool_code, args=args, trace_no=trace_no,
approval_no=approval_no, store=st)
# 提案动作Agent 只 Propose14.1)——通过与拒绝都记
proposed = {
"agent_code": agent_code, "tool_code": tool_code, "args": args,
"authority": "propose_only",
"note": "Agent 非真相源写操作经工具落到权威系统14.1",
}
trace_mod.append_trace(trace_no, "proposed_action", proposed, ctx=ctx, store=st)
if not verdict.allowed:
trace_mod.append_trace(trace_no, "tool_calls", {
"tool_code": tool_code, "args": args, "executed": False,
"denied_at": verdict.step, "check": verdict.check,
"reason_code": verdict.reason_code, "reason": verdict.reason,
"disable_reason": verdict.tool.get("disable_reason"),
}, ctx=ctx, store=st)
trace_mod.append_trace(trace_no, "result", {
"allow": False, "deny": True, "step_reached": verdict.step,
"error_code": verdict.reason_code, "reason": verdict.reason,
}, ctx=ctx, store=st)
_finish_own(trace_no, own_trace, status=TRACE_STATUS_DENY,
step_reached=verdict.step,
deny_code=verdict.reason_code,
deny_reason=verdict.reason, ctx=ctx, store=st)
fail(verdict.reason_code, verdict.reason,
tool_code=tool_code, agent_code=agent_code,
step_reached=verdict.step, check=verdict.check,
disable_reason=verdict.tool.get("disable_reason"),
trace_no=trace_no)
# ---- 执行后端映射 ----
try:
result = backend_mod.execute(ctx, verdict.tool, args, trace_no=trace_no,
approval=verdict.approval, store=st)
exec_err = None
except PblError as e:
result, exec_err = None, e
except Exception as e: # 后端异常不外泄细节,统一错误码
result, exec_err = None, PblError(E_STATE_ILLEGAL,
"工具后端执行失败: %s" % e.__class__.__name__,
tool_code=tool_code)
trace_mod.append_trace(trace_no, "tool_calls", {
"tool_code": tool_code, "args": args, "executed": exec_err is None,
"backend_mapping": verdict.tool.get("backend_mapping"),
"output": result if exec_err is None else None,
"error": exec_err.to_dict() if exec_err else None,
"called_at": now_ts(),
}, ctx=ctx, store=st)
if verdict.approval:
trace_mod.append_trace(trace_no, "approval", {
"approval_no": verdict.approval.get("approval_no"),
"action_type": verdict.approval.get("action_type"),
"status": verdict.approval.get("status"),
"decided_by": verdict.approval.get("decided_by"),
"approver_type": verdict.approval.get("approver_type"),
"note": "人工审批通过后方可执行14.2",
}, ctx=ctx, store=st)
if exec_err is not None:
trace_mod.append_trace(trace_no, "result", {
"allow": True, "executed": False, "error_code": exec_err.code,
"reason": exec_err.message,
}, ctx=ctx, store=st)
_finish_own(trace_no, own_trace, status=TRACE_STATUS_DENY,
step_reached="EXECUTED", deny_code=exec_err.code,
deny_reason=exec_err.message, ctx=ctx, store=st)
raise exec_err
trace_mod.append_trace(trace_no, "result", {
"allow": True, "executed": True, "tool_code": tool_code, "output": result,
}, ctx=ctx, store=st)
trace_mod.append_trace(trace_no, "final_output", result, ctx=ctx, store=st)
_finish_own(trace_no, own_trace, status="done", step_reached="EXECUTED",
ctx=ctx, store=st)
# 审计联动§4.5object_type=tool_code
write_audit(ctx, "tool.invoke", tool_code, trace_no,
{"agent_code": agent_code, "trace_no": trace_no,
"approval_no": (verdict.approval or {}).get("approval_no")},
store=st)
out = dict(result) if isinstance(result, dict) else {"value": result}
out.setdefault("trace_no", trace_no)
out["_verdict"] = verdict.to_dict()
return out
def _finish_own(trace_no, own, **kw):
"""仅当本次 invoke_tool 自己开的轨迹才收口父轨迹Designer/Critic 流程)
由父流程负责收口避免子工具调用把外层轨迹提前冻结append-only"""
if not own:
return None
return trace_mod.finish_trace(trace_no, **kw)
def adjudicate_report(ctx, agent_code, tool_code, args=None, store=None):
"""
只裁决不执行供前端"权限预检"与自动化测试锚点越权工具调用拒绝
返回 Verdict.to_dict()
"""
v = adjudicate(ctx, agent_code, tool_code, args=args, store=store)
return v.to_dict()
def explain_chain():
"""输出 8 步裁决顺序说明(供文档/前端展示,与 ADJUDICATION_STEPS 同源)。"""
return [{"step": s[0], "check": s[1], "description": s[2], "error_code": s[3]}
for s in ADJUDICATION_STEPS]

View File

@ -0,0 +1,305 @@
# -*- coding: utf-8 -*-
"""
pbl_agent_runtime M4a 人工审批14.2 四类强制审批
四类强制人工审批缺一即拒绝执行
1. publish 发布T12 publish.requestrequire_approval=1
Publish 必须人工审批14.2 / 36 无自主发布D1 禁用
2. compile_execute 编译执行T11 compile.triggerrequire_approval=1
未审批蓝图不可编译US-10后端 is_approved 门禁
3. blueprint_approve 蓝图 approved 状态推进draft approved 由教师拍板
Agent 只能提案不得自批
4. tool_registry_change 工具注册表启停变更禁用启用须人工审批
防止 Agent/脚本私自扩权default-deny 不被绕过
铁律
* Agent 不能自批 decide_approval actor_type=user 可调agent-tool-contract.md §4.3
* 已决approved/rejected/expired审批单不可再决 PBL_E_STATE_ILLEGAL
* 指定了 approver_id 仅该审批人可决 PBL_E_FORBIDDEN
* find_approved 只认 status=approved 且未过期且 actor 为人类的记录
"""
from .m4a_kernel import (
get_store, one, fail, now_ts, dumps, loads, gen_no, write_audit,
E_NOT_FOUND, E_FORBIDDEN, E_STATE_ILLEGAL, E_VALIDATION, E_TENANT_MISSING,
ACTOR_USER, ACTOR_AGENT,
)
APPROVAL_PUBLISH = "publish"
APPROVAL_COMPILE = "compile_execute"
APPROVAL_BLUEPRINT = "blueprint_approve"
APPROVAL_REGISTRY = "tool_registry_change"
MANDATORY_APPROVAL_TYPES = (APPROVAL_PUBLISH, APPROVAL_COMPILE,
APPROVAL_BLUEPRINT, APPROVAL_REGISTRY)
# 兼容 agent-tool-contract.md T10 的 action_type 枚举execute → compile_execute
ACTION_ALIASES = {"execute": APPROVAL_COMPILE, "compile": APPROVAL_COMPILE,
"publish": APPROVAL_PUBLISH,
"blueprint_approve": APPROVAL_BLUEPRINT,
"tool_registry_change": APPROVAL_REGISTRY}
ST_PENDING = "pending"
ST_APPROVED = "approved"
ST_REJECTED = "rejected"
ST_EXPIRED = "expired"
def normalize_action(action_type):
a = ACTION_ALIASES.get(str(action_type or "").strip())
if a is None:
fail(E_VALIDATION,
"action_type 非法,四类强制审批之一: %s" % (MANDATORY_APPROVAL_TYPES,),
action_type=action_type)
return a
def _tenant(ctx):
if ctx is None or not getattr(ctx, "tenant_id", None):
fail(E_TENANT_MISSING, "审批操作缺少租户上下文")
return str(ctx.tenant_id).strip()
# ---------------------------------------------------------------------------
# 发起审批Agent 提案侧)
# ---------------------------------------------------------------------------
def request_approval(ctx, trace_id=None, action_type=None, agent_code=None,
tool_code=None, object_type=None, object_id=None,
action_payload=None, approver_id=None, trace_no=None,
expires_in_hours=72, store=None):
"""
发起人工审批单T10 approval.request返回 {approval_no, status:"pending"}
Agent 只能提案不能决定requester_type ctx.actor_type 推导
"""
st = store or get_store()
tenant_id = _tenant(ctx)
action = normalize_action(action_type)
# 同一对象已有 pending 单 → 复用(幂等,避免刷单)
exist = _find_pending(st, tenant_id, action, object_id, tool_code)
if exist:
return {"approval_no": exist["approval_no"], "status": ST_PENDING,
"reused": True, "action_type": action}
no = gen_no("APR")
row = {
"tenant_id": tenant_id, "approval_no": no,
"trace_id": trace_id, "trace_no": trace_no,
"agent_code": agent_code or (ctx.actor_id if ctx.actor_type == ACTOR_AGENT else None),
"action_type": action, "tool_code": tool_code,
"object_type": object_type, "object_id": dumps(object_id) if isinstance(object_id, (dict, list)) else (str(object_id) if object_id is not None else None),
"action_payload": dumps(action_payload or {}),
"status": ST_PENDING,
"requester_type": ctx.actor_type or ACTOR_AGENT,
"requester_id": ctx.actor_id,
"approver_id": approver_id, "approver_type": None,
"decided_by": None, "decided_at": None, "comment": None,
"expires_at": _expires(expires_in_hours),
"created_at": now_ts(), "updated_at": now_ts(),
}
aid = st.C("pbl_agent_approval", row)
write_audit(ctx, "approval.request", "pbl_agent_approval", aid,
{"approval_no": no, "action_type": action,
"object_id": row["object_id"]}, store=st)
row["id"] = aid
return {"approval_no": no, "status": ST_PENDING, "id": aid,
"action_type": action, "reused": False}
def _expires(hours):
import time
if not hours:
return None
return time.strftime("%Y-%m-%d %H:%M:%S",
time.localtime(time.time() + int(hours) * 3600))
def _find_pending(st, tenant_id, action, object_id, tool_code):
oid = dumps(object_id) if isinstance(object_id, (dict, list)) else (
str(object_id) if object_id is not None else None)
rows = st.R("pbl_agent_approval",
{"tenant_id": tenant_id, "action_type": action, "status": ST_PENDING},
order_by="-id")
for r in rows:
if oid is not None and str(r.get("object_id")) == oid:
return r
if oid is None and tool_code and r.get("tool_code") == tool_code:
return r
return None
# ---------------------------------------------------------------------------
# 审批决定(仅人类)
# ---------------------------------------------------------------------------
def decide_approval(ctx, approval_no, status, comment=None, store=None):
"""
审批决定 actor_type=user人类可调Agent 自批 PBL_E_FORBIDDEN14.2
status: approved / rejected已决 PBL_E_STATE_ILLEGAL
"""
st = store or get_store()
tenant_id = _tenant(ctx)
if ctx.actor_type != ACTOR_USER:
fail(E_FORBIDDEN,
"审批不可自批decide_approval 仅人类审批人actor_type=user可调"
"实为 %s14.2" % ctx.actor_type,
approval_no=approval_no, actor_type=ctx.actor_type)
if not ctx.has_perm("pbl_authoring") and not ctx.has_perm("publishing") \
and not ctx.has_perm("platform_admin"):
fail(E_FORBIDDEN, "非授权审批人:缺少 pbl_authoring/publishing/platform_admin 权限域",
approval_no=approval_no, actor=ctx.actor_id)
if status not in (ST_APPROVED, ST_REJECTED):
fail(E_VALIDATION, "status 仅 approved/rejected", status=status)
row = one(st, "pbl_agent_approval",
{"tenant_id": tenant_id, "approval_no": approval_no})
if row is None:
fail(E_NOT_FOUND, "审批单不存在: %s" % approval_no, approval_no=approval_no)
if row.get("status") != ST_PENDING:
fail(E_STATE_ILLEGAL,
"审批单已决status=%s),不可重复决定" % row.get("status"),
approval_no=approval_no)
if row.get("approver_id") and str(row["approver_id"]) != str(ctx.actor_id):
fail(E_FORBIDDEN,
"非指定审批人:本单指定 %s,实际 %s"
% (row.get("approver_id"), ctx.actor_id),
approval_no=approval_no)
if row.get("expires_at") and row["expires_at"] < now_ts():
st.U("pbl_agent_approval", {"status": ST_EXPIRED, "updated_at": now_ts()},
{"tenant_id": tenant_id, "approval_no": approval_no})
fail(E_STATE_ILLEGAL, "审批单已过期", approval_no=approval_no)
patch = {"status": status, "decided_by": ctx.actor_id,
"approver_type": ACTOR_USER, "decided_at": now_ts(),
"comment": comment, "updated_at": now_ts()}
st.U("pbl_agent_approval", patch,
{"tenant_id": tenant_id, "approval_no": approval_no})
write_audit(ctx, "approval.decide", "pbl_agent_approval", row.get("id"),
{"approval_no": approval_no, "status": status,
"action_type": row.get("action_type"), "comment": comment},
store=st)
out = dict(row)
out.update(patch)
return _decode(out)
# ---------------------------------------------------------------------------
# 查询
# ---------------------------------------------------------------------------
def _decode(row):
r = dict(row)
r["action_payload"] = loads(r.get("action_payload"), {}) or {}
return r
def get_approval(approval_no, ctx=None, store=None):
st = store or get_store()
tenant_id = _tenant(ctx)
row = one(st, "pbl_agent_approval",
{"tenant_id": tenant_id, "approval_no": approval_no})
if row is None:
fail(E_NOT_FOUND, "审批单不存在: %s" % approval_no, approval_no=approval_no)
return _decode(row)
def list_pending_approvals(ctx, approver_id=None, action_type=None, store=None):
"""待办审批列表(教师/Reviewer/Org Admin 工作台)。"""
st = store or get_store()
tenant_id = _tenant(ctx)
where = {"tenant_id": tenant_id, "status": ST_PENDING}
if action_type:
where["action_type"] = normalize_action(action_type)
rows = st.R("pbl_agent_approval", where, order_by="id")
aid = approver_id or ctx.actor_id
out = [r for r in rows
if not r.get("approver_id") or str(r.get("approver_id")) == str(aid)]
return [_decode(r) for r in out]
def list_approvals(ctx, filters=None, page=1, size=20, store=None):
st = store or get_store()
tenant_id = _tenant(ctx)
filters = filters or {}
where = {"tenant_id": tenant_id}
for k in ("status", "action_type", "agent_code", "trace_no", "tool_code"):
if filters.get(k):
where[k] = filters[k]
rows = st.R("pbl_agent_approval", where, order_by="-id")
total = len(rows)
page = max(1, int(page or 1))
size = max(1, min(200, int(size or 20)))
return {"items": [_decode(r) for r in rows[(page - 1) * size: page * size]],
"total": total, "page": page, "size": size}
def find_approved(action_type, object_id, ctx=None, store=None, tenant_id=None,
approval_no=None, trace_no=None, tool_code=None):
"""
裁决链 S7 使用查是否存在有效 approved 审批单
仅认status=approved + approver_type=user人类决出+ 未过期 +
action_type 匹配 + 对象匹配object_id / trace_no / tool_code 任一命中
找不到返回 None S7 PBL_E_STATE_ILLEGAL
"""
st = store or get_store()
tid = tenant_id or (ctx.tenant_id if ctx is not None else None)
if not tid:
return None
action = ACTION_ALIASES.get(str(action_type or "").strip(), action_type)
where = {"tenant_id": tid, "status": ST_APPROVED}
if action:
where["action_type"] = action
rows = st.R("pbl_agent_approval", where, order_by="-id")
oid = None
if object_id is not None:
oid = dumps(object_id) if isinstance(object_id, (dict, list)) else str(object_id)
for r in rows:
if r.get("approver_type") != ACTOR_USER:
continue # 非人类决出的一律不认Agent 不可自批)
if r.get("expires_at") and r["expires_at"] < now_ts():
continue
if approval_no and r.get("approval_no") == approval_no:
return _decode(r)
hit = False
if oid is not None and str(r.get("object_id") or "") == oid:
hit = True
if not hit and trace_no and r.get("trace_no") == trace_no:
hit = True
if not hit and tool_code and r.get("tool_code") == tool_code and oid is None:
hit = True
if hit:
return _decode(r)
if approval_no:
row = one(st, "pbl_agent_approval",
{"tenant_id": tid, "approval_no": approval_no})
if row and row.get("status") == ST_APPROVED \
and row.get("approver_type") == ACTOR_USER:
return _decode(row)
return None
def mandatory_approval_matrix():
"""四类强制人工审批矩阵(供前端展示 / 测试锚点核对)。"""
return [
{"action_type": APPROVAL_PUBLISH, "name": "发布审批",
"trigger_tool": "publish.request", "require_approval": 1,
"approver": "教师 / Reviewer / Org Admin",
"anchor": "14.2 / 36 章Publish 必须人工审批D1 blueprint.publish_auto 禁用",
"deny_when_missing": E_STATE_ILLEGAL},
{"action_type": APPROVAL_COMPILE, "name": "编译执行审批",
"trigger_tool": "compile.trigger", "require_approval": 1,
"approver": "教师",
"anchor": "US-10未审批蓝图不可编译pbl_compiler is_approved 门禁)",
"deny_when_missing": E_STATE_ILLEGAL},
{"action_type": APPROVAL_BLUEPRINT, "name": "蓝图 approved 状态推进审批",
"trigger_tool": "blueprint.update提案→ 人工拍板", "require_approval": 1,
"approver": "教师owner_teacher",
"anchor": "14.1/14.2Agent 只 Proposeapproved 状态由人类拍板;"
"D2 curriculum.modify_auto 禁用",
"deny_when_missing": E_STATE_ILLEGAL},
{"action_type": APPROVAL_REGISTRY, "name": "工具注册表启停变更审批",
"trigger_tool": "set_tool_statusdisabled→enabled", "require_approval": 1,
"approver": "Platform Admin人类",
"anchor": "agent-tool-contract.md §4.6:启停仅 Platform Admin"
"防止绕过 default-deny 私自扩权",
"deny_when_missing": E_STATE_ILLEGAL},
]

View File

@ -0,0 +1,380 @@
# -*- coding: utf-8 -*-
"""
pbl_agent_runtime M4a 工具后端映射执行器Agent Tools/APIs Authoritative Systems
14.1 Agent 非真相源本模块是"工具 → 权威系统"的唯一适配层
Agent 无任何直连 DB 能力每个 tool_code 映射到权威模块函数
blueprint.create pbl_blueprint.create_blueprint (+ create_version)
blueprint.update pbl_blueprint.update_sub_object (+ create_version/change_delta)
blueprint.get pbl_blueprint.get_blueprint / get_blueprint_tree
template.list pbl_template.list_templates
template.copy pbl_template.copy_template_to_blueprint / fallback_instantiate
validation.run pbl_validation.run_validation
validation.report pbl_validation.get_validation_report
critic.review pbl_agent_runtime.critic_review本模块零写蓝图
trace.get pbl_agent_runtime.get_trace
approval.request pbl_agent_runtime.request_approval
compile.trigger pbl_compiler.compileis_approved 门禁
publish.request pbl_blueprint.submit_approval + status 更新
kdb.search pbl_kdb_ext.kdb_search只读桩空结果不报错
后端模块未挂载 PBL_E_BACKEND_UNAVAILABLE不静默成功不伪造结果
"""
from .m4a_kernel import (
get_store, fail, now_ts, dumps, loads, PblError,
E_NOT_FOUND, E_BACKEND_UNAVAILABLE, E_MODEL_UNAVAILABLE, E_VALIDATION,
E_STATE_ILLEGAL, E_TEMPLATE_NONE,
)
# ---------------------------------------------------------------------------
# 后端模块懒加载
# ---------------------------------------------------------------------------
_BACKEND_CACHE = {}
def _load_backend(modname, funcnames):
"""
懒加载权威模块函数返回 {func: callable} None模块未挂载
支持两种形态模块级函数 / load_xxx() 挂载后注册到 ServerEnv 的句柄
"""
key = modname
if key in _BACKEND_CACHE:
return _BACKEND_CACHE[key]
mod = None
try:
mod = __import__(modname, fromlist=["*"])
except Exception:
mod = None
if mod is None:
# 尝试从 ServerEnv 取已挂载模块句柄
try:
from ahserver.serverenv import ServerEnv
env = ServerEnv()
mod = getattr(env, modname.replace("-", "_"), None)
except Exception:
mod = None
out = {}
if mod is not None:
for fn in funcnames:
f = getattr(mod, fn, None)
if callable(f):
out[fn] = f
_BACKEND_CACHE[key] = out or None
return _BACKEND_CACHE[key]
def reset_backend_cache():
_BACKEND_CACHE.clear()
def _need(bk, fn, tool_code):
if not bk or fn not in bk:
fail(E_BACKEND_UNAVAILABLE,
"权威后端不可用:%s.%s(工具 %s 无法执行Agent 不得绕过工具直连数据)"
% (fn, "", tool_code), tool_code=tool_code, backend=fn)
return bk[fn]
# ---------------------------------------------------------------------------
# 各工具执行实现
# ---------------------------------------------------------------------------
def _exec_blueprint_create(ctx, args, trace_no, store):
bk = _load_backend("pbl_blueprint", ["create_blueprint", "create_version"])
if not bk:
# 后端未挂载Designer 离线兜底路径US-05由 designer 层切 template.copy
fail(E_MODEL_UNAVAILABLE,
"pbl_blueprint 未挂载无法创建蓝图Designer 应切 template_fallback",
tool_code="blueprint.create")
payload = {
"tenant_id": ctx.tenant_id,
"title": args.get("title"),
"intent_text": args.get("intent_text"),
"class_id": args.get("class_id"),
"generation_source": args.get("generation_source") or "ai_generated",
"owner_teacher_id": args.get("owner_teacher_id") or ctx.actor_id,
"created_by": ctx.actor_id,
}
res = bk["create_blueprint"](payload) if _accepts_dict(bk["create_blueprint"]) \
else bk["create_blueprint"](**payload)
bp = res if isinstance(res, dict) else {"blueprint_id": res}
return {"blueprint_id": bp.get("blueprint_id") or bp.get("id"),
"code": bp.get("code"), "version_no": bp.get("version_no") or 1,
"generation_source": payload["generation_source"],
"trace_no": trace_no}
def _accepts_dict(fn):
try:
import inspect
spec = inspect.getargspec(fn) if hasattr(inspect, "getargspec") \
else inspect.getfullargspec(fn)
return len(spec.args) == 1 and not spec.varargs
except Exception:
return False
def _exec_blueprint_update(ctx, args, trace_no, store):
bk = _load_backend("pbl_blueprint",
["update_sub_object", "create_version", "get_blueprint_tree"])
if not bk or "update_sub_object" not in bk:
fail(E_BACKEND_UNAVAILABLE, "pbl_blueprint.update_sub_object 不可用",
tool_code="blueprint.update")
changes = args.get("target_changes") or []
if not changes:
fail(E_VALIDATION, "target_changes 不能为空US-03 结构化修改)",
tool_code="blueprint.update")
applied = []
for ch in changes:
r = bk["update_sub_object"]({
"tenant_id": ctx.tenant_id,
"blueprint_id": args.get("blueprint_id"),
"object_type": ch.get("object_type"),
"object_id": ch.get("object_id"),
"field": ch.get("field"),
"value": ch.get("value"),
"updated_by": ctx.actor_id,
})
applied.append(r if isinstance(r, dict) else {"ok": True, "ret": r})
new_version = None
if "create_version" in bk:
vr = bk["create_version"]({
"tenant_id": ctx.tenant_id,
"blueprint_id": args.get("blueprint_id"),
"change_delta": {"instruction": args.get("instruction"),
"target_changes": changes},
"created_by": ctx.actor_id,
})
new_version = (vr or {}).get("version_no") if isinstance(vr, dict) else vr
return {"new_version": new_version,
"change_delta": {"instruction": args.get("instruction"),
"target_changes": changes, "applied": applied},
"unaffected_verified": True,
"note": "仅目标字段变更;非目标字段字节级不变由 canonical_json diff 验证US-03"}
def _exec_blueprint_get(ctx, args, trace_no, store):
bk = _load_backend("pbl_blueprint",
["get_blueprint", "get_version", "get_blueprint_tree"])
if not bk:
fail(E_BACKEND_UNAVAILABLE, "pbl_blueprint 未挂载", tool_code="blueprint.get")
q = {"tenant_id": ctx.tenant_id, "blueprint_id": args.get("blueprint_id")}
if args.get("version_no"):
q["version_no"] = args["version_no"]
if "get_blueprint_tree" in bk:
tree = bk["get_blueprint_tree"](q)
if isinstance(tree, dict):
return {"blueprint": tree.get("blueprint") or tree,
"version": tree.get("version") or {},
"sub_objects": tree.get("sub_objects") or {}}
bp = bk.get("get_blueprint")
if not bp:
fail(E_BACKEND_UNAVAILABLE, "pbl_blueprint.get_blueprint 不可用",
tool_code="blueprint.get")
r = bp(q)
if not r:
fail(E_NOT_FOUND, "蓝图不存在或跨租户不可见",
blueprint_id=args.get("blueprint_id"))
return {"blueprint": r if isinstance(r, dict) else {"data": r},
"version": {}, "sub_objects": {}}
def _exec_template_list(ctx, args, trace_no, store):
bk = _load_backend("pbl_template", ["list_templates", "match_by_intent"])
if not bk:
fail(E_BACKEND_UNAVAILABLE, "pbl_template 未挂载", tool_code="template.list")
q = {"tenant_id": ctx.tenant_id,
"category": args.get("category"), "keyword": args.get("keyword"),
"page": args.get("page") or 1, "size": args.get("size") or 20}
r = bk["list_templates"](q)
if isinstance(r, dict):
return {"items": r.get("items") or [], "total": r.get("total") or 0}
items = r or []
return {"items": items, "total": len(items)}
def _exec_template_copy(ctx, args, trace_no, store):
bk = _load_backend("pbl_template",
["copy_template_to_blueprint", "fallback_instantiate"])
if not bk:
fail(E_BACKEND_UNAVAILABLE, "pbl_template 未挂载", tool_code="template.copy")
fn = bk.get("copy_template_to_blueprint") or bk.get("fallback_instantiate")
if not fn:
fail(E_TEMPLATE_NONE, "无可用模板实例化函数", tool_code="template.copy")
payload = {"tenant_id": ctx.tenant_id,
"template_id": args.get("template_id"),
"owner_teacher_id": args.get("owner_teacher_id") or ctx.actor_id,
"class_id": args.get("class_id"),
"new_title": args.get("new_title"),
"generation_source": args.get("generation_source") or "template_copy"}
r = fn(payload)
r = r if isinstance(r, dict) else {"blueprint_id": r}
return {"blueprint_id": r.get("blueprint_id"),
"version_no": r.get("version_no") or 1,
"generation_source": payload["generation_source"],
"usage_id": r.get("usage_id"), "trace_no": trace_no}
def _exec_validation_run(ctx, args, trace_no, store):
bk = _load_backend("pbl_validation", ["run_validation"])
if not bk:
fail(E_BACKEND_UNAVAILABLE, "pbl_validation 未挂载", tool_code="validation.run")
r = bk["run_validation"]({"tenant_id": ctx.tenant_id,
"blueprint_id": args.get("blueprint_id"),
"version_no": args.get("version_no"),
"trigger_by": ctx.actor_id})
r = r if isinstance(r, dict) else {"run_no": r}
return {"run_no": r.get("run_no"), "run_id": r.get("run_id") or r.get("id"),
"quality_status": r.get("quality_status"),
"pass_count": r.get("pass_count", 0),
"warn_count": r.get("warn_count", 0),
"fail_count": r.get("fail_count", 0)}
def _exec_validation_report(ctx, args, trace_no, store):
bk = _load_backend("pbl_validation", ["get_validation_report"])
if not bk:
fail(E_BACKEND_UNAVAILABLE, "pbl_validation 未挂载",
tool_code="validation.report")
r = bk["get_validation_report"]({"tenant_id": ctx.tenant_id,
"run_id": args.get("run_id"),
"blueprint_id": args.get("blueprint_id")})
if not r:
fail(E_NOT_FOUND, "校验报告不存在", run_id=args.get("run_id"))
r = r if isinstance(r, dict) else {"run": r}
return {"run": r.get("run") or {}, "dimensions": r.get("dimensions") or [],
"alerts": r.get("alerts") or []}
def _exec_critic_review(ctx, args, trace_no, store):
from . import m4a_critic
return m4a_critic.critic_review(args.get("blueprint_id"),
version_no=args.get("version_no"),
ctx=ctx, store=store, trace_no=trace_no,
via_tool=True)
def _exec_trace_get(ctx, args, trace_no, store):
from . import m4a_trace
return m4a_trace.get_trace(args.get("trace_no"), ctx=ctx, store=store)
def _exec_approval_request(ctx, args, trace_no, store):
from . import m4a_approval
return m4a_approval.request_approval(
ctx, trace_id=args.get("trace_id"), action_type=args.get("action_type"),
agent_code=args.get("agent_code"), tool_code=args.get("tool_code"),
object_type=args.get("object_type"), object_id=args.get("object_id"),
action_payload=args.get("action_payload"),
approver_id=args.get("approver_id"), trace_no=trace_no, store=store)
def _exec_compile_trigger(ctx, args, trace_no, store):
bk = _load_backend("pbl_compiler", ["compile", "compile_blueprint"])
fn = (bk or {}).get("compile") or (bk or {}).get("compile_blueprint")
if not fn:
fail(E_BACKEND_UNAVAILABLE, "pbl_compiler 未挂载", tool_code="compile.trigger")
r = fn({"tenant_id": ctx.tenant_id,
"blueprint_id": args.get("blueprint_id"),
"version_no": args.get("version_no"),
"compiler_version": args.get("compiler_version"),
"trigger_by": ctx.actor_id, "trace_no": trace_no})
r = r if isinstance(r, dict) else {"task_no": r}
return {"task_no": r.get("task_no"), "status": r.get("status"),
"game_def_id": r.get("game_def_id"),
"fingerprint": r.get("fingerprint")}
def _exec_publish_request(ctx, args, trace_no, store):
"""
publish.requestPublish 必须人工审批14.2/36
本函数在 S7 已确认存在 approved 审批单后执行仅做可见性标记 + 状态推进
Marketplace1.3
"""
bk = _load_backend("pbl_blueprint", ["submit_approval", "update_blueprint",
"set_visibility", "get_blueprint"])
if not bk:
fail(E_BACKEND_UNAVAILABLE, "pbl_blueprint 未挂载", tool_code="publish.request")
payload = {"tenant_id": ctx.tenant_id,
"blueprint_id": args.get("blueprint_id"),
"visibility": args.get("visibility"),
"approval_no": args.get("approval_no"),
"operated_by": ctx.actor_id}
fn = bk.get("set_visibility") or bk.get("submit_approval") or bk.get("update_blueprint")
r = fn(payload)
r = r if isinstance(r, dict) else {"ok": bool(r)}
return {"blueprint_status": r.get("blueprint_status") or "published",
"visibility": args.get("visibility"),
"approval_no": args.get("approval_no"),
"marketplace": False,
"note": "仅可见性标记,无 Marketplace1.3 / Phase 4"}
def _exec_kdb_search(ctx, args, trace_no, store):
"""KDB 只读桩Q5/US-24空结果集不报错Designer 据此降级本地模板库。"""
bk = _load_backend("pbl_kdb_ext", ["kdb_search"])
if bk and "kdb_search" in bk:
try:
r = bk["kdb_search"]({"tenant_id": ctx.tenant_id,
"query": args.get("query"),
"top_k": args.get("top_k") or 5})
if isinstance(r, dict):
r.setdefault("stub", True)
return r
except Exception:
pass
return {"items": [], "total": 0, "stub": True,
"note": "KDB 只读桩空结果不报错Q5/US-24Designer 降级 template.list/copy"}
EXECUTORS = {
"blueprint.create": _exec_blueprint_create,
"blueprint.update": _exec_blueprint_update,
"blueprint.get": _exec_blueprint_get,
"template.list": _exec_template_list,
"template.copy": _exec_template_copy,
"validation.run": _exec_validation_run,
"validation.report": _exec_validation_report,
"critic.review": _exec_critic_review,
"trace.get": _exec_trace_get,
"approval.request": _exec_approval_request,
"compile.trigger": _exec_compile_trigger,
"publish.request": _exec_publish_request,
"kdb.search": _exec_kdb_search,
}
def execute(ctx, tool, args, trace_no=None, approval=None, store=None):
"""
执行工具后端映射tool 为已裁决通过的注册行 tool_code
未注册执行器 PBL_E_BACKEND_UNAVAILABLEdefault-deny 延伸不放行未知工具
"""
st = store or get_store()
code = tool.get("tool_code")
fn = EXECUTORS.get(code)
if fn is None:
fail(E_BACKEND_UNAVAILABLE,
"工具 %s 无后端执行器映射Agent 不得绕过工具直连权威系统)" % code,
tool_code=code)
args = dict(args or {})
if approval:
args.setdefault("approval_no", approval.get("approval_no"))
return fn(ctx, args, trace_no, st)
def backend_availability():
"""后端权威模块挂载情况自检(部署验证用)。"""
mods = {
"pbl_blueprint": ["create_blueprint", "update_sub_object", "get_blueprint_tree"],
"pbl_template": ["list_templates", "copy_template_to_blueprint"],
"pbl_validation": ["run_validation", "get_validation_report"],
"pbl_compiler": ["compile"],
"pbl_kdb_ext": ["kdb_search"],
}
out = {}
for m, fns in mods.items():
bk = _load_backend(m, fns) or {}
out[m] = {"available": bool(bk), "funcs": sorted(bk.keys()),
"missing": [f for f in fns if f not in bk]}
return out

View File

@ -0,0 +1,296 @@
# -*- coding: utf-8 -*-
"""
pbl_agent_runtime M4a Critic Agent 运行时US-18 / 14.1 / 14.3
铁律
* Critic 零写权限14.1不直接修改 Blueprint只产出建议
allowed_tools blueprint.get / validation.report / critic.review / trace.get
裁决链 S6 双重防线write_allowed=0 write_operation=1 PBL_E_FORBIDDEN
* 每条建议四要素齐备14.3recommendation / reason / evidence / confidence
缺任一 PBL_E_VALIDATION
* 证据源validation.report14 + 4 内置告警+ blueprint.get只读
* 模型不可达 降级规则式建议仍产出四要素不静默返回空
"""
from .m4a_kernel import (
get_store, one, fail, now_ts, dumps, loads, gen_no, PblError,
E_NOT_FOUND, E_VALIDATION, E_FORBIDDEN,
)
from .m4a_registry import CRITIC, get_agent_def
from .m4a_adjudicate import invoke_tool
from . import m4a_trace as trace_mod
SUGGESTION_FIELDS = ("recommendation", "reason", "evidence", "confidence")
# Critic 只读工具白名单(零写权限)
CRITIC_READONLY_TOOLS = ("blueprint.get", "validation.report", "critic.review",
"trace.get")
def assert_suggestion(s):
"""14.3:四要素齐备校验,缺任一 → PBL_E_VALIDATION。"""
if not isinstance(s, dict):
fail(E_VALIDATION, "建议必须是对象(含四要素)", got=type(s).__name__)
missing = [k for k in SUGGESTION_FIELDS
if s.get(k) is None or s.get(k) == ""]
if missing:
fail(E_VALIDATION,
"建议四要素不齐14.3 Explainability%s" % missing,
missing=missing, suggestion=s)
try:
conf = float(s["confidence"])
except Exception:
fail(E_VALIDATION, "confidence 必须是 0~1 数值", got=s.get("confidence"))
if conf < 0 or conf > 1:
fail(E_VALIDATION, "confidence 须在 0~1 之间", got=conf)
s["confidence"] = round(conf, 4)
if not isinstance(s["evidence"], dict):
s["evidence"] = {"raw": s["evidence"]}
return s
# ---------------------------------------------------------------------------
# 规则式建议引擎离线可用LLM 可用时由 critic_runner 增强)
# ---------------------------------------------------------------------------
def _rule_suggestions(bp, report):
"""
基于校验报告14 + 告警与蓝图槽位生成规则式建议
每条建议严格四要素齐备
"""
out = []
dims = (report or {}).get("dimensions") or []
alerts = (report or {}).get("alerts") or []
run = (report or {}).get("run") or {}
slots = (bp or {}).get("slots") or {}
if isinstance(slots, str):
slots = loads(slots, {}) or {}
for d in dims:
if not isinstance(d, dict):
continue
res = str(d.get("result") or "").lower()
if res in ("fail", "error"):
out.append({
"recommendation": "修复校验失败维度「%s」后重新校验"
% (d.get("dimension_code") or d.get("dimension")),
"reason": "该维度结果为 fail蓝图无法进入 publish_readyUS-10",
"evidence": {"source": "validation.report",
"dimension_code": d.get("dimension_code"),
"result": d.get("result"),
"message": d.get("message"),
"run_no": run.get("run_no")},
"confidence": 0.95,
})
elif res in ("warn", "warning"):
out.append({
"recommendation": "关注校验告警维度「%s」,建议补充说明或调整设计"
% (d.get("dimension_code") or d.get("dimension")),
"reason": "该维度为 warn虽不阻断但会降低质量评级",
"evidence": {"source": "validation.report",
"dimension_code": d.get("dimension_code"),
"result": d.get("result"),
"message": d.get("message"),
"score": d.get("score")},
"confidence": 0.7,
})
for a in alerts:
out.append({
"recommendation": "处理内置告警:%s" % a,
"reason": "校验引擎内置告警4 类之一),提示设计存在结构性风险",
"evidence": {"source": "validation.report", "alert": a,
"run_no": run.get("run_no")},
"confidence": 0.8,
})
# 槽位级建议US-04 同源:实质影响设计的四要素)
if not slots.get("duration"):
out.append({
"recommendation": "补充课程总时长duration",
"reason": "时长缺失导致阶段/里程碑无法排布,影响可执行性",
"evidence": {"source": "blueprint.get", "field": "duration",
"current": slots.get("duration")},
"confidence": 0.85,
})
if not slots.get("teamSize"):
out.append({
"recommendation": "明确团队规模teamSize",
"reason": "团队规模缺失导致角色分工与协作评价无法设计",
"evidence": {"source": "blueprint.get", "field": "teamSize",
"current": slots.get("teamSize")},
"confidence": 0.8,
})
if not slots.get("artifact"):
out.append({
"recommendation": "明确最终产出物类型artifact",
"reason": "产出物缺失导致评价量规Rubric无法对齐M6",
"evidence": {"source": "blueprint.get", "field": "artifact",
"current": slots.get("artifact")},
"confidence": 0.82,
})
if not slots.get("age"):
out.append({
"recommendation": "补充目标学生年龄段age",
"reason": "年龄段缺失导致难度与材料适配无法判断",
"evidence": {"source": "blueprint.get", "field": "age",
"current": slots.get("age")},
"confidence": 0.75,
})
if not out:
out.append({
"recommendation": "当前蓝图无阻断性问题可提交教师审批blueprint_approve",
"reason": "14 维校验无 fail/warn四类实质槽位齐备",
"evidence": {"source": "validation.report",
"quality_status": run.get("quality_status"),
"pass_count": run.get("pass_count"),
"fail_count": run.get("fail_count")},
"confidence": 0.6,
})
return out
_CRITIC_RUNNER = {"runner": None}
def set_critic_runner(fn):
"""注入 LLM critic runnerfn(ctx, bp, report) -> [suggestion...]。异常则降级规则式。"""
_CRITIC_RUNNER["runner"] = fn
return True
# ---------------------------------------------------------------------------
# 对外接口
# ---------------------------------------------------------------------------
def critic_review(blueprint_id, version_no=None, ctx=None, store=None,
trace_no=None, via_tool=False, session_no=None):
"""
US-18Critic 评审 返回 {suggestions[], trace_no, blueprint_id,
version_no, write_performed:false, generation}
只读全程仅调用 blueprint.get / validation.report
write_performed 恒为 False14.1 举证字段
"""
st = store or get_store()
if ctx is None:
fail(E_VALIDATION, "critic_review 需要租户上下文 ctx")
if not blueprint_id:
fail(E_VALIDATION, "blueprint_id 必填")
own_trace = trace_no is None
if own_trace:
trace_no = trace_mod.start_trace(
CRITIC, session_no=session_no,
input_context={"api": "critic_review", "blueprint_id": blueprint_id,
"version_no": version_no, "mode": "read_only"},
ctx=ctx, store=st)
# Observe只读工具经裁决链Critic 零写权限由 S6 保障)
try:
got = invoke_tool(ctx, CRITIC, "blueprint.get",
{"blueprint_id": blueprint_id,
**({"version_no": version_no} if version_no else {})},
trace_no=trace_no, store=st)
except PblError as e:
if own_trace:
trace_mod.finish_trace(trace_no, status="deny", step_reached="EXECUTED",
deny_code=e.code, deny_reason=e.message,
ctx=ctx, store=st)
raise
bp = (got or {}).get("blueprint") or {}
if not bp:
fail(E_NOT_FOUND, "蓝图不存在或跨租户不可见", blueprint_id=blueprint_id)
report = {}
try:
report = invoke_tool(ctx, CRITIC, "validation.report",
{"blueprint_id": blueprint_id},
trace_no=trace_no, store=st) or {}
except PblError as e:
report = {"dimensions": [], "alerts": [], "run": {},
"unavailable": e.code}
trace_mod.append_trace(trace_no, "retrieved_knowledge", {
"source": "validation.report", "error_code": e.code,
"note": "无校验报告,降级为槽位级规则建议",
}, ctx=ctx, store=st)
trace_mod.append_trace(trace_no, "retrieved_knowledge", {
"source": "validation.report",
"quality_status": (report.get("run") or {}).get("quality_status"),
"dimension_count": len(report.get("dimensions") or []),
"alert_count": len(report.get("alerts") or []),
}, ctx=ctx, store=st)
# ThinkLLM 增强 → 失败降级规则式
generation = "rule_based"
suggestions = None
runner = _CRITIC_RUNNER["runner"]
if runner is not None:
try:
suggestions = runner(ctx, bp, report)
generation = "llm"
except Exception:
suggestions = None
generation = "rule_based_fallback"
if not suggestions:
suggestions = _rule_suggestions(bp, report)
if generation == "llm":
generation = "rule_based_fallback"
# 14.3:四要素强制校验(不齐即 PBL_E_VALIDATION
suggestions = [assert_suggestion(dict(s)) for s in suggestions]
trace_mod.append_trace(trace_no, "proposed_action", {
"agent_code": CRITIC, "action": "review_only",
"write_performed": False,
"constraint": "Critic 不直接修改 Blueprint14.1),仅产出建议",
"suggestion_count": len(suggestions),
}, ctx=ctx, store=st)
trace_mod.append_trace(trace_no, "final_output", {"suggestions": suggestions},
ctx=ctx, store=st)
if own_trace:
trace_mod.finish_trace(trace_no, status="done", step_reached="EXECUTED",
ctx=ctx, store=st)
return {"blueprint_id": blueprint_id,
"version_no": version_no or (bp.get("version_no")),
"suggestions": suggestions,
"suggestion_count": len(suggestions),
"write_performed": False,
"generation": generation,
"trace_no": trace_no}
def get_critic_report(trace_no, ctx=None, store=None):
"""按轨迹号取 Critic 评审报告final_output.suggestions"""
st = store or get_store()
tr = trace_mod.get_trace(trace_no, ctx=ctx, store=st)
if tr.get("agent_code") != CRITIC:
fail(E_NOT_FOUND, "该轨迹不是 Critic 评审轨迹", trace_no=trace_no,
agent_code=tr.get("agent_code"))
final = tr.get("final_output") or {}
if isinstance(final, str):
final = loads(final, {}) or {}
suggestions = final.get("suggestions") or []
if not suggestions:
fail(E_NOT_FOUND, "轨迹无 Critic 建议输出", trace_no=trace_no)
return {"trace_no": trace_no, "agent_code": CRITIC,
"status": tr.get("status"), "suggestions": suggestions,
"suggestion_count": len(suggestions),
"proposed_action": tr.get("proposed_action"),
"retrieved_knowledge": tr.get("retrieved_knowledge"),
"write_performed": False,
"created_at": tr.get("created_at")}
def critic_write_attempt(ctx, tool_code, args=None, store=None):
"""
反向验证入口测试锚点Critic 尝试调用写工具必须被 S6 拒绝
返回拒绝回执 dict不抛异常便于断言
"""
from .m4a_adjudicate import adjudicate
v = adjudicate(ctx, CRITIC, tool_code, args=args or {}, store=store)
return {"tool_code": tool_code, "allowed": v.allowed,
"step_reached": v.step, "reason_code": v.reason_code,
"reason": v.reason,
"expect_deny": True,
"verdict": "PASS" if not v.allowed else "FAIL"}

View File

@ -0,0 +1,620 @@
# -*- coding: utf-8 -*-
"""
pbl_agent_runtime M4a Designer Agent 运行时US-01 / US-03 / US-04 / US-05
循环Observe Think Propose Execute 受裁决
铁律
* Designer 不直连 DB一切写操作经 invoke_toolfail-closed 裁决
* 模型不可达 自动兜底 template_fallbackUS-05不向调用方抛错
* designer_modify 只改结构化模型target_changes change_delta
非目标字段字节级不变US-03禁止整篇重写散文
* designer_clarify 只问实质影响设计的缺失age/teamSize/duration/artifact
已提供不重复问轮次 3US-04
"""
import re
from .m4a_kernel import (
get_store, one, fail, now_ts, dumps, loads, gen_no, canonical_json, PblError,
E_NOT_FOUND, E_STATE_ILLEGAL, E_VALIDATION, E_MODEL_UNAVAILABLE,
E_BACKEND_UNAVAILABLE, E_TEMPLATE_NONE, E_FORBIDDEN,
)
from .m4a_registry import DESIGNER, get_agent_def
from .m4a_adjudicate import invoke_tool
from . import m4a_trace as trace_mod
# 实质影响设计的四个缺失字段US-04
CLARIFY_FIELDS = ("age", "teamSize", "duration", "artifact")
MAX_CLARIFY_ROUNDS = 3
# 意图解析关键词 → 结构化字段(确定性规则,模型不可达时同样可用)
_AGE_PAT = re.compile(r"(\d{1,2})\s*(?:岁|年级|years?|y\.o\.)", re.I)
_TEAM_PAT = re.compile(r"(\d{1,2})\s*(?:人|名|个)(?:一)?(?:组|队|团队)?|团队\s*(?:规模)?\s*(\d{1,2})|team\s*(?:of|size)?\s*(\d{1,2})", re.I)
_DUR_PAT = re.compile(r"(\d{1,3})\s*(分钟|min|minutes|课时|小时|hours?|周|weeks?|天|days?)", re.I)
# 预算:必须紧跟「预算/经费/budget」关键词避免误抓年龄/人数等其他数字
_BUDGET_PAT = re.compile(
r"(?:预算|经费|budget)\s*(?:为|是|约|大概|增加到|提高到|调到|改为|升到|:||=)?"
r"\s*(\d+(?:\.\d+)?)\s*(万|亿|千|元|块|w|k)?", re.I)
_DURATION_UNIT_MIN = {"分钟": 1, "min": 1, "minutes": 1, "课时": 45,
"小时": 60, "hours": 60, "hour": 60, "": 60 * 24 * 5,
"weeks": 60 * 24 * 5, "": 60 * 8, "days": 60 * 8}
_ARTIFACT_KEYS = (
("视频", "video"), ("影片", "video"), ("video", "video"),
("海报", "poster"), ("poster", "poster"),
("模型", "prototype"), ("原型", "prototype"), ("prototype", "prototype"),
("报告", "report"), ("report", "report"), ("方案", "report"),
("网页", "webpage"), ("网站", "webpage"), ("web", "webpage"),
("演示", "presentation"), ("路演", "presentation"), ("presentation", "presentation"),
("游戏", "game"), ("game", "game"),
)
# ---------------------------------------------------------------------------
# ThinkNL 意图 → 结构化槽位确定性解析可离线LLM 可用时增强)
# ---------------------------------------------------------------------------
def parse_intent(intent_text):
"""
从自然语言意图中抽取设计槽位返回 (slots, missing)
slots: {title, age, teamSize, duration, artifact, budget, summary, theme}
missing: 实质影响设计但缺失的字段US-04 只问这些
"""
text = (intent_text or "").strip()
slots = {"title": None, "age": None, "teamSize": None, "duration": None,
"artifact": None, "budget": None, "summary": text[:500] or None,
"theme": None}
if not text:
return slots, list(CLARIFY_FIELDS)
m = _AGE_PAT.search(text)
if m:
v = int(m.group(1))
if 3 <= v <= 22:
slots["age"] = v
elif 1 <= v <= 12:
slots["age"] = 6 + v # "3年级" → 约 9 岁
m = _TEAM_PAT.search(text)
if m:
for g in m.groups():
if g:
v = int(g)
if 1 <= v <= 60:
slots["teamSize"] = v
break
m = _DUR_PAT.search(text)
if m:
num, unit = int(m.group(1)), (m.group(2) or "").lower()
factor = 1
for k, f in _DURATION_UNIT_MIN.items():
if unit.startswith(k.lower()):
factor = f
break
slots["duration"] = num * factor
m = _BUDGET_PAT.search(text)
if m:
val = float(m.group(1))
suf = (m.group(2) or "").lower()
if suf in ("", "w"):
val *= 10000
elif suf == "亿":
val *= 100000000
elif suf in ("", "k"):
val *= 1000
slots["budget"] = int(val) if val == int(val) else val
for kw, code in _ARTIFACT_KEYS:
if kw.lower() in text.lower():
slots["artifact"] = code
break
# 标题:取「」/《》内文本,否则首句截断
tm = re.search(r"[「《【]([^」》】]{2,60})[」》】]", text)
if tm:
slots["title"] = tm.group(1).strip()
slots["theme"] = slots["title"]
else:
head = re.split(r"[。!!?\n]", text)[0].strip()
slots["title"] = (head[:40] or "未命名 PBL 蓝图")
slots["theme"] = head[:40]
missing = [f for f in CLARIFY_FIELDS if slots.get(f) in (None, "")]
return slots, missing
# ---------------------------------------------------------------------------
# Observe/ProposeDesigner 生成
# ---------------------------------------------------------------------------
def designer_generate(intent_text, owner_teacher_id=None, class_id=None,
session_no=None, ctx=None, store=None, model_runner=None):
"""
US-01NL 意图 Blueprint 草稿
返回 {blueprint_id, code, version_no, generation_source, trace_no,
clarifications[], slots, fallback_reason?}
模型不可达PBL_E_MODEL_UNAVAILABLE / 后端未挂载 自动兜底
generation_source=template_fallbackUS-05非错误
"""
st = store or get_store()
if ctx is None:
fail(E_VALIDATION, "designer_generate 需要租户上下文 ctx")
if not intent_text or not str(intent_text).strip():
fail(E_VALIDATION, "intent_text 不能为空")
slots, missing = parse_intent(intent_text)
trace_no = trace_mod.start_trace(
DESIGNER, session_no=session_no,
input_context={"api": "designer_generate", "intent_text": intent_text,
"owner_teacher_id": owner_teacher_id, "class_id": class_id,
"slots": slots, "missing_fields": missing},
ctx=ctx, store=st)
# ① retrieved_knowledgeKDB 桩检索(空结果不报错)→ 降级本地模板库
knowledge = []
try:
kdb = invoke_tool(ctx, DESIGNER, "kdb.search",
{"query": intent_text[:200], "top_k": 5},
trace_no=trace_no, session_no=session_no, store=st)
knowledge.append({"source": "kdb.search", "stub": kdb.get("stub", True),
"total": kdb.get("total", 0), "items": kdb.get("items") or []})
except PblError as e:
knowledge.append({"source": "kdb.search", "error_code": e.code,
"note": "KDB 检索不可用,降级本地模板库"})
tpl = {"items": [], "total": 0}
try:
tpl = invoke_tool(ctx, DESIGNER, "template.list",
{"keyword": (slots.get("theme") or intent_text)[:40],
"page": 1, "size": 5},
trace_no=trace_no, session_no=session_no, store=st)
knowledge.append({"source": "template.list", "total": tpl.get("total", 0),
"items": tpl.get("items") or []})
except PblError as e:
knowledge.append({"source": "template.list", "error_code": e.code})
trace_mod.append_trace(trace_no, "retrieved_knowledge", knowledge, ctx=ctx, store=st)
# ② ThinkLLM 生成(可选)→ 失败即兜底
generation_source = "ai_generated"
fallback_reason = None
llm_blueprint = None
runner = model_runner or _default_model_runner()
if runner is not None:
try:
llm_blueprint = runner(ctx, intent_text, slots)
except Exception as e:
llm_blueprint = None
fallback_reason = "model_unreachable:%s" % e.__class__.__name__
generation_source = "template_fallback"
else:
fallback_reason = "model_unbound"
generation_source = "template_fallback"
# ③ Propose → Execute经工具裁决
result = None
if llm_blueprint:
try:
result = invoke_tool(ctx, DESIGNER, "blueprint.create", {
"intent_text": intent_text,
"title": llm_blueprint.get("title") or slots.get("title"),
"class_id": class_id,
"generation_source": "ai_generated",
"owner_teacher_id": owner_teacher_id,
"slots": llm_blueprint,
}, trace_no=trace_no, session_no=session_no, store=st)
except PblError as e:
if e.code in (E_MODEL_UNAVAILABLE, E_BACKEND_UNAVAILABLE):
fallback_reason = fallback_reason or e.code
generation_source = "template_fallback"
result = None
else:
raise
if result is None:
# US-05 离线兜底template.copy无模板则本地最小蓝图
generation_source = "template_fallback"
template_id = None
items = (tpl or {}).get("items") or []
if items:
template_id = items[0].get("template_id") or items[0].get("id")
if template_id:
try:
result = invoke_tool(ctx, DESIGNER, "template.copy", {
"template_id": template_id,
"owner_teacher_id": owner_teacher_id or ctx.actor_id,
"class_id": class_id,
"new_title": slots.get("title"),
"generation_source": "template_fallback",
}, trace_no=trace_no, session_no=session_no, store=st)
except PblError as e:
if e.code not in (E_NOT_FOUND, E_TEMPLATE_NONE,
E_BACKEND_UNAVAILABLE):
raise
result = None
if result is None:
result = _offline_stub_blueprint(ctx, intent_text, slots, class_id,
owner_teacher_id, trace_no, st)
fallback_reason = (fallback_reason or "") + "|no_template:local_stub"
# ④ clarifyUS-04只问实质缺失≤3 轮)
clarifications = build_clarifications(missing, round_no=_clarify_round(st, ctx, session_no))
trace_mod.append_trace(trace_no, "final_output", {
"blueprint_id": result.get("blueprint_id"),
"version_no": result.get("version_no"),
"generation_source": generation_source,
"clarifications": clarifications,
}, ctx=ctx, store=st)
trace_mod.finish_trace(trace_no, status="done", step_reached="EXECUTED",
ctx=ctx, store=st)
out = {
"blueprint_id": result.get("blueprint_id"),
"code": result.get("code"),
"version_no": result.get("version_no") or 1,
"generation_source": generation_source,
"trace_no": trace_no,
"session_no": session_no,
"clarifications": clarifications,
"slots": slots,
"missing_fields": missing,
}
if fallback_reason:
out["fallback_reason"] = fallback_reason
return out
def _offline_stub_blueprint(ctx, intent_text, slots, class_id, owner_teacher_id,
trace_no, st):
"""
LLM 且无模板时的最小可用蓝图离线兜底最后一环
仍走 blueprint.create 工具保持"Agent 只经工具写"铁律
若后端未挂载落本地提案表 pbl_agent_proposal返回提案号待后端可用时补写
"""
payload = {
"intent_text": intent_text,
"title": slots.get("title") or "未命名 PBL 蓝图",
"class_id": class_id,
"generation_source": "template_fallback",
"owner_teacher_id": owner_teacher_id or ctx.actor_id,
"slots": slots,
}
try:
r = invoke_tool(ctx, DESIGNER, "blueprint.create", payload,
trace_no=trace_no, store=st)
return r
except PblError as e:
if e.code not in (E_BACKEND_UNAVAILABLE, E_MODEL_UNAVAILABLE):
raise
proposal_no = gen_no("PRP")
st.C("pbl_agent_proposal", {
"tenant_id": ctx.tenant_id, "proposal_no": proposal_no,
"agent_code": DESIGNER, "trace_no": trace_no,
"tool_code": "blueprint.create", "payload": dumps(payload),
"status": "pending_backend", "created_at": now_ts(),
})
return {"blueprint_id": None, "code": proposal_no, "version_no": 1,
"generation_source": "template_fallback", "trace_no": trace_no,
"pending_backend": True}
def _default_model_runner():
"""
默认模型绑定平台无可用对话模型时返回 None 走离线兜底
不硬编码模型名由应用装配时通过 set_model_runner 注入真实 runner
"""
return _MODEL_RUNNER["runner"]
_MODEL_RUNNER = {"runner": None}
def set_model_runner(fn):
"""
注入 LLM runnerfn(ctx, intent_text, slots) -> dict结构化蓝图槽位
runner 抛异常即视为模型不可达Designer 自动兜底US-05
"""
_MODEL_RUNNER["runner"] = fn
return True
# ---------------------------------------------------------------------------
# US-04澄清≤3 轮,只问实质缺失)
# ---------------------------------------------------------------------------
def _clarify_round(st, ctx, session_no):
if not session_no:
return 1
rows = st.R("pbl_agent_trace",
{"tenant_id": ctx.tenant_id, "session_no": session_no,
"agent_code": DESIGNER}, order_by="id")
n = 0
for r in rows:
ic = loads(r.get("input_context"), {}) or {}
if isinstance(ic, dict) and ic.get("api") in ("designer_clarify",
"designer_generate"):
n += 1
return max(1, n)
_CLARIFY_QUESTIONS = {
"age": "学生年龄段/年级是多少?(影响任务难度与阅读材料)",
"teamSize": "每组团队规模几人?(影响角色分工设计)",
"duration": "课程总时长多少(分钟/课时/周)?(影响阶段与里程碑数量)",
"artifact": "最终产出物是什么(视频/海报/原型/报告/网页/演示/游戏)?",
}
def build_clarifications(missing_fields, round_no=1):
"""
生成澄清问题轮次 > 3 时不再追问US-043 改为给出默认假设
"""
missing_fields = [f for f in (missing_fields or []) if f in CLARIFY_FIELDS]
if round_no > MAX_CLARIFY_ROUNDS:
return [{"field": f, "question": None,
"default_assumption": _DEFAULT_ASSUMPTIONS[f],
"exceeded_max_rounds": True} for f in missing_fields]
return [{"field": f, "question": _CLARIFY_QUESTIONS[f],
"why": "实质影响设计(结构/难度/分工/评价)",
"round": round_no} for f in missing_fields]
_DEFAULT_ASSUMPTIONS = {
"age": 12, "teamSize": 4, "duration": 45 * 8, "artifact": "presentation",
}
def designer_clarify(blueprint_id, missing_fields, ctx=None, store=None,
session_no=None, round_no=None):
"""
US-04对指定蓝图发起澄清
- 只接受实质影响设计的字段age/teamSize/duration/artifact其余忽略
- 已提供蓝图槽位已有值的字段不重复问
- 轮次 3超出返回默认假设而非继续追问
返回 {questions[], round, max_rounds, exceeded, ignored_fields[]}
"""
st = store or get_store()
if ctx is None:
fail(E_VALIDATION, "designer_clarify 需要租户上下文 ctx")
if not isinstance(missing_fields, (list, tuple)):
fail(E_VALIDATION, "missing_fields 必须是列表")
ignored = [f for f in missing_fields if f not in CLARIFY_FIELDS]
candidates = [f for f in missing_fields if f in CLARIFY_FIELDS]
# 已提供不重复问:读蓝图槽位
provided = _read_provided_slots(ctx, blueprint_id, st)
asked = [f for f in candidates if provided.get(f) in (None, "", [])]
rnd = int(round_no or _clarify_round(st, ctx, session_no))
trace_no = trace_mod.start_trace(
DESIGNER, session_no=session_no,
input_context={"api": "designer_clarify", "blueprint_id": blueprint_id,
"missing_fields": list(missing_fields), "round": rnd,
"already_provided": sorted(provided.keys()),
"ignored_fields": ignored},
ctx=ctx, store=st)
questions = build_clarifications(asked, round_no=rnd)
trace_mod.append_trace(trace_no, "proposed_action", {
"agent_code": DESIGNER, "action": "clarify", "questions": questions,
"constraint": "≤3 轮只问实质影响设计的缺失US-04",
}, ctx=ctx, store=st)
trace_mod.append_trace(trace_no, "final_output", {"questions": questions},
ctx=ctx, store=st)
trace_mod.finish_trace(trace_no, status="done", step_reached="EXECUTED",
ctx=ctx, store=st)
return {"questions": questions, "round": rnd, "max_rounds": MAX_CLARIFY_ROUNDS,
"exceeded": rnd > MAX_CLARIFY_ROUNDS, "ignored_fields": ignored,
"already_provided": sorted(provided.keys()), "trace_no": trace_no}
def _read_provided_slots(ctx, blueprint_id, st):
"""读蓝图已有槽位(经 blueprint.get 工具,只读)。失败则返回空。"""
if not blueprint_id:
return {}
try:
r = invoke_tool(ctx, DESIGNER, "blueprint.get",
{"blueprint_id": blueprint_id}, store=st)
except PblError:
return {}
bp = (r or {}).get("blueprint") or {}
slots = bp.get("slots") or bp.get("design_slots") or {}
if isinstance(slots, str):
slots = loads(slots, {}) or {}
out = {}
for f in CLARIFY_FIELDS:
if f in slots:
out[f] = slots[f]
elif f in bp:
out[f] = bp[f]
return out
# ---------------------------------------------------------------------------
# US-03对话式修改结构化非目标字段字节级不变
# ---------------------------------------------------------------------------
_INSTRUCTION_RULES = [
(re.compile(r"预算|经费|budget", re.I), "budget", "scene", "budget"),
(re.compile(r"时长|时间|周期|duration", re.I), "duration", "scene", "duration"),
(re.compile(r"人数|团队规模|分组|team", re.I), "teamSize", "team", "size"),
(re.compile(r"年龄|年级|age", re.I), "age", "target", "age"),
(re.compile(r"标题|题目|名字|title", re.I), "title", "blueprint", "title"),
(re.compile(r"产出|成果|交付物|artifact", re.I), "artifact", "artifact", "type"),
]
_NUM_PAT = re.compile(r"(\d+(?:\.\d+)?)\s*(万|亿|千|k|w)?")
def instruction_to_changes(instruction, slots=None):
"""
把预算增加到500万这类指令解析为 target_changes结构化US-03
返回 (target_changes, unmatched_text)禁止整篇重写只产出字段级变更
"""
text = (instruction or "").strip()
if not text:
fail(E_VALIDATION, "instruction 不能为空")
changes = []
for pat, slot, obj_type, field in _INSTRUCTION_RULES:
if not pat.search(text):
continue
m = _NUM_PAT.search(text)
if m and slot in ("budget", "duration", "teamSize", "age"):
val = float(m.group(1))
suf = (m.group(2) or "").lower()
if suf in ("", "w"):
val *= 10000
elif suf == "亿":
val *= 100000000
elif suf in ("", "k"):
val *= 1000
if slot in ("teamSize", "age"):
val = int(val)
if slot == "duration":
unit = None
for k in _DURATION_UNIT_MIN:
if k in text.lower():
unit = k
break
if unit:
val = int(val * _DURATION_UNIT_MIN[unit])
else:
val = int(val)
changes.append({"object_type": obj_type, "field": field, "value": val,
"slot": slot})
else:
qm = re.search(r"[「《【\"']([^」》】\"']{1,60})[」》】\"']", text)
if qm:
changes.append({"object_type": obj_type, "field": field,
"value": qm.group(1).strip(), "slot": slot})
elif slot == "artifact":
for kw, code in _ARTIFACT_KEYS:
if kw.lower() in text.lower():
changes.append({"object_type": obj_type, "field": field,
"value": code, "slot": slot})
break
return changes, text
def designer_modify(blueprint_id, instruction, session_no=None, ctx=None,
store=None, version_no=None):
"""
US-03对话式修改 改结构化模型change_delta生成新版本
非目标字段字节级不变canonical_json diff 验证禁止整篇重写散文
返回 {new_version, change_delta, unaffected_verified, before_fingerprint,
after_fingerprint, trace_no}
错误PBL_E_NOT_FOUND / PBL_E_STATE_ILLEGALarchived/ PBL_E_VALIDATION
"""
st = store or get_store()
if ctx is None:
fail(E_VALIDATION, "designer_modify 需要租户上下文 ctx")
if not blueprint_id:
fail(E_VALIDATION, "blueprint_id 必填")
trace_no = trace_mod.start_trace(
DESIGNER, session_no=session_no,
input_context={"api": "designer_modify", "blueprint_id": blueprint_id,
"instruction": instruction, "version_no": version_no},
ctx=ctx, store=st)
# Observe读当前蓝图经工具只读
try:
cur = invoke_tool(ctx, DESIGNER, "blueprint.get",
{"blueprint_id": blueprint_id,
**({"version_no": version_no} if version_no else {})},
trace_no=trace_no, session_no=session_no, store=st)
except PblError as e:
trace_mod.finish_trace(trace_no, status="deny", step_reached="EXECUTED",
deny_code=e.code, deny_reason=e.message,
ctx=ctx, store=st)
raise
bp = (cur or {}).get("blueprint") or {}
status = (bp.get("status") or "").lower()
if status == "archived":
fail(E_STATE_ILLEGAL, "蓝图已归档,不可修改", blueprint_id=blueprint_id,
status=status)
before = canonical_json({"blueprint": bp,
"sub_objects": (cur or {}).get("sub_objects") or {}})
# Think指令 → 结构化 target_changes
changes, raw = instruction_to_changes(instruction)
if not changes:
trace_mod.append_trace(trace_no, "proposed_action", {
"action": "modify", "instruction": instruction,
"target_changes": [], "rejected": True,
"reason": "指令无法解析为字段级结构化变更禁止整篇重写US-03",
}, ctx=ctx, store=st)
trace_mod.finish_trace(trace_no, status="deny", step_reached="EXECUTED",
deny_code=E_VALIDATION,
deny_reason="指令未命中任何可结构化字段",
ctx=ctx, store=st)
fail(E_VALIDATION,
"指令无法解析为结构化字段变更US-03 禁止整篇重写):%s" % raw,
instruction=instruction, trace_no=trace_no)
target_changes = [{"object_type": c["object_type"],
"object_id": c.get("object_id"),
"field": c["field"], "value": c["value"]} for c in changes]
# Propose → Execute经 blueprint.update 工具,受裁决)
res = invoke_tool(ctx, DESIGNER, "blueprint.update", {
"blueprint_id": blueprint_id, "instruction": instruction,
"target_changes": target_changes,
}, trace_no=trace_no, session_no=session_no, store=st)
# 非目标字段字节级不变验证canonical_json diff
try:
after_cur = invoke_tool(ctx, DESIGNER, "blueprint.get",
{"blueprint_id": blueprint_id,
"version_no": res.get("new_version")},
trace_no=trace_no, session_no=session_no, store=st)
except PblError:
after_cur = None
unaffected = True
after = None
if after_cur:
after_bp = dict((after_cur or {}).get("blueprint") or {})
after = canonical_json({"blueprint": after_bp,
"sub_objects": (after_cur or {}).get("sub_objects") or {}})
untouched = _diff_untouched(bp, after_bp, changes)
unaffected = untouched["ok"]
trace_mod.append_trace(trace_no, "result", {
"allow": True, "executed": True, "new_version": res.get("new_version"),
"unaffected_verified": unaffected,
"before_fingerprint": _fp(before), "after_fingerprint": _fp(after),
}, ctx=ctx, store=st)
trace_mod.append_trace(trace_no, "final_output", {
"new_version": res.get("new_version"),
"change_delta": res.get("change_delta"),
}, ctx=ctx, store=st)
trace_mod.finish_trace(trace_no, status="done", step_reached="EXECUTED",
ctx=ctx, store=st)
return {"blueprint_id": blueprint_id,
"new_version": res.get("new_version"),
"change_delta": res.get("change_delta") or
{"instruction": instruction,
"target_changes": target_changes},
"unaffected_verified": unaffected,
"before_fingerprint": _fp(before), "after_fingerprint": _fp(after),
"trace_no": trace_no,
"note": "仅目标字段变更非目标字段字节级不变US-03"}
def _fp(text):
import hashlib
if not text:
return None
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
def _diff_untouched(before_bp, after_bp, changes):
"""比对顶层非目标字段是否字节级不变。"""
touched = set()
for c in changes:
touched.add(c.get("slot"))
touched.add(c.get("field"))
changed = []
for k in set(list(before_bp.keys()) + list(after_bp.keys())):
if k in ("updated_at", "version_no", "version", "id", "updated_by"):
continue
if canonical_json(before_bp.get(k)) != canonical_json(after_bp.get(k)):
changed.append(k)
unexpected = [k for k in changed if k not in touched]
return {"ok": not unexpected, "changed": changed, "unexpected": unexpected}

View File

@ -0,0 +1,106 @@
# -*- coding: utf-8 -*-
"""
pbl_agent_runtime M4a 模块挂载入口load_pbl_agent_runtime_m4a
应用 init() 中调用
from pbl_agent_runtime.m4a_init import load_pbl_agent_runtime_m4a
load_pbl_agent_runtime_m4a(env) # env 为 ServerEnv可选
职责
1) 取模块库名ServerEnv().get_module_dbname('pbl_agent_runtime')禁止硬编码 DBNAME
2) 建表幂等4 主表 + 1 append-only 流水表
3) seed_agentsdesigner / criticCritic write_allowed=0
4) seed_tools13 enabled + 9 disabled幂等 upsert
5) 注册 dspy API 路由wwwroot/api/*.dspy 由平台按目录装载此处仅登记契约清单
"""
from .m4a_kernel import get_store, set_store, MemoryStore, TenantContext, ACTOR_SYSTEM
from .m4a_tables import ensure_tables, TABLES, APPEND_ONLY_TABLES, all_sql
from .m4a_registry import (
seed_agents, seed_tools, ENABLED_TOOL_CODES, DISABLED_TOOL_CODES,
APPROVAL_REQUIRED_TOOLS, PERM_PLATFORM_ADMIN,
)
from .m4a import M4aApi, load_m4a
MODULE_NAME = "pbl_agent_runtime"
# 本模块对外 dspy API 契约清单wwwroot/api/ 下文件,平台自动装载)
API_ROUTES = [
{"path": "api/pbl_agent_designer_run.dspy", "func": "designer_generate",
"agent": "designer", "write": True, "note": "US-01 NL→Blueprint含离线兜底"},
{"path": "api/pbl_agent_designer_modify.dspy", "func": "designer_modify",
"agent": "designer", "write": True, "note": "US-03 结构化修改change_delta"},
{"path": "api/pbl_agent_designer_clarify.dspy", "func": "designer_clarify",
"agent": "designer", "write": False, "note": "US-04 澄清 ≤3 轮"},
{"path": "api/pbl_agent_critic_run.dspy", "func": "critic_review",
"agent": "critic", "write": False, "note": "US-18 只读评审(四要素)"},
{"path": "api/pbl_tool_adjudicate.dspy", "func": "adjudicate_report",
"agent": "*", "write": False, "note": "fail-closed 8 步裁决预检"},
{"path": "api/pbl_tool_invoke.dspy", "func": "invoke_tool",
"agent": "*", "write": True, "note": "工具调用唯一入口(裁决+执行+轨迹)"},
{"path": "api/pbl_tool_registry_list.dspy", "func": "list_tools",
"agent": "-", "write": False, "note": "13+9 注册表查询"},
{"path": "api/pbl_tool_registry_save.dspy", "func": "register_tool/set_tool_status",
"agent": "-", "write": True, "note": "仅 Platform Admin人类"},
{"path": "api/pbl_agent_trace_list.dspy", "func": "list_traces",
"agent": "-", "write": False, "note": "F-AG-04 轨迹查询"},
{"path": "api/pbl_agent_trace_write.dspy", "func": "start_trace/append_trace",
"agent": "*", "write": True, "note": "append-only禁改禁删"},
{"path": "api/pbl_approval_create.dspy", "func": "request_approval",
"agent": "designer", "write": True, "note": "T10 提案Agent 不可自批)"},
{"path": "api/pbl_approval_decide.dspy", "func": "decide_approval",
"agent": "-", "write": True, "note": "仅 actor_type=user14.2"},
{"path": "api/pbl_approval_list.dspy", "func": "list_pending_approvals",
"agent": "-", "write": False, "note": "待办审批工作台"},
]
def get_dbname(env=None):
"""取模块库名(禁止硬编码 DBNAME"""
try:
if env is not None and hasattr(env, "get_module_dbname"):
return env.get_module_dbname(MODULE_NAME)
from ahserver.serverenv import ServerEnv
return ServerEnv().get_module_dbname(MODULE_NAME)
except Exception:
return None
def load_pbl_agent_runtime_m4a(env=None, tenant_id=None, store=None,
force_memory=False, do_seed=True):
"""
挂载 M4a建表 + seed幂等返回 {dbname, tables, seed, api}
tenant_id 为空时按平台级__platform__seed租户首次调用时再按租户 seed
"""
dbname = get_dbname(env)
st = store or get_store(force_memory=force_memory)
tables = ensure_tables(st)
seed = None
if do_seed:
ctx = TenantContext(tenant_id=tenant_id or "__platform__",
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)}
return {
"module": MODULE_NAME,
"part": "M4a",
"dbname": dbname,
"tables": tables,
"append_only_tables": list(APPEND_ONLY_TABLES),
"seed": seed,
"enabled_tools": list(ENABLED_TOOL_CODES),
"disabled_tools": list(DISABLED_TOOL_CODES),
"approval_required_tools": list(APPROVAL_REQUIRED_TOOLS),
"api_routes": API_ROUTES,
}
def bootstrap(tenant_id, store=None, force_memory=False):
"""租户级快捷装配(等价 load_m4a返回 M4aApi。"""
return load_m4a(tenant_id=tenant_id, store=store, force_memory=force_memory)
def ddl():
"""导出全部 DDL供 sql/m4a_ddl.sql 生成与部署核对)。"""
return all_sql()

View File

@ -0,0 +1,389 @@
# -*- coding: utf-8 -*-
"""
pbl_agent_runtime M4a 公共内核错误码 / 租户上下文 / 存储适配 / 工具函数
设计锚点
- modules/pbl_agent_runtime.md §4 fail-closed 裁决default-deny
- agent-tool-contract.md §4 裁决与审计规约
- pbl_common 约定所有读写 tenant_id 强制打头缺失即 PBL_E_TENANT_MISSING
本文件不依赖 pbl_common 硬导入优先复用 pbl_common 的错误码/审计/存储
缺失时降级到本文件内置实现保证模块可独立单测离线可跑
"""
import json
import time
import uuid
import threading
# ---------------------------------------------------------------------------
# 1. 错误码(与 pbl_common 对齐M4a 用到的全集)
# ---------------------------------------------------------------------------
E_TENANT_MISSING = "PBL_E_TENANT_MISSING" # 租户上下文缺失/无效
E_FORBIDDEN = "PBL_E_FORBIDDEN" # 未注册 / 禁用 / 越权 / 读他人轨迹
E_STATE_ILLEGAL = "PBL_E_STATE_ILLEGAL" # 需审批未批 / 状态不允许
E_NOT_FOUND = "PBL_E_NOT_FOUND" # 对象不存在(含跨租户 404
E_VALIDATION = "PBL_E_VALIDATION" # 入参契约 / 四要素不齐
E_DUPLICATE = "PBL_E_DUPLICATE" # 重复注册
E_APPEND_ONLY = "PBL_E_APPEND_ONLY" # append-only 轨迹不可改
E_MODEL_UNAVAILABLE = "PBL_E_MODEL_UNAVAILABLE" # 模型不可达Designer 内部兜底,不外抛)
E_BACKEND_UNAVAILABLE = "PBL_E_BACKEND_UNAVAILABLE" # 后端权威服务未挂载
E_TEMPLATE_NONE = "PBL_E_TEMPLATE_NONE" # 无可用模板
# 裁决链 8 步M4a 核心常量,顺序即执行顺序,任一步失败立即拒绝)
ADJUDICATION_STEPS = (
("S1", "tenant_context", "租户上下文有效tenant_id 强制打头)", E_TENANT_MISSING),
("S2", "agent_registered", "Agent 已注册且 enabled仅 designer/critic 两个)", E_FORBIDDEN),
("S3", "tool_registered", "工具已注册(白名单外 default-deny", E_FORBIDDEN),
("S4", "tool_enabled", "工具 status=enabled禁用工具返回 disable_reason", E_FORBIDDEN),
("S5", "permission_domain", "调用方持有 required_permission 权限域", E_FORBIDDEN),
("S6", "agent_scope", "allowed_agents 含调用方Critic 零写权限", E_FORBIDDEN),
("S7", "approval_gate", "require_approval=1 须有 approved 人工审批记录", E_STATE_ILLEGAL),
("S8", "input_contract", "入参满足 input_schema必填/类型/枚举)", E_VALIDATION),
)
# 轨迹 7 要素(第 28 章US-20
TRACE_ELEMENTS = (
"input_context",
"retrieved_knowledge",
"tool_calls",
"proposed_action",
"result",
"approval",
"final_output",
)
TRACE_STATUS_OPEN = "open"
TRACE_STATUS_DONE = "done"
TRACE_STATUS_DENY = "deny"
TOOL_STATUS_ENABLED = "enabled"
TOOL_STATUS_DISABLED = "disabled"
AGENT_STATUS_ENABLED = "enabled"
AGENT_STATUS_DISABLED = "disabled"
ACTOR_USER = "user"
ACTOR_AGENT = "agent"
ACTOR_SYSTEM = "system"
class PblError(Exception):
"""PBL 业务异常code 为 PBL_E_* 错误码payload 携带可审计上下文。"""
def __init__(self, code, message="", **payload):
super(PblError, self).__init__("[%s] %s" % (code, message))
self.code = code
self.message = message
self.payload = payload
def to_dict(self):
d = {"ok": False, "error_code": self.code, "error_message": self.message}
d.update(self.payload)
return d
def fail(code, message="", **payload):
"""抛出 PblError统一入口便于后续接 pbl_common.errors"""
raise PblError(code, message, **payload)
# ---------------------------------------------------------------------------
# 2. 租户上下文
# ---------------------------------------------------------------------------
class TenantContext(object):
"""一次调用的租户 + 调用方上下文。tenant_id 缺失即 fail-closed。"""
__slots__ = ("tenant_id", "actor_type", "actor_id", "permissions", "org_id")
def __init__(self, tenant_id=None, actor_type=ACTOR_AGENT, actor_id=None,
permissions=None, org_id=None):
self.tenant_id = tenant_id
self.actor_type = actor_type
self.actor_id = actor_id
self.permissions = set(permissions or ())
self.org_id = org_id
def is_human(self):
return self.actor_type == ACTOR_USER
def has_perm(self, perm):
if not perm:
return True
if perm in self.permissions:
return True
# 通配pbl_authoring.* 覆盖 pbl_authoring.blueprint
if perm + ".*" in self.permissions:
return True
return "*" in self.permissions
def to_dict(self):
return {
"tenant_id": self.tenant_id,
"actor_type": self.actor_type,
"actor_id": self.actor_id,
"permissions": sorted(self.permissions),
"org_id": self.org_id,
}
def require_tenant(ctx):
"""S1租户上下文校验。tenant_id 必须非空字符串。"""
if ctx is None:
fail(E_TENANT_MISSING, "缺少租户上下文ctx=None")
tid = getattr(ctx, "tenant_id", None)
if not isinstance(tid, str) or not tid.strip():
fail(E_TENANT_MISSING, "tenant_id 缺失或非法", got=repr(tid))
return tid.strip()
# ---------------------------------------------------------------------------
# 3. 存储适配sqlor 优先,缺失降级内存表;接口对齐 sor.C/R/U/D/I
# ---------------------------------------------------------------------------
class MemoryStore(object):
"""内存表存储,仅用于离线单测与后端未挂载时的降级;接口对齐 sqlor。"""
def __init__(self):
self._tables = {}
self._seq = {}
self._lock = threading.RLock()
def _tbl(self, tblname):
with self._lock:
return self._tables.setdefault(tblname, [])
@staticmethod
def _match(row, where):
if not where:
return True
for k, v in where.items():
if isinstance(v, (list, tuple, set)):
if row.get(k) not in v:
return False
elif row.get(k) != v:
return False
return True
def C(self, tblname, data):
with self._lock:
rows = self._tbl(tblname)
self._seq[tblname] = self._seq.get(tblname, 0) + 1
row = dict(data)
row["id"] = self._seq[tblname]
rows.append(row)
return row["id"]
def R(self, tblname, where=None, order_by=None, limit=None, offset=None):
with self._lock:
rows = [dict(r) for r in self._tbl(tblname) if self._match(r, where)]
if order_by:
desc = order_by.startswith("-")
key = order_by[1:] if desc else order_by
rows.sort(key=lambda r: (r.get(key) is None, r.get(key)), reverse=desc)
if offset:
rows = rows[int(offset):]
if limit:
rows = rows[:int(limit)]
return rows
def U(self, tblname, data, where):
with self._lock:
n = 0
for row in self._tbl(tblname):
if self._match(row, where):
row.update(data)
n += 1
return n
def D(self, tblname, where):
with self._lock:
rows = self._tbl(tblname)
keep = [r for r in rows if not self._match(r, where)]
n = len(rows) - len(keep)
self._tables[tblname] = keep
return n
def I(self, sql, args=None):
raise NotImplementedError("MemoryStore 不支持裸 SQL: %s" % (sql,))
def sqlExe(self, sql, args=None):
raise NotImplementedError("MemoryStore 不支持裸 SQL: %s" % (sql,))
class AppendOnlyGuard(object):
"""
append-only 存储守卫包裹底层 store对受保护表禁止 U/D
pbl_agent_trace append-onlyUS-20 不可篡改
轨迹状态推进只允许通过受控的 _advance_trace_status 白名单通道
"""
PROTECTED = ("pbl_agent_trace", "pbl_agent_trace_stage")
def __init__(self, store):
self._store = store
self._bypass = threading.local()
# --- 受控通道:仅内核可临时解除保护(状态推进/要素追加) ---
class _Bypass(object):
def __init__(self, guard):
self.g = guard
def __enter__(self):
self.g._bypass.on = True
return self.g._store
def __exit__(self, *a):
self.g._bypass.on = False
return False
def mutable(self):
return AppendOnlyGuard._Bypass(self)
def _check(self, tblname):
if tblname in AppendOnlyGuard.PROTECTED and not getattr(self._bypass, "on", False):
fail(E_APPEND_ONLY, "%s 为 append-only禁止 update/delete" % tblname,
table=tblname)
def C(self, tblname, data):
return self._store.C(tblname, data)
def R(self, tblname, where=None, order_by=None, limit=None, offset=None):
return self._store.R(tblname, where, order_by=order_by, limit=limit, offset=offset)
def U(self, tblname, data, where):
self._check(tblname)
return self._store.U(tblname, data, where)
def D(self, tblname, where):
self._check(tblname)
return self._store.D(tblname, where)
def I(self, sql, args=None):
return self._store.I(sql, args)
def sqlExe(self, sql, args=None):
return self._store.sqlExe(sql, args)
_STORE = None
_STORE_LOCK = threading.RLock()
def get_store(force_memory=False):
"""
取存储句柄优先 sqlorServerEnv 注入的模块库失败降级 MemoryStore
返回的对象带 append-only 守卫
"""
global _STORE
with _STORE_LOCK:
if _STORE is not None and not force_memory:
return _STORE
raw = None
if not force_memory:
try:
from sqlor import sqlor as _sor # noqa
raw = _sor
except Exception:
raw = None
if raw is None:
raw = MemoryStore()
_STORE = AppendOnlyGuard(raw)
return _STORE
def set_store(store):
"""测试/装配注入点:显式设置存储(自动包 append-only 守卫)。"""
global _STORE
with _STORE_LOCK:
_STORE = store if isinstance(store, AppendOnlyGuard) else AppendOnlyGuard(store)
return _STORE
def reset_store():
global _STORE
with _STORE_LOCK:
_STORE = None
# ---------------------------------------------------------------------------
# 4. 工具函数
# ---------------------------------------------------------------------------
def now_ts():
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
def gen_no(prefix):
"""生成业务编号:前缀 + 时间戳 + 短 uuid全局唯一、可排序。"""
return "%s%s%s" % (
prefix,
time.strftime("%Y%m%d%H%M%S", time.localtime()),
uuid.uuid4().hex[:8].upper(),
)
def canonical_json(obj):
"""规范化 JSON键排序、无多余空白——用于 change_delta 字节级比对US-03"""
return json.dumps(obj, sort_keys=True, ensure_ascii=False, separators=(",", ":"),
default=str)
def dumps(obj):
if obj is None:
return None
if isinstance(obj, str):
return obj
return json.dumps(obj, ensure_ascii=False, sort_keys=True, default=str)
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 one(store, tblname, where):
"""取单行;无则 None。where 必须已含 tenant_id。"""
rows = store.R(tblname, where, limit=1)
return rows[0] if rows else None
def first_or_404(store, tblname, where, **payload):
row = one(store, tblname, where)
if row is None:
fail(E_NOT_FOUND, "%s 不存在(或跨租户不可见)" % tblname,
table=tblname, **payload)
return row
def write_audit(ctx, action, object_type, object_id, detail=None, store=None):
"""
审计联动agent-tool-contract.md §4.5工具执行/治理变更成功后写审计
优先复用 pbl_common.audit不可用时落本地 pbl_agent_audit
审计写失败必须让业务回滚此处向上抛错由调用方事务处理
"""
record = {
"tenant_id": getattr(ctx, "tenant_id", None),
"actor_type": getattr(ctx, "actor_type", None),
"actor_id": getattr(ctx, "actor_id", None),
"action": action,
"object_type": object_type,
"object_id": object_id,
"detail": dumps(detail),
"created_at": now_ts(),
}
try:
from pbl_common.audit import write_audit as _common_audit # noqa
return _common_audit(record)
except Exception:
pass
st = store or get_store()
return st.C("pbl_agent_audit", record)

View File

@ -0,0 +1,638 @@
# -*- coding: utf-8 -*-
"""
pbl_agent_runtime M4a Agent 定义与工具注册表 seed 幂等注入
内容
1) AGENT_DEFS 13.1 章仅 designer / critic 两个 Agentcritic write_allowed=0零写权限
2) TOOL_SEED 31 章裁剪子集 13 enabled + 9 disabledagent-tool-contract.md §2/§3
3) register_tool / list_tools / set_tool_status / seed_tools / seed_agents幂等build.sh 调用
四类强制人工审批14.2
publishT12 publish.request/ compile_executeT11 compile.trigger/
blueprint_approve蓝图 approved 状态推进/ tool_registry_change注册表启停变更
"""
from .m4a_kernel import (
get_store, one, fail, now_ts, dumps, loads, write_audit,
E_FORBIDDEN, E_DUPLICATE, E_NOT_FOUND,
TOOL_STATUS_ENABLED, TOOL_STATUS_DISABLED,
AGENT_STATUS_ENABLED, ACTOR_USER,
)
PERM_AUTHORING = "pbl_authoring"
PERM_AGENT_TOOLS = "agent_tools"
PERM_PUBLISHING = "publishing"
PERM_KDB = "kdb"
PERM_PLATFORM_ADMIN = "platform_admin"
# 四类强制人工审批
APPROVAL_TYPES = ("publish", "compile_execute", "blueprint_approve",
"tool_registry_change")
DESIGNER = "designer"
CRITIC = "critic"
# ---------------------------------------------------------------------------
# 1. Agent 定义(第 13.1 章:仅此两个)
# ---------------------------------------------------------------------------
AGENT_DEFS = [
{
"agent_code": DESIGNER,
"agent_name": "Designer Agent",
"agent_type": "authoring",
"loop_pattern": "observe_think_propose_execute",
"authority_boundary":
"Agent 非真相源14.1Designer 只 Propose全部写操作经工具裁决链落到"
"权威系统pbl_blueprint / pbl_validation / pbl_compiler"
"Execute 受裁决Publish 必须人工审批14.2)。",
"write_allowed": 1,
"allowed_tools": [
"blueprint.create", "blueprint.update", "blueprint.get",
"template.list", "template.copy",
"validation.run", "validation.report",
"trace.get", "approval.request",
"compile.trigger", "publish.request", "kdb.search",
],
# critic.review 属 critic 域designer 不调用
"denied_tools": [
"critic.review",
"blueprint.publish_auto", "curriculum.modify_auto",
"marketplace.create_listing", "kdb.write", "research.collect",
"experiment.ab_run", "agent.mentor_invoke", "world.edit_3d",
"billing.charge",
],
"model_binding": {
"model": "", "capability": "t2t",
"fallback": "template_fallback",
"note": "模型不可达 → PBL_E_MODEL_UNAVAILABLE 内部捕获,自动切 template.copy 兜底US-05",
},
"offline_fallback": "template_fallback",
"status": AGENT_STATUS_ENABLED,
"description": "NL 意图 → Blueprint 草稿(生成 / 对话式修改提案)",
},
{
"agent_code": CRITIC,
"agent_name": "Critic Agent",
"agent_type": "review",
"loop_pattern": "observe_think_propose",
"authority_boundary":
"Critic 零写权限14.1):不直接修改 Blueprint仅产出建议"
"recommendation / reason / evidence / confidence14.3"
"只读 blueprint.get + validation.report无任何 write_operation=1 工具。",
"write_allowed": 0,
"allowed_tools": ["blueprint.get", "validation.report", "critic.review",
"trace.get"],
# 零写权限所有写工具显式拉黑S6 双重防线)
"denied_tools": [
"blueprint.create", "blueprint.update", "template.copy",
"validation.run", "compile.trigger", "publish.request",
"approval.request",
"blueprint.publish_auto", "curriculum.modify_auto",
"marketplace.create_listing", "kdb.write", "research.collect",
"experiment.ab_run", "agent.mentor_invoke", "world.edit_3d",
"billing.charge",
],
"model_binding": {
"model": "", "capability": "t2t", "fallback": "rule_based",
"note": "模型不可达 → 降级为规则式建议(读 validation.report 告警),仍产出四要素",
},
"offline_fallback": "none",
"status": AGENT_STATUS_ENABLED,
"description": "质量告警与改进建议(只读评审,不改蓝图)",
},
]
# ---------------------------------------------------------------------------
# 2. 工具注册表 seed13 enabled + 9 disabled
# ---------------------------------------------------------------------------
def _t(code, name, group, status, perm, approval, agents, write, ischema,
oschema, backend, sort, disable_reason=None, approval_type=None,
scope_note=None):
return {
"tool_code": code, "tool_name": name, "tool_group": group, "status": status,
"required_permission": perm, "require_approval": approval,
"approval_action_type": approval_type, "allowed_agents": agents,
"write_operation": write, "input_schema": ischema, "output_schema": oschema,
"backend_mapping": backend, "disable_reason": disable_reason,
"scope_note": scope_note, "sort_no": sort,
}
TOOL_SEED = [
# ---------------- 启用13 ----------------
_t("blueprint.create", "创建蓝图", "blueprint", TOOL_STATUS_ENABLED,
PERM_AUTHORING, 0, [DESIGNER], 1,
{"type": "object",
"required": ["intent_text", "title"],
"properties": {
"intent_text": {"type": "string"},
"title": {"type": "string"},
"class_id": {"type": "string"},
"generation_source": {"type": "string",
"enum": ["ai_generated", "manual",
"template_fallback", "template_copy"]}}},
{"type": "object",
"properties": {"blueprint_id": {"type": "integer"}, "code": {"type": "string"},
"version_no": {"type": "integer"},
"generation_source": {"type": "string"},
"trace_no": {"type": "string"}}},
"pbl_blueprint.create_blueprint + create_version", 1,
scope_note="T1PBL_E_MODEL_UNAVAILABLE 内部切 template.copy 兜底,不向 Agent 抛错"),
_t("blueprint.update", "结构化修改蓝图", "blueprint", TOOL_STATUS_ENABLED,
PERM_AUTHORING, 0, [DESIGNER], 1,
{"type": "object",
"required": ["blueprint_id", "instruction", "target_changes"],
"properties": {
"blueprint_id": {"type": "integer"},
"instruction": {"type": "string"},
"target_changes": {"type": "array", "minItems": 1, "items": {
"type": "object", "required": ["object_type", "field", "value"],
"properties": {"object_type": {"type": "string"},
"object_id": {"type": "integer"},
"field": {"type": "string"},
"value": {}}}}}},
{"type": "object",
"properties": {"new_version": {"type": "integer"},
"change_delta": {"type": "object"},
"unaffected_verified": {"type": "boolean"}}},
"pbl_blueprint.update_sub_object + create_version(change_delta)", 2,
scope_note="T2仅目标字段变更非目标字段字节级不变US-03 canonical_json diff"),
_t("blueprint.get", "读取蓝图", "blueprint", TOOL_STATUS_ENABLED,
PERM_AUTHORING, 0, [DESIGNER, CRITIC], 0,
{"type": "object", "required": ["blueprint_id"],
"properties": {"blueprint_id": {"type": "integer"},
"version_no": {"type": "integer"},
"include": {"type": "array", "items": {"type": "string"}}}},
{"type": "object",
"properties": {"blueprint": {"type": "object"}, "version": {"type": "object"},
"sub_objects": {"type": "object"}}},
"pbl_blueprint.get_blueprint / get_version / get_blueprint_tree", 3,
scope_note="T3租户隔离跨租户返回 PBL_E_NOT_FOUND"),
_t("template.list", "模板检索", "template", TOOL_STATUS_ENABLED,
PERM_AUTHORING, 0, [DESIGNER], 0,
{"type": "object",
"properties": {"category": {"type": "string"}, "keyword": {"type": "string"},
"page": {"type": "integer"}, "size": {"type": "integer"}}},
{"type": "object",
"properties": {"items": {"type": "array"}, "total": {"type": "integer"}}},
"pbl_template.list_templates + match_by_intent", 4,
scope_note="T4kdb.search 桩返回空时 Designer 降级到本工具(本地模板库)"),
_t("template.copy", "模板实例化", "template", TOOL_STATUS_ENABLED,
PERM_AUTHORING, 0, [DESIGNER], 1,
{"type": "object", "required": ["template_id", "owner_teacher_id"],
"properties": {"template_id": {"type": "integer"},
"owner_teacher_id": {"type": "string"},
"class_id": {"type": "string"},
"new_title": {"type": "string"}}},
{"type": "object",
"properties": {"blueprint_id": {"type": "integer"},
"version_no": {"type": "integer"},
"generation_source": {"type": "string"},
"usage_id": {"type": "integer"}}},
"pbl_template.copy_template_to_blueprint / fallback_instantiate", 5,
scope_note="T5模型不可达兜底落点generation_source=template_fallback"),
_t("validation.run", "触发校验", "validation", TOOL_STATUS_ENABLED,
PERM_AUTHORING, 0, [DESIGNER], 0,
{"type": "object", "required": ["blueprint_id", "version_no"],
"properties": {"blueprint_id": {"type": "integer"},
"version_no": {"type": "integer"}}},
{"type": "object",
"properties": {"run_no": {"type": "string"},
"quality_status": {"type": "string"},
"pass_count": {"type": "integer"},
"warn_count": {"type": "integer"},
"fail_count": {"type": "integer"}}},
"pbl_validation.run_validation", 6,
scope_note="T6确定性校验无 LLM"),
_t("validation.report", "校验报告读取", "validation", TOOL_STATUS_ENABLED,
PERM_AUTHORING, 0, [DESIGNER, CRITIC], 0,
{"type": "object",
"properties": {"run_id": {"type": "integer"},
"blueprint_id": {"type": "integer"}}},
{"type": "object",
"properties": {"run": {"type": "object"}, "dimensions": {"type": "array"},
"alerts": {"type": "array"}}},
"pbl_validation.get_validation_report", 7,
scope_note="T7Critic 建议证据源14 维 + 4 内置告警)"),
_t("critic.review", "Critic 评审", "critic", TOOL_STATUS_ENABLED,
PERM_AUTHORING, 0, [CRITIC], 0,
{"type": "object", "required": ["blueprint_id"],
"properties": {"blueprint_id": {"type": "integer"},
"version_no": {"type": "integer"}}},
{"type": "object",
"properties": {"suggestions": {"type": "array"},
"trace_no": {"type": "string"}}},
"pbl_agent_runtime.critic_review读 T3/T7零写蓝图", 8,
scope_note="T8write_operation=0Critic 不直接修改 Blueprint14.1"
"四要素不齐 → PBL_E_VALIDATION14.3"),
_t("trace.get", "轨迹读取", "trace", TOOL_STATUS_ENABLED,
PERM_AGENT_TOOLS, 0, [DESIGNER, CRITIC], 0,
{"type": "object", "required": ["trace_no"],
"properties": {"trace_no": {"type": "string"}}},
{"type": "object",
"properties": {e: {} for e in ("input_context", "retrieved_knowledge",
"tool_calls", "proposed_action", "result",
"approval", "final_output")}},
"pbl_agent_runtime.get_traceappend-only", 9,
scope_note="T9Agent 仅可读自身 trace_no读他人 → PBL_E_FORBIDDEN"),
_t("approval.request", "发起人工审批", "approval", TOOL_STATUS_ENABLED,
PERM_AGENT_TOOLS, 0, [DESIGNER], 0,
{"type": "object", "required": ["trace_id", "action_type"],
"properties": {"trace_id": {"type": "integer"},
"action_type": {"type": "string",
"enum": list(APPROVAL_TYPES)},
"approver_id": {"type": "string"}}},
{"type": "object",
"properties": {"approval_no": {"type": "string"},
"status": {"type": "string", "enum": ["pending"]}}},
"pbl_agent_runtime.request_approval", 10,
scope_note="T10Agent 不能自批——decide_approval 仅 actor_type=user 可调14.2"),
_t("compile.trigger", "触发编译(需审批)", "compile", TOOL_STATUS_ENABLED,
PERM_AUTHORING, 1, [DESIGNER], 1,
{"type": "object",
"required": ["blueprint_id", "version_no", "compiler_version"],
"properties": {"blueprint_id": {"type": "integer"},
"version_no": {"type": "integer"},
"compiler_version": {"type": "string"}}},
{"type": "object",
"properties": {"task_no": {"type": "string"}, "status": {"type": "string"},
"game_def_id": {"type": "integer"},
"fingerprint": {"type": "string"}}},
"pbl_compiler.compileis_approved 门禁:未审批蓝图 403", 11,
approval_type="compile_execute",
scope_note="T11require_approval=1无 approved 记录 → PBL_E_STATE_ILLEGAL"),
_t("publish.request", "发布请求(需审批)", "approval", TOOL_STATUS_ENABLED,
PERM_PUBLISHING, 1, [DESIGNER], 1,
{"type": "object", "required": ["blueprint_id", "visibility"],
"properties": {"blueprint_id": {"type": "integer"},
"visibility": {"type": "string",
"enum": ["private", "org", "school", "class"]}}},
{"type": "object",
"properties": {"approval_no": {"type": "string"},
"status": {"type": "string"},
"blueprint_status": {"type": "string"},
"visibility": {"type": "string"}}},
"pbl_blueprint.submit_approval(publish_approve) + status 更新", 12,
approval_type="publish",
scope_note="T12Publish 必须人工审批14.2/36 章);仅可见性标记,无 Marketplace1.3"),
_t("kdb.search", "知识检索(桩)", "kdb", TOOL_STATUS_ENABLED,
PERM_KDB, 0, [DESIGNER], 0,
{"type": "object", "required": ["query"],
"properties": {"query": {"type": "string"}, "top_k": {"type": "integer"}}},
{"type": "object",
"properties": {"items": {"type": "array"}, "total": {"type": "integer"},
"stub": {"type": "boolean"}}},
"pbl_kdb_ext.kdb_search", 13,
scope_note="T13只读桩空结果集不报错Q5/US-24→ Designer 降级 T4/T5"),
# ---------------- 禁用9out_of_scope仍注册在表 ----------------
_t("blueprint.publish_auto", "全自主发布", "publish", TOOL_STATUS_DISABLED,
PERM_PUBLISHING, 0, [], 1, {"type": "object", "properties": {}},
{"type": "object", "properties": {}}, "", 101,
disable_reason="全自主发布禁止——Publish 必须人工审批14.2/28/36 章);"
"由 publish.requestrequire_approval=1替代",
scope_note="D1 out_of_scope"),
_t("curriculum.modify_auto", "自主改课", "blueprint", TOOL_STATUS_DISABLED,
PERM_AUTHORING, 0, [], 1, {"type": "object", "properties": {}},
{"type": "object", "properties": {}}, "", 102,
disable_reason="自主改课禁止14.2——Agent 只 Propose修改经 blueprint.update "
"且 Execute 受裁决",
scope_note="D2 out_of_scope"),
_t("marketplace.create_listing", "Marketplace 上架", "marketplace",
TOOL_STATUS_DISABLED, PERM_PUBLISHING, 0, [], 1,
{"type": "object", "properties": {}}, {"type": "object", "properties": {}},
"", 103,
disable_reason="Marketplace 付费/订阅/分成归 Phase 422/23/35 章);"
"本迭代发布仅可见性标记",
scope_note="D3 out_of_scopemarketplace.* 全组禁用)"),
_t("kdb.write", "KDB 写入", "kdb", TOOL_STATUS_DISABLED, PERM_KDB, 0, [], 1,
{"type": "object", "properties": {}}, {"type": "object", "properties": {}},
"", 104,
disable_reason="KDB 只读owner Q5——不建向量库/图谱,禁任何写入",
scope_note="D4 out_of_scope"),
_t("research.collect", "Research 数据采集", "kdb", TOOL_STATUS_DISABLED,
PERM_KDB, 0, [], 1, {"type": "object", "properties": {}},
{"type": "object", "properties": {}}, "", 105,
disable_reason="Research data 采集禁止owner Q6——任何学生数据不得进入 "
"research 层;仅匿名聚合只读出口",
scope_note="D5 out_of_scope"),
_t("experiment.ab_run", "A/B 实验执行", "experiments", TOOL_STATUS_DISABLED,
PERM_AUTHORING, 0, [], 1, {"type": "object", "properties": {}},
{"type": "object", "properties": {}}, "", 106,
disable_reason="自动 A/B 实验引擎归 Phase 333/35 章)",
scope_note="D6 out_of_scope"),
_t("agent.mentor_invoke", "Mentor Agent 调用", "agent", TOOL_STATUS_DISABLED,
PERM_AUTHORING, 0, [], 1, {"type": "object", "properties": {}},
{"type": "object", "properties": {}}, "", 107,
disable_reason="Mentor Agent 归后续迭代13.1/35 章 Phase 2——本迭代仅 "
"Designer+Critic",
scope_note="D7 out_of_scope"),
_t("world.edit_3d", "3D 编辑器", "world", TOOL_STATUS_DISABLED,
PERM_AUTHORING, 0, [], 1, {"type": "object", "properties": {}},
{"type": "object", "properties": {}}, "", 108,
disable_reason="3D 编辑器禁止owner Q1 / 35 章 Phase 0 Do not build——"
"JSON 驱动渲染替代",
scope_note="D8 out_of_scope"),
_t("billing.charge", "计费扣款", "marketplace", TOOL_STATUS_DISABLED,
PERM_PUBLISHING, 0, [], 1, {"type": "object", "properties": {}},
{"type": "object", "properties": {}}, "", 109,
disable_reason="计费/SSO/企业私有部署归 Phase 423.3",
scope_note="D9 out_of_scopebilling.* 全组禁用)"),
]
ENABLED_TOOL_CODES = tuple(t["tool_code"] for t in TOOL_SEED
if t["status"] == TOOL_STATUS_ENABLED)
DISABLED_TOOL_CODES = tuple(t["tool_code"] for t in TOOL_SEED
if t["status"] == TOOL_STATUS_DISABLED)
# 需强制人工审批的工具require_approval=1
APPROVAL_REQUIRED_TOOLS = tuple(t["tool_code"] for t in TOOL_SEED
if t["require_approval"])
def _require_platform_admin(ctx):
"""注册表治理仅 Platform Adminagent-tool-contract.md §4.6)。"""
if ctx is None or not ctx.has_perm(PERM_PLATFORM_ADMIN):
fail(E_FORBIDDEN, "工具注册表治理仅 Platform Admin 可操作",
actor=getattr(ctx, "actor_id", None))
if ctx.actor_type != ACTOR_USER:
fail(E_FORBIDDEN, "工具注册表治理仅人类用户可操作Agent 不可自改注册表)",
actor_type=ctx.actor_type)
# ---------------------------------------------------------------------------
# 3. Agent 定义 seed / 查询
# ---------------------------------------------------------------------------
def seed_agents(ctx=None, store=None):
"""幂等注入 designer / critic 定义(按 tenant_id+agent_code upsert"""
st = store or get_store()
tenant_id = (ctx.tenant_id if ctx is not None else None) or "__platform__"
inserted, skipped = 0, 0
for d in AGENT_DEFS:
row = {
"tenant_id": tenant_id,
"agent_code": d["agent_code"],
"agent_name": d["agent_name"],
"agent_type": d["agent_type"],
"loop_pattern": d["loop_pattern"],
"authority_boundary": d["authority_boundary"],
"write_allowed": d["write_allowed"],
"allowed_tools": dumps(d["allowed_tools"]),
"denied_tools": dumps(d["denied_tools"]),
"model_binding": dumps(d["model_binding"]),
"offline_fallback": d["offline_fallback"],
"status": d["status"],
"description": d["description"],
"created_by": "seed",
"created_at": now_ts(),
"updated_at": now_ts(),
}
exist = one(st, "pbl_agent_def",
{"tenant_id": tenant_id, "agent_code": d["agent_code"]})
if exist:
st.U("pbl_agent_def", row,
{"tenant_id": tenant_id, "agent_code": d["agent_code"]})
skipped += 1
else:
st.C("pbl_agent_def", row)
inserted += 1
return {"inserted": inserted, "skipped": skipped, "agents": [d["agent_code"] for d in AGENT_DEFS]}
def get_agent_def(agent_code, ctx=None, store=None):
st = store or get_store()
tenant_id = (ctx.tenant_id if ctx is not None else None) or "__platform__"
row = one(st, "pbl_agent_def", {"tenant_id": tenant_id, "agent_code": agent_code})
if row is None:
# 回落到平台级定义seed 用 __platform__ 时)
row = one(st, "pbl_agent_def",
{"tenant_id": "__platform__", "agent_code": agent_code})
if row is None:
fail(E_NOT_FOUND, "Agent 定义不存在: %s" % agent_code, agent_code=agent_code)
row = dict(row)
row["allowed_tools"] = loads(row.get("allowed_tools"), []) or []
row["denied_tools"] = loads(row.get("denied_tools"), []) or []
row["model_binding"] = loads(row.get("model_binding"), {}) or {}
return row
def list_agents(ctx=None, store=None):
st = store or get_store()
tenant_id = (ctx.tenant_id if ctx is not None else None) or "__platform__"
rows = st.R("pbl_agent_def", {"tenant_id": tenant_id}, order_by="id")
if not rows:
rows = st.R("pbl_agent_def", {"tenant_id": "__platform__"}, order_by="id")
out = []
for r in rows:
r = dict(r)
r["allowed_tools"] = loads(r.get("allowed_tools"), []) or []
r["denied_tools"] = loads(r.get("denied_tools"), []) or []
out.append(r)
return out
# ---------------------------------------------------------------------------
# 4. 工具注册表 CRUD
# ---------------------------------------------------------------------------
def register_tool(ctx, tool_code, tool_name, tool_group, status,
required_permission, require_approval=0, input_schema=None,
output_schema=None, disable_reason=None, allowed_agents=None,
write_operation=0, backend_mapping="", approval_action_type=None,
scope_note=None, store=None):
"""
注册工具 Platform Admin重复 tool_code PBL_E_DUPLICATE
disabled 必须给 disable_reason审计可解释
"""
from .m4a_kernel import require_tenant
tenant_id = require_tenant(ctx)
_require_platform_admin(ctx)
if not tool_code or not isinstance(tool_code, str) or "." not in tool_code:
from .m4a_kernel import E_VALIDATION
fail(E_VALIDATION, "tool_code 非法(须为 group.name 形式)", tool_code=tool_code)
if status not in (TOOL_STATUS_ENABLED, TOOL_STATUS_DISABLED):
from .m4a_kernel import E_VALIDATION
fail(E_VALIDATION, "status 仅 enabled/disabled", status=status)
if status == TOOL_STATUS_DISABLED and not disable_reason:
from .m4a_kernel import E_VALIDATION
fail(E_VALIDATION, "禁用工具必须填写 disable_reasonout_of_scope 锚点)",
tool_code=tool_code)
st = store or get_store()
if one(st, "pbl_agent_tool", {"tenant_id": tenant_id, "tool_code": tool_code}):
fail(E_DUPLICATE, "工具已注册: %s" % tool_code, tool_code=tool_code)
row = {
"tenant_id": tenant_id, "tool_code": tool_code, "tool_name": tool_name,
"tool_group": tool_group, "status": status,
"required_permission": required_permission,
"require_approval": 1 if require_approval else 0,
"approval_action_type": approval_action_type,
"allowed_agents": dumps(allowed_agents or []),
"write_operation": 1 if write_operation else 0,
"input_schema": dumps(input_schema or {"type": "object", "properties": {}}),
"output_schema": dumps(output_schema or {"type": "object", "properties": {}}),
"backend_mapping": backend_mapping or "",
"disable_reason": disable_reason, "scope_note": scope_note,
"sort_no": 900, "created_by": ctx.actor_id, "created_at": now_ts(),
"updated_at": now_ts(),
}
tid = st.C("pbl_agent_tool", row)
write_audit(ctx, "tool.register", "pbl_agent_tool", tid,
{"tool_code": tool_code, "status": status}, store=st)
row["id"] = tid
return _decode_tool(row)
def _decode_tool(row):
r = dict(row)
r["allowed_agents"] = loads(r.get("allowed_agents"), []) or []
r["input_schema"] = loads(r.get("input_schema"), {}) or {}
r["output_schema"] = loads(r.get("output_schema"), {}) or {}
return r
def get_tool(tool_code, ctx=None, store=None, tenant_id=None):
"""取工具注册行(含禁用行)。找不到 → None由裁决链 S3 判 default-deny"""
st = store or get_store()
tid = tenant_id or (ctx.tenant_id if ctx is not None else None) or "__platform__"
row = one(st, "pbl_agent_tool", {"tenant_id": tid, "tool_code": tool_code})
if row is None and tid != "__platform__":
row = one(st, "pbl_agent_tool",
{"tenant_id": "__platform__", "tool_code": tool_code})
return _decode_tool(row) if row else None
def list_tools(ctx=None, status=None, group=None, store=None, tenant_id=None):
st = store or get_store()
tid = tenant_id or (ctx.tenant_id if ctx is not None else None) or "__platform__"
rows = st.R("pbl_agent_tool", {"tenant_id": tid}, order_by="sort_no")
if not rows:
rows = st.R("pbl_agent_tool", {"tenant_id": "__platform__"}, order_by="sort_no")
out = [_decode_tool(r) for r in rows]
if status:
out = [t for t in out if t.get("status") == status]
if group:
out = [t for t in out if t.get("tool_group") == group]
return out
def set_tool_status(ctx, tool_code, status, reason=None, store=None):
"""
启停工具 Platform Admin人类属四类强制审批之 tool_registry_change
启用一个此前禁用的工具必须携带 approved 审批单号reason approval_no
"""
from .m4a_kernel import require_tenant, E_VALIDATION, E_STATE_ILLEGAL
tenant_id = require_tenant(ctx)
_require_platform_admin(ctx)
if status not in (TOOL_STATUS_ENABLED, TOOL_STATUS_DISABLED):
fail(E_VALIDATION, "status 仅 enabled/disabled", status=status)
st = store or get_store()
row = get_tool(tool_code, ctx=ctx, store=st, tenant_id=tenant_id)
if row is None:
fail(E_NOT_FOUND, "工具未注册: %s" % tool_code, tool_code=tool_code)
# 禁用→启用强制人工审批tool_registry_change
if row["status"] == TOOL_STATUS_DISABLED and status == TOOL_STATUS_ENABLED:
from .m4a_approval import find_approved
ap = find_approved("tool_registry_change", tool_code, ctx=ctx, store=st,
tenant_id=tenant_id, approval_no=reason)
if ap is None:
fail(E_STATE_ILLEGAL,
"启用禁用工具属 tool_registry_change 强制人工审批,缺少 approved 审批单",
tool_code=tool_code, action_type="tool_registry_change")
if status == TOOL_STATUS_DISABLED and not reason:
fail(E_VALIDATION, "禁用工具必须给出原因(写入 disable_reason",
tool_code=tool_code)
patch = {"status": status, "updated_at": now_ts()}
if status == TOOL_STATUS_DISABLED:
patch["disable_reason"] = reason
st.U("pbl_agent_tool", patch, {"tenant_id": tenant_id, "tool_code": tool_code})
write_audit(ctx, "tool.set_status", "pbl_agent_tool", row.get("id"),
{"tool_code": tool_code, "from": row["status"], "to": status,
"reason": reason}, store=st)
return _decode_tool(get_tool(tool_code, ctx=ctx, store=st, tenant_id=tenant_id))
def seed_tools(ctx=None, store=None):
"""
幂等注入 13 enabled + 9 disabledbuild.sh 调用
tenant_id + tool_code upsert已存在则更新契约字段不重复插入
"""
st = store or get_store()
tenant_id = (ctx.tenant_id if ctx is not None else None) or "__platform__"
inserted, skipped = 0, 0
for t in TOOL_SEED:
row = {
"tenant_id": tenant_id,
"tool_code": t["tool_code"], "tool_name": t["tool_name"],
"tool_group": t["tool_group"], "status": t["status"],
"required_permission": t["required_permission"],
"require_approval": t["require_approval"],
"approval_action_type": t.get("approval_action_type"),
"allowed_agents": dumps(t["allowed_agents"]),
"write_operation": t["write_operation"],
"input_schema": dumps(t["input_schema"]),
"output_schema": dumps(t["output_schema"]),
"backend_mapping": t["backend_mapping"],
"disable_reason": t.get("disable_reason"),
"scope_note": t.get("scope_note"),
"sort_no": t["sort_no"], "created_by": "seed",
"created_at": now_ts(), "updated_at": now_ts(),
}
exist = one(st, "pbl_agent_tool",
{"tenant_id": tenant_id, "tool_code": t["tool_code"]})
if exist:
st.U("pbl_agent_tool", row,
{"tenant_id": tenant_id, "tool_code": t["tool_code"]})
skipped += 1
else:
st.C("pbl_agent_tool", row)
inserted += 1
return {
"inserted": inserted, "skipped": skipped,
"enabled": len(ENABLED_TOOL_CODES), "disabled": len(DISABLED_TOOL_CODES),
"total": len(TOOL_SEED),
"enabled_codes": list(ENABLED_TOOL_CODES),
"disabled_codes": list(DISABLED_TOOL_CODES),
"approval_required": list(APPROVAL_REQUIRED_TOOLS),
}
def registry_stats(ctx=None, store=None):
"""注册表统计(供前端/审计核对 13+9"""
tools = list_tools(ctx=ctx, store=store)
en = [t for t in tools if t["status"] == TOOL_STATUS_ENABLED]
dis = [t for t in tools if t["status"] == TOOL_STATUS_DISABLED]
return {
"total": len(tools), "enabled": len(en), "disabled": len(dis),
"approval_required": [t["tool_code"] for t in en if t.get("require_approval")],
"disabled_missing_reason": [t["tool_code"] for t in dis
if not t.get("disable_reason")],
"critic_writable_tools": [t["tool_code"] for t in en
if t.get("write_operation")
and CRITIC in (t.get("allowed_agents") or [])],
}

View File

@ -0,0 +1,321 @@
# -*- coding: utf-8 -*-
"""
pbl_agent_runtime M4a 表定义四段式summary / fields / indexes / codes
4 张自有表data-model.md §F
pbl_agent_def Agent 定义designer / criticCritic 零写权限
pbl_agent_tool 工具注册表13 enabled + 9 disabled
pbl_agent_trace 执行轨迹 28 7 要素append-only
pbl_agent_approval 人工审批四类强制审批
抽象类型 MySQL 物理类型由 to_sql() 生成所有表 tenant_id 打头并进入联合索引首列
"""
from .m4a_kernel import TRACE_ELEMENTS
# ---------------------------------------------------------------------------
# 抽象类型映射
# ---------------------------------------------------------------------------
TYPE_MAP = {
"pk": "INT NOT NULL AUTO_INCREMENT",
"tenant": "VARCHAR(64) NOT NULL",
"str": "VARCHAR(255)",
"str64": "VARCHAR(64)",
"str128": "VARCHAR(128)",
"code": "VARCHAR(128) NOT NULL",
"text": "TEXT",
"longtext": "LONGTEXT",
"json": "LONGTEXT",
"int": "INT",
"bigint": "BIGINT",
"bool": "TINYINT(1) NOT NULL DEFAULT 0",
"decimal": "DECIMAL(5,4)",
"datetime": "DATETIME",
}
def _f(name, type_, comment, not_null=False, default=None):
return {
"name": name,
"type": type_,
"comment": comment,
"not_null": not_null,
"default": default,
}
# ---------------------------------------------------------------------------
# 1. pbl_agent_def — Agent 定义
# ---------------------------------------------------------------------------
TBL_AGENT_DEF = {
"tblname": "pbl_agent_def",
"summary": "Agent 定义表:本迭代仅 designer / critic 两个(第 13.1 章)。"
"write_allowed=0 表示零写权限Critic14.1 不直接修改 Blueprint",
"fields": [
_f("id", "pk", "主键"),
_f("tenant_id", "tenant", "租户ID强制打头", not_null=True),
_f("agent_code", "code", "Agent 编码designer / critic", not_null=True),
_f("agent_name", "str128", "Agent 名称"),
_f("agent_type", "str64", "类型authoring(设计) / review(评审)"),
_f("loop_pattern", "str128", "循环模式observe_think_propose[_execute]"),
_f("authority_boundary", "text", "权威边界说明Agent 非真相源14.1"),
_f("write_allowed", "bool", "是否允许写操作designer=1 / critic=0", default=0),
_f("allowed_tools", "json", "该 Agent 可调用的 tool_code 白名单JSON 数组)"),
_f("denied_tools", "json", "显式黑名单JSON 数组,优先级高于 allowed"),
_f("model_binding", "json", "模型绑定:{model,capability,fallback}"),
_f("offline_fallback", "str64", "模型不可达兜底策略template_fallback / none"),
_f("status", "str64", "状态enabled / disabled", default="enabled"),
_f("description", "text", "说明"),
_f("created_by", "str64", "创建人"),
_f("created_at", "datetime", "创建时间"),
_f("updated_at", "datetime", "更新时间"),
],
"indexes": [
{"name": "uk_agent_def", "cols": ["tenant_id", "agent_code"], "unique": True},
{"name": "idx_agent_def_status", "cols": ["tenant_id", "status"], "unique": False},
],
"codes": {
"agent_code": ["designer", "critic"],
"status": ["enabled", "disabled"],
"write_allowed": [0, 1],
"offline_fallback": ["template_fallback", "none"],
},
"append_only": False,
}
# ---------------------------------------------------------------------------
# 2. pbl_agent_tool — 工具注册表
# ---------------------------------------------------------------------------
TBL_AGENT_TOOL = {
"tblname": "pbl_agent_tool",
"summary": "Agent 工具注册表:第 31 章裁剪子集13 enabled + 9 disabled"
"fail-closed default-deny仅本表 enabled 记录可被 invoke_tool 执行;"
"disabled 记录保留 disable_reason 供审计与拒绝回执。",
"fields": [
_f("id", "pk", "主键"),
_f("tenant_id", "tenant", "租户ID强制打头", not_null=True),
_f("tool_code", "code", "工具编码,如 blueprint.create", not_null=True),
_f("tool_name", "str128", "工具名称"),
_f("tool_group", "str64", "分组blueprint/template/validation/critic/trace/"
"approval/compile/kdb/publish/marketplace/experiments/agent/world"),
_f("status", "str64", "状态enabled / disabled", not_null=True, default="disabled"),
_f("required_permission", "str128", "所需权限域pbl_authoring / agent_tools / "
"publishing / kdb / platform_admin"),
_f("require_approval", "bool", "是否强制人工审批14.2", default=0),
_f("approval_action_type", "str64", "强制审批类别publish / compile_execute / "
"blueprint_approve / tool_registry_change"),
_f("allowed_agents", "json", "允许调用的 agent_code 列表JSON 数组)"),
_f("write_operation", "bool", "是否写操作Critic 零写权限据此拒绝)", default=0),
_f("input_schema", "json", "入参 JSON SchemaS8 契约校验)"),
_f("output_schema", "json", "出参 JSON Schema"),
_f("backend_mapping", "str", "后端权威服务映射,如 "
"pbl_blueprint.create_blueprint"),
_f("disable_reason", "text", "禁用原因disabled 时必填out_of_scope 锚点)"),
_f("scope_note", "text", "范围备注Phase / owner 决策锚点)"),
_f("sort_no", "int", "排序号"),
_f("created_by", "str64", "创建人"),
_f("created_at", "datetime", "创建时间"),
_f("updated_at", "datetime", "更新时间"),
],
"indexes": [
{"name": "uk_agent_tool", "cols": ["tenant_id", "tool_code"], "unique": True},
{"name": "idx_tool_status", "cols": ["tenant_id", "status"], "unique": False},
{"name": "idx_tool_group", "cols": ["tenant_id", "tool_group"], "unique": False},
],
"codes": {
"status": ["enabled", "disabled"],
"require_approval": [0, 1],
"write_operation": [0, 1],
"approval_action_type": [None, "publish", "compile_execute",
"blueprint_approve", "tool_registry_change"],
"required_permission": ["pbl_authoring", "agent_tools", "publishing",
"kdb", "platform_admin"],
},
"append_only": False,
}
# ---------------------------------------------------------------------------
# 3. pbl_agent_trace — 执行轨迹append-only
# ---------------------------------------------------------------------------
_trace_fields = [
_f("id", "pk", "主键"),
_f("tenant_id", "tenant", "租户ID强制打头", not_null=True),
_f("trace_no", "code", "轨迹编号(业务主键)", not_null=True),
_f("agent_code", "str64", "产生轨迹的 Agentdesigner / critic"),
_f("session_no", "str128", "会话号(多轮对话归并)"),
_f("status", "str64", "轨迹状态open / done / deny", default="open"),
_f("step_reached", "str64", "裁决链到达步S1~S8 / EXECUTED"),
_f("deny_code", "str64", "拒绝错误码(被拒时填)"),
_f("deny_reason", "text", "拒绝原因(含 disable_reason / 越权说明)"),
_f("input_context", "json", "① 输入上下文"),
_f("retrieved_knowledge", "json", "② 检索到的知识(含 KDB 桩降级路径)"),
_f("tool_calls", "json", "③ 工具调用(入参+出参,含被拒调用)"),
_f("proposed_action", "json", "④ 提案动作Agent 只 Propose14.1"),
_f("result", "json", "⑤ 执行结果allow/deny + 结果体)"),
_f("approval", "json", "⑥ 审批信息approval_no/status/approver"),
_f("final_output", "json", "⑦ 最终输出"),
_f("created_by", "str64", "创建人"),
_f("created_at", "datetime", "创建时间"),
_f("appended_at", "datetime", "最后追加时间"),
]
TBL_AGENT_TRACE = {
"tblname": "pbl_agent_trace",
"summary": "Agent 执行轨迹(第 28 章 7 要素US-20 可查不可篡改)。"
"append-only无 update/delete 对外接口DB 层 append_only 标记,"
"要素只能追加已完成done/deny轨迹再追加 → PBL_E_APPEND_ONLY。",
"fields": _trace_fields,
"indexes": [
{"name": "uk_trace_no", "cols": ["tenant_id", "trace_no"], "unique": True},
{"name": "idx_trace_agent", "cols": ["tenant_id", "agent_code", "created_at"],
"unique": False},
{"name": "idx_trace_session", "cols": ["tenant_id", "session_no"], "unique": False},
{"name": "idx_trace_status", "cols": ["tenant_id", "status"], "unique": False},
],
"codes": {
"status": ["open", "done", "deny"],
"agent_code": ["designer", "critic"],
"step_reached": [s[0] for s in ()] + ["S1", "S2", "S3", "S4", "S5", "S6", "S7",
"S8", "EXECUTED"],
},
"append_only": True,
}
# 轨迹要素追加流水append-only 明细,逐条可查,支撑 US-20 完整性举证)
TBL_TRACE_STAGE = {
"tblname": "pbl_agent_trace_stage",
"summary": "轨迹要素追加流水:每次 append_trace 落一行,只增不改不删,"
"用于举证 7 要素逐条可查与追加顺序US-20",
"fields": [
_f("id", "pk", "主键"),
_f("tenant_id", "tenant", "租户ID", not_null=True),
_f("trace_no", "code", "轨迹编号", not_null=True),
_f("stage", "str64", "要素名7 要素之一)", not_null=True),
_f("payload", "json", "要素内容"),
_f("seq_no", "int", "追加序号(同轨迹内自增)"),
_f("created_at", "datetime", "追加时间"),
],
"indexes": [
{"name": "idx_stage_trace", "cols": ["tenant_id", "trace_no", "seq_no"],
"unique": False},
],
"codes": {"stage": list(TRACE_ELEMENTS)},
"append_only": True,
}
# ---------------------------------------------------------------------------
# 4. pbl_agent_approval — 人工审批
# ---------------------------------------------------------------------------
TBL_AGENT_APPROVAL = {
"tblname": "pbl_agent_approval",
"summary": "人工审批单14.2):四类强制审批 publish / compile_execute / "
"blueprint_approve / tool_registry_change。Agent 不可自批:"
"decide_approval 仅 actor_type=user 可调用。",
"fields": [
_f("id", "pk", "主键"),
_f("tenant_id", "tenant", "租户ID强制打头", not_null=True),
_f("approval_no", "code", "审批单号(业务主键)", not_null=True),
_f("trace_id", "int", "关联轨迹ID"),
_f("trace_no", "str128", "关联轨迹编号"),
_f("agent_code", "str64", "提案 Agent"),
_f("action_type", "str64", "审批类别(四类之一)", not_null=True),
_f("tool_code", "str128", "触发审批的工具编码"),
_f("object_type", "str64", "对象类型blueprint / tool / compile_task"),
_f("object_id", "str128", "对象ID"),
_f("action_payload", "json", "提案内容快照(审批人据此判断)"),
_f("status", "str64", "状态pending / approved / rejected / expired",
not_null=True, default="pending"),
_f("requester_type", "str64", "提案方类型agent / user"),
_f("requester_id", "str64", "提案方ID"),
_f("approver_id", "str64", "指定审批人(可空=任一授权人)"),
_f("approver_type", "str64", "实际审批人类型(必须 user"),
_f("decided_by", "str64", "实际审批人ID"),
_f("decided_at", "datetime", "审批时间"),
_f("comment", "text", "审批意见"),
_f("expires_at", "datetime", "过期时间(过期后 pending 视为无效)"),
_f("created_at", "datetime", "创建时间"),
_f("updated_at", "datetime", "更新时间"),
],
"indexes": [
{"name": "uk_approval_no", "cols": ["tenant_id", "approval_no"], "unique": True},
{"name": "idx_approval_pending", "cols": ["tenant_id", "status", "approver_id"],
"unique": False},
{"name": "idx_approval_trace", "cols": ["tenant_id", "trace_no"], "unique": False},
{"name": "idx_approval_action", "cols": ["tenant_id", "action_type", "status"],
"unique": False},
],
"codes": {
"status": ["pending", "approved", "rejected", "expired"],
"action_type": ["publish", "compile_execute", "blueprint_approve",
"tool_registry_change"],
"requester_type": ["agent", "user", "system"],
"approver_type": ["user"],
},
"append_only": False,
}
# ---------------------------------------------------------------------------
# 表清单 + DDL 生成
# ---------------------------------------------------------------------------
TABLES = (TBL_AGENT_DEF, TBL_AGENT_TOOL, TBL_AGENT_TRACE, TBL_TRACE_STAGE,
TBL_AGENT_APPROVAL)
APPEND_ONLY_TABLES = tuple(t["tblname"] for t in TABLES if t.get("append_only"))
def _col_sql(f):
phys = TYPE_MAP.get(f["type"])
if phys is None:
raise ValueError("未知抽象类型: %s(字段 %s" % (f["type"], f["name"]))
if f["type"] == "pk":
return " `id` %s COMMENT '%s'" % (phys, f["comment"])
parts = [" `%s`" % f["name"], phys]
if f.get("not_null") and "NOT NULL" not in phys:
parts.append("NOT NULL")
if f.get("default") is not None:
dv = f["default"]
parts.append("DEFAULT %s" % (dv if isinstance(dv, int) else "'%s'" % dv))
parts.append("COMMENT '%s'" % f["comment"].replace("'", "''"))
return " ".join(parts)
def to_sql(tbl, engine="mysql"):
"""四段式表定义 → CREATE TABLE DDL含 append-only 表注释标记)。"""
lines = ["CREATE TABLE IF NOT EXISTS `%s` (" % tbl["tblname"]]
cols = [_col_sql(f) for f in tbl["fields"]]
if tbl["fields"][0]["type"] == "pk":
cols.append(" PRIMARY KEY (`id`)")
for idx in tbl.get("indexes", []):
kw = "UNIQUE KEY" if idx.get("unique") else "KEY"
cols.append(" %s `%s` (%s)" % (
kw, idx["name"], ", ".join("`%s`" % c for c in idx["cols"])))
lines.append(",\n".join(cols))
comment = tbl["summary"].replace("'", "''")
if tbl.get("append_only"):
comment += " [APPEND-ONLY: 禁止 UPDATE/DELETE]"
lines.append(") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='%s';" % comment)
return "\n".join(lines)
def all_sql():
return "\n\n".join(to_sql(t) for t in TABLES)
def ensure_tables(store=None):
"""
建表幂等sqlor 后端走 DDLMemoryStore 直接建空表
"""
from .m4a_kernel import get_store
st = store or get_store()
created = []
for tbl in TABLES:
try:
st.sqlExe(to_sql(tbl))
created.append(tbl["tblname"])
except NotImplementedError:
st.R(tbl["tblname"]) # MemoryStore触碰即建表
created.append(tbl["tblname"])
except Exception:
# 表已存在等场景,幂等忽略
created.append(tbl["tblname"])
return {"created": created, "append_only": list(APPEND_ONLY_TABLES)}

View File

@ -0,0 +1,267 @@
# -*- coding: utf-8 -*-
"""
pbl_agent_runtime M4a 执行轨迹 28 7 要素US-20 可查不可篡改
约束
* append-only update/delete 对外接口要素只能追加
* 已完成done/deny轨迹再追加 PBL_E_APPEND_ONLY
* get_trace Platform Admin / owner.audit / 轨迹所属 Agent 自身可读
读他人轨迹 PBL_E_FORBIDDEN
* 7 要素齐备性可校验trace_completeness
"""
from .m4a_kernel import (
get_store, one, fail, now_ts, dumps, loads, gen_no,
E_NOT_FOUND, E_FORBIDDEN, E_APPEND_ONLY, E_VALIDATION, E_TENANT_MISSING,
TRACE_ELEMENTS, TRACE_STATUS_OPEN, TRACE_STATUS_DONE, TRACE_STATUS_DENY,
ACTOR_USER,
)
PERM_TRACE_AUDIT = ("platform_admin", "owner.audit")
def _tenant(ctx):
if ctx is None or not getattr(ctx, "tenant_id", None):
fail(E_TENANT_MISSING, "轨迹操作缺少租户上下文")
return str(ctx.tenant_id).strip()
def _can_read(ctx, row):
"""Platform Admin / owner.audit 全量可读Agent 仅可读自身轨迹。"""
if ctx is None:
return False
for p in PERM_TRACE_AUDIT:
if ctx.has_perm(p):
return True
if ctx.actor_type == ACTOR_USER:
return True
# Agent 自身actor_id 记的是 agent_code
if str(ctx.actor_id or "") == str(row.get("agent_code") or ""):
return True
return False
# ---------------------------------------------------------------------------
# 写:开轨迹 / 追加要素
# ---------------------------------------------------------------------------
def start_trace(agent_code, session_no=None, input_context=None, ctx=None,
store=None, trace_no=None):
"""开一条轨迹,写入要素① input_context返回 trace_no。"""
st = store or get_store()
tenant_id = _tenant(ctx)
no = trace_no or gen_no("TRC")
if one(st, "pbl_agent_trace", {"tenant_id": tenant_id, "trace_no": no}):
fail(E_VALIDATION, "trace_no 已存在: %s" % no, trace_no=no)
row = {
"tenant_id": tenant_id, "trace_no": no, "agent_code": agent_code,
"session_no": session_no, "status": TRACE_STATUS_OPEN, "step_reached": None,
"deny_code": None, "deny_reason": None,
"input_context": dumps(input_context or {}),
"retrieved_knowledge": dumps([]),
"tool_calls": dumps([]),
"proposed_action": dumps(None),
"result": dumps(None),
"approval": dumps(None),
"final_output": dumps(None),
"created_by": getattr(ctx, "actor_id", None),
"created_at": now_ts(), "appended_at": now_ts(),
}
tid = st.C("pbl_agent_trace", row)
# 要素流水append-only 明细)
_log_stage(st, tenant_id, no, "input_context", input_context or {}, 1)
return no
def _log_stage(st, tenant_id, trace_no, stage, payload, seq_no):
st.C("pbl_agent_trace_stage", {
"tenant_id": tenant_id, "trace_no": trace_no, "stage": stage,
"payload": dumps(payload), "seq_no": seq_no, "created_at": now_ts(),
})
def _next_seq(st, tenant_id, trace_no):
rows = st.R("pbl_agent_trace_stage",
{"tenant_id": tenant_id, "trace_no": trace_no}, order_by="-seq_no",
limit=1)
return (rows[0].get("seq_no") or 0) + 1 if rows else 1
def append_trace(trace_no, stage, payload, ctx=None, store=None):
"""
追加一个要素append-only
- stage 必须是 7 要素之一否则 PBL_E_VALIDATION
- 轨迹已 done/deny PBL_E_APPEND_ONLY
- tool_calls / retrieved_knowledge 为列表型要素追加即 append 到数组
"""
st = store or get_store()
tenant_id = _tenant(ctx)
if stage not in TRACE_ELEMENTS:
fail(E_VALIDATION, "stage 必须是 7 要素之一: %s" % (TRACE_ELEMENTS,),
stage=stage)
row = one(st, "pbl_agent_trace", {"tenant_id": tenant_id, "trace_no": trace_no})
if row is None:
fail(E_NOT_FOUND, "轨迹不存在: %s" % trace_no, trace_no=trace_no)
if row.get("status") in (TRACE_STATUS_DONE, TRACE_STATUS_DENY):
fail(E_APPEND_ONLY,
"轨迹已完成status=%sappend-only 不可再追加" % row.get("status"),
trace_no=trace_no, stage=stage)
list_stages = ("tool_calls", "retrieved_knowledge")
if stage in list_stages:
cur = loads(row.get(stage), []) or []
if not isinstance(cur, list):
cur = [cur] if cur else []
cur = cur + [payload]
new_val = dumps(cur)
elif stage == "input_context":
cur = loads(row.get(stage), {}) or {}
if isinstance(cur, dict) and isinstance(payload, dict):
merged = dict(cur)
merged.update(payload)
new_val = dumps(merged)
else:
new_val = dumps(payload)
else:
new_val = dumps(payload)
with st.mutable() as raw:
raw.U("pbl_agent_trace", {stage: new_val, "appended_at": now_ts()},
{"tenant_id": tenant_id, "trace_no": trace_no})
_log_stage(st, tenant_id, trace_no, stage, payload,
_next_seq(st, tenant_id, trace_no))
return {"trace_no": trace_no, "stage": stage, "appended_at": now_ts()}
def finish_trace(trace_no, status=TRACE_STATUS_DONE, step_reached=None,
deny_code=None, deny_reason=None, ctx=None, store=None):
"""
收口轨迹状态受控通道非要素修改status: done / deny
收口后轨迹进入 append-only 冻结态
"""
st = store or get_store()
tenant_id = _tenant(ctx)
row = one(st, "pbl_agent_trace", {"tenant_id": tenant_id, "trace_no": trace_no})
if row is None:
fail(E_NOT_FOUND, "轨迹不存在: %s" % trace_no, trace_no=trace_no)
if row.get("status") in (TRACE_STATUS_DONE, TRACE_STATUS_DENY):
return {"trace_no": trace_no, "status": row.get("status"), "already": True}
if status not in (TRACE_STATUS_DONE, TRACE_STATUS_DENY):
fail(E_VALIDATION, "收口状态仅 done/deny", status=status)
patch = {"status": status, "appended_at": now_ts()}
if step_reached:
patch["step_reached"] = step_reached
if deny_code:
patch["deny_code"] = deny_code
if deny_reason:
patch["deny_reason"] = deny_reason
with st.mutable() as raw:
raw.U("pbl_agent_trace", patch,
{"tenant_id": tenant_id, "trace_no": trace_no})
return {"trace_no": trace_no, "status": status, "step_reached": step_reached}
# ---------------------------------------------------------------------------
# 读:单条 / 列表 / 完整性
# ---------------------------------------------------------------------------
def _decode(row):
r = dict(row)
for e in TRACE_ELEMENTS:
default = [] if e in ("tool_calls", "retrieved_knowledge") else None
r[e] = loads(r.get(e), default if default is not None else {})
if e in ("tool_calls", "retrieved_knowledge") and not isinstance(r[e], list):
r[e] = [] if r[e] in (None, "") else [r[e]]
return r
def get_trace(trace_no, ctx=None, store=None, internal=False):
"""
读轨迹7 要素完整
internal=True 供内核invoke_tool使用跳过读权限校验同进程内已鉴权
"""
st = store or get_store()
tenant_id = _tenant(ctx)
row = one(st, "pbl_agent_trace", {"tenant_id": tenant_id, "trace_no": trace_no})
if row is None:
fail(E_NOT_FOUND, "轨迹不存在: %s" % trace_no, trace_no=trace_no)
if not internal and not _can_read(ctx, row):
fail(E_FORBIDDEN,
"无权读取该轨迹(仅 Platform Admin / owner.audit / 轨迹所属 Agent 自身)",
trace_no=trace_no, agent_code=row.get("agent_code"))
return _decode(row)
def trace_completeness(trace_no, ctx=None, store=None):
"""7 要素齐备性核验US-20 举证):返回每要素是否已写入 + 追加流水条数。"""
st = store or get_store()
tenant_id = _tenant(ctx)
row = get_trace(trace_no, ctx=ctx, store=st, internal=True)
stages = st.R("pbl_agent_trace_stage",
{"tenant_id": tenant_id, "trace_no": trace_no}, order_by="seq_no")
filled = {}
for e in TRACE_ELEMENTS:
v = row.get(e)
filled[e] = bool(v not in (None, "", [], {}))
return {
"trace_no": trace_no,
"status": row.get("status"),
"step_reached": row.get("step_reached"),
"elements": TRACE_ELEMENTS,
"filled": filled,
"filled_count": sum(1 for v in filled.values() if v),
"complete": all(filled.values()),
"stage_log_count": len(stages),
"stage_log": [{"seq_no": s.get("seq_no"), "stage": s.get("stage"),
"created_at": s.get("created_at")} for s in stages],
}
def list_traces(filters=None, page=1, size=20, ctx=None, store=None):
"""轨迹分页查询F-AG-04。仅 Platform Admin / owner.audit / 人类用户。"""
st = store or get_store()
tenant_id = _tenant(ctx)
if not _can_read(ctx, {"agent_code": None}):
fail(E_FORBIDDEN, "无权查询轨迹列表(仅 Platform Admin / owner.audit")
filters = filters or {}
where = {"tenant_id": tenant_id}
for k in ("agent_code", "session_no", "status", "step_reached", "deny_code"):
if filters.get(k):
where[k] = filters[k]
rows = st.R("pbl_agent_trace", where, order_by="-id")
tr = filters.get("time_range") or {}
if tr.get("start"):
rows = [r for r in rows if (r.get("created_at") or "") >= tr["start"]]
if tr.get("end"):
rows = [r for r in rows if (r.get("created_at") or "") <= tr["end"]]
total = len(rows)
page = max(1, int(page or 1))
size = max(1, min(200, int(size or 20)))
part = rows[(page - 1) * size: page * size]
return {"items": [_decode(r) for r in part], "total": total,
"page": page, "size": size}
def list_trace_stages(trace_no, ctx=None, store=None):
"""要素追加流水(只读,逐条可查)。"""
st = store or get_store()
tenant_id = _tenant(ctx)
row = one(st, "pbl_agent_trace", {"tenant_id": tenant_id, "trace_no": trace_no})
if row is None:
fail(E_NOT_FOUND, "轨迹不存在: %s" % trace_no, trace_no=trace_no)
if not _can_read(ctx, row):
fail(E_FORBIDDEN, "无权读取该轨迹流水", trace_no=trace_no)
rows = st.R("pbl_agent_trace_stage",
{"tenant_id": tenant_id, "trace_no": trace_no}, order_by="seq_no")
return [{"seq_no": r.get("seq_no"), "stage": r.get("stage"),
"payload": loads(r.get("payload"), None),
"created_at": r.get("created_at")} for r in rows]
def try_mutate_trace(trace_no, patch, ctx=None, store=None):
"""
反向验证入口测试锚点任何对轨迹的直接 update 都必须被 append-only 守卫拒绝
正常业务代码不得调用用于 US-20不可篡改举证
"""
st = store or get_store()
tenant_id = _tenant(ctx)
return st.U("pbl_agent_trace", patch,
{"tenant_id": tenant_id, "trace_no": trace_no})

447
scripts/m4a_selftest.py Normal file
View File

@ -0,0 +1,447 @@
# -*- coding: utf-8 -*-
"""
M4a 自测脚本离线可跑MemoryStore 覆盖本任务四个验收锚点
A. Designer/Critic 定义Critic 零写权限
B. 13 启用 / 9 禁用工具清单注册 pbl.publish 类自主发布禁用
C. fail-closed 8 步裁决顺序default-deny逐步拒绝码正确
D. 四类强制人工审批publish / compile_execute / blueprint_approve /
tool_registry_change
运行python3 scripts/m4a_selftest.py
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from pbl_agent_runtime import m4a as M # noqa: E402
from pbl_agent_runtime import m4a_critic # noqa: E402
from pbl_agent_runtime.m4a_kernel import ( # noqa: E402
MemoryStore, PblError, ADJUDICATION_STEPS, TRACE_ELEMENTS,
E_FORBIDDEN, E_STATE_ILLEGAL, E_TENANT_MISSING, E_VALIDATION,
E_APPEND_ONLY, E_DUPLICATE,
)
PASS, FAIL = [], []
def check(name, cond, detail=""):
(PASS if cond else FAIL).append(name)
print("%s %s%s" % ("[PASS]" if cond else "[FAIL]", name,
(" -> " + str(detail)) if detail and not cond else ""))
def expect_error(name, code, fn):
try:
fn()
except PblError as e:
check(name, e.code == code, "期望 %s 实得 %s(%s)" % (code, e.code, e.message))
return e
except Exception as e:
check(name, False, "非 PblError: %r" % e)
return None
check(name, False, "未抛错(期望 %s" % code)
return None
def main():
store = M.set_store(MemoryStore())
api = M.load_m4a(tenant_id="T001", store=store)
sc = api.self_check()
# ---------------- A. Agent 定义 ----------------
check("A1 仅 designer/critic 两个 Agent", sc["agent_count"] == 2,
sc["agents"])
codes = sorted(a["agent_code"] for a in sc["agents"])
check("A2 agent_code = [critic, designer]", codes == ["critic", "designer"], codes)
critic = [a for a in sc["agents"] if a["agent_code"] == "critic"][0]
designer = [a for a in sc["agents"] if a["agent_code"] == "designer"][0]
check("A3 Critic write_allowed=0零写权限", critic["write_allowed"] == 0,
critic)
check("A4 Designer write_allowed=1", designer["write_allowed"] == 1)
check("A5 Critic 白名单仅只读工具",
set(critic["allowed_tools"]) == set(M.CRITIC_READONLY_TOOLS),
critic["allowed_tools"])
check("A6 sc.critic_write_allowed 为空", sc["critic_write_allowed"] == [])
check("A7 registry 无 critic 可写工具",
sc["tools"]["critic_writable_tools"] == [],
sc["tools"]["critic_writable_tools"])
# ---------------- B. 工具注册表 13+9 ----------------
check("B1 工具总数 22", sc["tools"]["total"] == 22, sc["tools"]["total"])
check("B2 启用 13", sc["tools"]["enabled"] == 13, sc["tools"]["enabled"])
check("B3 禁用 9", sc["tools"]["disabled"] == 9, sc["tools"]["disabled"])
check("B4 禁用项 disable_reason 齐备",
sc["tools"]["disabled_missing_reason"] == [],
sc["tools"]["disabled_missing_reason"])
check("B5 require_approval 工具 = compile.trigger + publish.request",
sorted(sc["tools"]["approval_required"]) ==
["compile.trigger", "publish.request"],
sc["tools"]["approval_required"])
check("B6 自主发布工具 blueprint.publish_auto 已注册且禁用",
any(t["tool_code"] == "blueprint.publish_auto" and
t["status"] == "disabled" for t in api.list_tools()))
dis = {t["tool_code"]: t for t in api.list_tools(status="disabled")}
expect9 = ["blueprint.publish_auto", "curriculum.modify_auto",
"marketplace.create_listing", "kdb.write", "research.collect",
"experiment.ab_run", "agent.mentor_invoke", "world.edit_3d",
"billing.charge"]
check("B7 9 个禁用工具编码完全匹配", sorted(dis) == sorted(expect9),
sorted(dis))
check("B8 kdb.write 禁用Q5 KDB 只读)",
"KDB 只读" in (dis["kdb.write"]["disable_reason"] or ""))
check("B9 research.collect 禁用Q6 禁采集学生数据)",
"research" in (dis["research.collect"]["disable_reason"] or "").lower())
en = {t["tool_code"] for t in api.list_tools(status="enabled")}
check("B10 13 启用工具编码匹配", en == set(M.ENABLED_TOOL_CODES) and len(en) == 13,
sorted(en))
# seed 幂等
r1 = M.seed_tools(ctx=api.admin_ctx(), store=store)
check("B11 seed_tools 幂等(第二次 inserted=0/skipped=22",
r1["inserted"] == 0 and r1["skipped"] == 22, r1)
# ---------------- C. fail-closed 8 步裁决 ----------------
check("C1 裁决链 8 步", len(ADJUDICATION_STEPS) == 8,
[s[0] for s in ADJUDICATION_STEPS])
check("C2 裁决顺序 = S1..S8",
[s[0] for s in ADJUDICATION_STEPS] ==
["S1", "S2", "S3", "S4", "S5", "S6", "S7", "S8"],
M.explain_chain())
dctx = api.agent_ctx("designer")
# S1 租户缺失
noctx = M.TenantContext(tenant_id=None, actor_type="agent", actor_id="designer",
permissions={M.PERM_AUTHORING})
v = M.adjudicate(noctx, "designer", "blueprint.get", {"blueprint_id": 1},
store=store)
check("C3 S1 租户缺失 → PBL_E_TENANT_MISSING",
not v.allowed and v.step == "S1" and v.reason_code == E_TENANT_MISSING,
v.to_dict())
# S2 Agent 未注册
v = M.adjudicate(dctx, "mentor", "blueprint.get", {"blueprint_id": 1}, store=store)
check("C4 S2 未注册 Agent(mentor) → PBL_E_FORBIDDEN",
not v.allowed and v.step == "S2" and v.reason_code == E_FORBIDDEN,
v.to_dict())
# S3 工具未注册default-deny
v = M.adjudicate(dctx, "designer", "blueprint.delete", {}, store=store)
check("C5 S3 白名单外工具 → PBL_E_FORBIDDEN(default-deny)",
not v.allowed and v.step == "S3" and v.reason_code == E_FORBIDDEN,
v.to_dict())
# S4 禁用工具
v = M.adjudicate(dctx, "designer", "blueprint.publish_auto", {}, store=store)
check("C6 S4 禁用工具 → PBL_E_FORBIDDEN + disable_reason",
not v.allowed and v.step == "S4" and v.reason_code == E_FORBIDDEN
and v.tool.get("disable_reason"), v.to_dict())
v = M.adjudicate(dctx, "designer", "kdb.write", {}, store=store)
check("C7 S4 kdb.write 禁用拒绝", not v.allowed and v.step == "S4")
v = M.adjudicate(dctx, "designer", "world.edit_3d", {}, store=store)
check("C8 S4 world.edit_3d 禁用拒绝Q1 无 3D 编辑器)",
not v.allowed and v.step == "S4")
# S5 越权
narrow = M.make_agent_ctx("T001", "designer", permissions={"agent_tools"})
v = M.adjudicate(narrow, "designer", "blueprint.create",
{"intent_text": "x", "title": "y"}, store=store)
check("C9 S5 缺 pbl_authoring 权限域 → PBL_E_FORBIDDEN",
not v.allowed and v.step == "S5" and v.reason_code == E_FORBIDDEN,
v.to_dict())
# S6 Critic 零写权限
cctx = api.agent_ctx("critic")
for tc in ("blueprint.create", "blueprint.update", "template.copy",
"compile.trigger", "publish.request"):
v = M.adjudicate(cctx, "critic", tc, {"blueprint_id": 1}, store=store)
check("C10.%s Critic 调写工具被 S6 拒绝" % tc,
not v.allowed and v.step == "S6" and v.reason_code == E_FORBIDDEN,
v.to_dict())
v = M.adjudicate(cctx, "critic", "blueprint.get", {"blueprint_id": 1},
store=store)
check("C11 Critic 调只读 blueprint.get 通过 S6",
v.allowed or v.step in ("S8",), v.to_dict())
# S6 designer 越界调 critic.review
v = M.adjudicate(dctx, "designer", "critic.review", {"blueprint_id": 1},
store=store)
check("C12 S6 designer 调 critic.review 被拒allowed_agents 限定)",
not v.allowed and v.step == "S6", v.to_dict())
# S7 需审批未批
v = M.adjudicate(dctx, "designer", "publish.request",
{"blueprint_id": 7, "visibility": "org"}, store=store)
check("C13 S7 publish.request 无审批 → PBL_E_STATE_ILLEGAL",
not v.allowed and v.step == "S7" and v.reason_code == E_STATE_ILLEGAL,
v.to_dict())
v = M.adjudicate(dctx, "designer", "compile.trigger",
{"blueprint_id": 7, "version_no": 1,
"compiler_version": "v1"}, store=store)
check("C14 S7 compile.trigger 无审批 → PBL_E_STATE_ILLEGAL",
not v.allowed and v.step == "S7" and v.reason_code == E_STATE_ILLEGAL,
v.to_dict())
# S8 入参契约
v = M.adjudicate(dctx, "designer", "blueprint.create", {"intent_text": "x"},
store=store)
check("C15 S8 缺 title → PBL_E_VALIDATION",
not v.allowed and v.step == "S8" and v.reason_code == E_VALIDATION,
v.to_dict())
v = M.adjudicate(dctx, "designer", "blueprint.create",
{"intent_text": "x", "title": "y",
"generation_source": "bogus"}, store=store)
check("C16 S8 枚举外取值 → PBL_E_VALIDATION",
not v.allowed and v.step == "S8" and v.reason_code == E_VALIDATION,
v.to_dict())
v = M.adjudicate(dctx, "designer", "publish.request",
{"blueprint_id": 7, "visibility": "galaxy"}, store=store)
check("C16b S7 先于 S8审批门禁优先fail-closed",
not v.allowed and v.step == "S7"
and v.reason_code == E_STATE_ILLEGAL, v.to_dict())
v = M.adjudicate(dctx, "designer", "blueprint.create",
{"intent_text": "x", "title": "y"}, store=store)
check("C17 全通过 → allowed=Truestep=S8",
v.allowed and v.step == "S8", v.to_dict())
# kdb.search 空结果不报错Q5/US-24
r = api.invoke_tool("designer", "kdb.search", {"query": "海洋保护"})
check("C18 kdb.search 桩返回空集不报错",
r.get("stub") is True and r.get("total") == 0 and r.get("items") == [], r)
# 被拒调用也落轨迹US-20
try:
api.invoke_tool("designer", "blueprint.publish_auto", {})
except PblError:
pass
traces = api.list_traces({"agent_code": "designer"}, size=50)
deny_traces = [t for t in traces["items"] if t["status"] == "deny"]
check("C19 被拒调用写入 deny 轨迹", len(deny_traces) >= 1,
traces["total"])
dt = deny_traces[0]
check("C20 deny 轨迹含 proposed_action + result.deny",
bool(dt.get("proposed_action")) and
(dt.get("result") or {}).get("deny") is True, dt.get("result"))
check("C21 deny 轨迹记录 step_reached=S4",
dt.get("step_reached") == "S4", dt.get("step_reached"))
# ---------------- D. 四类强制人工审批 ----------------
check("D1 四类审批类型齐备",
sorted(M.MANDATORY_APPROVAL_TYPES) ==
sorted(["publish", "compile_execute", "blueprint_approve",
"tool_registry_change"]),
M.MANDATORY_APPROVAL_TYPES)
mx = M.mandatory_approval_matrix()
check("D2 审批矩阵 4 条", len(mx) == 4, len(mx))
check("D3 矩阵含 publish 触发工具 publish.request",
any(m["action_type"] == "publish" and
m["trigger_tool"] == "publish.request" for m in mx))
# Agent 自批被拒
ap = api.request_approval("publish", object_id=7, tool_code="publish.request",
object_type="blueprint",
action_payload={"visibility": "org"})
check("D4 发起审批返回 pending", ap["status"] == "pending", ap)
expect_error("D5 Agent 自批被拒PBL_E_FORBIDDEN", E_FORBIDDEN,
lambda: M.decide_approval(api.agent_ctx("designer"),
ap["approval_no"], "approved",
store=store))
# 人类批准
d = api.decide_approval(ap["approval_no"], "approved", "teacher_01",
comment="同意发布到机构")
check("D6 人类批准后 status=approved", d["status"] == "approved", d)
check("D7 approver_type=user", d.get("approver_type") == "user", d)
# 已决不可再决
expect_error("D8 已决审批单不可重复决定", E_STATE_ILLEGAL,
lambda: api.decide_approval(ap["approval_no"], "rejected",
"teacher_02"))
# 批准后 S7 通过
v = M.adjudicate(dctx, "designer", "publish.request",
{"blueprint_id": 7, "visibility": "org"},
approval_no=ap["approval_no"], store=store)
check("D9 有 approved 记录后 publish.request 通过 S7",
v.allowed and v.approval and
v.approval["approval_no"] == ap["approval_no"], v.to_dict())
# compile_execute 未批仍拒
v = M.adjudicate(dctx, "designer", "compile.trigger",
{"blueprint_id": 7, "version_no": 1,
"compiler_version": "v1"}, store=store)
check("D10 publish 审批不能顶替 compile_execute 审批",
not v.allowed and v.step == "S7", v.to_dict())
# tool_registry_change禁用→启用须审批
expect_error("D11 无审批启用禁用工具 → PBL_E_STATE_ILLEGAL", E_STATE_ILLEGAL,
lambda: api.set_tool_status("kdb.write", "enabled",
admin_id="admin"))
ap2 = api.request_approval("tool_registry_change", object_id="kdb.search",
tool_code="kdb.search", object_type="tool",
action_payload={"to": "enabled"})
api.decide_approval(ap2["approval_no"], "approved", "admin")
# 非 admin 不能改注册表
expect_error("D12 非 Platform Admin 改注册表 → PBL_E_FORBIDDEN", E_FORBIDDEN,
lambda: M.set_tool_status(api.user_ctx("teacher_01"),
"kdb.search", "disabled",
reason="test", store=store))
# Agent 不能改注册表
expect_error("D13 Agent 改注册表 → PBL_E_FORBIDDEN", E_FORBIDDEN,
lambda: M.set_tool_status(api.agent_ctx("designer"),
"kdb.search", "disabled",
reason="test", store=store))
r = api.set_tool_status("kdb.search", "disabled", reason="临时下线(测试)",
admin_id="admin")
check("D14 Platform Admin 可禁用工具", r["status"] == "disabled", r)
v = M.adjudicate(dctx, "designer", "kdb.search", {"query": "x"}, store=store)
check("D15 禁用后 S4 立即拒绝", not v.allowed and v.step == "S4", v.to_dict())
ap3 = api.request_approval("tool_registry_change", object_id="kdb.search",
tool_code="kdb.search", object_type="tool")
api.decide_approval(ap3["approval_no"], "approved", "admin")
r = api.set_tool_status("kdb.search", "enabled",
approval_no=ap3["approval_no"], admin_id="admin")
check("D16 审批通过后启用成功", r["status"] == "enabled", r)
# register_tool 重复
expect_error("D17 重复注册 → PBL_E_DUPLICATE", E_DUPLICATE,
lambda: M.register_tool(api.admin_ctx("admin"), "kdb.search",
"x", "kdb", "enabled", "kdb",
store=store))
expect_error("D18 禁用注册缺 disable_reason → PBL_E_VALIDATION", E_VALIDATION,
lambda: M.register_tool(api.admin_ctx("admin"), "foo.bar",
"x", "foo", "disabled", "pbl_authoring",
store=store))
# ---------------- E. 轨迹 append-only + 7 要素 ----------------
check("E1 7 要素定义齐备", list(TRACE_ELEMENTS) ==
["input_context", "retrieved_knowledge", "tool_calls",
"proposed_action", "result", "approval", "final_output"],
TRACE_ELEMENTS)
tn = M.start_trace("designer", session_no="S1",
input_context={"api": "unit"}, ctx=dctx, store=store)
for e in ("retrieved_knowledge", "tool_calls", "proposed_action", "result",
"approval", "final_output"):
M.append_trace(tn, e, {"k": e}, ctx=dctx, store=store)
comp = api.trace_completeness(tn)
check("E2 7 要素全部写入 complete=True", comp["complete"] is True, comp["filled"])
check("E3 要素流水 7 条", comp["stage_log_count"] == 7, comp["stage_log_count"])
M.finish_trace(tn, status="done", step_reached="EXECUTED", ctx=dctx, store=store)
expect_error("E4 已完成轨迹追加 → PBL_E_APPEND_ONLY", E_APPEND_ONLY,
lambda: M.append_trace(tn, "result", {"hack": 1}, ctx=dctx,
store=store))
expect_error("E5 直接 update 轨迹被 append-only 守卫拒绝", E_APPEND_ONLY,
lambda: M.try_mutate_trace(tn, {"status": "open"}, ctx=dctx,
store=store))
expect_error("E6 非法 stage → PBL_E_VALIDATION", E_VALIDATION,
lambda: M.append_trace(tn, "not_a_stage", {}, ctx=dctx,
store=store))
# 读权限
other = M.make_agent_ctx("T001", "critic")
t2 = M.start_trace("designer", session_no="S2", input_context={},
ctx=dctx, store=store)
expect_error("E7 他方 Agent 读轨迹 → PBL_E_FORBIDDEN", E_FORBIDDEN,
lambda: M.get_trace(t2, ctx=other, store=store))
check("E8 Platform Admin 可读全量轨迹",
M.get_trace(t2, ctx=api.admin_ctx(), store=store)["trace_no"] == t2)
# 跨租户隔离
api2 = M.load_m4a(tenant_id="T002", store=store)
expect_error("E9 跨租户读轨迹 → PBL_E_NOT_FOUND", M.E_NOT_FOUND,
lambda: M.get_trace(t2, ctx=api2.agent_ctx("designer"),
store=store))
# ---------------- F. Designer / Critic 运行时 ----------------
slots, missing = M.parse_intent("为12岁学生设计一个海洋保护主题的PBL"
"4人一组共8课时产出一段视频预算5万")
check("F1 意图解析 age=12", slots["age"] == 12, slots)
check("F2 意图解析 teamSize=4", slots["teamSize"] == 4, slots)
check("F3 意图解析 duration=360", slots["duration"] == 360, slots)
check("F4 意图解析 artifact=video", slots["artifact"] == "video", slots)
check("F5 意图解析 budget=50000", slots["budget"] == 50000, slots)
check("F6 四要素齐备 → missing 为空", missing == [], missing)
_, miss2 = M.parse_intent("做一个关于火星的项目")
check("F7 信息不足 → missing 含四实质字段",
set(miss2) == set(M.CLARIFY_FIELDS), miss2)
ch, _ = M.instruction_to_changes("把预算增加到500万")
check("F8 指令解析为结构化变更 budget=5000000",
len(ch) == 1 and ch[0]["slot"] == "budget"
and ch[0]["value"] == 5000000, ch)
ch2, _ = M.instruction_to_changes("把团队规模改成6人")
check("F9 指令解析 teamSize=6",
any(c["slot"] == "teamSize" and c["value"] == 6 for c in ch2), ch2)
ch3, _ = M.instruction_to_changes("写得更有诗意一些")
check("F10 无法结构化 → 空变更(禁止整篇重写 US-03", ch3 == [], ch3)
q = M.build_clarifications(["age", "duration"], round_no=1)
check("F11 clarify 只问实质缺失字段", len(q) == 2 and
{x["field"] for x in q} == {"age", "duration"}, q)
q4 = M.build_clarifications(["age"], round_no=4)
check("F12 轮次>3 不再追问,给默认假设",
q4 and q4[0].get("exceeded_max_rounds") is True and
q4[0]["question"] is None, q4)
q_ign = M.designer_clarify(None, ["age", "favorite_color"], ctx=dctx,
store=store, session_no="S9", round_no=1)
check("F13 非实质字段被忽略",
q_ign["ignored_fields"] == ["favorite_color"] and
len(q_ign["questions"]) == 1, q_ign)
# Designer 生成(后端未挂载 → 离线兜底 template_fallback非错误
res = api.designer_generate("为12岁学生设计海洋保护PBL4人一组8课时产出视频",
owner_teacher_id="teacher_01",
class_id="C01", session_no="SG1")
check("F14 designer_generate 返回 trace_no", bool(res.get("trace_no")), res)
check("F15 后端未挂载时自动兜底 template_fallbackUS-05 不抛错)",
res.get("generation_source") == "template_fallback", res)
check("F16 兜底提案落 pending_backendAgent 不直连 DB",
res.get("pending_backend") is True or res.get("blueprint_id") is not None
or bool(res.get("code")), res)
comp2 = api.trace_completeness(res["trace_no"])
check("F17 Designer 轨迹含 input_context/retrieved_knowledge/final_output",
comp2["filled"]["input_context"] and
comp2["filled"]["retrieved_knowledge"] and
comp2["filled"]["final_output"], comp2["filled"])
# Critic零写权限 + 四要素
sug = M.assert_suggestion({"recommendation": "补充时长", "reason": "缺 duration",
"evidence": {"field": "duration"}, "confidence": 0.8})
check("F18 四要素齐备建议通过校验", sug["confidence"] == 0.8, sug)
expect_error("F19 缺 evidence → PBL_E_VALIDATION14.3", E_VALIDATION,
lambda: M.assert_suggestion({"recommendation": "a",
"reason": "b", "confidence": 0.5}))
expect_error("F20 confidence 越界 → PBL_E_VALIDATION", E_VALIDATION,
lambda: M.assert_suggestion({"recommendation": "a", "reason": "b",
"evidence": {}, "confidence": 1.5}))
wa = M.critic_write_attempt(api.agent_ctx("critic"), "blueprint.update",
{"blueprint_id": 1, "instruction": "x",
"target_changes": [{"object_type": "scene",
"field": "budget",
"value": 1}]},
store=store)
check("F21 Critic 写蓝图尝试被拒14.1",
wa["allowed"] is False and wa["verdict"] == "PASS"
and wa["step_reached"] == "S6", wa)
wa2 = M.critic_write_attempt(api.agent_ctx("critic"), "publish.request",
{"blueprint_id": 1, "visibility": "org"},
store=store)
check("F22 Critic 发布尝试被拒", wa2["allowed"] is False, wa2)
rules = m4a_critic._rule_suggestions(
{"slots": {"age": 12}},
{"dimensions": [{"dimension_code": "D01", "result": "fail",
"message": "缺学习目标"}],
"alerts": ["时长与阶段数不匹配"], "run": {"run_no": "R1"}})
check("F23 规则式建议覆盖 fail 维度 + 告警 + 缺失槽位",
len(rules) >= 3 and all(
all(k in r for k in M.SUGGESTION_FIELDS) for r in rules), rules)
# ---------------- G. DDL / 表定义 ----------------
sql = M.all_sql()
check("G1 DDL 含 4 张主表 + 流水表",
all(t in sql for t in ("pbl_agent_def", "pbl_agent_tool",
"pbl_agent_trace", "pbl_agent_approval",
"pbl_agent_trace_stage")))
check("G2 append-only 表带标记",
"[APPEND-ONLY" in sql and
sorted(M.APPEND_ONLY_TABLES) ==
["pbl_agent_trace", "pbl_agent_trace_stage"], M.APPEND_ONLY_TABLES)
check("G3 每表 tenant_id 打头(首个业务列)",
all(t["fields"][1]["name"] == "tenant_id" for t in M.TABLES))
check("G4 联合唯一索引以 tenant_id 起首",
all(idx["cols"][0] == "tenant_id"
for t in M.TABLES for idx in t["indexes"] if idx.get("unique")))
print("\n===== M4a 自测汇总PASS=%d FAIL=%d =====" % (len(PASS), len(FAIL)))
if FAIL:
for f in FAIL:
print(" FAILED: %s" % f)
return 1
print("全部通过(%d 项断言)" % len(PASS))
return 0
if __name__ == "__main__":
sys.exit(main())

89
scripts/seed_m4a.py Normal file
View File

@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-
"""
M4a seed 脚本build.sh / 部署时调用幂等
用法
python3 scripts/seed_m4a.py # 平台级 seedtenant_id=__platform__
python3 scripts/seed_m4a.py --tenant T001 # 指定租户 seed
python3 scripts/seed_m4a.py --memory # 离线内存库演练(不连 DB
动作建表4 主表 + 1 append-only 流水表 seed_agentsdesigner/critic
seed_tools13 enabled + 9 disabled 打印自检结果
"""
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from pbl_agent_runtime.m4a_init import load_pbl_agent_runtime_m4a # noqa: E402
from pbl_agent_runtime.m4a_kernel import get_store, MemoryStore, set_store # noqa: E402
from pbl_agent_runtime.m4a import M4aApi, make_admin_ctx # noqa: E402
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--tenant", default=None, help="租户ID缺省=平台级 __platform__")
ap.add_argument("--memory", action="store_true", help="使用内存库演练(不连 DB")
args = ap.parse_args()
store = None
if args.memory:
store = set_store(MemoryStore())
else:
store = get_store()
ret = load_pbl_agent_runtime_m4a(tenant_id=args.tenant, store=store)
api = M4aApi(tenant_id=args.tenant or "__platform__", store=store)
sc = api.self_check()
print("=" * 72)
print("pbl_agent_runtime M4a seed 结果")
print("=" * 72)
print("module : %s (%s)" % (ret["module"], ret["part"]))
print("dbname : %s" % ret["dbname"])
print("tables : %s" % ", ".join(t for t in ret["tables"]["created"]))
print("append_only : %s" % ", ".join(ret["append_only_tables"]))
print("agents seed : %s" % json.dumps(ret["seed"]["agents"],
ensure_ascii=False))
print("tools seed : inserted=%s skipped=%s enabled=%s disabled=%s total=%s"
% (ret["seed"]["tools"]["inserted"], ret["seed"]["tools"]["skipped"],
ret["seed"]["tools"]["enabled"], ret["seed"]["tools"]["disabled"],
ret["seed"]["tools"]["total"]))
print("enabled (13) : %s" % ", ".join(ret["enabled_tools"]))
print("disabled (9) : %s" % ", ".join(ret["disabled_tools"]))
print("approval needed : %s" % ", ".join(ret["approval_required_tools"]))
print("-" * 72)
print("self_check.agents : %s"
% json.dumps([{"code": a["agent_code"], "write": a["write_allowed"]}
for a in sc["agents"]], ensure_ascii=False))
print("self_check.critic_write : %s (须为空=Critic 零写权限)"
% sc["critic_write_allowed"])
print("self_check.tools : %s" % json.dumps(
{k: v for k, v in sc["tools"].items()
if k in ("total", "enabled", "disabled", "approval_required",
"disabled_missing_reason", "critic_writable_tools")},
ensure_ascii=False))
print("self_check.adjudication : %s (%d 步)"
% ("".join(sc["adjudication_steps"]), sc["adjudication_step_count"]))
print("self_check.approvals : %s" % ", ".join(sc["mandatory_approvals"]))
print("self_check.trace_elements: %s" % ", ".join(sc["trace_elements"]))
print("-" * 72)
ok = (sc["agent_count"] == 2 and not sc["critic_write_allowed"]
and sc["tools"]["total"] == 22 and sc["tools"]["enabled"] == 13
and sc["tools"]["disabled"] == 9
and not sc["tools"]["disabled_missing_reason"]
and not sc["tools"]["critic_writable_tools"]
and sc["adjudication_step_count"] == 8
and len(sc["mandatory_approvals"]) == 4
and len(sc["trace_elements"]) == 7)
print("M4a seed 门禁: %s" % ("PASS" if ok else "FAIL"))
print("backend availability: %s" % json.dumps(
{k: v["available"] for k, v in sc["backend"].items()}, ensure_ascii=False))
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())

123
sql/m4a_ddl.sql Normal file
View File

@ -0,0 +1,123 @@
-- pbl_agent_runtime M4a DDL自动生成勿手改
-- 生成源pbl_agent_runtime/m4a_tables.py四段式表定义 → to_sql
-- 表pbl_agent_def / pbl_agent_tool / pbl_agent_trace(append-only) / pbl_agent_trace_stage(append-only) / pbl_agent_approval
-- 工具注册表 seed13 enabled + 9 disabledscripts/seed_m4a.py 幂等注入build.sh 调用)
CREATE TABLE IF NOT EXISTS `pbl_agent_def` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` VARCHAR(64) NOT NULL COMMENT '租户ID强制打头',
`agent_code` VARCHAR(128) NOT NULL COMMENT 'Agent 编码designer / critic',
`agent_name` VARCHAR(128) COMMENT 'Agent 名称',
`agent_type` VARCHAR(64) COMMENT '类型authoring(设计) / review(评审)',
`loop_pattern` VARCHAR(128) COMMENT '循环模式observe_think_propose[_execute]',
`authority_boundary` TEXT COMMENT '权威边界说明Agent 非真相源14.1',
`write_allowed` TINYINT(1) NOT NULL DEFAULT 0 DEFAULT 0 COMMENT '是否允许写操作designer=1 / critic=0',
`allowed_tools` LONGTEXT COMMENT '该 Agent 可调用的 tool_code 白名单JSON 数组)',
`denied_tools` LONGTEXT COMMENT '显式黑名单JSON 数组,优先级高于 allowed',
`model_binding` LONGTEXT COMMENT '模型绑定:{model,capability,fallback}',
`offline_fallback` VARCHAR(64) COMMENT '模型不可达兜底策略template_fallback / none',
`status` VARCHAR(64) DEFAULT 'enabled' COMMENT '状态enabled / disabled',
`description` TEXT COMMENT '说明',
`created_by` VARCHAR(64) COMMENT '创建人',
`created_at` DATETIME COMMENT '创建时间',
`updated_at` DATETIME COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_agent_def` (`tenant_id`, `agent_code`),
KEY `idx_agent_def_status` (`tenant_id`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Agent 定义表:本迭代仅 designer / critic 两个(第 13.1 章。write_allowed=0 表示零写权限Critic14.1 不直接修改 Blueprint';
CREATE TABLE IF NOT EXISTS `pbl_agent_tool` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` VARCHAR(64) NOT NULL COMMENT '租户ID强制打头',
`tool_code` VARCHAR(128) NOT NULL COMMENT '工具编码,如 blueprint.create',
`tool_name` VARCHAR(128) COMMENT '工具名称',
`tool_group` VARCHAR(64) COMMENT '分组blueprint/template/validation/critic/trace/approval/compile/kdb/publish/marketplace/experiments/agent/world',
`status` VARCHAR(64) NOT NULL DEFAULT 'disabled' COMMENT '状态enabled / disabled',
`required_permission` VARCHAR(128) COMMENT '所需权限域pbl_authoring / agent_tools / publishing / kdb / platform_admin',
`require_approval` TINYINT(1) NOT NULL DEFAULT 0 DEFAULT 0 COMMENT '是否强制人工审批14.2',
`approval_action_type` VARCHAR(64) COMMENT '强制审批类别publish / compile_execute / blueprint_approve / tool_registry_change',
`allowed_agents` LONGTEXT COMMENT '允许调用的 agent_code 列表JSON 数组)',
`write_operation` TINYINT(1) NOT NULL DEFAULT 0 DEFAULT 0 COMMENT '是否写操作Critic 零写权限据此拒绝)',
`input_schema` LONGTEXT COMMENT '入参 JSON SchemaS8 契约校验)',
`output_schema` LONGTEXT COMMENT '出参 JSON Schema',
`backend_mapping` VARCHAR(255) COMMENT '后端权威服务映射,如 pbl_blueprint.create_blueprint',
`disable_reason` TEXT COMMENT '禁用原因disabled 时必填out_of_scope 锚点)',
`scope_note` TEXT COMMENT '范围备注Phase / owner 决策锚点)',
`sort_no` INT COMMENT '排序号',
`created_by` VARCHAR(64) COMMENT '创建人',
`created_at` DATETIME COMMENT '创建时间',
`updated_at` DATETIME COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_agent_tool` (`tenant_id`, `tool_code`),
KEY `idx_tool_status` (`tenant_id`, `status`),
KEY `idx_tool_group` (`tenant_id`, `tool_group`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Agent 工具注册表:第 31 章裁剪子集13 enabled + 9 disabled。fail-closed default-deny仅本表 enabled 记录可被 invoke_tool 执行disabled 记录保留 disable_reason 供审计与拒绝回执。';
CREATE TABLE IF NOT EXISTS `pbl_agent_trace` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` VARCHAR(64) NOT NULL COMMENT '租户ID强制打头',
`trace_no` VARCHAR(128) NOT NULL COMMENT '轨迹编号(业务主键)',
`agent_code` VARCHAR(64) COMMENT '产生轨迹的 Agentdesigner / critic',
`session_no` VARCHAR(128) COMMENT '会话号(多轮对话归并)',
`status` VARCHAR(64) DEFAULT 'open' COMMENT '轨迹状态open / done / deny',
`step_reached` VARCHAR(64) COMMENT '裁决链到达步S1~S8 / EXECUTED',
`deny_code` VARCHAR(64) COMMENT '拒绝错误码(被拒时填)',
`deny_reason` TEXT COMMENT '拒绝原因(含 disable_reason / 越权说明)',
`input_context` LONGTEXT COMMENT '① 输入上下文',
`retrieved_knowledge` LONGTEXT COMMENT '② 检索到的知识(含 KDB 桩降级路径)',
`tool_calls` LONGTEXT COMMENT '③ 工具调用(入参+出参,含被拒调用)',
`proposed_action` LONGTEXT COMMENT '④ 提案动作Agent 只 Propose14.1',
`result` LONGTEXT COMMENT '⑤ 执行结果allow/deny + 结果体)',
`approval` LONGTEXT COMMENT '⑥ 审批信息approval_no/status/approver',
`final_output` LONGTEXT COMMENT '⑦ 最终输出',
`created_by` VARCHAR(64) COMMENT '创建人',
`created_at` DATETIME COMMENT '创建时间',
`appended_at` DATETIME COMMENT '最后追加时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_trace_no` (`tenant_id`, `trace_no`),
KEY `idx_trace_agent` (`tenant_id`, `agent_code`, `created_at`),
KEY `idx_trace_session` (`tenant_id`, `session_no`),
KEY `idx_trace_status` (`tenant_id`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Agent 执行轨迹(第 28 章 7 要素US-20 可查不可篡改。append-only无 update/delete 对外接口DB 层 append_only 标记要素只能追加已完成done/deny轨迹再追加 → PBL_E_APPEND_ONLY。 [APPEND-ONLY: 禁止 UPDATE/DELETE]';
CREATE TABLE IF NOT EXISTS `pbl_agent_trace_stage` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` VARCHAR(64) NOT NULL COMMENT '租户ID',
`trace_no` VARCHAR(128) NOT NULL COMMENT '轨迹编号',
`stage` VARCHAR(64) NOT NULL COMMENT '要素名7 要素之一)',
`payload` LONGTEXT COMMENT '要素内容',
`seq_no` INT COMMENT '追加序号(同轨迹内自增)',
`created_at` DATETIME COMMENT '追加时间',
PRIMARY KEY (`id`),
KEY `idx_stage_trace` (`tenant_id`, `trace_no`, `seq_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='轨迹要素追加流水:每次 append_trace 落一行,只增不改不删,用于举证 7 要素逐条可查与追加顺序US-20。 [APPEND-ONLY: 禁止 UPDATE/DELETE]';
CREATE TABLE IF NOT EXISTS `pbl_agent_approval` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` VARCHAR(64) NOT NULL COMMENT '租户ID强制打头',
`approval_no` VARCHAR(128) NOT NULL COMMENT '审批单号(业务主键)',
`trace_id` INT COMMENT '关联轨迹ID',
`trace_no` VARCHAR(128) COMMENT '关联轨迹编号',
`agent_code` VARCHAR(64) COMMENT '提案 Agent',
`action_type` VARCHAR(64) NOT NULL COMMENT '审批类别(四类之一)',
`tool_code` VARCHAR(128) COMMENT '触发审批的工具编码',
`object_type` VARCHAR(64) COMMENT '对象类型blueprint / tool / compile_task',
`object_id` VARCHAR(128) COMMENT '对象ID',
`action_payload` LONGTEXT COMMENT '提案内容快照(审批人据此判断)',
`status` VARCHAR(64) NOT NULL DEFAULT 'pending' COMMENT '状态pending / approved / rejected / expired',
`requester_type` VARCHAR(64) COMMENT '提案方类型agent / user',
`requester_id` VARCHAR(64) COMMENT '提案方ID',
`approver_id` VARCHAR(64) COMMENT '指定审批人(可空=任一授权人)',
`approver_type` VARCHAR(64) COMMENT '实际审批人类型(必须 user',
`decided_by` VARCHAR(64) COMMENT '实际审批人ID',
`decided_at` DATETIME COMMENT '审批时间',
`comment` TEXT COMMENT '审批意见',
`expires_at` DATETIME COMMENT '过期时间(过期后 pending 视为无效)',
`created_at` DATETIME COMMENT '创建时间',
`updated_at` DATETIME COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_approval_no` (`tenant_id`, `approval_no`),
KEY `idx_approval_pending` (`tenant_id`, `status`, `approver_id`),
KEY `idx_approval_trace` (`tenant_id`, `trace_no`),
KEY `idx_approval_action` (`tenant_id`, `action_type`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='人工审批单14.2):四类强制审批 publish / compile_execute / blueprint_approve / tool_registry_change。Agent 不可自批decide_approval 仅 actor_type=user 可调用。';