From c2f683690e35a3c7f30325e49326b64dec599ef9 Mon Sep 17 00:00:00 2001 From: "agent.develop" Date: Thu, 17 Sep 2026 23:44:00 +0800 Subject: [PATCH] =?UTF-8?q?deliver:=20=E4=BA=A4=E4=BB=98=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=EF=BC=88=E5=BC=95=E6=93=8E=E4=BB=A3=E4=B8=BA=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pbl_agent_runtime/m4a.py | 265 ++++++++++++ pbl_agent_runtime/m4a_adjudicate.py | 371 ++++++++++++++++ pbl_agent_runtime/m4a_approval.py | 305 +++++++++++++ pbl_agent_runtime/m4a_backend.py | 380 +++++++++++++++++ pbl_agent_runtime/m4a_critic.py | 296 +++++++++++++ pbl_agent_runtime/m4a_designer.py | 620 +++++++++++++++++++++++++++ pbl_agent_runtime/m4a_init.py | 106 +++++ pbl_agent_runtime/m4a_kernel.py | 389 +++++++++++++++++ pbl_agent_runtime/m4a_registry.py | 638 ++++++++++++++++++++++++++++ pbl_agent_runtime/m4a_tables.py | 321 ++++++++++++++ pbl_agent_runtime/m4a_trace.py | 267 ++++++++++++ scripts/m4a_selftest.py | 447 +++++++++++++++++++ scripts/seed_m4a.py | 89 ++++ sql/m4a_ddl.sql | 123 ++++++ 14 files changed, 4617 insertions(+) create mode 100644 pbl_agent_runtime/m4a.py create mode 100644 pbl_agent_runtime/m4a_adjudicate.py create mode 100644 pbl_agent_runtime/m4a_approval.py create mode 100644 pbl_agent_runtime/m4a_backend.py create mode 100644 pbl_agent_runtime/m4a_critic.py create mode 100644 pbl_agent_runtime/m4a_designer.py create mode 100644 pbl_agent_runtime/m4a_init.py create mode 100644 pbl_agent_runtime/m4a_kernel.py create mode 100644 pbl_agent_runtime/m4a_registry.py create mode 100644 pbl_agent_runtime/m4a_tables.py create mode 100644 pbl_agent_runtime/m4a_trace.py create mode 100644 scripts/m4a_selftest.py create mode 100644 scripts/seed_m4a.py create mode 100644 sql/m4a_ddl.sql diff --git a/pbl_agent_runtime/m4a.py b/pbl_agent_runtime/m4a.py new file mode 100644 index 0000000..19a29cf --- /dev/null +++ b/pbl_agent_runtime/m4a.py @@ -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_agents(designer/critic)→ seed_tools(13+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", +] diff --git a/pbl_agent_runtime/m4a_adjudicate.py b/pbl_agent_runtime/m4a_adjudicate.py new file mode 100644 index 0000000..082ab8e --- /dev/null +++ b/pbl_agent_runtime/m4a_adjudicate.py @@ -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_schema(JSON 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 只 Propose,14.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.5):object_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] diff --git a/pbl_agent_runtime/m4a_approval.py b/pbl_agent_runtime/m4a_approval.py new file mode 100644 index 0000000..a848996 --- /dev/null +++ b/pbl_agent_runtime/m4a_approval.py @@ -0,0 +1,305 @@ +# -*- coding: utf-8 -*- +""" +pbl_agent_runtime M4a — 人工审批(14.2 四类强制审批) + +四类强制人工审批(缺一即拒绝执行): + 1. publish 发布(T12 publish.request,require_approval=1) + —— Publish 必须人工审批(14.2 / 36 章),无自主发布(D1 禁用) + 2. compile_execute 编译执行(T11 compile.trigger,require_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_FORBIDDEN(14.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)可调," + "实为 %s(14.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.2:Agent 只 Propose,approved 状态由人类拍板;" + "D2 curriculum.modify_auto 禁用", + "deny_when_missing": E_STATE_ILLEGAL}, + {"action_type": APPROVAL_REGISTRY, "name": "工具注册表启停变更审批", + "trigger_tool": "set_tool_status(disabled→enabled)", "require_approval": 1, + "approver": "Platform Admin(人类)", + "anchor": "agent-tool-contract.md §4.6:启停仅 Platform Admin;" + "防止绕过 default-deny 私自扩权", + "deny_when_missing": E_STATE_ILLEGAL}, + ] diff --git a/pbl_agent_runtime/m4a_backend.py b/pbl_agent_runtime/m4a_backend.py new file mode 100644 index 0000000..9515cfa --- /dev/null +++ b/pbl_agent_runtime/m4a_backend.py @@ -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.compile(is_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.request:Publish 必须人工审批(14.2/36 章)。 + 本函数在 S7 已确认存在 approved 审批单后执行;仅做可见性标记 + 状态推进, + 无 Marketplace(1.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": "仅可见性标记,无 Marketplace(1.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-24),Designer 降级 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_UNAVAILABLE(default-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 diff --git a/pbl_agent_runtime/m4a_critic.py b/pbl_agent_runtime/m4a_critic.py new file mode 100644 index 0000000..0b6505e --- /dev/null +++ b/pbl_agent_runtime/m4a_critic.py @@ -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.3):recommendation / reason / evidence / confidence + 缺任一 → PBL_E_VALIDATION + * 证据源:validation.report(14 维 + 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_ready(US-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 runner:fn(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-18:Critic 评审 —— 返回 {suggestions[], trace_no, blueprint_id, + version_no, write_performed:false, generation} + + 只读:全程仅调用 blueprint.get / validation.report; + write_performed 恒为 False(14.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) + + # Think:LLM 增强 → 失败降级规则式 + 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 不直接修改 Blueprint(14.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"} diff --git a/pbl_agent_runtime/m4a_designer.py b/pbl_agent_runtime/m4a_designer.py new file mode 100644 index 0000000..a9ff328 --- /dev/null +++ b/pbl_agent_runtime/m4a_designer.py @@ -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_tool(fail-closed 裁决) + * 模型不可达 → 自动兜底 template_fallback(US-05),不向调用方抛错 + * designer_modify 只改结构化模型(target_changes → change_delta), + 非目标字段字节级不变(US-03),禁止整篇重写散文 + * designer_clarify 只问实质影响设计的缺失(age/teamSize/duration/artifact), + 已提供不重复问,轮次 ≤ 3(US-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"), +) + + +# --------------------------------------------------------------------------- +# Think:NL 意图 → 结构化槽位(确定性解析,可离线;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/Propose:Designer 生成 +# --------------------------------------------------------------------------- +def designer_generate(intent_text, owner_teacher_id=None, class_id=None, + session_no=None, ctx=None, store=None, model_runner=None): + """ + US-01:NL 意图 → Blueprint 草稿。 + 返回 {blueprint_id, code, version_no, generation_source, trace_no, + clarifications[], slots, fallback_reason?} + + 模型不可达(PBL_E_MODEL_UNAVAILABLE / 后端未挂载)→ 自动兜底: + generation_source=template_fallback(US-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_knowledge:KDB 桩检索(空结果不报错)→ 降级本地模板库 + 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) + + # ② Think:LLM 生成(可选)→ 失败即兜底 + 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" + + # ④ clarify(US-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 runner:fn(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-04:≤3 轮),改为给出默认假设。 + """ + 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_ILLEGAL(archived)/ 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} diff --git a/pbl_agent_runtime/m4a_init.py b/pbl_agent_runtime/m4a_init.py new file mode 100644 index 0000000..c8652d8 --- /dev/null +++ b/pbl_agent_runtime/m4a_init.py @@ -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_agents:designer / critic(Critic write_allowed=0) + 4) seed_tools:13 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=user(14.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() diff --git a/pbl_agent_runtime/m4a_kernel.py b/pbl_agent_runtime/m4a_kernel.py new file mode 100644 index 0000000..93336b5 --- /dev/null +++ b/pbl_agent_runtime/m4a_kernel.py @@ -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-only(US-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): + """ + 取存储句柄。优先 sqlor(ServerEnv 注入的模块库),失败降级 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) diff --git a/pbl_agent_runtime/m4a_registry.py b/pbl_agent_runtime/m4a_registry.py new file mode 100644 index 0000000..54cbf8a --- /dev/null +++ b/pbl_agent_runtime/m4a_registry.py @@ -0,0 +1,638 @@ +# -*- coding: utf-8 -*- +""" +pbl_agent_runtime M4a — Agent 定义与工具注册表(含 seed 幂等注入) + +内容: + 1) AGENT_DEFS:第 13.1 章仅 designer / critic 两个 Agent;critic write_allowed=0(零写权限) + 2) TOOL_SEED:第 31 章裁剪子集 —— 13 enabled + 9 disabled(agent-tool-contract.md §2/§3) + 3) register_tool / list_tools / set_tool_status / seed_tools / seed_agents(幂等,build.sh 调用) + +四类强制人工审批(14.2): + publish(T12 publish.request)/ compile_execute(T11 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.1):Designer 只 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 / confidence,14.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. 工具注册表 seed(13 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="T1;PBL_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="T4;kdb.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="T7;Critic 建议证据源(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="T8;write_operation=0,Critic 不直接修改 Blueprint(14.1);" + "四要素不齐 → PBL_E_VALIDATION(14.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_trace(append-only)", 9, + scope_note="T9;Agent 仅可读自身 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="T10;Agent 不能自批——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.compile(is_approved 门禁:未审批蓝图 403)", 11, + approval_type="compile_execute", + scope_note="T11;require_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="T12;Publish 必须人工审批(14.2/36 章);仅可见性标记,无 Marketplace(1.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"), + + # ---------------- 禁用(9,out_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.request(require_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 4(22/23/35 章);" + "本迭代发布仅可见性标记", + scope_note="D3 out_of_scope(marketplace.* 全组禁用)"), + + _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 3(33/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 4(23.3)", + scope_note="D9 out_of_scope(billing.* 全组禁用)"), +] + +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 Admin(agent-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_reason(out_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 disabled(build.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 [])], + } diff --git a/pbl_agent_runtime/m4a_tables.py b/pbl_agent_runtime/m4a_tables.py new file mode 100644 index 0000000..3e507db --- /dev/null +++ b/pbl_agent_runtime/m4a_tables.py @@ -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 / critic,Critic 零写权限) + 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 表示零写权限(Critic,14.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 Schema(S8 契约校验)"), + _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", "产生轨迹的 Agent:designer / 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 只 Propose,14.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 后端走 DDL;MemoryStore 直接建空表。 + """ + 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)} diff --git a/pbl_agent_runtime/m4a_trace.py b/pbl_agent_runtime/m4a_trace.py new file mode 100644 index 0000000..75a921e --- /dev/null +++ b/pbl_agent_runtime/m4a_trace.py @@ -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=%s),append-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}) diff --git a/scripts/m4a_selftest.py b/scripts/m4a_selftest.py new file mode 100644 index 0000000..77943f5 --- /dev/null +++ b/scripts/m4a_selftest.py @@ -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=True(step=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岁学生设计海洋保护PBL,4人一组,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_fallback(US-05 不抛错)", + res.get("generation_source") == "template_fallback", res) + check("F16 兜底提案落 pending_backend(Agent 不直连 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_VALIDATION(14.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()) diff --git a/scripts/seed_m4a.py b/scripts/seed_m4a.py new file mode 100644 index 0000000..511951d --- /dev/null +++ b/scripts/seed_m4a.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +""" +M4a seed 脚本(build.sh / 部署时调用,幂等) + +用法: + python3 scripts/seed_m4a.py # 平台级 seed(tenant_id=__platform__) + python3 scripts/seed_m4a.py --tenant T001 # 指定租户 seed + python3 scripts/seed_m4a.py --memory # 离线内存库演练(不连 DB) + +动作:建表(4 主表 + 1 append-only 流水表)→ seed_agents(designer/critic) + → seed_tools(13 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()) diff --git a/sql/m4a_ddl.sql b/sql/m4a_ddl.sql new file mode 100644 index 0000000..19b7ef4 --- /dev/null +++ b/sql/m4a_ddl.sql @@ -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 +-- 工具注册表 seed:13 enabled + 9 disabled(scripts/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 表示零写权限(Critic,14.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 Schema(S8 契约校验)', + `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 '产生轨迹的 Agent:designer / 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 只 Propose,14.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 可调用。';