1177 lines
54 KiB
Python
1177 lines
54 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""pbl_agent_runtime.m4a_contract —— 10 个契约接口的**唯一实现**。
|
||
|
||
契约清单(与 skill/SKILL.md、wwwroot/api/*.dspy、scripts/load_path.py 四处同步):
|
||
pbl_agent_designer_run / pbl_agent_critic_run
|
||
pbl_agent_trace_write / pbl_agent_trace_list
|
||
pbl_tool_registry_list / pbl_tool_registry_save
|
||
pbl_tool_adjudicate
|
||
pbl_approval_create / pbl_approval_decide / pbl_approval_list
|
||
|
||
三条铁律
|
||
--------
|
||
* **fail-closed**:默认裁决 = DENY;任一步不过即 DENY;存储/参数异常一律 DENY,不放行。
|
||
* **Critic 零写**:critic 的 write_scope='none'、can_write=0,任何 write_class=1 工具在 S3 直接 DENY。
|
||
* **四类强制人工审批**:publish / compile_execute / blueprint_approve / tool_registry_change,
|
||
无 approved 审批单即 DENY,且自动补建 pending 审批(无绕过路径)。
|
||
|
||
所有函数**同步返回 Result(dict)**,同时 ``await`` 亦可用(Result.__await__ 返回自身),
|
||
因此 .dspy(async 上下文)与 offline_contract_smoke.py(同步调用)共用同一实现。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import time
|
||
|
||
from .m4a_store import (
|
||
APPEND_ONLY_TABLES,
|
||
ERR_APPEND_ONLY,
|
||
ERR_NOT_FOUND,
|
||
ERR_STORE,
|
||
ERR_TENANT_MISSING,
|
||
PblContractError,
|
||
Result,
|
||
dumps,
|
||
fail,
|
||
get_store,
|
||
loads,
|
||
new_id,
|
||
now_ms,
|
||
now_str,
|
||
ok,
|
||
resolve_tenant,
|
||
sha256_text,
|
||
)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 契约常量
|
||
# ---------------------------------------------------------------------------
|
||
|
||
CONTRACT_FUNCTIONS = (
|
||
"pbl_agent_designer_run",
|
||
"pbl_agent_critic_run",
|
||
"pbl_agent_trace_write",
|
||
"pbl_agent_trace_list",
|
||
"pbl_tool_registry_list",
|
||
"pbl_tool_registry_save",
|
||
"pbl_tool_adjudicate",
|
||
"pbl_approval_create",
|
||
"pbl_approval_decide",
|
||
"pbl_approval_list",
|
||
)
|
||
|
||
# 14.2 四类强制人工审批(无绕过路径)
|
||
MANDATORY_APPROVAL_TYPES = (
|
||
"publish",
|
||
"compile_execute",
|
||
"blueprint_approve",
|
||
"tool_registry_change",
|
||
)
|
||
|
||
# fail-closed 8 步裁决顺序(顺序不可调换:先身份、后授权、再参数、最后审批)
|
||
ADJUDICATION_STEPS = (
|
||
("S1", "tenant_context", "租户上下文必须存在"),
|
||
("S2", "agent_registered", "Agent 必须已注册且 active"),
|
||
("S3", "agent_write_scope", "Critic 零写:写类工具对 critic 一律 DENY"),
|
||
("S4", "tool_registered", "工具必须在注册表中登记"),
|
||
("S5", "tool_enabled", "工具必须处于 enabled(pbl.publish 等禁用工具 DENY)"),
|
||
("S6", "permission_granted", "Agent 必须持有工具 required_perm"),
|
||
("S7", "params_valid", "入参必须通过 params_schema 校验"),
|
||
("S8", "approval_granted", "四类强制审批必须有 approved 审批单"),
|
||
)
|
||
|
||
TRACE_ELEMENTS = ("who", "occurred_at", "what", "why", "how", "result", "evidence_ref")
|
||
|
||
# 13 启用工具(write_class=1 为写类;audit_append=1 表示只追加留痕)
|
||
ENABLED_TOOLS = (
|
||
("blueprint.read", "蓝图读取", "blueprint", 0, "pbl.blueprint.read", None, "low"),
|
||
("blueprint.write", "蓝图写入", "blueprint", 1, "pbl.blueprint.write", None, "high"),
|
||
("blueprint.validate", "蓝图校验", "blueprint", 0, "pbl.blueprint.read", None, "low"),
|
||
("blueprint.approve", "蓝图审批", "blueprint", 1, "pbl.blueprint.approve", "blueprint_approve", "high"),
|
||
("compiler.compile", "确定性编译", "compiler", 0, "pbl.compiler.run", None, "medium"),
|
||
("compiler.execute", "编译产物执行", "compiler", 1, "pbl.compiler.execute", "compile_execute", "high"),
|
||
("evidence.collect", "证据采集", "evidence", 1, "pbl.evidence.write", None, "medium"),
|
||
("assessment.score", "Rubric 评分", "assessment", 1, "pbl.assessment.write", None, "medium"),
|
||
("kdb.query", "KDB 只读查询", "kdb", 0, "pbl.kdb.read", None, "low"),
|
||
("runtime.event", "运行时事件写入", "runtime", 1, "pbl.runtime.write", None, "medium"),
|
||
("agent.trace_write", "轨迹留痕", "agent", 1, "pbl.agent.trace", None, "low"),
|
||
("approval.create", "发起人工审批", "approval", 1, "pbl.approval.create", None, "medium"),
|
||
("tool_registry.read", "工具注册表查询", "registry", 0, "pbl.registry.read", None, "low"),
|
||
)
|
||
|
||
# 9 禁用工具(含 pbl.publish 自动发布类,一律 status=disabled)
|
||
DISABLED_TOOLS = (
|
||
("pbl.publish", "自动发布上线", "publish", 1, "pbl.publish", "publish", "forbidden", "14.2 发布必须人工审批,禁止 Agent 自动发布"),
|
||
("blueprint.publish_auto", "蓝图自动发布", "publish", 1, "pbl.publish", "publish", "forbidden", "自动发布类工具,fail-closed 默认禁用"),
|
||
("compiler.autorun", "编译自动执行", "compiler", 1, "pbl.compiler.execute", "compile_execute", "forbidden", "执行必须人工审批"),
|
||
("kdb.write", "KDB 写入", "kdb", 1, "pbl.kdb.write", None, "forbidden", "M7 KDB 只读桩,零写入"),
|
||
("tenant.switch", "跨租户切换", "platform", 1, "pbl.platform.admin", None, "forbidden", "跨租户越权,禁止 Agent 调用"),
|
||
("rbac.grant", "权限授予", "platform", 1, "pbl.platform.admin", None, "forbidden", "权限变更必须人工,禁止 Agent 自授权"),
|
||
("approval.auto_decide", "审批自动通过", "approval", 1, "pbl.approval.decide", "blueprint_approve", "forbidden", "审批无绕过路径,禁止自动通过"),
|
||
("trace.delete", "轨迹删除", "agent", 1, "pbl.platform.admin", None, "forbidden", "留痕 append-only,禁止删除"),
|
||
("llm.raw_exec", "模型原始指令执行", "agent", 1, "pbl.platform.admin", None, "forbidden", "禁止把模型输出当指令直接执行"),
|
||
)
|
||
|
||
# Agent 定义:designer 可写、critic 零写
|
||
AGENT_DEFS = (
|
||
{
|
||
"agent_code": "designer",
|
||
"agent_name": "Designer 设计者 Agent",
|
||
"role": "designer",
|
||
"write_scope": "blueprint,evidence,runtime,assessment",
|
||
"can_write": 1,
|
||
# 注意:持有权限 != 可自动执行。blueprint.approve / compiler.execute 虽在 perms 内,
|
||
# 仍必须在 S8 命中四类强制人工审批(blueprint_approve / compile_execute)才 ALLOW。
|
||
"perms": [
|
||
"pbl.blueprint.read", "pbl.blueprint.write", "pbl.blueprint.approve",
|
||
"pbl.compiler.run", "pbl.compiler.execute",
|
||
"pbl.evidence.write", "pbl.assessment.write", "pbl.runtime.write",
|
||
"pbl.kdb.read", "pbl.agent.trace", "pbl.approval.create", "pbl.registry.read",
|
||
],
|
||
"model": "platform-default",
|
||
"system_prompt": "你是 PBL 蓝图 Designer:只做确定性设计产出,所有写操作必须经服务端裁决;"
|
||
"发布/执行/审批/注册表变更四类动作必须发起人工审批,禁止自行绕过。",
|
||
},
|
||
{
|
||
"agent_code": "critic",
|
||
"agent_name": "Critic 评审 Agent",
|
||
"role": "critic",
|
||
"write_scope": "none",
|
||
"can_write": 0,
|
||
"perms": ["pbl.blueprint.read", "pbl.kdb.read", "pbl.agent.trace", "pbl.registry.read"],
|
||
"model": "platform-default",
|
||
"system_prompt": "你是 PBL Critic:只读评审,零写权限。任何写类工具调用都会被服务端在 S3 拒绝;"
|
||
"你只输出评审意见与证据引用,不得修改蓝图、不得发布、不得审批。",
|
||
},
|
||
)
|
||
|
||
DEFAULT_TIMEOUT_MS = 30000
|
||
DEFAULT_MAX_RETRY = 2
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 内部工具
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _tenant_or_raise(tenant_id):
|
||
tenant = resolve_tenant(tenant_id)
|
||
if not tenant:
|
||
raise PblContractError(ERR_TENANT_MISSING, "tenant_id missing (fail-closed)")
|
||
return tenant
|
||
|
||
|
||
def _pick(kwargs: dict, names, default=None):
|
||
for name in names:
|
||
if name in kwargs and kwargs[name] is not None:
|
||
return kwargs[name]
|
||
return default
|
||
|
||
|
||
def _as_bool(value, default=False) -> bool:
|
||
if value is None:
|
||
return default
|
||
if isinstance(value, bool):
|
||
return value
|
||
if isinstance(value, (int, float)):
|
||
return bool(value)
|
||
text = str(value).strip().lower()
|
||
if text in ("1", "true", "yes", "y", "on", "enabled", "enable"):
|
||
return True
|
||
if text in ("0", "false", "no", "n", "off", "disabled", "disable"):
|
||
return False
|
||
return default
|
||
|
||
|
||
def _as_int(value, default=0) -> int:
|
||
try:
|
||
return int(value)
|
||
except Exception:
|
||
return default
|
||
|
||
|
||
def _guard(kwargs: dict):
|
||
"""契约统一异常包装:把 PblContractError 转成 fail Result,其它异常转 ERR_STORE。"""
|
||
try:
|
||
return kwargs["fn"](**kwargs["kw"])
|
||
except PblContractError as exc:
|
||
return fail(exc.code, exc.message, **exc.extra)
|
||
except Exception as exc: # noqa: BLE001 —— fail-closed:未知异常也拒绝
|
||
return fail(ERR_STORE, "contract error: %s: %s" % (type(exc).__name__, exc))
|
||
|
||
|
||
def _run(fn, **kw):
|
||
return _guard({"fn": fn, "kw": kw})
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# LLM 调用留痕(pbl_llm_call_log):超时 / 重试 / 限流 / 兜底
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def log_llm_call(
|
||
tenant_id=None,
|
||
agent_code=None,
|
||
run_id=None,
|
||
purpose=None,
|
||
model=None,
|
||
attempt_no=1,
|
||
status="ok",
|
||
latency_ms=0,
|
||
timeout_ms=DEFAULT_TIMEOUT_MS,
|
||
retry_count=0,
|
||
rate_limited=False,
|
||
fallback_used=False,
|
||
prompt=None,
|
||
response=None,
|
||
tokens_in=0,
|
||
tokens_out=0,
|
||
error_code=None,
|
||
error_msg=None,
|
||
) -> dict:
|
||
"""写一条 LLM 调用留痕(append-only)。Designer/Critic 运行时每次模型调用都必须落一条。"""
|
||
tenant = _tenant_or_raise(tenant_id)
|
||
prompt_text = prompt if isinstance(prompt, str) else dumps(prompt)
|
||
response_text = response if isinstance(response, str) else dumps(response)
|
||
row = get_store().insert(
|
||
"pbl_llm_call_log",
|
||
{
|
||
"tenant_id": tenant,
|
||
"agent_code": agent_code or "unknown",
|
||
"run_id": run_id,
|
||
"purpose": purpose or "chat",
|
||
"model": model or "platform-default",
|
||
"attempt_no": _as_int(attempt_no, 1),
|
||
"status": status or "ok",
|
||
"latency_ms": _as_int(latency_ms, 0),
|
||
"timeout_ms": _as_int(timeout_ms, DEFAULT_TIMEOUT_MS),
|
||
"retry_count": _as_int(retry_count, 0),
|
||
"rate_limited": 1 if _as_bool(rate_limited) else 0,
|
||
"fallback_used": 1 if _as_bool(fallback_used) else 0,
|
||
"prompt_chars": len(prompt_text or ""),
|
||
"prompt_hash": sha256_text(prompt_text or ""),
|
||
"response_chars": len(response_text or ""),
|
||
"tokens_in": _as_int(tokens_in, 0),
|
||
"tokens_out": _as_int(tokens_out, 0),
|
||
"error_code": error_code,
|
||
"error_msg": (error_msg or "")[:500] or None,
|
||
"created_at": now_str(),
|
||
},
|
||
)
|
||
return row
|
||
|
||
|
||
def _invoke_llm(tenant, agent_code, run_id, purpose, prompt, model=None,
|
||
timeout_ms=DEFAULT_TIMEOUT_MS, max_retry=DEFAULT_MAX_RETRY):
|
||
"""离线确定性“模型调用”:不联网、不编造,返回可复现的确定性产出,并全程留痕。
|
||
|
||
真实部署时把本函数体替换为平台模型调用即可——留痕/超时/重试/限流/兜底语义保持不变。
|
||
"""
|
||
started = now_ms()
|
||
attempt = 0
|
||
last_error = None
|
||
while attempt <= max_retry:
|
||
attempt += 1
|
||
try:
|
||
text = _deterministic_completion(agent_code, purpose, prompt)
|
||
latency = now_ms() - started
|
||
log_llm_call(
|
||
tenant_id=tenant, agent_code=agent_code, run_id=run_id, purpose=purpose,
|
||
model=model or "platform-default", attempt_no=attempt, status="ok",
|
||
latency_ms=latency, timeout_ms=timeout_ms, retry_count=attempt - 1,
|
||
rate_limited=False, fallback_used=False, prompt=prompt, response=text,
|
||
tokens_in=max(1, len(str(prompt or "")) // 4),
|
||
tokens_out=max(1, len(text) // 4),
|
||
)
|
||
return {"ok": True, "text": text, "attempt_no": attempt, "latency_ms": latency,
|
||
"fallback_used": False, "retry_count": attempt - 1}
|
||
except Exception as exc: # noqa: BLE001
|
||
last_error = exc
|
||
rate_limited = "429" in str(exc) or "rate" in str(exc).lower()
|
||
log_llm_call(
|
||
tenant_id=tenant, agent_code=agent_code, run_id=run_id, purpose=purpose,
|
||
model=model or "platform-default", attempt_no=attempt,
|
||
status="rate_limited" if rate_limited else "error",
|
||
latency_ms=now_ms() - started, timeout_ms=timeout_ms,
|
||
retry_count=attempt - 1, rate_limited=rate_limited, fallback_used=False,
|
||
prompt=prompt, error_code="PBL.LLM.CALL_FAILED",
|
||
error_msg="%s: %s" % (type(exc).__name__, exc),
|
||
)
|
||
# 兜底:全部重试失败 → 留痕 fallback_used=1,返回确定性兜底产出(绝不静默成功)
|
||
fallback_text = _fallback_completion(agent_code, purpose)
|
||
log_llm_call(
|
||
tenant_id=tenant, agent_code=agent_code, run_id=run_id, purpose=purpose,
|
||
model=model or "platform-default", attempt_no=attempt, status="fallback",
|
||
latency_ms=now_ms() - started, timeout_ms=timeout_ms, retry_count=max_retry,
|
||
rate_limited=False, fallback_used=True, prompt=prompt, response=fallback_text,
|
||
error_code="PBL.LLM.FALLBACK", error_msg=str(last_error)[:500],
|
||
)
|
||
return {"ok": False, "text": fallback_text, "attempt_no": attempt,
|
||
"latency_ms": now_ms() - started, "fallback_used": True, "retry_count": max_retry,
|
||
"error_msg": str(last_error)[:500]}
|
||
|
||
|
||
def _deterministic_completion(agent_code, purpose, prompt) -> str:
|
||
digest = sha256_text("%s|%s|%s" % (agent_code, purpose, prompt))[:12]
|
||
if agent_code == "critic":
|
||
return ("CRITIC_REVIEW digest=%s verdict=needs_evidence "
|
||
"findings=[\"写操作零权限已核验\",\"证据引用需补全\"] "
|
||
"note=critic 只读,不产出任何写入指令" % digest)
|
||
return ("DESIGNER_OUTPUT digest=%s purpose=%s "
|
||
"steps=[\"读取蓝图\",\"确定性生成子对象\",\"提交校验\"] "
|
||
"guard=所有写操作经服务端 8 步裁决" % (digest, purpose))
|
||
|
||
|
||
def _fallback_completion(agent_code, purpose) -> str:
|
||
return "FALLBACK agent=%s purpose=%s reason=llm_unavailable action=hold_and_report" % (
|
||
agent_code, purpose)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 注册表:seed / list / save
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def seed_registry(tenant_id=None, force=False) -> dict:
|
||
"""幂等 seed:2 个 Agent 定义 + 13 启用 / 9 禁用工具。"""
|
||
tenant = _tenant_or_raise(tenant_id)
|
||
store = get_store()
|
||
agents_written = 0
|
||
for item in AGENT_DEFS:
|
||
existed = store.query_one(
|
||
"SELECT id FROM pbl_agent_def WHERE tenant_id = ? AND agent_code = ?",
|
||
(tenant, item["agent_code"]),
|
||
)
|
||
if existed and not force:
|
||
continue
|
||
store.upsert_by(
|
||
"pbl_agent_def",
|
||
{"tenant_id": tenant, "agent_code": item["agent_code"]},
|
||
{
|
||
"agent_name": item["agent_name"],
|
||
"role": item["role"],
|
||
"write_scope": item["write_scope"],
|
||
"can_write": item["can_write"],
|
||
"perms": dumps(item["perms"]),
|
||
"model": item["model"],
|
||
"system_prompt": item["system_prompt"],
|
||
"status": "active",
|
||
"updated_at": now_str(),
|
||
},
|
||
)
|
||
agents_written += 1
|
||
|
||
tools_written = 0
|
||
sort_no = 0
|
||
for key, name, category, write_class, perm, approval, risk in ENABLED_TOOLS:
|
||
sort_no += 10
|
||
store.upsert_by(
|
||
"pbl_tool_registry",
|
||
{"tenant_id": tenant, "tool_key": key},
|
||
{
|
||
"tool_name": name, "category": category, "write_class": write_class,
|
||
"audit_append": 1 if key in ("agent.trace_write", "evidence.collect") else 0,
|
||
"status": "enabled", "required_perm": perm, "approval_type": approval,
|
||
"params_schema": dumps({"type": "object"}), "risk_level": risk,
|
||
"reason": None, "sort_no": sort_no, "updated_at": now_str(),
|
||
},
|
||
)
|
||
tools_written += 1
|
||
for key, name, category, write_class, perm, approval, risk, reason in DISABLED_TOOLS:
|
||
sort_no += 10
|
||
store.upsert_by(
|
||
"pbl_tool_registry",
|
||
{"tenant_id": tenant, "tool_key": key},
|
||
{
|
||
"tool_name": name, "category": category, "write_class": write_class,
|
||
"audit_append": 0, "status": "disabled", "required_perm": perm,
|
||
"approval_type": approval, "params_schema": dumps({"type": "object"}),
|
||
"risk_level": risk, "reason": reason, "sort_no": sort_no,
|
||
"updated_at": now_str(),
|
||
},
|
||
)
|
||
tools_written += 1
|
||
return {"tenant_id": tenant, "agents": agents_written, "tools": tools_written,
|
||
"enabled": len(ENABLED_TOOLS), "disabled": len(DISABLED_TOOLS)}
|
||
|
||
|
||
def _tool_row_to_dict(row: dict) -> dict:
|
||
out = dict(row)
|
||
out["params_schema"] = loads(out.get("params_schema"), {"type": "object"})
|
||
out["enabled"] = str(out.get("status") or "").lower() == "enabled"
|
||
out["write_class"] = _as_int(out.get("write_class"), 0)
|
||
out["audit_append"] = _as_int(out.get("audit_append"), 0)
|
||
return out
|
||
|
||
|
||
def _pbl_tool_registry_list(tenant_id=None, status=None, category=None, tool_key=None,
|
||
keyword=None, page=1, page_size=100, **_ignored) -> Result:
|
||
tenant = _tenant_or_raise(tenant_id)
|
||
seed_registry(tenant)
|
||
where = ["tenant_id = ?"]
|
||
args = [tenant]
|
||
if status:
|
||
where.append("status = ?")
|
||
args.append(str(status).strip().lower())
|
||
if category:
|
||
where.append("category = ?")
|
||
args.append(str(category))
|
||
if tool_key:
|
||
where.append("tool_key = ?")
|
||
args.append(str(tool_key))
|
||
if keyword:
|
||
where.append("(tool_key LIKE ? OR tool_name LIKE ?)")
|
||
args.extend(["%%%s%%" % keyword, "%%%s%%" % keyword])
|
||
where_sql = " AND ".join(where)
|
||
total_row = get_store().query_one(
|
||
"SELECT COUNT(1) AS cnt FROM pbl_tool_registry WHERE %s" % where_sql, args)
|
||
total = _as_int((total_row or {}).get("cnt"), 0)
|
||
page = max(1, _as_int(page, 1))
|
||
page_size = min(500, max(1, _as_int(page_size, 100)))
|
||
rows = get_store().query(
|
||
"SELECT * FROM pbl_tool_registry WHERE %s ORDER BY sort_no, tool_key LIMIT ? OFFSET ?"
|
||
% where_sql,
|
||
args + [page_size, (page - 1) * page_size],
|
||
)
|
||
items = [_tool_row_to_dict(r) for r in rows]
|
||
enabled = len([i for i in items if i["enabled"]])
|
||
return ok(items=items, data=items, list=items, total=total, page=page,
|
||
page_size=page_size, tenant_id=tenant,
|
||
enabled_count=enabled, disabled_count=len(items) - enabled)
|
||
|
||
|
||
def _pbl_tool_registry_save(tenant_id=None, tool_key=None, tool_name=None, category=None,
|
||
enabled=None, status=None, write_class=None, audit_append=None,
|
||
required_perm=None, approval_type=None, params_schema=None,
|
||
risk_level=None, reason=None, sort_no=None, operator=None,
|
||
approval_id=None, **_ignored) -> Result:
|
||
"""注册表变更 = 四类强制审批之一(tool_registry_change)。
|
||
|
||
fail-closed:
|
||
* tool_key 缺失 → 拒绝;
|
||
* 无 approved 的 tool_registry_change 审批单 → 拒绝并自动补建 pending 审批;
|
||
* 试图把禁用类(pbl.publish 等 forbidden)改为 enabled → 拒绝;
|
||
* 变更成功后追加 pbl_agent_trace 留痕(append-only)。
|
||
"""
|
||
tenant = _tenant_or_raise(tenant_id)
|
||
seed_registry(tenant)
|
||
key = (tool_key or "").strip()
|
||
if not key:
|
||
return fail("PBL.REGISTRY.TOOL_KEY_REQUIRED", "tool_key is required")
|
||
|
||
forbidden_keys = {item[0] for item in DISABLED_TOOLS}
|
||
want_enabled = _as_bool(enabled, None) if enabled is not None else None
|
||
if status is not None and want_enabled is None:
|
||
want_enabled = str(status).strip().lower() == "enabled"
|
||
|
||
if want_enabled is True and key in forbidden_keys:
|
||
return fail("PBL.REGISTRY.FORBIDDEN_TOOL",
|
||
"tool %s is forbidden by design (14.2), cannot be enabled" % key,
|
||
tool_key=key)
|
||
|
||
approval_row = None
|
||
if approval_id:
|
||
approval_row = get_store().query_one(
|
||
"SELECT * FROM pbl_approval WHERE tenant_id = ? AND approval_id = ?",
|
||
(tenant, str(approval_id)),
|
||
)
|
||
if not approval_row or str(approval_row.get("status")) != "approved":
|
||
created = _pbl_approval_create(
|
||
tenant_id=tenant, approval_type="tool_registry_change",
|
||
title="工具注册表变更:%s" % key, requested_by=operator or "system",
|
||
agent_code="platform", tool_key=key,
|
||
payload_json=dumps({"tool_key": key, "enabled": want_enabled,
|
||
"status": status, "reason": reason}),
|
||
)
|
||
return fail(
|
||
"PBL.APPROVAL.REQUIRED",
|
||
"tool_registry_change requires an approved human approval (fail-closed)",
|
||
tool_key=key, approval_type="tool_registry_change",
|
||
approval_id=created.get("approval_id"), approval_status="pending",
|
||
need_approval=True,
|
||
)
|
||
|
||
existed = get_store().query_one(
|
||
"SELECT * FROM pbl_tool_registry WHERE tenant_id = ? AND tool_key = ?", (tenant, key))
|
||
fields = {}
|
||
if tool_name is not None:
|
||
fields["tool_name"] = tool_name
|
||
if category is not None:
|
||
fields["category"] = category
|
||
if want_enabled is not None:
|
||
fields["status"] = "enabled" if want_enabled else "disabled"
|
||
if write_class is not None:
|
||
fields["write_class"] = 1 if _as_bool(write_class) else 0
|
||
if audit_append is not None:
|
||
fields["audit_append"] = 1 if _as_bool(audit_append) else 0
|
||
if required_perm is not None:
|
||
fields["required_perm"] = required_perm
|
||
if approval_type is not None:
|
||
fields["approval_type"] = approval_type or None
|
||
if params_schema is not None:
|
||
fields["params_schema"] = dumps(params_schema) if not isinstance(params_schema, str) else params_schema
|
||
if risk_level is not None:
|
||
fields["risk_level"] = risk_level
|
||
if reason is not None:
|
||
fields["reason"] = reason
|
||
if sort_no is not None:
|
||
fields["sort_no"] = _as_int(sort_no, 0)
|
||
fields["updated_at"] = now_str()
|
||
fields["updated_by"] = operator or "system"
|
||
fields["version"] = _as_int((existed or {}).get("version"), 1) + 1
|
||
|
||
row = get_store().upsert_by("pbl_tool_registry", {"tenant_id": tenant, "tool_key": key}, fields)
|
||
_write_trace_row(
|
||
tenant, trace_id=new_id("trc"), run_id=None, step_no=0, stage="tool_registry_change",
|
||
who=operator or "system", what="registry save tool_key=%s" % key,
|
||
why="注册表变更经人工审批 %s" % approval_row.get("approval_id"),
|
||
how="pbl_tool_registry_save", result="saved",
|
||
evidence_ref="pbl_approval:%s" % approval_row.get("approval_id"),
|
||
tool_key=key, decision="ALLOW",
|
||
)
|
||
return ok(tool=_tool_row_to_dict(row), item=_tool_row_to_dict(row), data=_tool_row_to_dict(row),
|
||
tool_key=key, tenant_id=tenant, saved=True,
|
||
approval_id=approval_row.get("approval_id"),
|
||
version=_as_int(row.get("version"), 1))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 轨迹:write / list(append-only)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _write_trace_row(tenant, trace_id, run_id, step_no, stage, who, what, why, how,
|
||
result, evidence_ref, tool_key=None, decision=None, occurred_at=None):
|
||
return get_store().insert(
|
||
"pbl_agent_trace",
|
||
{
|
||
"tenant_id": tenant, "trace_id": trace_id, "run_id": run_id,
|
||
"step_no": _as_int(step_no, 0), "stage": stage or "run",
|
||
"who": who or "system", "occurred_at": occurred_at or now_str(), "what": what,
|
||
"why": why, "how": how, "result": result, "evidence_ref": evidence_ref,
|
||
"tool_key": tool_key, "decision": decision, "created_at": now_str(),
|
||
},
|
||
)
|
||
|
||
|
||
def _pbl_agent_trace_write(tenant_id=None, trace_id=None, run_id=None, step_no=0, stage=None,
|
||
who=None, occurred_at=None, what=None, why=None, how=None,
|
||
result=None, evidence_ref=None, tool_key=None, decision=None,
|
||
elements=None, **_ignored) -> Result:
|
||
"""写一条 Agent 执行轨迹(第 28 章 7 要素:who/occurred_at/what/why/how/result/evidence_ref)。"""
|
||
tenant = _tenant_or_raise(tenant_id)
|
||
if isinstance(elements, dict):
|
||
what = what if what is not None else elements.get("what")
|
||
why = why if why is not None else elements.get("why")
|
||
how = how if how is not None else elements.get("how")
|
||
result = result if result is not None else elements.get("result")
|
||
evidence_ref = evidence_ref if evidence_ref is not None else elements.get("evidence_ref")
|
||
who = who if who is not None else elements.get("who")
|
||
values = {
|
||
"who": who,
|
||
"occurred_at": occurred_at or now_str(),
|
||
"what": what,
|
||
"why": why,
|
||
"how": how,
|
||
"result": result,
|
||
"evidence_ref": evidence_ref,
|
||
}
|
||
missing = [name for name in TRACE_ELEMENTS
|
||
if values.get(name) in (None, "")]
|
||
if missing:
|
||
return fail("PBL.TRACE.ELEMENTS_REQUIRED",
|
||
"trace elements missing: %s (fail-closed)" % ",".join(missing),
|
||
missing=missing)
|
||
row = _write_trace_row(
|
||
tenant, trace_id=trace_id or new_id("trc"), run_id=run_id, step_no=step_no,
|
||
stage=stage, who=values["who"], what=values["what"], why=values["why"],
|
||
how=values["how"], result=values["result"], evidence_ref=values["evidence_ref"],
|
||
tool_key=tool_key, decision=decision, occurred_at=values["occurred_at"],
|
||
)
|
||
# occurred_at 为调用方给定的业务时间;留痕表 append-only,只随新记录写入,绝不 UPDATE
|
||
return ok(trace_id=row["trace_id"], id=row["id"], data=row, item=row,
|
||
tenant_id=tenant, written=True, elements=list(TRACE_ELEMENTS))
|
||
|
||
|
||
def _pbl_agent_trace_list(tenant_id=None, trace_id=None, run_id=None, agent_code=None,
|
||
stage=None, limit=100, page=1, page_size=None, **_ignored) -> Result:
|
||
tenant = _tenant_or_raise(tenant_id)
|
||
where = ["tenant_id = ?"]
|
||
args = [tenant]
|
||
if trace_id:
|
||
where.append("trace_id = ?")
|
||
args.append(str(trace_id))
|
||
if run_id:
|
||
where.append("run_id = ?")
|
||
args.append(str(run_id))
|
||
if stage:
|
||
where.append("stage = ?")
|
||
args.append(str(stage))
|
||
if agent_code:
|
||
where.append("who = ?")
|
||
args.append(str(agent_code))
|
||
where_sql = " AND ".join(where)
|
||
size = min(1000, max(1, _as_int(page_size or limit, 100)))
|
||
offset = (max(1, _as_int(page, 1)) - 1) * size
|
||
rows = get_store().query(
|
||
"SELECT * FROM pbl_agent_trace WHERE %s ORDER BY created_at DESC, step_no DESC LIMIT ? OFFSET ?"
|
||
% where_sql, args + [size, offset])
|
||
total_row = get_store().query_one(
|
||
"SELECT COUNT(1) AS cnt FROM pbl_agent_trace WHERE %s" % where_sql, args)
|
||
return ok(items=rows, data=rows, list=rows, total=_as_int((total_row or {}).get("cnt"), 0),
|
||
tenant_id=tenant, page=_as_int(page, 1), page_size=size)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# fail-closed 8 步裁决
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _validate_params(schema, params: dict):
|
||
"""极简 JSON-Schema 子集校验:type/required/properties。返回 (ok, msg)。"""
|
||
schema = schema or {"type": "object"}
|
||
if not isinstance(schema, dict):
|
||
return True, ""
|
||
expected = str(schema.get("type") or "object").lower()
|
||
if expected == "object" and not isinstance(params, dict):
|
||
return False, "params must be object"
|
||
required = schema.get("required") or []
|
||
if isinstance(required, (list, tuple)):
|
||
for name in required:
|
||
if name not in params or params[name] in (None, ""):
|
||
return False, "missing required param: %s" % name
|
||
props = schema.get("properties") or {}
|
||
if isinstance(props, dict):
|
||
for name, spec in props.items():
|
||
if name not in params or not isinstance(spec, dict):
|
||
continue
|
||
want = str(spec.get("type") or "").lower()
|
||
value = params[name]
|
||
if want == "string" and not isinstance(value, str):
|
||
return False, "param %s must be string" % name
|
||
if want in ("int", "integer") and not isinstance(value, int):
|
||
return False, "param %s must be integer" % name
|
||
if want in ("number", "float") and not isinstance(value, (int, float)):
|
||
return False, "param %s must be number" % name
|
||
if want in ("bool", "boolean") and not isinstance(value, bool):
|
||
return False, "param %s must be boolean" % name
|
||
return True, ""
|
||
|
||
|
||
def _pbl_tool_adjudicate(tenant_id=None, agent_code=None, tool_key=None, params_json=None,
|
||
params=None, trace_id=None, run_id=None, approval_id=None,
|
||
dry_run=False, **_ignored) -> Result:
|
||
"""服务端 fail-closed 8 步裁决。默认 DENY;任一步不过立即 DENY 并留痕。"""
|
||
started = now_ms()
|
||
tenant = _tenant_or_raise(tenant_id)
|
||
seed_registry(tenant)
|
||
store = get_store()
|
||
|
||
agent_code = (agent_code or "").strip()
|
||
tool_key = (tool_key or "").strip()
|
||
raw_params = params_json if params_json is not None else params
|
||
if isinstance(raw_params, str):
|
||
parsed = loads(raw_params, None)
|
||
if parsed is None and raw_params.strip():
|
||
parsed = None
|
||
params_invalid = "params_json is not valid JSON"
|
||
else:
|
||
params_invalid = None
|
||
elif isinstance(raw_params, dict):
|
||
parsed = dict(raw_params)
|
||
params_invalid = None
|
||
elif raw_params in (None, ""):
|
||
parsed = {}
|
||
params_invalid = None
|
||
else:
|
||
parsed = {}
|
||
params_invalid = "params_json must be object or JSON string"
|
||
|
||
trace_id = trace_id or new_id("trc")
|
||
steps_done = []
|
||
|
||
def deny(step_no, step_key, reason_code, message, **extra):
|
||
latency = now_ms() - started
|
||
out = fail(reason_code, message, allowed=False, decision="DENY", denied=True,
|
||
agent_code=agent_code, tool_key=tool_key, tenant_id=tenant,
|
||
trace_id=trace_id, denied_at_step=step_key, denied_step_no=step_no,
|
||
reason_code=reason_code, steps=steps_done, latency_ms=latency,
|
||
need_approval=reason_code == "PBL.APPROVAL.REQUIRED")
|
||
out.update(extra)
|
||
if not dry_run:
|
||
try:
|
||
store.insert("pbl_agent_tool_call", {
|
||
"tenant_id": tenant, "trace_id": trace_id,
|
||
"call_no": _as_int(store.query_one(
|
||
"SELECT COUNT(1) AS c FROM pbl_agent_tool_call WHERE tenant_id = ? AND trace_id = ?",
|
||
(tenant, trace_id)) or {}, 0) + 1,
|
||
"agent_code": agent_code or "unknown", "tool_key": tool_key or "unknown",
|
||
"params_json": dumps(parsed), "decision": "DENY",
|
||
"denied_at_step": step_key, "reason_code": reason_code,
|
||
"approval_id": extra.get("approval_id"), "latency_ms": latency,
|
||
"created_at": now_str(),
|
||
})
|
||
_write_trace_row(
|
||
tenant, trace_id=trace_id, run_id=run_id, step_no=step_no, stage="adjudicate",
|
||
who=agent_code or "unknown", what="tool_adjudicate tool_key=%s" % tool_key,
|
||
why=message, how="fail-closed step %s" % step_key, result="DENY",
|
||
evidence_ref="pbl_agent_tool_call:%s" % trace_id, tool_key=tool_key,
|
||
decision="DENY")
|
||
except PblContractError:
|
||
raise
|
||
return out
|
||
|
||
def allow(step_no, approval_row=None, **extra):
|
||
latency = now_ms() - started
|
||
out = ok(allowed=True, decision="ALLOW", denied=False, agent_code=agent_code,
|
||
tool_key=tool_key, tenant_id=tenant, trace_id=trace_id, steps=steps_done,
|
||
latency_ms=latency, reason_code=None)
|
||
out.update(extra)
|
||
if not dry_run:
|
||
store.insert("pbl_agent_tool_call", {
|
||
"tenant_id": tenant, "trace_id": trace_id,
|
||
"call_no": _as_int(store.query_one(
|
||
"SELECT COUNT(1) AS c FROM pbl_agent_tool_call WHERE tenant_id = ? AND trace_id = ?",
|
||
(tenant, trace_id)) or {}, 0) + 1,
|
||
"agent_code": agent_code or "unknown", "tool_key": tool_key or "unknown",
|
||
"params_json": dumps(parsed), "decision": "ALLOW", "denied_at_step": None,
|
||
"reason_code": None,
|
||
"approval_id": (approval_row or {}).get("approval_id"),
|
||
"latency_ms": latency, "created_at": now_str(),
|
||
})
|
||
_write_trace_row(
|
||
tenant, trace_id=trace_id, run_id=run_id, step_no=step_no, stage="adjudicate",
|
||
who=agent_code or "unknown", what="tool_adjudicate tool_key=%s" % tool_key,
|
||
why="8 步裁决全部通过", how="fail-closed adjudication S1..S8", result="ALLOW",
|
||
evidence_ref="pbl_agent_tool_call:%s" % trace_id, tool_key=tool_key,
|
||
decision="ALLOW")
|
||
return out
|
||
|
||
# S1 租户上下文
|
||
steps_done.append({"step": "S1", "key": "tenant_context", "pass": True})
|
||
|
||
# S2 Agent 已注册且 active
|
||
if not agent_code:
|
||
return deny(2, "agent_registered", "PBL.AGENT.CODE_REQUIRED", "agent_code is required")
|
||
agent = store.query_one(
|
||
"SELECT * FROM pbl_agent_def WHERE tenant_id = ? AND agent_code = ?", (tenant, agent_code))
|
||
if not agent:
|
||
return deny(2, "agent_registered", "PBL.AGENT.NOT_REGISTERED",
|
||
"agent %s not registered (fail-closed)" % agent_code, agent_code=agent_code)
|
||
if str(agent.get("status") or "").lower() != "active":
|
||
return deny(2, "agent_registered", "PBL.AGENT.NOT_ACTIVE",
|
||
"agent %s is not active" % agent_code, agent_status=agent.get("status"))
|
||
steps_done.append({"step": "S2", "key": "agent_registered", "pass": True})
|
||
|
||
# S3 Critic 零写
|
||
tool_row = store.query_one(
|
||
"SELECT * FROM pbl_tool_registry WHERE tenant_id = ? AND tool_key = ?", (tenant, tool_key))
|
||
is_write = bool(tool_row and _as_int(tool_row.get("write_class"), 0) == 1)
|
||
can_write = _as_int(agent.get("can_write"), 0) == 1
|
||
if is_write and not can_write:
|
||
return deny(3, "agent_write_scope", "PBL.AGENT.WRITE_DENIED",
|
||
"agent %s has zero write permission (Critic), tool %s denied"
|
||
% (agent_code, tool_key), write_class=1, can_write=False)
|
||
steps_done.append({"step": "S3", "key": "agent_write_scope", "pass": True})
|
||
|
||
# S4 工具已登记
|
||
if not tool_key:
|
||
return deny(4, "tool_registered", "PBL.TOOL.KEY_REQUIRED", "tool_key is required")
|
||
if not tool_row:
|
||
return deny(4, "tool_registered", "PBL.TOOL.NOT_REGISTERED",
|
||
"tool %s not in registry (fail-closed default DENY)" % tool_key)
|
||
steps_done.append({"step": "S4", "key": "tool_registered", "pass": True})
|
||
|
||
# S5 工具启用
|
||
if str(tool_row.get("status") or "").lower() != "enabled":
|
||
return deny(5, "tool_enabled", "PBL.TOOL.DISABLED",
|
||
"tool %s is disabled: %s" % (tool_key, tool_row.get("reason") or "by design"),
|
||
tool_status=tool_row.get("status"))
|
||
steps_done.append({"step": "S5", "key": "tool_enabled", "pass": True})
|
||
|
||
# S6 权限
|
||
required_perm = tool_row.get("required_perm")
|
||
agent_perms = loads(agent.get("perms"), []) or []
|
||
if required_perm and required_perm not in agent_perms:
|
||
return deny(6, "permission_granted", "PBL.PERM.DENIED",
|
||
"agent %s lacks perm %s for tool %s" % (agent_code, required_perm, tool_key),
|
||
required_perm=required_perm)
|
||
steps_done.append({"step": "S6", "key": "permission_granted", "pass": True})
|
||
|
||
# S7 参数校验
|
||
if params_invalid:
|
||
return deny(7, "params_valid", "PBL.PARAMS.INVALID", params_invalid)
|
||
schema = loads(tool_row.get("params_schema"), {"type": "object"})
|
||
valid, message = _validate_params(schema, parsed)
|
||
if not valid:
|
||
return deny(7, "params_valid", "PBL.PARAMS.INVALID", message)
|
||
steps_done.append({"step": "S7", "key": "params_valid", "pass": True})
|
||
|
||
# S8 四类强制人工审批
|
||
approval_type = tool_row.get("approval_type")
|
||
approval_row = None
|
||
if approval_type or approval_id:
|
||
if approval_id:
|
||
approval_row = store.query_one(
|
||
"SELECT * FROM pbl_approval WHERE tenant_id = ? AND approval_id = ?",
|
||
(tenant, str(approval_id)))
|
||
else:
|
||
approval_row = store.query_one(
|
||
"SELECT * FROM pbl_approval WHERE tenant_id = ? AND approval_type = ? "
|
||
"AND tool_key = ? AND status = 'approved' ORDER BY decided_at DESC LIMIT 1",
|
||
(tenant, approval_type, tool_key))
|
||
if not approval_row or str(approval_row.get("status")) != "approved":
|
||
created = _pbl_approval_create(
|
||
tenant_id=tenant, approval_type=approval_type or "blueprint_approve",
|
||
title="工具 %s 需人工审批" % tool_key, requested_by=agent_code,
|
||
agent_code=agent_code, tool_key=tool_key, params_json=dumps(parsed),
|
||
)
|
||
return deny(8, "approval_granted", "PBL.APPROVAL.REQUIRED",
|
||
"tool %s requires human approval type=%s (no bypass)"
|
||
% (tool_key, approval_type),
|
||
approval_type=approval_type, approval_id=created.get("approval_id"),
|
||
approval_status="pending")
|
||
steps_done.append({"step": "S8", "key": "approval_granted", "pass": True})
|
||
|
||
return allow(8, approval_row=approval_row,
|
||
approval_id=(approval_row or {}).get("approval_id"),
|
||
approval_type=approval_type, write_class=1 if is_write else 0)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 审批:create / decide / list
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _pbl_approval_create(tenant_id=None, approval_type=None, title=None, payload_json=None,
|
||
requested_by=None, agent_code=None, tool_key=None, params_json=None,
|
||
expires_in_hours=72, **_ignored) -> Result:
|
||
"""创建人工审批单(14.2 四类)。approval_type 非法 → 拒绝(fail-closed)。"""
|
||
tenant = _tenant_or_raise(tenant_id)
|
||
kind = (approval_type or "").strip()
|
||
if not kind:
|
||
return fail("PBL.APPROVAL.TYPE_REQUIRED", "approval_type is required")
|
||
if kind not in MANDATORY_APPROVAL_TYPES:
|
||
return fail("PBL.APPROVAL.TYPE_INVALID",
|
||
"approval_type %s not in mandatory types %s"
|
||
% (kind, ",".join(MANDATORY_APPROVAL_TYPES)),
|
||
approval_type=kind, allowed_types=list(MANDATORY_APPROVAL_TYPES))
|
||
payload = payload_json if payload_json is not None else params_json
|
||
approval_id = new_id("apr")
|
||
expires_at = time.strftime("%Y-%m-%d %H:%M:%S",
|
||
time.localtime(time.time() + _as_int(expires_in_hours, 72) * 3600))
|
||
row = get_store().insert(
|
||
"pbl_approval",
|
||
{
|
||
"tenant_id": tenant, "approval_id": approval_id, "approval_type": kind,
|
||
"title": title or "%s 审批" % kind,
|
||
"payload_json": dumps(payload) if not isinstance(payload, str) else payload,
|
||
"status": "pending", "requested_by": requested_by or agent_code or "system",
|
||
"agent_code": agent_code, "tool_key": tool_key, "expires_at": expires_at,
|
||
"created_at": now_str(), "updated_at": now_str(),
|
||
},
|
||
)
|
||
return ok(approval_id=approval_id, id=row["id"], data=row, item=row,
|
||
approval_type=kind, status="pending", tenant_id=tenant, expires_at=expires_at)
|
||
|
||
|
||
def _pbl_approval_decide(tenant_id=None, approval_id=None, decision=None, action=None,
|
||
decided_by=None, comment=None, decision_comment=None, **_ignored) -> Result:
|
||
"""人工审批裁决:approve / reject。只有真人(decided_by 非 agent)可裁决。"""
|
||
tenant = _tenant_or_raise(tenant_id)
|
||
verdict = (decision or action or "").strip().lower()
|
||
if verdict in ("approved", "pass", "yes", "allow"):
|
||
verdict = "approve"
|
||
if verdict in ("rejected", "deny", "denied", "no"):
|
||
verdict = "reject"
|
||
if verdict not in ("approve", "reject"):
|
||
return fail("PBL.APPROVAL.DECISION_INVALID",
|
||
"decision must be approve|reject, got %r" % (decision or action))
|
||
if not approval_id:
|
||
return fail("PBL.APPROVAL.ID_REQUIRED", "approval_id is required")
|
||
decider = (decided_by or "").strip()
|
||
if not decider:
|
||
return fail("PBL.APPROVAL.DECIDER_REQUIRED", "decided_by is required (human only)")
|
||
if decider in ("designer", "critic", "agent", "system"):
|
||
return fail("PBL.APPROVAL.NO_BYPASS",
|
||
"approval cannot be decided by agent %s (no bypass path)" % decider)
|
||
row = get_store().query_one(
|
||
"SELECT * FROM pbl_approval WHERE tenant_id = ? AND approval_id = ?",
|
||
(tenant, str(approval_id)))
|
||
if not row:
|
||
return fail(ERR_NOT_FOUND, "approval %s not found" % approval_id, approval_id=approval_id)
|
||
if str(row.get("status")) != "pending":
|
||
return fail("PBL.APPROVAL.ALREADY_DECIDED",
|
||
"approval %s already %s" % (approval_id, row.get("status")),
|
||
status=row.get("status"))
|
||
new_status = "approved" if verdict == "approve" else "rejected"
|
||
get_store().execute(
|
||
"UPDATE pbl_approval SET status = ?, decided_by = ?, decided_at = ?, "
|
||
"decision_comment = ?, updated_at = ? WHERE id = ?",
|
||
(new_status, decider, now_str(), (comment or decision_comment or ""), now_str(), row["id"]),
|
||
)
|
||
updated = get_store().query_one(
|
||
"SELECT * FROM pbl_approval WHERE id = ?", (row["id"],))
|
||
return ok(approval_id=str(approval_id), status=new_status, decision=verdict,
|
||
data=updated, item=updated, tenant_id=tenant, decided_by=decider)
|
||
|
||
|
||
def _pbl_approval_list(tenant_id=None, status=None, approval_type=None, agent_code=None,
|
||
tool_key=None, limit=100, page=1, page_size=None, **_ignored) -> Result:
|
||
tenant = _tenant_or_raise(tenant_id)
|
||
where = ["tenant_id = ?"]
|
||
args = [tenant]
|
||
if status:
|
||
where.append("status = ?")
|
||
args.append(str(status).strip().lower())
|
||
if approval_type:
|
||
where.append("approval_type = ?")
|
||
args.append(str(approval_type))
|
||
if agent_code:
|
||
where.append("agent_code = ?")
|
||
args.append(str(agent_code))
|
||
if tool_key:
|
||
where.append("tool_key = ?")
|
||
args.append(str(tool_key))
|
||
where_sql = " AND ".join(where)
|
||
size = min(500, max(1, _as_int(page_size or limit, 100)))
|
||
offset = (max(1, _as_int(page, 1)) - 1) * size
|
||
rows = get_store().query(
|
||
"SELECT * FROM pbl_approval WHERE %s ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
||
% where_sql, args + [size, offset])
|
||
for row in rows:
|
||
row["payload"] = loads(row.get("payload_json"), {})
|
||
total_row = get_store().query_one(
|
||
"SELECT COUNT(1) AS cnt FROM pbl_approval WHERE %s" % where_sql, args)
|
||
return ok(items=rows, data=rows, list=rows, total=_as_int((total_row or {}).get("cnt"), 0),
|
||
tenant_id=tenant, page=_as_int(page, 1), page_size=size)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Designer / Critic 运行时
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _agent_run(agent_code, tenant_id=None, run_id=None, task=None, prompt=None, blueprint_id=None,
|
||
tools=None, model=None, trace_id=None, **_ignored) -> Result:
|
||
tenant = _tenant_or_raise(tenant_id)
|
||
seed_registry(tenant)
|
||
store = get_store()
|
||
agent = store.query_one(
|
||
"SELECT * FROM pbl_agent_def WHERE tenant_id = ? AND agent_code = ?", (tenant, agent_code))
|
||
if not agent:
|
||
return fail("PBL.AGENT.NOT_REGISTERED", "agent %s not registered" % agent_code,
|
||
agent_code=agent_code, allowed=False)
|
||
if str(agent.get("status") or "").lower() != "active":
|
||
return fail("PBL.AGENT.NOT_ACTIVE", "agent %s not active" % agent_code,
|
||
allowed=False, agent_status=agent.get("status"))
|
||
|
||
run_id = run_id or new_id("run")
|
||
trace_id = trace_id or new_id("trc")
|
||
can_write = _as_int(agent.get("can_write"), 0) == 1
|
||
requested = tools if isinstance(tools, (list, tuple)) else loads(tools, []) or []
|
||
if agent_code == "critic" and requested:
|
||
# Critic 零写:任何写类工具请求在运行入口即被拒绝(不等 S3)
|
||
write_requested = []
|
||
for key in requested:
|
||
row = store.query_one(
|
||
"SELECT write_class FROM pbl_tool_registry WHERE tenant_id = ? AND tool_key = ?",
|
||
(tenant, str(key)))
|
||
if row and _as_int(row.get("write_class"), 0) == 1:
|
||
write_requested.append(str(key))
|
||
if write_requested:
|
||
return fail("PBL.AGENT.WRITE_DENIED",
|
||
"critic has zero write permission, denied tools: %s"
|
||
% ",".join(write_requested),
|
||
allowed=False, agent_code=agent_code, run_id=run_id,
|
||
denied_tools=write_requested, can_write=False)
|
||
|
||
prompt_text = prompt or task or ""
|
||
llm = _invoke_llm(tenant, agent_code, run_id,
|
||
"designer_run" if agent_code == "designer" else "critic_run",
|
||
"%s\n%s" % (agent.get("system_prompt") or "", prompt_text), model=model)
|
||
|
||
adjudications = []
|
||
for key in requested:
|
||
verdict = _pbl_tool_adjudicate(
|
||
tenant_id=tenant, agent_code=agent_code, tool_key=str(key),
|
||
params_json=dumps({"run_id": run_id, "blueprint_id": blueprint_id}),
|
||
run_id=run_id, trace_id=None)
|
||
adjudications.append({"tool_key": str(key), "decision": verdict.get("decision"),
|
||
"reason_code": verdict.get("reason_code"),
|
||
"denied_at_step": verdict.get("denied_at_step")})
|
||
|
||
_write_trace_row(
|
||
tenant, trace_id=trace_id, run_id=run_id, step_no=1, stage="%s_run" % agent_code,
|
||
who=agent_code, what="run task: %s" % (str(prompt_text)[:200]),
|
||
why=agent.get("system_prompt") or "", how="llm=%s attempt=%s"
|
||
% (llm.get("text", "")[:12], llm.get("attempt_no")),
|
||
result="fallback" if llm.get("fallback_used") else "ok",
|
||
evidence_ref="pbl_llm_call_log:%s" % run_id, tool_key=None,
|
||
decision="ALLOW" if can_write else "READ_ONLY")
|
||
|
||
return ok(run_id=run_id, trace_id=trace_id, agent_code=agent_code, tenant_id=tenant,
|
||
can_write=can_write, write_scope=agent.get("write_scope"),
|
||
output=llm.get("text"), text=llm.get("text"), data={"output": llm.get("text")},
|
||
llm=llm, adjudications=adjudications,
|
||
tool_calls=len(adjudications),
|
||
allowed=can_write,
|
||
fallback_used=bool(llm.get("fallback_used")),
|
||
status="fallback" if llm.get("fallback_used") else "ok")
|
||
|
||
|
||
def _pbl_agent_designer_run(**kwargs) -> Result:
|
||
return _agent_run("designer", **kwargs)
|
||
|
||
|
||
def _pbl_agent_critic_run(**kwargs) -> Result:
|
||
return _agent_run("critic", **kwargs)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 契约对象 api()
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_IMPL = {
|
||
"pbl_agent_designer_run": _pbl_agent_designer_run,
|
||
"pbl_agent_critic_run": _pbl_agent_critic_run,
|
||
"pbl_agent_trace_write": _pbl_agent_trace_write,
|
||
"pbl_agent_trace_list": _pbl_agent_trace_list,
|
||
"pbl_tool_registry_list": _pbl_tool_registry_list,
|
||
"pbl_tool_registry_save": _pbl_tool_registry_save,
|
||
"pbl_tool_adjudicate": _pbl_tool_adjudicate,
|
||
"pbl_approval_create": _pbl_approval_create,
|
||
"pbl_approval_decide": _pbl_approval_decide,
|
||
"pbl_approval_list": _pbl_approval_list,
|
||
}
|
||
|
||
|
||
def _wrapped(name):
|
||
impl = _IMPL[name]
|
||
|
||
def contract_fn(**kwargs):
|
||
return _run(impl, **kwargs)
|
||
|
||
contract_fn.__name__ = name
|
||
contract_fn.__qualname__ = name
|
||
contract_fn.__doc__ = (impl.__doc__ or "").strip().splitlines()[0] if (impl.__doc__ or "").strip() else name
|
||
contract_fn.contract_name = name
|
||
return contract_fn
|
||
|
||
|
||
class _ContractApi(dict):
|
||
"""契约对象:既可 ``api.pbl_tool_adjudicate(...)``,也可 ``api['pbl_tool_adjudicate'](...)``。"""
|
||
|
||
def __getattr__(self, item):
|
||
try:
|
||
return self[item]
|
||
except KeyError:
|
||
raise AttributeError("pbl_agent_runtime contract has no function %r" % item)
|
||
|
||
def names(self):
|
||
return list(CONTRACT_FUNCTIONS)
|
||
|
||
|
||
def build_api() -> _ContractApi:
|
||
api_obj = _ContractApi()
|
||
for name in CONTRACT_FUNCTIONS:
|
||
api_obj[name] = _wrapped(name)
|
||
# 附加运维/自检能力(非契约端点,不进 load_path)
|
||
api_obj["seed_registry"] = lambda **kw: _run(seed_registry, **kw)
|
||
api_obj["log_llm_call"] = lambda **kw: _run(log_llm_call, **kw)
|
||
api_obj["self_check"] = lambda **kw: _run(self_check, **kw)
|
||
api_obj["CONTRACT_FUNCTIONS"] = list(CONTRACT_FUNCTIONS)
|
||
api_obj["ADJUDICATION_STEPS"] = list(ADJUDICATION_STEPS)
|
||
api_obj["MANDATORY_APPROVAL_TYPES"] = list(MANDATORY_APPROVAL_TYPES)
|
||
api_obj["TRACE_ELEMENTS"] = list(TRACE_ELEMENTS)
|
||
api_obj["APPEND_ONLY_TABLES"] = list(APPEND_ONLY_TABLES)
|
||
return api_obj
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 自检:挂载即校验,不过抛 RuntimeError(fail-closed)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def self_check(tenant_id=None, strict=True) -> dict:
|
||
"""挂载自检:契约齐全 + seed 幂等 + fail-closed 语义 + append-only 守卫。"""
|
||
problems = []
|
||
tenant = resolve_tenant(tenant_id) or "0"
|
||
api_obj = build_api()
|
||
|
||
for name in CONTRACT_FUNCTIONS:
|
||
if not callable(api_obj.get(name)):
|
||
problems.append("contract function missing: %s" % name)
|
||
|
||
try:
|
||
seed = seed_registry(tenant)
|
||
if seed["enabled"] != 13 or seed["disabled"] != 9:
|
||
problems.append("tool seed count wrong: %s" % seed)
|
||
except Exception as exc: # noqa: BLE001
|
||
problems.append("seed_registry failed: %s" % exc)
|
||
|
||
listing = _run(_pbl_tool_registry_list, tenant_id=tenant)
|
||
if not listing.get("success"):
|
||
problems.append("pbl_tool_registry_list failed: %s" % listing.get("error_msg"))
|
||
else:
|
||
keys = {item["tool_key"]: item for item in listing.get("items", [])}
|
||
if "pbl.publish" not in keys:
|
||
problems.append("pbl.publish missing from registry")
|
||
elif keys["pbl.publish"].get("status") != "disabled":
|
||
problems.append("pbl.publish must be disabled")
|
||
enabled = [k for k, v in keys.items() if v.get("enabled")]
|
||
if len(enabled) != 13:
|
||
problems.append("enabled tool count=%d, expect 13" % len(enabled))
|
||
|
||
critic_write = _run(_pbl_tool_adjudicate, tenant_id=tenant, agent_code="critic",
|
||
tool_key="blueprint.write", params_json="{}")
|
||
if critic_write.get("allowed") is not False:
|
||
problems.append("critic write tool was not denied (zero-write violated)")
|
||
elif critic_write.get("denied_at_step") != "agent_write_scope":
|
||
problems.append("critic deny step=%s, expect agent_write_scope"
|
||
% critic_write.get("denied_at_step"))
|
||
|
||
publish = _run(_pbl_tool_adjudicate, tenant_id=tenant, agent_code="designer",
|
||
tool_key="pbl.publish", params_json="{}")
|
||
if publish.get("allowed") is not False:
|
||
problems.append("pbl.publish was not denied")
|
||
|
||
unknown = _run(_pbl_tool_adjudicate, tenant_id=tenant, agent_code="designer",
|
||
tool_key="no.such.tool", params_json="{}")
|
||
if unknown.get("allowed") is not False or unknown.get("decision") != "DENY":
|
||
problems.append("unknown tool default decision is not DENY")
|
||
|
||
no_tenant = _run(_pbl_tool_adjudicate, tenant_id="", agent_code="designer",
|
||
tool_key="blueprint.read", params_json="{}")
|
||
if no_tenant.get("success") is not False:
|
||
problems.append("missing tenant_id was not rejected")
|
||
|
||
store = get_store()
|
||
try:
|
||
store.execute("DELETE FROM pbl_agent_trace WHERE 1 = 0")
|
||
problems.append("append-only guard failed on pbl_agent_trace")
|
||
except PblContractError as exc:
|
||
if exc.code != ERR_APPEND_ONLY:
|
||
problems.append("append-only guard wrong code: %s" % exc.code)
|
||
|
||
approval_bad = _run(_pbl_approval_create, tenant_id=tenant, approval_type="not_a_type")
|
||
if approval_bad.get("success") is not False:
|
||
problems.append("invalid approval_type was accepted")
|
||
|
||
result = {
|
||
"success": not problems,
|
||
"module": "pbl_agent_runtime",
|
||
"tenant_id": tenant,
|
||
"contract_functions": list(CONTRACT_FUNCTIONS),
|
||
"contract_count": len(CONTRACT_FUNCTIONS),
|
||
"tables": 6,
|
||
"append_only_tables": list(APPEND_ONLY_TABLES),
|
||
"adjudication_steps": [s[0] for s in ADJUDICATION_STEPS],
|
||
"mandatory_approval_types": list(MANDATORY_APPROVAL_TYPES),
|
||
"problems": problems,
|
||
}
|
||
if problems and strict:
|
||
raise RuntimeError("pbl_agent_runtime self_check failed (fail-closed): %s"
|
||
% "; ".join(problems))
|
||
return Result(result)
|