aps-agent/docs/architecture/fallback.md

135 lines
8.8 KiB
Markdown
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.

# 智能兜底(Pi Agent)
> 对齐《Pi-Agent兜底能力详细方案》§4/§6 与 `poc/pi-fallback/`(GOAL-P1 / P1-DESIGN / P1-IMPL-NOTES / P1-VALIDATION)。
> 落地态:**P1 只读兜底**(方案 S1 意图未识别 / S5 自由分析进产品路径)。
## 定位
用户在对话里说了一句产品功能覆盖不了的话(意图识别落到 `assistant.reply`/`unknown` 分支)时,
不再只得到固定话术,而是拉起 Pi headless 做**一次只读分析**,返回一份真实产出的分析草稿:
- **草稿语义(P1)**:产物只是回复文本与 run 目录归档,**不写主干世界**;
回复带 `[智能兜底 · 草稿]` 前缀与「未改动任何数据」声明。
- **失败显式**:任何失败(熔断/不可用/伪造凭证/空报告)都回退原话术 + 一行中文化失败原因,
并写 FAILED 审计;`propose_reply` 绝不抛出,聊天链路永远有回复。
- **接线点唯一**:`server/aps_domain/workflow.py` 的 `assistant.reply/unknown` 分支
→ `fallback_lane.propose_reply()`;开关关时返回 `None`,原路径逐字节不变。
## 组件
| 组件 | moduleId | 职责 |
| --- | --- | --- |
| `server/agent_core/fallback_lane.py` | core-fallback-lane | 兜底编排器:触发判定、任务简报、拉起/回收 Pi 子进程、三重熔断、环境清洗、run 目录管理、审计 |
| `server/integrations/pi_bridge.py` | integ-pi-bridge | 围墙内工具桥:只读工具注册表、callId 凭证账(calls.jsonl)、报告凭证校验、只读快照导出、任务简报模板 |
Harness 登记:`agent.fallback.propose = P1`(见 [harness.md](./harness.md) 权力矩阵)。
它只是权力登记动作名,**不是意图枚举成员**(`IntentName` 封闭 Literal 未变),LLM 无法产出它。
## 开关语义(FF-01 `fallback`,默认关)
- 配置:复用 FF-01 文件化开关(`server/data/features.json`,`APS_FEATURES_PATH` 可覆盖);
`{"features": {"fallback": true}}` 显式开启。
- **默认关**:`feature_flags._DEFAULT_OFF_KEYS = {"fallback"}`——文件缺失/损坏/未配置/取值非 bool
时 `fallback` 默认 **False**,与其余键的 fail-open **相反**;`/api/features` 响应携带
`defaultOff` 字段显式标注。
- **为什么相反**:fallback 会拉起外部 LLM 子进程并产生 token 成本,默认开会在无模型/离线现场
制造意外副作用;兜底是增强能力而非主干功能,关了只回到现状话术,系统不失能(有意取舍,
fail-open 的「损坏不锁死」保护对该键反向成 fail-closed)。
- 查询入口 `fallback_feature_enabled()` 直接消费 `load_feature_flags()` 单一事实源;
任何异常 → False(宁可误关不可误开)。
## 运行目录
根:`<APS_FALLBACK_DIR>` 或 `path_under_data("fallback")/`
(web=`server/data/fallback/`,desktop=`~/.aps/data/fallback/`)。
```
<根>/
├── pi-home/models.json # PI_CODING_AGENT_DIR 配置圈禁;apiKey 只写环境变量名引用,不落明文
└── fb-YYYYMMDD-HHMMSS-xxxxxx/
├── inbox/ # 注入区:snapshot.md / orders.csv(export_snapshot 只读快照)
├── work/ # pi 子进程 cwd 圈禁于此
├── outbox/report.md # 产物唯一出口
├── events.jsonl / orchestrator.log / result.json / calls.jsonl
└── guard-<runId>.ts # 本次运行加载的守卫扩展(随运行归档)
```
配置环境变量(`APS_FALLBACK_*`):`DIR` / `PI_CLI`(默认复用 `poc/pi-fallback/runtime/` 的 P0 安装,
打包留后续阶段)/ `PI_HOME` / `NODE` / `MODEL` / `TIMEOUT_SEC`(90) / `MAX_STEPS`(30) /
`MAX_OUTPUT_BYTES`(2MiB)。模型 key 复用 `LLM_BASE_URL`/`LLM_API_KEY`/`LLM_MODEL`,只经白名单清洗后的
子进程环境注入,**绝不打印、绝不落盘**;`resolve_model()` 经 `GET /models` 协商(避开 P0 实测的
404 坑),进程内缓存 300s。
node 解析顺序(`_resolve_node`,P1 真实冒烟坑 B 对策):① 显式 `APS_FALLBACK_NODE` 最高优先级
原样命中;② 默认 `"node"` 且 Windows 时 `shutil.which("node.exe")` **优先于** `shutil.which("node")`
——避开 PATH 中先于 node.exe 命中的 `node.CMD` 垫片(Popen 起 .cmd 引号语义会炸,pi 秒败,
2026-09-02 冒烟实测);③ 其余平台/兜底回退 `shutil.which("node")`。
## 三重熔断与成败判定
| 闸 | 上限 | 触发后果 |
| --- | --- | --- |
| 超时 | 90s(聊天同步预算;executor 线程内运行,不堵事件循环) | 杀进程树,`breaker:timeout(...)` 显式判败 |
| 工具步数 | 30 | 同上,`breaker:max_steps(...)` |
| 输出体量 | 2MiB | 同上,`breaker:max_output(...)` |
成败**只看事件流 stopReason**(`stop` 才为成功),绝不相信进程退出码(P0 实测 pi 恒退 0);
`stop` 但报告为空判 `error:empty_report`;runner 抛错/桥违规归并 `harness_error`;
运行时不可用(无 node/pi/模型配置)判 `unavailable:<原因>`。
## 围墙与工具桥只读面
- **L1 工具层**:pi 启动参数只挂 `read,grep,find,ls` 只读四件套;守卫扩展在 pi 侧拦截
`bash/edit/write` 全禁、文件工具路径限 run 目录(拦截落 guard-blocked-calls.jsonl)。
- **L2 目录圈禁**:子进程 cwd=work/;桥侧 `handle_fs_read` resolve + is_relative_to,
越界(`../`、绝对路径逃逸)抛 `ToolBridgeViolation`。
- **L4 环境清洗**:白名单制子进程环境,剥离 `CONDA_*`/`PYTHON*`/`PIP_*`/`VIRTUAL_ENV*`。
桥注册表(`TOOL_REGISTRY`)P1 只暴露三项,写类工具(fs_write/shell_run/aps_invoke 等)
**刻意不登记——不登记即不可见,这是墙的一部分**:
| 工具 | 权力 | 语义 |
| --- | --- | --- |
| `fs_read` | P0 | 读 run 目录内文件(真实 pi 侧由内置 read/grep/find/ls + 守卫扩展实现) |
| `aps_query` | P0 | 快照制:run 启动时把只读世界视图导出为 inbox/snapshot.md + orders.csv,pi 经 fs_read 消费;**不做 pi 进程内实时查询工具** |
| `report_emit` | P1 | 产物唯一出口:编排器把 pi 最终文本写 outbox/report.md |
**callId 凭证**:每次工具事件由编排器在围墙外签发 `call-<uuid4>` 落 calls.jsonl
(入参只落 sha256 摘要,不落明文;桥 callId 与 pi 事件流 toolCallId 双向登记)。
报告引用校验 `validate_report_citations()`:**报告中出现的每个 callId 必须真实存在,
否则判 `forged_citation` 物理失败**(伪造成果防线,安全性质不可降级)。
**凭证语义的已知限制与 P1 处置(2026-09-02)**:凭证由编排器从事件流**事后签发**,
真实 Pi 进程内的 LLM 无法获知其值——若简报强制要求标注 callId,真实引用必然被误判 forged
(设计张力,P1-IMPL-NOTES 遗留风险 #2)。P1 处置 = **任务简报不再要求标注凭证**,并明示
「凭证由围墙外签发、你不可获知、不得编造;出现不存在的凭证编号即判伪造成果整轮失败」。
即引用从「强制」降级为「出现即必须是真」的防伪绊线;报告数字的可靠性改由
「唯一数据来源 = inbox 只读快照」保证。伪造 callId 识别能力不变
(`test_forged_callid_rejected` 保持绿)。
## 审计
- 完成时 1 条 `TOOL agent.fallback.propose`(成败都写,power=P1):rationale 携带
runId / queryDigest(sha256 前 16 位,不落原话明文)/ stopReason / steps / elapsedSec /
citationCheck 计数 / runDir / reportPath;`evidence_refs=["fallback-run:<runId>"]`。
- 每个工具事件 1 条 `TOOL tool.run`(actor=`pi-fallback:<runId>`,power=P0)——
GovConsole「工具调用」视图零改动即可见。
## 边界(P1 明确不做)
1. 一切写操作(fs_write/shell_run/aps_invoke/checkpoint_create 不登记);
2. 确认卡执行路径(`agent.fallback.execute` 不登记、不实现);
3. pi 进程内实时工具 / HTTP 桥回调(aps_query 只做启动时快照,不新增网络监听面);
4. 后台/异步兜底与结果回投(同步 + 90s 硬上限是有意取舍);
5. 桌面打包 / sidecar 集成(路径已可配置,sidecar.cjs 未动);
6. SSE 实时转发 pi 事件、token 预算闸(以输出字节数近似)、端口级网络限制、junction/8.3 短路径防护;
7. S2(已登记意图执行失败的兜底)、S3/S9 写路径、真实 pi 端到端冒烟(需带 key 环境,见
`poc/pi-fallback/P1-VALIDATION.md` 未验证清单)。
## 验证
`tests/golden/test_fallback_lane.py` 13 例全确定性(fake runner 注入 +
tmp_path 隔离 + 清 LLM env,不依赖真实 node/pi/网络/LLM):开关关原行为不变 /
开关开 propose 成功 / 未登记意图仍拒绝 / 审计链不断 / 三重熔断显式失败 /
伪造凭证判败 / runner 异常显式失败 / 默认关三态 / 路径越界拦截 / 运行时不可用回话术。