aps-agent/server/agent_core/fallback_lane.py

1736 lines
80 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 兜底车道编排器 v1(moduleId: core-fallback-lane, 可重生 ✅)
# 《Pi-Agent兜底能力详细方案》§4.1/§4.6 + P1-DESIGN §2.1/§3/§5:
# 意图未识别(assistant.reply/unknown)时,拉起 Pi headless 做一次
# **只读分析兜底**(草稿语义,不写世界),失败显式回退原话术。
# - 三重熔断:超时 / 步数上限 / 输出体量上限,触发即杀进程树并显式判败;
# - 成败只看事件流 stopReason,绝不相信进程退出码(P0 实测坑:pi 恒退 0);
# - 环境白名单清洗 + PI_CODING_AGENT_DIR 配置圈禁 + taskkill 进程树回收;
# - 模型端点协商(GET /models,避开 P0 踩过的 404 坑),结果缓存 300s。
# 吸收 poc/pi-fallback/orchestrator.py 设计但**产品级重写,不 import poc**。
# ============================================================
from __future__ import annotations
import hashlib
import json
import os
import queue
import shutil
import subprocess
import threading
import time
import urllib.request
import uuid
from collections.abc import Callable, Iterator
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Any
# AgentRunner:给定 (task, work_dir) 产出 JSONL 事件 dict 迭代器。
# 真实实现 = pi headless 子进程(build_pi_runner);fake runner 只允许测试注入。
AgentRunner = Callable[[str, Path], Iterator[dict]]
class FallbackUnavailable(Exception):
"""运行时不可用(无 node / 无 pi / 模型协商失败 / 无模型 key)。一律显式失败。"""
# ---------------------------------------------------------------------------
# 集中配置(改行为只改这里 + 环境变量)
# ---------------------------------------------------------------------------
@dataclass
class FallbackConfig:
"""集中配置。全部为类默认值,由 from_env() 覆盖。"""
timeout_sec: float = 90.0 # 闸 1:单次运行超时(chat 同步预算)
max_steps: int = 30 # 闸 2:工具调用步数上限
max_output_bytes: int = 2 * 1024 * 1024 # 闸 3:assistant 输出累计体量(2MiB)
poll_interval_sec: float = 1.0 # 读事件流轮询间隔(进程挂起也能被闸 1 抓到)
model: str = "" # 显式模型(APS_FALLBACK_MODEL);空 = 协商
pi_cli: str = "" # pi cli.js 路径;空 = 默认解析
pi_home: str = "" # PI_CODING_AGENT_DIR;空 = <run根>/pi-home
node_bin: str = "node" # APS_FALLBACK_NODE 可覆盖
tools: str = "read,grep,find,ls" # pi 启动工具白名单(L1 第一道墙,只读四件套)
exec_timeout_sec: float = 120.0 # 执行段 ASSISTED run 预算闸(APS_FALLBACK_EXEC_TIMEOUT_SEC)
exec_max_steps: int = 40 # 执行段工具/请求步数闸(APS_FALLBACK_EXEC_MAX_STEPS)
max_plan_steps: int = 10 # 计划步骤数上限(APS_FALLBACK_MAX_PLAN_STEPS)
@classmethod
def from_env(cls) -> FallbackConfig:
def _float(name: str, default: float) -> float:
try:
return float(os.environ.get(name, "") or default)
except ValueError:
return default
def _int(name: str, default: int) -> int:
try:
return int(os.environ.get(name, "") or default)
except ValueError:
return default
# P1 复用 P0 安装;打包(桌面 sidecar)留后续阶段,路径必须可配置。
repo_root = Path(__file__).resolve().parents[2]
default_cli = (repo_root / "poc" / "pi-fallback" / "runtime" / "node_modules"
/ "@mariozechner" / "pi-coding-agent" / "dist" / "cli.js")
return cls(
timeout_sec=_float("APS_FALLBACK_TIMEOUT_SEC", 90.0),
max_steps=_int("APS_FALLBACK_MAX_STEPS", 30),
max_output_bytes=_int("APS_FALLBACK_MAX_OUTPUT_BYTES", 2 * 1024 * 1024),
model=(os.environ.get("APS_FALLBACK_MODEL") or "").strip(),
pi_cli=(os.environ.get("APS_FALLBACK_PI_CLI") or "").strip() or str(default_cli),
pi_home=(os.environ.get("APS_FALLBACK_PI_HOME") or "").strip(),
node_bin=(os.environ.get("APS_FALLBACK_NODE") or "").strip() or "node",
exec_timeout_sec=_float("APS_FALLBACK_EXEC_TIMEOUT_SEC", 120.0),
exec_max_steps=_int("APS_FALLBACK_EXEC_MAX_STEPS", 40),
max_plan_steps=_int("APS_FALLBACK_MAX_PLAN_STEPS", 10),
)
@dataclass
class FallbackOutcome:
"""一次兜底运行的最终判定。ok 只由 stopReason=="stop" 且凭证校验通过决定。"""
run_id: str
ok: bool
stop_reason: str = "" # "stop" / "error" / "breaker:timeout(...)" /
# "breaker:max_steps(...)" / "breaker:max_output(...)" /
# "harness_error" / "unavailable:<原因>" / "forged_citation"
error_message: str = ""
steps: int = 0
output_bytes: int = 0
elapsed_sec: float = 0.0
report_text: str = "" # Pi 最终 assistant 文本(= outbox/report.md 内容)
run_dir: str = ""
citation_check: dict = field(default_factory=dict)
# ---------------------------------------------------------------------------
# FF-01 开关查询(默认关语义,单一事实源 = feature_flags.load_feature_flags)
# ---------------------------------------------------------------------------
def fallback_feature_enabled() -> bool:
"""fallback 键显式 true 才为 True(默认关)。任何异常 → False(宁可误关不可误开)。"""
try:
from server.agent_core.feature_flags import load_feature_flags
flags = load_feature_flags()
return bool(flags["features"]["fallback"]["enabled"])
except Exception: # noqa: BLE001 - 设计要求:任何异常 → False(宁可误关不可误开)
return False
# ---------------------------------------------------------------------------
# run 目录与 L4 环境清洗
# ---------------------------------------------------------------------------
def fallback_root() -> Path:
"""run 根目录:APS_FALLBACK_DIR 或 path_under_data("fallback")(与 aps_home 口径一致)。"""
configured = (os.environ.get("APS_FALLBACK_DIR") or "").strip()
if configured:
return Path(configured).expanduser().resolve()
from server.aps_home import path_under_data
return path_under_data("fallback")
def _pi_home(config: FallbackConfig) -> Path:
return Path(config.pi_home).expanduser().resolve() if config.pi_home \
else fallback_root() / "pi-home"
def new_run_id() -> str:
""""fb-" + 时间戳 + uuid4 短串。贯穿审计/calls.jsonl/run 目录。"""
return time.strftime("fb-%Y%m%d-%H%M%S-") + uuid.uuid4().hex[:6]
def create_run_dirs(run_id: str, config: FallbackConfig) -> dict[str, Path]:
"""建 L2 三区:{"root","inbox","work","outbox"},并确保 pi-home 配置圈禁目录存在。"""
root = fallback_root() / run_id
dirs = {
"root": root,
"inbox": root / "inbox",
"work": root / "work",
"outbox": root / "outbox",
}
for p in dirs.values():
p.mkdir(parents=True, exist_ok=True)
_pi_home(config).mkdir(parents=True, exist_ok=True)
return dirs
# 子进程环境变量白名单(L4 进程层围墙)
_ENV_WHITELIST = (
"PATH", "PATHEXT", "SYSTEMROOT", "SYSTEMDRIVE", "WINDIR", "COMSPEC",
"TEMP", "TMP", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "HOME",
"NODE_OPTIONS",
)
# 显式剥离名单(即使同名出现在白名单也再剥一层,双保险)
_ENV_STRIP_PREFIXES = ("CONDA_", "PYTHON", "PIP_", "VIRTUAL_ENV")
def build_child_env(config: FallbackConfig, extra: dict | None = None) -> dict:
"""L4 环境清洗:白名单制,剥离 CONDA_*/PYTHON*/PIP_*/VIRTUAL_ENV*;
强制 PI_CODING_AGENT_DIR=config.pi_home。extra 用于注入 LLM_API_KEY
(值只进子进程内存,绝不打印/落盘)。"""
env: dict = {}
for key in _ENV_WHITELIST:
if key in os.environ and not key.upper().startswith(_ENV_STRIP_PREFIXES):
env[key] = os.environ[key]
env["PI_CODING_AGENT_DIR"] = str(_pi_home(config))
if extra:
for k, v in extra.items():
if not k.upper().startswith(_ENV_STRIP_PREFIXES):
env[k] = v
return env
def kill_process_tree(pid: int) -> None:
"""Windows taskkill /PID /T /F;非 Windows 降级 os.killpg。异常吞掉(尽力回收)。"""
try:
if os.name == "nt":
subprocess.run(
["taskkill", "/PID", str(pid), "/T", "/F"],
capture_output=True, timeout=30, check=False,
)
else:
import signal
os.killpg(pid, signal.SIGKILL)
except Exception: # noqa: BLE001, S110 - 尽力回收:杀进程树失败不阻断失败判定
pass
# ---------------------------------------------------------------------------
# 模型端点协商(P0 的 404 坑对策)+ pi-home/models.json
# ---------------------------------------------------------------------------
_MODEL_CACHE_TTL_SEC = 300.0
_MODEL_CACHE: dict[str, Any] = {"model": None, "note": "", "ts": 0.0}
_PROVIDER_NAME = "aps-fallback"
def resolve_model(config: FallbackConfig) -> str | None:
"""模型协商。返回 "<provider>/<model>" 或 None(不可用)。
顺序:显式 config.model → 直接用(操作员显式负责,不探测);
缺 LLM_BASE_URL/LLM_API_KEY → None;GET /models 协商(LLM_MODEL 不在清单
则取第一个并显式记录协商说明);进程内缓存 300s。
"""
if config.model:
return config.model
base_url = (os.environ.get("LLM_BASE_URL") or "").strip().rstrip("/")
api_key = (os.environ.get("LLM_API_KEY") or "").strip()
if not base_url or not api_key:
return None
now = time.monotonic()
if _MODEL_CACHE["model"] and now - _MODEL_CACHE["ts"] < _MODEL_CACHE_TTL_SEC:
return str(_MODEL_CACHE["model"])
try:
req = urllib.request.Request(
f"{base_url}/models",
headers={"Authorization": f"Bearer {api_key}"},
)
with urllib.request.urlopen(req, timeout=5) as resp:
payload = json.loads(resp.read().decode("utf-8"))
ids = [m.get("id") for m in (payload.get("data") or []) if m.get("id")]
except Exception: # noqa: BLE001 - 网络/解析错误异构,统一归并为「不可用」
return None
if not ids:
return None
wanted = (os.environ.get("LLM_MODEL") or "").strip()
note = ""
if wanted and wanted in ids:
chosen = wanted
else:
chosen = ids[0]
note = f"配置的 LLM_MODEL={wanted or '(空)'} 不可用,协商改用 {chosen}"
model = f"{_PROVIDER_NAME}/{chosen}"
_MODEL_CACHE.update({"model": model, "note": note, "ts": now})
return model
def _write_models_json(config: FallbackConfig, model: str) -> None:
"""把协商结果写 pi-home/models.json。apiKey 只写环境变量名引用 "LLM_API_KEY",
绝不落 key 明文(P0 已实测此机制有效)。"""
model_id = model.split("/", 1)[1] if "/" in model else model
base_url = (os.environ.get("LLM_BASE_URL") or "").strip().rstrip("/")
doc = {
"providers": {
_PROVIDER_NAME: {
"baseUrl": base_url,
"api": "openai-completions",
"apiKey": "LLM_API_KEY", # 环境变量名引用,非明文
"authHeader": True,
"models": [{
"id": model_id,
"name": model_id,
"reasoning": False,
"input": ["text"],
"contextWindow": 262144,
"maxTokens": 8192,
"cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0},
}],
},
},
}
pi_home = _pi_home(config)
pi_home.mkdir(parents=True, exist_ok=True)
(pi_home / "models.json").write_text(
json.dumps(doc, ensure_ascii=False, indent=2), encoding="utf-8")
# ---------------------------------------------------------------------------
# L1 第二道墙:守卫扩展(bash/edit 全 block、文件工具限 run 目录)
# ---------------------------------------------------------------------------
_GUARD_TS_TEMPLATE = """// AUTO-GENERATED by fallback_lane.py — 守卫扩展(L1/L2 围墙的 pi 侧执行点)。
// pi.on("tool_call") 返回 {{ block: true, reason }} 即可在工具执行前拦截(P0 已实测)。
import fs from "node:fs";
import path from "node:path";
const RUN_ROOT = path.normalize("{RUN_ROOT_POSIX}");
const BLOCKLOG = path.join(RUN_ROOT, "guard-blocked-calls.jsonl");
function inRunRoot(p: string): boolean {{
const abs = path.resolve(process.cwd(), p);
const norm = path.normalize(abs);
return norm === RUN_ROOT || norm.startsWith(RUN_ROOT + path.sep);
}}
function deny(toolName: string, toolCallId: string, reason: string, input: any) {{
fs.appendFileSync(
BLOCKLOG,
JSON.stringify({{ ts: new Date().toISOString(), toolName, toolCallId, reason, input }}) + "\\n",
);
return {{ block: true, reason }};
}}
export default function (pi: any) {{
pi.on("tool_call", async (event: any, _ctx: any) => {{
const name: string = event.toolName;
const input: any = event.input || {{}};
// 1) bash / edit / write:P1 全禁(只读兜底,无写面、无任意命令执行面)。
if (name === "bash" || name === "edit" || name === "write") {{
return deny(name, event.toolCallId, "disabled by fallback guard (read-only lane)", input);
}}
// 2) 文件类工具:路径必须落在 run 目录内(L2 圈禁的 pi 侧执行点)。
const fileTools = ["read", "grep", "find", "ls"];
if (fileTools.includes(name)) {{
const p: string = String(input.path || input.pattern || ".");
if (!inRunRoot(p)) return deny(name, event.toolCallId, "path escapes run root", input);
}}
// 放行
}});
}}
"""
# plan/execute 模式守卫(v2):bash 仍全禁、防逃逸不变、inbox 只读;
# 唯一放松 = write/edit 限 run 目录内 work/ 与 outbox/(计划草稿/制品/动作请求的
# 唯一落点;世界写入仍只能走动作请求邮箱 → 计划锁)。
_GUARD_TS_TEMPLATE_WRITE = """// AUTO-GENERATED by fallback_lane.py — 守卫扩展 v2({MODE_LABEL} 模式:work/outbox 可写)。
// pi.on("tool_call") 返回 {{ block: true, reason }} 即可在工具执行前拦截(P0 已实测)。
import fs from "node:fs";
import path from "node:path";
const RUN_ROOT = path.normalize("{RUN_ROOT_POSIX}");
const WRITE_DIRS = [path.join(RUN_ROOT, "work"), path.join(RUN_ROOT, "outbox")];
const BLOCKLOG = path.join(RUN_ROOT, "guard-blocked-calls.jsonl");
function inRunRoot(p: string): boolean {{
const abs = path.resolve(process.cwd(), p);
const norm = path.normalize(abs);
return norm === RUN_ROOT || norm.startsWith(RUN_ROOT + path.sep);
}}
function inWriteDirs(p: string): boolean {{
const abs = path.normalize(path.resolve(process.cwd(), p));
return WRITE_DIRS.some((d) => abs === d || abs.startsWith(d + path.sep));
}}
function deny(toolName: string, toolCallId: string, reason: string, input: any) {{
fs.appendFileSync(
BLOCKLOG,
JSON.stringify({{ ts: new Date().toISOString(), toolName, toolCallId, reason, input }}) + "\\n",
);
return {{ block: true, reason }};
}}
export default function (pi: any) {{
pi.on("tool_call", async (event: any, _ctx: any) => {{
const name: string = event.toolName;
const input: any = event.input || {{}};
// 1) bash:全禁(无任意命令执行面)。
if (name === "bash") {{
return deny(name, event.toolCallId, "disabled by fallback guard (no shell)", input);
}}
// 2) write / edit:仅放行 run 目录内 work/ 与 outbox/(inbox 只读、其余全拒)。
if (name === "write" || name === "edit") {{
const p: string = String(input.path || ".");
if (!inWriteDirs(p)) {{
return deny(name, event.toolCallId, "write outside work/outbox (fallback guard)", input);
}}
return;
}}
// 3) 文件类只读工具:路径必须落在 run 目录内(L2 圈禁的 pi 侧执行点)。
const fileTools = ["read", "grep", "find", "ls"];
if (fileTools.includes(name)) {{
const p: string = String(input.path || input.pattern || ".");
if (!inRunRoot(p)) return deny(name, event.toolCallId, "path escapes run root", input);
}}
// 放行
}});
}}
"""
_GUARD_WRITE_TOOLS = "read,grep,find,ls,write,edit" # plan/execute 模式 pi 工具白名单
def write_guard_extension(run_dir: Path, mode: str = "readonly") -> Path:
"""生成 guard-<runId>.ts(L1 第二道墙)。返回路径供 pi `-e` 加载,随运行归档。
mode:readonly(默认,P1 模板逐字节保持)/ plan / execute(v2 模板,
放开 write/edit 至 run 目录内 work/+outbox/,其余围墙不变)。
"""
run_dir = Path(run_dir).resolve()
if mode == "readonly":
content = _GUARD_TS_TEMPLATE.format(RUN_ROOT_POSIX=run_dir.as_posix())
elif mode in ("plan", "execute"):
content = _GUARD_TS_TEMPLATE_WRITE.format(
RUN_ROOT_POSIX=run_dir.as_posix(), MODE_LABEL=mode)
else:
raise ValueError(f"未知守卫模式: {mode}")
out = run_dir / f"guard-{run_dir.name}.ts"
out.write_text(content, encoding="utf-8")
return out
# ---------------------------------------------------------------------------
# 真实 pi headless runner
# ---------------------------------------------------------------------------
def _resolve_node(node_bin: str) -> str | None:
"""node 解析顺序(P1 真实冒烟坑 B 对策):
1) 显式配置(APS_FALLBACK_NODE / config.node_bin 非默认值)最高优先级,原样命中;
2) 默认 "node" 且 Windows 时优先 shutil.which("node.exe")——避开 PATH 中先于
node.exe 命中的 node.CMD 垫片(Popen 起 .cmd 引号语义会炸,pi 秒败);
3) 其余情况回退 shutil.which("node")。
"""
if node_bin != "node":
return shutil.which(node_bin)
if os.name == "nt":
return shutil.which("node.exe") or shutil.which("node")
return shutil.which(node_bin)
def build_pi_runner(config: FallbackConfig, *, mode: str = "readonly") -> AgentRunner:
"""构造真实 pi headless runner(读线程+queue 轮询、心跳事件、finally 杀进程树)。
mode:守卫模式(readonly/plan/execute),决定生成的守卫扩展放行面。
Raises FallbackUnavailable:node/pi_cli 缺失或模型协商失败——调用方把它当
「不可用」显式失败处理。
"""
pi_cli = Path(config.pi_cli) if config.pi_cli else None
if not pi_cli or not pi_cli.is_file():
raise FallbackUnavailable(f"pi cli 不存在: {config.pi_cli or '(未配置)'}")
node = _resolve_node(config.node_bin)
if not node:
raise FallbackUnavailable(f"node 不在 PATH(APS_FALLBACK_NODE={config.node_bin})")
api_key = (os.environ.get("LLM_API_KEY") or "").strip()
if not (os.environ.get("LLM_BASE_URL") or "").strip() or not api_key:
raise FallbackUnavailable("无模型配置(LLM_BASE_URL/LLM_API_KEY 缺失)")
model = resolve_model(config)
if not model:
raise FallbackUnavailable("模型协商失败(GET /models 不可达或清单为空)")
_write_models_json(config, model)
def runner(task: str, work_dir: Path) -> Iterator[dict]:
guard = write_guard_extension(work_dir.parent, mode=mode)
cmd = [
node, str(pi_cli),
"-p", "--mode", "json",
"--model", model,
"--tools", config.tools,
"-e", str(guard),
task,
]
env = build_child_env(config, extra={"LLM_API_KEY": api_key})
proc = subprocess.Popen(
cmd, cwd=str(work_dir), env=env,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0,
)
q: queue.Queue[str | None] = queue.Queue()
def reader() -> None:
try:
for raw in proc.stdout:
q.put(raw.decode("utf-8", errors="replace"))
finally:
q.put(None) # EOF 哨兵
t = threading.Thread(target=reader, daemon=True)
t.start()
killed = False
try:
while True:
try:
line = q.get(timeout=config.poll_interval_sec)
except queue.Empty:
if proc.poll() is not None and not t.is_alive():
break
# 让上层熔断循环有机会检查超时:产出一个心跳事件
yield {"type": "harness_heartbeat"}
continue
if line is None:
break
line = line.strip()
if not line:
continue
try:
yield json.loads(line)
except json.JSONDecodeError:
yield {"type": "harness_nonjson", "raw": line[:500]}
finally:
# 进程回收:编排器熔断 break 时会 close 本生成器,
# 这里只杀进程树、不许再 yield(closing 中 yield 会 RuntimeError)。
if proc.poll() is None:
killed = True
kill_process_tree(proc.pid)
try:
proc.wait(timeout=15)
except Exception: # noqa: BLE001, S110 - 进程已杀,wait 失败无需处理
pass
# 以下仅正常 EOF 退出时可达(被 close 时 GeneratorExit 直接传播出去)
if killed:
yield {"type": "harness_killed", "pid": proc.pid}
err = proc.stderr.read().decode("utf-8", errors="replace") if proc.stderr else ""
if err.strip():
yield {"type": "harness_stderr", "text": err[-2000:]}
return runner
# ---------------------------------------------------------------------------
# 事件解析辅助
# ---------------------------------------------------------------------------
def _extract_text_delta(event: dict) -> str:
"""从 message_update/message_end 事件取 assistant 文本增量(用于输出体量闸)。"""
if event.get("type") == "message_update":
delta = event.get("delta") or {}
if isinstance(delta, dict):
return str(delta.get("text") or "")
if event.get("type") == "message_end":
msg = event.get("message") or {}
for part in (msg.get("content") or []):
if isinstance(part, dict) and part.get("type") == "text":
return str(part.get("text") or "")
return ""
def _extract_stop(event: dict) -> tuple[str | None, str | None, str | None]:
"""从事件里提取 (stopReason, errorMessage, 文本)。
兼容两个位置:message_end.message.stopReason 与 agent_end.messages 最后一条。"""
etype = event.get("type")
msg = None
if etype == "message_end":
msg = event.get("message") or {}
elif etype == "agent_end":
msgs = event.get("messages") or []
assistants = [m for m in msgs if m.get("role") == "assistant"]
msg = assistants[-1] if assistants else None
if not msg:
return None, None, None
txt = ""
for part in (msg.get("content") or []):
if isinstance(part, dict) and part.get("type") == "text":
txt += str(part.get("text") or "")
return msg.get("stopReason"), msg.get("errorMessage"), txt
# ---------------------------------------------------------------------------
# 主循环:三重熔断 + stopReason 判定 + 事件落盘
# ---------------------------------------------------------------------------
def _run_events(
runner: AgentRunner,
task: str,
dirs: dict[str, Path],
config: FallbackConfig,
run_id: str,
on_tool_event: Callable[[dict], None] | None = None,
*,
events_name: str = "events.jsonl",
mailbox=None, # ActionMailbox(P2 执行段;None=P1 语义)
on_mailbox_request: Callable[[dict], None] | None = None,
is_done: Callable[[], bool] | None = None,
) -> FallbackOutcome:
"""消费事件流,执行熔断与判定,落 events.jsonl / orchestrator.log。
P2 执行段扩展(mailbox 非 None 时):每消费一个事件后扫描动作请求邮箱,
逐个交 on_mailbox_request 处理(计划锁比对 + 执行);DeviationError
→ breaker:plan_deviation 熔断。is_done 返回 True 时提前收束(步骤全部完成)。
"""
run_dir = dirs["root"]
log_path = run_dir / "orchestrator.log"
events_path = run_dir / events_name
def log(msg: str) -> None:
with open(log_path, "a", encoding="utf-8") as f:
f.write(f"[{time.strftime('%H:%M:%S')}] {msg}\n")
outcome = FallbackOutcome(run_id=run_id, ok=False, run_dir=str(run_dir))
t0 = time.monotonic()
log(f"run_id={run_id} task={task[:120]!r}")
log(f"config: {config}")
if _MODEL_CACHE.get("note"):
log(f"模型协商:{_MODEL_CACHE['note']}")
breaker_tripped: str | None = None
last_assistant_text = ""
stop_reason = ""
error_message = ""
def _write_event(evf, event: dict) -> None:
try:
evf.write(json.dumps(event, ensure_ascii=False) + "\n")
except Exception: # noqa: BLE001 - 事件落盘绝不能中断主循环,降级占位记录
evf.write(json.dumps({"type": "unserializable_event"}) + "\n")
try:
with open(events_path, "w", encoding="utf-8") as evf:
for event in runner(task, dirs["work"]):
elapsed = time.monotonic() - t0
# —— 闸 1:超时 ——
if elapsed > config.timeout_sec:
breaker_tripped = f"breaker:timeout({elapsed:.1f}s>{config.timeout_sec}s)"
_write_event(evf, {"type": "breaker", "reason": breaker_tripped})
break
_write_event(evf, event)
etype = event.get("type", "")
# —— 步数统计 + 闸 2 ——
if etype == "tool_execution_start":
outcome.steps += 1
if on_tool_event:
on_tool_event(event)
if outcome.steps > config.max_steps:
breaker_tripped = f"breaker:max_steps({outcome.steps}>{config.max_steps})"
_write_event(evf, {"type": "breaker", "reason": breaker_tripped})
break
elif etype == "tool_execution_end" and on_tool_event:
on_tool_event(event)
# —— 输出体量统计 + 闸 3 ——
text_delta = _extract_text_delta(event)
if text_delta:
outcome.output_bytes += len(text_delta.encode("utf-8"))
if outcome.output_bytes > config.max_output_bytes:
breaker_tripped = (
f"breaker:max_output({outcome.output_bytes}>{config.max_output_bytes})"
)
_write_event(evf, {"type": "breaker", "reason": breaker_tripped})
break
# —— stopReason 判定(成败唯一权威)——
sr, em, txt = _extract_stop(event)
if sr:
stop_reason, error_message = sr, em or ""
if txt:
last_assistant_text = txt
if etype == "auto_retry_start":
log(f"auto_retry_start attempt={event.get('attempt')}")
# —— P2 执行段:每消费一个事件后扫动作请求邮箱(桥侧事件流)——
if mailbox is not None and on_mailbox_request is not None:
try:
for request in mailbox.scan():
outcome.steps += 1
if outcome.steps > config.max_steps:
breaker_tripped = (
f"breaker:max_steps({outcome.steps}>{config.max_steps})")
_write_event(evf, {"type": "breaker", "reason": breaker_tripped})
break
on_mailbox_request(request)
if breaker_tripped:
break
except DeviationError as exc:
breaker_tripped = f"breaker:plan_deviation({exc.kind}:{exc.detail})"
_write_event(evf, {"type": "breaker", "reason": breaker_tripped})
break
# —— P2 执行段:计划步骤全部完成即收束(不等 agent 自然结束)——
if is_done is not None and is_done():
log("all plan steps executed -> closing runner")
break
if etype == "agent_end":
break
except Exception as exc: # noqa: BLE001 - runner 抛错/桥违规统一归并显式失败
stop_reason = "harness_error"
error_message = f"{type(exc).__name__}: {exc}"
log(f"HARNESS ERROR: {error_message}")
outcome.elapsed_sec = time.monotonic() - t0
outcome.report_text = last_assistant_text
if breaker_tripped:
outcome.stop_reason = breaker_tripped
outcome.error_message = "熔断触发,运行显式标记失败"
log(f"BREAKER TRIPPED: {breaker_tripped} -> failed")
elif stop_reason == "stop":
outcome.ok = True
outcome.stop_reason = "stop"
log(f"OK stopReason=stop steps={outcome.steps} out={outcome.output_bytes}B "
f"elapsed={outcome.elapsed_sec:.1f}s")
else:
outcome.stop_reason = stop_reason or "error:no_stop_reason"
outcome.error_message = error_message or "事件流未给出 stopReason=stop,按失败处理"
log(f"FAILED stopReason={outcome.stop_reason} err={outcome.error_message}")
return outcome
# ---------------------------------------------------------------------------
# 回复组装(精确文案契约,测试可断言)
# ---------------------------------------------------------------------------
def _stop_reason_cn(stop_reason: str) -> str:
if stop_reason.startswith("breaker:timeout"):
return "运行超时(已触发超时熔断)"
if stop_reason.startswith("breaker:max_steps"):
return "工具调用步数超限(已触发步数熔断)"
if stop_reason.startswith("breaker:max_output"):
return "输出体量超限(已触发输出熔断)"
if stop_reason.startswith("unavailable:"):
return f"兜底运行时不可用({stop_reason.split(':', 1)[1]})"
if stop_reason == "forged_citation":
return "报告引用了不存在的凭证(按伪造成果判失败)"
if stop_reason == "harness_error":
return "编排器内部错误"
return stop_reason or "未知原因"
def _compose_success_reply(outcome: FallbackOutcome) -> str:
text = f"[智能兜底 · 草稿] run {outcome.run_id}\n\n"
if outcome.steps == 0:
text += "(Pi 本次未读取项目数据,以下为纯推理草稿)\n\n"
return (
text + outcome.report_text
+ "\n\n---\n以上为 Pi 只读分析草稿(未改动任何数据),凭证与过程见审计。"
)
# ---------------------------------------------------------------------------
# 接线入口(workflow.py assistant.reply/unknown 分支调用)
# ---------------------------------------------------------------------------
async def propose_reply(
store,
session_id: str,
intent,
*,
actor: str = "planner",
runner: AgentRunner | None = None, # 测试注入点;None=真实 pi
config: FallbackConfig | None = None, # 测试注入点;None=from_env()
):
"""unknown/assistant.reply 分支的唯一接线入口。
返回语义:
- None → 未触发(开关关 / query 为空):调用方走原话术,零副作用零审计;
- AgentReply → 已触发。成功=Pi 报告正文;失败=原话术+一行显式失败说明。
保证:本函数绝不抛出——内部所有异常归并为显式失败,聊天链路永远有回复。
"""
# 第一关:开关(False → 零副作用直接放行原路径)
if not fallback_feature_enabled():
return None
try:
query = str(intent.params.get("query") or intent.params.get("text") or "").strip()
if not query:
return None
hist = intent.params.get("_history") or []
config = config or FallbackConfig.from_env()
run_id = new_run_id()
# 运行时可用性(仅真实 runner 检查;注入 runner 为测试路径,跳过)
if runner is None:
try:
runner = build_pi_runner(_plan_run_config(config), mode="plan")
except Exception as exc: # noqa: BLE001 - 不可用统一归并显式失败(不装死)
outcome = FallbackOutcome(
run_id=run_id, ok=False,
stop_reason=f"unavailable:{exc}", error_message=str(exc))
_write_completion_audit(store, actor, outcome, query)
return await _compose_failure_reply(store, query, hist, session_id, outcome)
from server.integrations.pi_bridge import PiBridge, render_plan_task_brief
dirs = create_run_dirs(run_id, config)
bridge = PiBridge(run_id, dirs["root"])
try:
snapshot_files = bridge.export_snapshot(store.data, dirs)
except Exception as exc: # noqa: BLE001 - 快照失败不阻断兜底,降级纯推理并显式记日志
snapshot_files = []
_append_run_log(dirs["root"], f"快照导出失败(继续纯推理): {exc}")
task = render_plan_task_brief(
run_id=run_id, query=query, snapshot_files=snapshot_files,
executable_intents=tuple(FALLBACK_EXECUTABLE_INTENTS))
on_tool_event = _make_tool_event_handler(store, bridge, run_id,
write_map=True) # plan 模式有 write/edit
outcome = _run_events(runner, task, dirs, config, run_id,
on_tool_event=on_tool_event)
# 凭证校验:引用不存在的 callId = 伪造成果,物理判失败
if outcome.ok:
outcome.citation_check = bridge.validate_report_citations(outcome.report_text)
if not outcome.citation_check["valid"]:
outcome.ok = False
outcome.stop_reason = "forged_citation"
outcome.error_message = (
f"报告引用了不存在的 callId: {outcome.citation_check['missing']}")
elif not outcome.report_text.strip():
outcome.ok = False
outcome.stop_reason = "error:empty_report"
outcome.error_message = "stopReason=stop 但最终报告为空,按失败处理"
# P2 计划锁:Pi 产了 outbox/plan.json → 出卡前全量校验(任一不过即显式失败,
# 非法计划绝不降级成草稿糊弄);没写 plan.json = P1 草稿语义(向后兼容)。
plan_doc: dict | None = None
if outcome.ok:
try:
plan_doc = load_plan(dirs["root"])
if plan_doc is not None:
validate_plan(plan_doc, dirs["root"], max_steps=config.max_plan_steps)
except PlanError as exc:
outcome.ok = False
outcome.stop_reason = "plan_invalid"
outcome.error_message = str(exc)
# 产物唯一出口 + 结果落盘
report_path = ""
if outcome.report_text:
report_path = str(dirs["outbox"] / "report.md")
(dirs["outbox"] / "report.md").write_text(outcome.report_text, encoding="utf-8")
outcome.citation_check = outcome.citation_check or {}
_write_result_json(dirs["root"], outcome)
_write_completion_audit(store, actor, outcome, query, report_path=report_path)
if outcome.ok and plan_doc is not None:
return _stage_plan_confirmation(store, session_id, run_id, dirs, plan_doc,
actor=actor)
if outcome.ok:
from server.contracts import AgentReply
return AgentReply(text=_compose_success_reply(outcome))
if outcome.stop_reason == "plan_invalid":
from server.agent_core.assistant import reply as _assistant_reply
original = await _assistant_reply(store.data, query, history=hist,
session_id=session_id)
original.text += (
f"\n\n---\n(智能兜底产出的执行计划未通过校验:{outcome.error_message},"
"未生成确认卡,你的数据未被改动)"
)
return original
return await _compose_failure_reply(store, query, hist, session_id, outcome)
except Exception: # noqa: BLE001 - 绝不抛出:意外异常回退原话术(用户无感知)
try:
from server.agent_core.assistant import reply as assistant_reply
q = str(intent.params.get("query") or intent.params.get("text") or "")
return await assistant_reply(store.data, q,
history=intent.params.get("_history") or [],
session_id=session_id)
except Exception: # noqa: BLE001 - 原话术也失败时返回 None,由调用方兜底
return None
def _append_run_log(run_dir: Path, msg: str) -> None:
"""追加一行 orchestrator.log(同步函数,避免在 async 接线入口里做阻塞 IO)。"""
with open(run_dir / "orchestrator.log", "a", encoding="utf-8") as f:
f.write(f"[{time.strftime('%H:%M:%S')}] {msg}\n")
def _write_result_json(run_dir: Path, outcome: FallbackOutcome) -> None:
"""落 result.json(含凭证校验结果;同步函数,理由同上)。"""
with open(run_dir / "result.json", "w", encoding="utf-8") as f:
json.dump(outcome.__dict__, f, ensure_ascii=False, indent=2, default=str)
# pi 内置工具 → 桥登记工具的映射(桥侧凭证签发;映射外工具出现即 ToolBridgeViolation,
# 经主循环 except 归并为 harness_error 显式失败——双保险,正常不会触达)
_PI_TOOL_MAP = {"read": "fs_read", "grep": "fs_read", "find": "fs_read", "ls": "fs_read"}
def _make_tool_event_handler(store, bridge, run_id: str, *,
write_map: bool = False) -> Callable[[dict], None]:
"""每个工具事件:签/补 callId 凭证 + 写 tool.run 审计(actor=pi-fallback:<runId>)。
write_map=True(P2 plan 模式 propose 段):pi 有 write/edit 工具(守卫 v2 放开
work/outbox),事件映射必须用含 fs_write 的扩展表,否则 Pi 写 plan.json 的
首个 write 事件即抛 ToolBridgeViolation(P2 真实冒烟实测 harness_error)。
默认 False 保持 P1 readonly 语义逐字节不变。
"""
from server.agent_core.audit import write_audit
pi_call_ids: dict[str, str] = {}
tool_map = _PI_TOOL_MAP_WRITE if write_map else _PI_TOOL_MAP
def on_tool_event(event: dict) -> None:
etype = event.get("type")
tool = str(event.get("toolName") or "")
pi_id = str(event.get("toolCallId") or "")
if etype == "tool_execution_start":
mapped = tool_map.get(tool)
if mapped is None:
from server.integrations.pi_bridge import ToolBridgeViolation
raise ToolBridgeViolation(f"pi 工具未在桥映射表登记: {tool}")
call_id = bridge.issue_call(
mapped,
params=event.get("args") or event.get("input"),
pi_tool_call_id=pi_id or None,
)
pi_call_ids[pi_id] = call_id
write_audit(
store.data, store.next_id,
actor=f"pi-fallback:{run_id}", category="TOOL", action="tool.run",
target={"type": "PI_TOOL", "id": f"{tool}/{call_id}"},
power="P0",
rationale={"runId": run_id, "piToolCallId": pi_id, "bridgeTool": mapped},
)
elif etype == "tool_execution_end":
call_id = pi_call_ids.get(pi_id)
if call_id:
bridge.complete_call(
call_id,
result=event.get("result") or event.get("output") or "",
ok=not event.get("isError"),
)
return on_tool_event
def _write_completion_audit(store, actor: str, outcome: FallbackOutcome, query: str,
report_path: str = "") -> None:
"""完成时 1 条审计(成败都写),随后 store.save()。"""
from server.agent_core.audit import write_audit
write_audit(
store.data, store.next_id,
actor=actor, category="TOOL", action="agent.fallback.propose",
target={"type": "FALLBACK_RUN", "id": outcome.run_id},
power="P1",
rationale={
"runId": outcome.run_id,
"queryDigest": hashlib.sha256(query.encode("utf-8")).hexdigest()[:16],
"stopReason": outcome.stop_reason,
"steps": outcome.steps,
"elapsedSec": round(outcome.elapsed_sec, 2),
"citationCheck": {
"cited": len(outcome.citation_check.get("cited") or []),
"missing": len(outcome.citation_check.get("missing") or []),
},
"runDir": outcome.run_dir,
"reportPath": report_path,
},
result="SUCCESS" if outcome.ok else "FAILED",
evidence_refs=[f"fallback-run:{outcome.run_id}"],
)
store.save()
async def _compose_failure_reply(store, query: str, hist, session_id: str,
outcome: FallbackOutcome):
"""失败 = 原 assistant_reply 话术全文 + 一行显式失败说明。"""
from server.agent_core.assistant import reply as assistant_reply
original = await assistant_reply(store.data, query, history=hist, session_id=session_id)
original.text += (
f"\n\n---\n(智能兜底本次未完成:{_stop_reason_cn(outcome.stop_reason)},"
f"已记录审计 run {outcome.run_id};你的数据未被改动)"
)
return original
# ---------------------------------------------------------------------------
# P2:写操作过确认卡门禁(GOAL-P2 + P2-DESIGN §2/§3)
# 计划锁:propose 产出结构化计划(outbox/plan.json planVersion=1),出卡时
# 冻结 plan + 计划指纹 sha256 进 pending.params;execute 逐步比对——计划外
# 工具/参数越界/跳步/制品指纹不符 → breaker:plan_deviation 立即熔断;
# checkpoint 强制:批准后立即建前快照,成功后建后快照,失败先存失败现场
# 再 restore 回滚(restore 会抹世界内审计——FAILED 总账由调用方分支在
# restore 之后补写);Pi 无新物理写通道(只有编排既有已登记意图的能力)。
# ---------------------------------------------------------------------------
class PlanError(Exception):
"""计划草稿未通过出卡前校验。任一违反 → 拒绝出卡(非法计划绝不降级成草稿)。"""
class DeviationError(Exception):
"""执行期偏离已批准计划(计划锁熔断依据)。kind ∈ tool/params/step_count/digest。"""
def __init__(self, kind: str, detail: str):
super().__init__(f"{kind}:{detail}")
self.kind = kind
self.detail = detail
# Pi 写世界的全部可能路径。未列入的意图 → 计划校验拒绝(不出卡)。
# 每个执行器复用 execute_confirmed 既有分支调用的同一个 apply_* 函数——
# 「Pi 没有新的物理写能力,只有编排既有写意图的能力」的代码级落实。
FALLBACK_EXECUTABLE_INTENTS: dict[str, str] = {
"import.commit": "importers.apply_import_commit(S3 主干;frozen 步先过 validate_batch)",
"data.import": "intake.apply_import(S9 自然语言批量)",
"order.upsert": "orders.apply_order_action(S9/S2 修复)",
"order.cancel": "orders.apply_order_action",
"order.complete": "orders.apply_order_action",
"master.material.upsert": "masterdata.apply_master_action(S3 附带新物料)",
}
_PLAN_SCENARIOS = ("S1", "S2", "S3", "S9") # 本轮开放集
def _canonical_sha256(obj: Any) -> str:
"""canonical JSON(sort_keys + 紧凑分隔符 + ensure_ascii)的 sha256。"""
blob = json.dumps(obj, ensure_ascii=True, sort_keys=True,
separators=(",", ":"), default=str)
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
def plan_fingerprint(plan: dict) -> str:
"""计划指纹:sha256(canonical_json({planVersion, scenario, steps:[{seq, mode,
intent, paramsDigest|artifactSha256, constraints}]}))。
goal/summary/expected 等展示性字段不入指纹(改措辞不算偏离;改动作/参数边界才算)。
"""
steps = []
for step in plan.get("steps") or []:
entry = {
"seq": step.get("seq"), "mode": step.get("mode"),
"intent": step.get("intent"),
"constraints": step.get("constraints") or {},
}
if step.get("params") is not None:
entry["paramsDigest"] = _canonical_sha256(step["params"])
if step.get("artifactSha256"):
entry["artifactSha256"] = step["artifactSha256"]
steps.append(entry)
return _canonical_sha256({
"planVersion": plan.get("planVersion"),
"scenario": plan.get("scenario"),
"steps": steps,
})
def load_plan(run_dir: Path) -> dict | None:
"""读 outbox/plan.json;不存在 → None(P1 草稿语义);坏 JSON → PlanError。"""
path = Path(run_dir) / "outbox" / "plan.json"
if not path.is_file():
return None
try:
doc = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise PlanError(f"plan.json 不是合法 JSON:{exc}") from exc
if not isinstance(doc, dict):
raise PlanError("plan.json 顶层不是 JSON 对象")
return doc
def _resolve_artifact(run_dir: Path, ref: str) -> Path:
"""制品路径圈禁:必须落在本 run 目录 outbox/artifacts/ 内(resolve 防 ../)。"""
run_dir = Path(run_dir).resolve()
candidate = Path(ref)
if not candidate.is_absolute():
candidate = run_dir / candidate
candidate = candidate.resolve()
artifacts_root = (run_dir / "outbox" / "artifacts").resolve()
if not candidate.is_relative_to(artifacts_root):
raise PlanError(f"制品路径越界(须落在 outbox/artifacts/ 内): {ref}")
return candidate
def validate_plan(plan: dict, run_dir: Path, *, max_steps: int = 10) -> None:
"""出卡前全量校验(P2-DESIGN §2.5 顺序),任一违反 → PlanError 拒绝出卡。
1) schema 结构 → 2) intent 白名单 + power 复查 → 3) artifact 路径圈禁 +
sha256 重算 → 4) constraints 合法性 → 5) 步骤数上限。
"""
from server.agent_core.harness import power_of
if plan.get("planVersion") != 1:
raise PlanError(f"planVersion 必须为 1(实际 {plan.get('planVersion')!r})")
scenario = plan.get("scenario")
if scenario not in _PLAN_SCENARIOS:
raise PlanError(f"scenario 未在开放集 {'/'.join(_PLAN_SCENARIOS)} 内: {scenario!r}")
steps = plan.get("steps")
if not isinstance(steps, list) or not steps:
raise PlanError("steps 为空或不是数组")
if len(steps) > max_steps:
raise PlanError(f"步骤数 {len(steps)} 超出上限 {max_steps}")
for i, step in enumerate(steps, 1):
if not isinstance(step, dict):
raise PlanError(f"步骤 {i} 不是对象")
if step.get("seq") != i:
raise PlanError(f"步骤 seq 必须从 1 严格连续:期望 {i},实际 {step.get('seq')!r}")
intent = str(step.get("intent") or "")
if intent not in FALLBACK_EXECUTABLE_INTENTS:
raise PlanError(f"步骤 {i} 意图未在兜底可执行白名单登记: {intent or '(空)'}")
power = power_of(intent)
if power not in ("P1", "P2"):
raise PlanError(
f"步骤 {i} 意图 {intent} 权力等级为 {power}(高危意图本轮不开放,整计划拒绝)")
mode = step.get("mode")
if mode not in ("frozen", "assisted"):
raise PlanError(f"步骤 {i} mode 非法: {mode!r}")
constraints = step.get("constraints") or {}
if mode == "frozen":
has_inline = step.get("params") is not None
has_artifact = bool(step.get("artifactRef")) and bool(step.get("artifactSha256"))
if not (has_inline or has_artifact):
raise PlanError(f"步骤 {i}(frozen)必须有 params 或 artifactRef+artifactSha256 之一")
elif not constraints:
raise PlanError(f"步骤 {i}(assisted)必须声明 constraints 边界")
if not isinstance(constraints, dict):
raise PlanError(f"步骤 {i} constraints 必须是对象")
if "maxRows" in constraints and (
not isinstance(constraints["maxRows"], int) or constraints["maxRows"] <= 0):
raise PlanError(f"步骤 {i} constraints.maxRows 必须为正整数")
for key in ("kinds", "allowedParamKeys"):
if key in constraints and not isinstance(constraints[key], list):
raise PlanError(f"步骤 {i} constraints.{key} 必须是数组")
expected = step.get("expected")
if expected is not None and not isinstance(expected, list):
raise PlanError(f"步骤 {i} expected 必须是数组")
ref = step.get("artifactRef")
if ref:
path = _resolve_artifact(run_dir, str(ref))
if not path.is_file():
raise PlanError(f"步骤 {i} 制品文件不存在: {ref}")
actual = hashlib.sha256(path.read_bytes()).hexdigest()
declared = str(step.get("artifactSha256") or "")
if actual != declared:
raise PlanError(
f"步骤 {i} 制品指纹虚报(重算 {actual[:12]}… ≠ 申报 {declared[:12]}…)")
# ---------------------------------------------------------------------------
# P2:执行段(execute_confirmed 的 agent.fallback.execute 分支调用)
# ---------------------------------------------------------------------------
@dataclass
class ExecuteResult:
"""一次兜底计划执行的最终结果(workflow 分支据此写审计 + 回文案)。"""
run_id: str
ok: bool
status: str = "failed" # success / failed / blocked / denied
plan_fingerprint: str = ""
steps_executed: int = 0
deviation: str = "" # blocked 时:<kind>:<detail>
error_message: str = ""
rolled_back: bool = False
rollback_verified: bool = False
cp_before: str = ""
cp_after: str = ""
cp_failed: str = ""
report_path: str = ""
execution_log: str = ""
verdict: str = "" # 对账 PASS / MISMATCH
diff_lines: list = field(default_factory=list)
message: str = "" # 面向用户的结果文案(confirm 端点返回值)
def _resolve_checkpoint_store(store):
"""checkpoint 仓解析(saga.py:469 同款先例):store.checkpoints 注入点优先。"""
cps = getattr(store, "checkpoints", None)
if cps is None:
from server.state.checkpoints import get_checkpoints
cps = get_checkpoints()
return cps
def _plan_run_config(config: FallbackConfig) -> FallbackConfig:
"""propose 段(plan 模式):围墙内放开 write/edit 至 work/+outbox/(守卫 v2 执行点)。"""
return replace(config, tools=_GUARD_WRITE_TOOLS)
def _exec_run_config(config: FallbackConfig) -> FallbackConfig:
"""执行段(execute 模式):同样的写面 + 独立预算闸/步数闸(§5.2)。"""
return replace(config, tools=_GUARD_WRITE_TOOLS,
timeout_sec=config.exec_timeout_sec, max_steps=config.exec_max_steps)
def _check_constraints(constraints: dict, params: dict) -> None:
"""assisted 步的参数边界比对(P2-DESIGN §2.4 检查 5)。"""
allowed = constraints.get("allowedParamKeys")
if allowed is not None:
extra = sorted(set(params) - set(allowed))
if extra:
raise DeviationError(
"params", f"参数键越界 {extra}(允许 {sorted(set(allowed))})")
max_rows = constraints.get("maxRows")
if max_rows is not None:
rows = 0
if isinstance(params.get("rows"), list):
rows += len(params["rows"])
for batch in params.get("batches") or []:
batch = batch or {}
rows += len(batch.get("rows") or batch.get("okRows") or [])
if rows > int(max_rows):
raise DeviationError("params", f"行数 {rows} 超出边界 maxRows={max_rows}")
kinds = constraints.get("kinds")
if kinds:
if params.get("kind") is not None and str(params["kind"]) not in kinds:
raise DeviationError("params", f"导入类型越界: {params['kind']} ∉ {kinds}")
for batch in params.get("batches") or []:
kind = str((batch or {}).get("kind") or "")
if kind not in kinds:
raise DeviationError("params", f"导入类型越界: {kind} ∉ {kinds}")
prefix = constraints.get("orderNoPrefix")
if prefix and not str(params.get("orderNo") or "").startswith(str(prefix)):
raise DeviationError(
"params", f"orderNo 不符合前缀约束 {prefix}(实际 {params.get('orderNo')!r})")
def check_step_request(plan: dict, request: dict, state: dict) -> None:
"""逐步比对(P2-DESIGN §2.4):偏离即 DeviationError(熔断依据)。
state = {"next_seq": int, "request_count": int}(request_count 由调用方先自增)。
"""
steps = plan.get("steps") or []
intent = str(request.get("intent") or "")
seq = request.get("seq")
# 4) 计划外追加步骤
if state["request_count"] > len(steps) or (isinstance(seq, int) and seq > len(steps)):
raise DeviationError(
"step_count", f"请求步骤 seq={seq} 超出计划步数 {len(steps)}(计划外追加步骤)")
# 1) 未登记意图 / 权力越级
if intent not in FALLBACK_EXECUTABLE_INTENTS or power_of_intent(intent) not in ("P1", "P2"):
raise DeviationError("tool", f"意图未在兜底可执行白名单或权力越级: {intent or '(空)'}")
# 2) 乱序/跳步/重复
if not isinstance(seq, int) or seq < 1:
raise DeviationError("tool", f"非法步骤序: {seq!r}")
if seq != state["next_seq"]:
raise DeviationError(
"tool", f"步骤序偏离:下一待执行 seq={state['next_seq']},收到 seq={seq}")
step = steps[seq - 1]
# 3) 计划外工具
if intent != step.get("intent"):
raise DeviationError(
"tool", f"计划外工具:步骤 {seq} 计划为 {step.get('intent')},收到 {intent}")
if step.get("mode") == "frozen":
raise DeviationError(
"tool", f"步骤 {seq} 为 frozen 模式(编排器自行执行),不接受执行期请求")
# 5) assisted 参数边界
_check_constraints(step.get("constraints") or {}, request.get("params") or {})
def power_of_intent(intent: str) -> str:
from server.agent_core.harness import power_of
return power_of(intent)
def _verify_frozen_digest(step: dict, run_dir: Path) -> None:
"""frozen 步双保险(P2-DESIGN §2.4 检查 6):出卡后制品文件被改 → digest 偏离。"""
ref = step.get("artifactRef")
if not ref:
return
try:
path = _resolve_artifact(run_dir, str(ref))
except PlanError as exc:
raise DeviationError("digest", str(exc)) from exc
if not path.is_file():
raise DeviationError("digest", f"制品文件缺失: {ref}")
actual = hashlib.sha256(path.read_bytes()).hexdigest()
if actual != str(step.get("artifactSha256") or ""):
raise DeviationError("digest", f"制品指纹与冻结值不符: {ref}")
def _resolve_step_params(step: dict, run_dir: Path) -> dict:
"""frozen 步参数解析:内联 params 深拷贝,或从制品文件载入(制品内容即意图参数)。"""
import copy as _copy
if step.get("params") is not None:
return _copy.deepcopy(step["params"])
ref = step.get("artifactRef")
if ref:
path = _resolve_artifact(run_dir, str(ref))
return json.loads(path.read_text(encoding="utf-8"))
return {}
# -- 步骤执行器(每个函数复用 execute_confirmed 既有分支的同一个 apply_*) ----------
def _apply_import_commit_step(store, params: dict) -> dict:
"""import.commit:先过 validate_batch 行级校验(§0.7 复用点),ok 行才入库。"""
from server.aps_domain.importers import apply_import_commit, validate_batch
batches = []
errors: list[str] = []
for batch in params.get("batches") or []:
kind = str((batch or {}).get("kind") or "")
rows = batch.get("rows") or batch.get("okRows") or []
result = validate_batch(kind, rows, store.data, sheet=batch.get("sheet"))
errors.extend(result.get("errors") or [])
if result.get("okRows"):
batches.append({"kind": kind, "sheet": batch.get("sheet"),
"okRows": result["okRows"]})
applied = apply_import_commit(store.data, store.next_id, batches)
return {"summary": applied.get("summary") or {}, "total": applied.get("total", 0),
"validationErrors": errors,
"auditTarget": {"type": "IMPORT", "id": params.get("filename") or "fallback-plan"}}
def _apply_data_import_step(store, params: dict) -> dict:
from server.aps_domain.intake import apply_import
applied = apply_import(store.data, store.next_id, params)
return {"summary": {applied["kind"]: applied["count"]},
"auditTarget": {"type": "IMPORT", "id": applied["kind"]}}
def _apply_order_step(store, intent: str, params: dict) -> dict:
from server.aps_domain.orders import apply_order_action
applied = apply_order_action(store.data, store.next_id, intent, params)
order = applied["order"]
return {"summary": {"orderNo": order.get("orderNo"), "status": order.get("status"),
"beforeStatus": applied.get("beforeStatus")},
"auditTarget": {"type": "SALES_ORDER", "id": order.get("id"),
"orderNo": order.get("orderNo")}}
def _apply_master_material_step(store, params: dict) -> dict:
from server.aps_domain.masterdata import apply_master_action
applied = apply_master_action(store.data, store.next_id, "master.material.upsert", params)
return {"summary": {"materialId": applied.get("id"), "name": applied.get("name")},
"auditTarget": {"type": "MATERIAL", "id": applied.get("id")}}
# 意图 → 执行器分发表(新增意图 = 改这张表 + harness 登记检查,除此之外无别路)
_STEP_APPLIERS: dict[str, Callable[[Any, dict], dict]] = {
"import.commit": _apply_import_commit_step,
"data.import": _apply_data_import_step,
"master.material.upsert": _apply_master_material_step,
}
def _apply_step(store, step: dict, run_dir: Path, params_override: dict | None = None) -> dict:
"""执行一个计划步:frozen 双保险 digest 校验 → 参数解析 → 分发 apply_*。"""
intent = str(step.get("intent"))
if step.get("mode") == "frozen":
_verify_frozen_digest(step, run_dir)
resolved = params_override if params_override is not None \
else _resolve_step_params(step, run_dir)
if intent in ("order.upsert", "order.cancel", "order.complete"):
return _apply_order_step(store, intent, resolved)
applier = _STEP_APPLIERS.get(intent)
if applier is None: # 理论不可达(出卡已校验白名单)
raise DeviationError("tool", f"意图无执行器登记: {intent}")
return applier(store, resolved)
_PI_TOOL_MAP_WRITE = {**_PI_TOOL_MAP, "write": "fs_write", "edit": "fs_write"}
def _make_exec_tool_event_handler(bridge, run_id: str,
log_rec: Callable[[dict], None]) -> Callable[[dict], None]:
"""执行段工具事件:桥签发/补登 callId + 落世界外 execution.jsonl(不写世界内审计——
失败回滚会抹世界内审计;成功路径的步骤级审计由 execute_plan 批量补写进链)。"""
from server.integrations.pi_bridge import ToolBridgeViolation
pi_call_ids: dict[str, str] = {}
def on_tool_event(event: dict) -> None:
etype = event.get("type")
tool = str(event.get("toolName") or "")
pi_id = str(event.get("toolCallId") or "")
if etype == "tool_execution_start":
mapped = _PI_TOOL_MAP_WRITE.get(tool)
if mapped is None:
raise ToolBridgeViolation(f"pi 工具未在桥映射表登记: {tool}")
call_id = bridge.issue_call(
mapped, params=event.get("args") or event.get("input"),
pi_tool_call_id=pi_id or None)
pi_call_ids[pi_id] = call_id
log_rec({"type": "tool_call", "tool": tool, "bridgeTool": mapped,
"callId": call_id})
elif etype == "tool_execution_end":
call_id = pi_call_ids.get(pi_id)
if call_id:
bridge.complete_call(
call_id, result=event.get("result") or event.get("output") or "",
ok=not event.get("isError"))
return on_tool_event
def _render_exec_task_brief(run_id: str, plan: dict) -> str:
"""ASSISTED 第二次 run 的任务简报(动作请求邮箱协议说明 + 冻结计划复述)。"""
lines = [
f"你是 APS 兜底执行助手(运行 {run_id} 的执行段)。",
"以下计划已获人类批准并冻结,你只能按计划逐步发起动作请求:",
"",
]
for step in plan.get("steps") or []:
lines.append(
f"- 步骤{step.get('seq')} [{step.get('intent')}]({step.get('mode')}):"
f"{step.get('summary') or ''};边界:"
f"{json.dumps(step.get('constraints') or {}, ensure_ascii=False)}")
lines += [
"",
"【动作请求协议】(唯一允许的写路径)",
("1. 轮到某 assisted 步骤时,写文件 `../outbox/actions/<seq>-<intent>.json`,"
"内容 {\"seq\": <步骤号>, \"intent\": \"<意图>\", \"params\": {...}};"),
"2. 然后轮询读同名 .result.json 拿执行结果(ok=false 即被拒绝,附原因);",
("3. 不得请求计划外意图、不得跳步、参数不得越过该步 constraints"
"——越界即熔断并自动回滚;"),
"4. 全部 assisted 步骤完成后,最后一行输出 `status: success` 即可结束。",
]
return "\n".join(lines)
def _execute_steps(store, plan: dict, run_dir: Path, exec_log: Path | None,
step_records: list, *,
runner: AgentRunner | None, config: FallbackConfig | None) -> None:
"""逐步执行计划(FROZEN 编排器直执 / ASSISTED 第二次 run + 邮箱逐步比对)。
偏离 → DeviationError;其余异常原样上抛(调用方统一回滚)。
"""
from server.integrations.pi_bridge import ActionMailbox, PiBridge
steps = plan.get("steps") or []
run_id = str(plan.get("runId") or "")
state = {"next_seq": 1, "request_count": 0}
bridge = PiBridge(run_id or "fb-exec", run_dir)
def log_rec(rec: dict) -> None:
if exec_log is None:
return
with open(exec_log, "a", encoding="utf-8") as f:
f.write(json.dumps(rec, ensure_ascii=False, default=str) + "\n")
def apply_one(step: dict, params_override: dict | None = None) -> None:
out = _apply_step(store, step, run_dir, params_override=params_override)
rec = {"seq": step.get("seq"), "intent": step.get("intent"),
"summary": out.get("summary"), "auditTarget": out.get("auditTarget")}
step_records.append(rec)
log_rec({"type": "step_executed", **rec})
state["next_seq"] = int(step.get("seq")) + 1
def apply_frozen_run() -> None:
while state["next_seq"] <= len(steps) \
and steps[state["next_seq"] - 1].get("mode") == "frozen":
apply_one(steps[state["next_seq"] - 1])
apply_frozen_run() # 头部连续 frozen 步确定性直执
if state["next_seq"] > len(steps):
return # 全 frozen:秒级完成,Pi 不在环
# 含 assisted 步 → 拉起第二次 run(独立预算/步数闸),邮箱逐步比对
cfg = _exec_run_config(config or FallbackConfig.from_env())
if runner is None:
runner = build_pi_runner(cfg, mode="execute")
mailbox = ActionMailbox(run_dir)
def on_request(request: dict) -> None:
state["request_count"] += 1
try:
check_step_request(plan, request, state)
except DeviationError as exc:
mailbox.write_result(
request["_file"],
{"ok": False, "error": f"BLOCKED: {exc.kind}:{exc.detail}"})
raise
step = steps[int(request["seq"]) - 1]
# 桥签发 callId 落 calls.jsonl(桥侧事件流;Pi 不知其值,防伪绊线保留)
call_id = bridge.issue_call(
"aps_invoke",
params={"seq": request["seq"], "intent": request["intent"]})
log_rec({"type": "action_request", "seq": request["seq"],
"intent": request["intent"], "callId": call_id})
try:
apply_one(step, params_override=dict(request.get("params") or {}))
except Exception as exc:
bridge.complete_call(call_id, str(exc), ok=False)
mailbox.write_result(request["_file"],
{"ok": False, "error": str(exc), "callId": call_id})
raise
bridge.complete_call(call_id, step_records[-1].get("summary"), ok=True)
mailbox.write_result(request["_file"],
{"ok": True, "callId": call_id,
"summary": step_records[-1].get("summary")})
apply_frozen_run() # 后续连续 frozen 步编排器直执
exec_dirs = {"root": run_dir, "inbox": run_dir / "inbox",
"work": run_dir / "work", "outbox": run_dir / "outbox"}
for p in exec_dirs.values():
p.mkdir(parents=True, exist_ok=True)
outcome = _run_events(
runner, _render_exec_task_brief(run_id, plan), exec_dirs, cfg, run_id,
on_tool_event=_make_exec_tool_event_handler(bridge, run_id, log_rec),
events_name="execution.events.jsonl",
mailbox=mailbox, on_mailbox_request=on_request,
is_done=lambda: state["next_seq"] > len(steps))
stop = outcome.stop_reason or ""
if stop.startswith("breaker:plan_deviation("): # 邮箱偏离(_run_events 已熔断杀进程树)
inner = stop[len("breaker:plan_deviation("):].rstrip(")")
kind, _, detail = inner.partition(":")
raise DeviationError(kind or "tool", detail or inner)
if stop.startswith("breaker:"):
raise RuntimeError(f"执行段熔断:{stop}")
if state["next_seq"] <= len(steps):
raise RuntimeError(
f"执行段提前结束,计划步骤未完成({state['next_seq'] - 1}/{len(steps)})"
f"(stopReason={stop or '无'})")
def execute_plan(store, pending: dict, *, actor: str,
evidence_refs: list[str] | tuple = (),
runner: AgentRunner | None = None, # 测试注入点;None=真实 pi
config: FallbackConfig | None = None) -> ExecuteResult:
"""执行已批准的兜底计划(execute_confirmed 新分支的唯一调用点)。
调用链(P2-DESIGN §3.1):计划指纹重算 → 世界漂移比对(beforeFingerprint
只存不比的沉睡机制在此补上执行端比对,不改 harness 函数)→ 前快照 →
逐步执行(步骤事件全程落世界外 execution.jsonl)→ 成功:后快照 + diff
验证报告;失败/偏离:失败现场快照 → restore 回滚 → 回滚指纹验证。
本函数绝不抛出;FAILED 总账由调用方分支在 restore 之后补写(世界内审计)。
"""
from server.agent_core import harness as _harness
params = pending.get("params") or {}
plan = params.get("plan") or {}
run_id = str(params.get("runId") or plan.get("runId") or "")
res = ExecuteResult(run_id=run_id, ok=False)
run_dir = fallback_root() / run_id
# 1) 计划指纹重算(防审批仓层篡改;不等 → 显式拒绝,零写入)
try:
res.plan_fingerprint = plan_fingerprint(plan)
except Exception as exc: # noqa: BLE001 - 不可解析计划按拒绝处理(fail closed)
res.status = "denied"
res.error_message = f"计划不可解析:{type(exc).__name__}: {exc}"
res.message = f"兜底执行被拒绝:{res.error_message},未执行任何变更。"
return res
if res.plan_fingerprint != str(params.get("planFingerprint") or ""):
res.status = "denied"
res.error_message = "计划指纹与出卡冻结值不符"
res.message = ("兜底执行被拒绝:已批准计划的完整性校验失败"
"(计划指纹与出卡时冻结值不符),未执行任何变更。")
return res
# 2) 世界漂移比对(fail closed;beforeFingerprint 为 None 时跳过强制)
before_fp = pending.get("beforeFingerprint")
if before_fp and _harness.world_fingerprint(store.data) != str(before_fp):
res.status = "denied"
res.error_message = "出卡后世界已漂移"
res.message = ("兜底执行被拒绝:出卡后项目数据已发生变化(世界指纹漂移),"
"为保证按批准时的口径执行,本次未做任何变更。请重新发起兜底。")
return res
# 3) 执行前快照(批准后才建——审批窗口内世界可能合法变化,出卡期快照会过时)
cps = _resolve_checkpoint_store(store)
cp_before = cps.create(store.data, label=f"兜底执行前基线 {run_id}",
reason="auto:fallback.execute",
conversation_note=f"批准兜底计划 {res.plan_fingerprint[:12]}")
res.cp_before = str(cp_before["pairId"])
run_dir.mkdir(parents=True, exist_ok=True)
(run_dir / "outbox").mkdir(parents=True, exist_ok=True)
exec_log = run_dir / "execution.jsonl"
res.execution_log = str(exec_log)
# 4) 逐步执行(FROZEN 直执 / ASSISTED 第二次 run + 邮箱比对)
step_records: list[dict] = []
try:
_execute_steps(store, plan, run_dir, exec_log, step_records,
runner=runner, config=config)
res.steps_executed = len(step_records)
res.status = "success" # 显式置位(ExecuteResult 默认 failed 兜底)
except DeviationError as exc:
res.status = "blocked"
res.deviation = f"{exc.kind}:{exc.detail}"
res.error_message = str(exc)
except Exception as exc: # noqa: BLE001 - 任一步异常 → 失败显式 + 自动回滚
res.status = "failed"
res.error_message = f"{type(exc).__name__}: {exc}"
res.steps_executed = len(step_records)
# 5b) 失败/偏离:失败现场快照先于 restore(取证留存),随后回滚 + 指纹验证
if res.status in ("blocked", "failed"):
cp_failed = cps.create(store.data, label=f"兜底失败现场 {run_id}",
reason="auto:fallback.execute.failed",
conversation_note=f"兜底执行 {res.status} 现场留档")
res.cp_failed = str(cp_failed["pairId"])
pair = cps.get(res.cp_before)
if pair is not None:
store.restore(pair["world"]) # 现状回滚原语(会抹世界内审计)
res.rolled_back = True
res.rollback_verified = (
_harness.world_fingerprint(store.data)
== _harness.world_fingerprint(pair["world"]))
res.message = _compose_execute_failure_message(res)
return res
# 5a) 全部成功:后快照 + diff 验证报告(数字只许来自冻结快照)
cp_after = cps.create(store.data, label=f"兜底执行后快照 {run_id}",
reason="auto:fallback.execute.post",
conversation_note=f"兜底计划 {res.plan_fingerprint[:12]} 执行完成")
res.cp_after = str(cp_after["pairId"])
before_world = (cps.get(res.cp_before) or {}).get("world") or {}
after_world = (cps.get(res.cp_after) or {}).get("world") or {}
from server.agent_core import fallback_verify
diff = fallback_verify.world_diff(before_world, after_world)
checks = fallback_verify.check_expectations(plan, diff)
res.verdict = "PASS" if all(c["ok"] for c in checks) else "MISMATCH"
res.diff_lines = fallback_verify.diff_summary_lines(diff)
report = fallback_verify.build_report(
run_dir, plan, cp_before_id=res.cp_before, cp_after_id=res.cp_after,
before_world=before_world, after_world=after_world, checks=checks)
res.report_path = str(report)
res.ok = True
res.status = "success"
# 步骤级 TOOL 审计补写进链(成功路径;失败路径由分支在 restore 后补 FAILED 总账)
from server.agent_core.audit import write_audit
for rec in step_records:
write_audit(store.data, store.next_id,
actor=f"pi-fallback:{run_id}", category="TOOL", action="tool.run",
target={"type": "FALLBACK_STEP", "id": f"{run_id}/{rec.get('seq')}"},
power="P2",
rationale={"runId": run_id, "seq": rec.get("seq"),
"intent": rec.get("intent"), "summary": rec.get("summary"),
"planFingerprint": res.plan_fingerprint},
evidence_refs=[f"fallback-run:{run_id}"])
res.message = _compose_execute_success_message(res)
return res
def _compose_execute_success_message(res: ExecuteResult) -> str:
recon = ";".join(res.diff_lines)
verdict_txt = ("与计划一致 ✅" if res.verdict == "PASS"
else "与计划声明不一致 ⚠(详见验证报告,可用检查点回滚)")
return (f"兜底计划已执行完成 ✅(run {res.run_id},{res.steps_executed} 步)\n"
f"对账:{recon}({verdict_txt})\n"
f"执行前后已自动建档({res.cp_before} → {res.cp_after}),可用检查点回滚;"
f"验证报告:{res.report_path}")
def _compose_execute_failure_message(res: ExecuteResult) -> str:
tail = "" if res.rollback_verified else ";⚠ 回滚校验不一致,请人工核查"
if res.status == "blocked":
return (f"兜底执行偏离已批准计划({res.deviation}),已熔断并自动回滚到执行前快照 "
f"{res.cp_before},你的数据未留下任何变更{tail}。"
f"失败现场已存档 {res.cp_failed}。")
return (f"兜底执行失败({res.error_message}),已自动回滚到执行前快照 {res.cp_before},"
f"你的数据未留下任何变更{tail}。失败现场已存档 {res.cp_failed}。")
# ---------------------------------------------------------------------------
# P2:计划确认卡组装(propose 段;复用 stage_confirmation 块,零前端改动)
# ---------------------------------------------------------------------------
def _constraints_summary(constraints: dict | None) -> str:
if not constraints:
return "无"
return ",".join(f"{k}={v}" for k, v in constraints.items())
def _expected_summary(expected: list | None) -> str:
if not expected:
return "未声明"
parts = []
for item in expected:
if not isinstance(item, dict):
continue
bits = []
if item.get("added") is not None:
bits.append(f"+{item['added']}")
if item.get("removed") is not None:
bits.append(f"-{item['removed']}")
if item.get("modified") is not None:
bits.append(f"~{item['modified']}")
parts.append(f"{item.get('table')} {'/'.join(bits)}")
return ",".join(parts) or "未声明"
def _stage_plan_confirmation(store, session_id: str, run_id: str,
dirs: dict[str, Path], plan: dict, *, actor: str):
"""计划校验通过 → 冻结 plan + 计划指纹进确认卡(P2-DESIGN §6.2 信任边界:
卡片全部内容由编排器从结构化字段再生成,Pi 的 goal/summary 散文不进卡)。"""
from server.agent_core import harness as _harness
from server.agent_core.audit import write_audit
from server.contracts import AgentReply
plan_doc = {**plan, "runId": run_id}
fp = plan_fingerprint(plan_doc)
steps = plan_doc["steps"]
title = f"智能兜底执行计划({plan_doc.get('scenario')} · {len(steps)} 步)"
lines = []
for step in steps:
core = ("冻结参数" if step.get("params") is not None
else f"冻结制品 {step.get('artifactRef')}")
if step.get("mode") == "assisted":
core = "执行期自适应(动作请求邮箱逐步发起)"
lines.append(
f"· 步骤{step.get('seq')} [{step.get('intent')}]({step.get('mode')}){core}"
f";边界:{_constraints_summary(step.get('constraints'))}"
f";预期:{_expected_summary(step.get('expected'))}")
lines.append(f"计划指纹 sha256:{fp[:12]} · run {run_id}")
lines.append("批准后将按计划逐步执行并自动建档;偏离计划即熔断回滚")
# 防御(真实冒烟实测):chat 管线在回复下发后会 setdefault("contextPolicies", {})
# (app.py 滚动摘要段,该键不在 world_fingerprint 的挥发性排除清单内)——
# 提前物化该簿记键,否则出卡期捕获的 beforeFingerprint 与 confirm 时世界
# 必然不一致,漂移比对永远误报。
store.data.setdefault("contextPolicies", {})
block = _harness.stage_confirmation(
session_id, "agent.fallback.execute",
{"plan": plan_doc, "planFingerprint": fp, "runId": run_id},
title=title, summary_lines=lines,
evidence_refs=[f"fallback-run:{run_id}", f"fallback-plan:{run_id}"])
write_audit(store.data, store.next_id, actor=actor, category="GATE",
action="agent.fallback.execute.stage",
target={"type": "FALLBACK_RUN", "id": run_id}, power="P2",
rationale={"confirmId": block.props["confirmId"],
"planFingerprint": fp, "stepCount": len(steps)})
store.save()
# 出卡审计落链本身改变了世界指纹(真实冒烟实测:不推进则执行端漂移比对
# 永远误报「世界已漂移」)——把冻结指纹对齐到「卡片就绪时刻」;
# 审批窗口内的后续业务改动仍会被漂移检测正常拦截(T-8 守护)。
_harness.refresh_confirmation_world_fingerprint(
block.props["confirmId"], _harness.world_fingerprint(store.data))
text = (f"[智能兜底 · 执行计划] run {run_id}\n\n"
f"已生成 {len(steps)} 步执行计划并通过出卡前校验。该计划属于 P2 写操作,"
"请在下方确认卡审批;批准后按计划逐步执行(自动建档可回滚,偏离计划即熔断)。\n\n"
+ "\n".join(lines))
return AgentReply(text=text, blocks=[block])