aps-agent/server/agent_core/fallback_lane.py

824 lines
35 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
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 第一道墙,只读四件套)
@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",
)
@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);
}}
// 放行
}});
}}
"""
def write_guard_extension(run_dir: Path) -> Path:
"""生成 guard-<runId>.ts(L1 第二道墙)。返回路径供 pi `-e` 加载,随运行归档。"""
run_dir = Path(run_dir).resolve()
content = _GUARD_TS_TEMPLATE.format(RUN_ROOT_POSIX=run_dir.as_posix())
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) -> AgentRunner:
"""构造真实 pi headless runner(读线程+queue 轮询、心跳事件、finally 杀进程树)。
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)
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,
) -> FallbackOutcome:
"""消费事件流,执行熔断与判定,落 events.jsonl / orchestrator.log。"""
run_dir = dirs["root"]
log_path = run_dir / "orchestrator.log"
events_path = run_dir / "events.jsonl"
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')}")
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(config)
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_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_task_brief(run_id=run_id, query=query, snapshot_files=snapshot_files)
on_tool_event = _make_tool_event_handler(store, bridge, run_id)
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 但最终报告为空,按失败处理"
# 产物唯一出口 + 结果落盘
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:
from server.contracts import AgentReply
return AgentReply(text=_compose_success_reply(outcome))
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) -> Callable[[dict], None]:
"""每个工具事件:签/补 callId 凭证 + 写 tool.run 审计(actor=pi-fallback:<runId>)。"""
from server.agent_core.audit import write_audit
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.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