# ADR-0008: quote-engine-probe LLM/Agent 协议层 4 设计修正 - **Status**: Accepted (PM 拍板 2026-05-02, "干") - **Date**: 2026-05-02 - **Deciders**: PM (产品经理) + Flyto Agent core team - **Related code**: `platform/common/cmd/quote-engine-probe/main.go`, `platform/common/cmd/quote-engine-probe/prompts/{sub_agent.md, main_agent.md}`, `platform/common/internal/billrecon/llm/reflect_tool.go`, `platform/common/internal/billrecon/llm/validator_adapter.go` - **Related TODO**: PM 反思 4 项 (1)+(2)+(3)+(4), 由 ADR-0007 follow-up r26+ 物流业务 round 2 收敛后 review 触发 - **Related ADRs**: ADR-0004 (WMS 价格模型对齐, schema 镜像目标), ADR-0005 (引擎中性化, await 路径不污染 engine), ADR-0007 (capability tracking, r26+ 实证胜利的前置) - **Commit chain**: C1 (`4616ae2` sub_agent 直出 WMS schema + _uncertain marker + reflect_tool WMS 输入) + C2 (`21fdadb` main verdict await_human_input + Go 日期 clamp) + C3 (本 commit doc + TODO + CHANGELOG + CLAUDE.md) --- ## 1. 背景 / Context ### 1.1 ADR-0007 实证胜利后 PM review 触发 ADR-0007 r26+ minimax-M2.7-highspeed main + deepseek-v4-flash sub mix engine 物流报价表抽取 round 2 收敛 7m37s (commit `ff08515`). PM review 价格输出后反思 4 个**我 (Claude) 之前没经讨论擅自做的设计**: | # | 设计错 | 真因 | |---|---|---| | (1) | sub_agent.md schema 跟 WMS 不对齐 | 我擅自设计 LLM-friendly schema (segments+rows+strip_fees+rules) 而非镜像 ADR-0004 alpha.11+ 实装的 WMS `ShipCostCfgMaster + ShipCostCfg`. LLM 输出后我自己写 mapper (`parser/quote_to_bundle.go SegmentsToOutput → bandToDetail`) 翻译, "多一层翻译多一层错的概率" | | (2) | 接受"无法识别"部分 | sub_agent.md 没 ambiguity marker, 模糊数据被强解. 实证 ytosample.xlsx 的崇明 "1.5+1" → sub agent 强解 base_weight=1000 / base_amount=1.5 主观推断原文没明示; 深圳 "0-50kg, 首重 0.47 元/票" 同款问题 | | (3) | main agent 与人对话能力 | main agent 只有 verdict ∈ {ok, retry}, 没 await_human. 子 agent 输出含模糊数据时 main 只能 retry 重派, 无法 escalate 给人工 | | (4) | 日期合法性校验从 LLM prompt 移到 Go 后处理 | sub_agent.md 有"日期合法性校验"段教 LLM "9月31号 → 9月30号" 等修正逻辑. `time.Parse` 一行 + 表 (4/6/9/11→30 / 2→28-29 / 其余→31) 兜底就能确定; LLM 处理浪费 token + 注意力 + 可能漂移 | PM 拍板"一气呵成" 4 项一起改, 走 3 commit 节奏 (C1 schema + reflector / C2 verdict + 日期 / C3 doc + ADR). ### 1.2 4 项不是 4 件独立事 (1)+(2) 强耦合 — sub_agent schema 改了反射器 (`reflect_tool.go`) input 必须同步改, 否则 LLM 调反射器立刻 type mismatch. _uncertain marker 协议跟反射器 skip 规则也耦合 (LLM 标 _uncertain → 反射器跳过 → 不会反复抛违规让 LLM 强解). 拆开 commit 等于 sub_agent.md + reflect_tool.go 各改两次, 没工程优势. (3)+(4) 都涉及 main_agent.md 加段 + main.go 加函数, 相对独立可同 commit. (C2 = (3)+(4); C1 = (1)+(2)) --- ## 2. 决策 / Decision ### 2.1 sub_agent 直出 WMS schema, 0 mapping 落库 (PM 反思 1) sub_agent 输出 schema 改成 `{master: AdjustmentMaster, details: []AdjustmentDetail, return_fee?: ReturnFee}` 直接对齐 WMS `ShipCostCfgMaster + ShipCostCfg + ReturnFee` 三表. 字段名 / 单位 / 嵌套结构 1:1 镜像 `parser/adjustment.go` (ADR-0004 alpha.11+ 实装). Go 侧 parser 一步 unmarshal 到 `parser.AdjustmentBundle` 即可落库. 旧 segments+rows 形态走 `SegmentsToOutput → bandToDetail` 翻译一跳, 现 details 已经是按 (省, 段, 城市?) fan-out 后的形态, 1:1 拷字段成 `billcost.Band` 即可. #### 2.1.1 单位铁律 g 而非 kg `AdjustmentDetail.LimitBottom / LimitTop / BaseWeight / IncrementWeight` 字段单位是 **g (克)**. LLM 训练数据里 WMS 字典常按 kg 描述, 默认会按 kg 出. sub_agent.md 加显式锚点段 "单位铁律 (容易错, 锁死)": > - 1kg 必须填 1000 (整数) 或 1000.0 (浮点) > - 50kg 必须填 50000 > - LLM 训练数据里 WMS 字典常按 kg 描述, 不要照搬 kg 每个示例里 `base_weight=1000` 等都是 g 形态, 重复一次让 LLM 不会误填 kg. #### 2.1.2 quote_to_bundle.go (441 行 mapper) 不动 `parser/quote_to_bundle.go` 是 bill-recon **平行 vlm 路径**在用 (`workflow.buildStandardArtifacts → SegmentsToOutput / LegacyQuoteToOutput / QuoteToBundle`), 跟 quote-engine-probe **不耦合**. 改 quote-engine-probe sub_agent schema **不波及** vlm 路径, 不必删 mapper. 等 vlm 路径未来也直出 WMS schema 时一起退役 (§ 6 触发条件). ### 2.2 _uncertain ambiguity marker 协议 (PM 反思 2) details 行加三件套: ``` "_uncertain": true, // 默认 false "_raw_text": "原表片段", // _uncertain=true 时填 "_reason": "为什么不确定" // _uncertain=true 时填 ``` sub agent 遇模糊数据 (原表用极简文本不明示首重 kg / 缩略语义无法判定段位 / 数值缺失) **主张推断值 + 标 _uncertain=true** 让上层决策, 不强解. 反射器 (`billcost_reflect` tool + `QuoteResponseValidator` engine 闸) 同步 **skip _uncertain=true 行不校验**, 避免 sub agent 在已标模糊的数据上反复触违规又强解. main agent 看到 _uncertain 行决定是否 escalate (决策树见 § 2.3.1). #### 2.2.1 反向论证 — 为什么不用顶层 unresolved_rows[] 可选方案 B: sub 输出顶层 `unresolved_rows: []` 单独跟 details 平行. 否决理由: 信息分散, 两数组要 join (按 row index? 按主键?). detail 行内挂 marker 跟数据耦合, 简单. 反射器 skip 规则也只需 1 个 if. 选方案 A (行内 marker). #### 2.2.2 反向论证 — 为什么不让 sub agent 漏掉模糊行 可选方案 C: sub agent 遇模糊行直接漏掉不输出. 否决理由: 上层无法区分"原表此行没数据"与"sub agent 漏了". marker 携带 `_raw_text` 让 PM 看原文 + `_reason` 让 PM 看推断逻辑, 信息保留更全. ### 2.3 main agent verdict=await_human_input 第三态 (PM 反思 3) `mainAgentVerdict.Verdict` 加 `"await_human_input"` 第三态. 子 agent JSON 含 _uncertain=true 行且原表确实模糊 (prompt rewrite 也无法消除歧义) 时, main agent 输出: ```json { "verdict": "await_human_input", "reason": "...", "human_question": "深圳的单带费首重重量是 1KG 还是 0.5KG?" } ``` `probe` 主循环阻塞 stdin 读人工答复, 答复经下轮 sub user message 重抽该行. #### 2.3.1 决策树 (main_agent.md § "选哪条路径") ``` 1. JSON 解析失败 / schema 完全错位 → retry (prompt rewrite 教 schema) 2. 结构错不源于原表模糊 → retry (prompt rewrite 加规则) 3. _uncertain 行歧义源于原表本身模糊 → await_human_input (问人) 4. _uncertain 行歧义可由 prompt 教解决 → retry 教规则不问人 5. 全合法无 uncertain → ok ``` 混合情况 (有结构错 + 有 uncertain): 先 retry 修结构, sub 重抽后 uncertain 还在再 await. 一轮一刀。 #### 2.3.2 bufio.Reader.ReadString('\n') 不用 Scanner main.go 用 `bufio.NewReader(os.Stdin).ReadString('\n')` 不用 `bufio.Scanner`. Scanner 默认 64KB token cap **silent truncate** 长粘贴 (PM 答复可能粘原表 prose), `ReadString` 暴露 EOF 干净 (Ctrl-D 当 abort, fail-loud 不静默收敛). advisor 反向论证击中. #### 2.3.3 不抽 PermissionBus 接口家族 — rule of two 不启动 await 路径走 stdin 单进程同步阻塞, **不抽** PermissionBus / SSE elicitation / IM bot 接口. 业务 logistics 部署后接 IM bot / 客服系统时再抽接口 (rule of two: probe 是第一个 consumer, IM bot 是第二个时启动抽象). 此前 `core/pkg/engine/permission` 已有 SDK 端 PermissionHandler, probe 当下走 stdin 是简化版 — 业务接 IM 时升级到 SDK 接口, stdin 路径作为 reference 实现保留. ### 2.4 日期合法性 Go 后处理 (PM 反思 4) sub_agent.md **删** "日期合法性校验"段 (旧版教 LLM "9月31号 → 9月30号" 等). `master.start_date / master.end_date` 字符串原样输出, Go 侧 `postProcessFinalJSON` 调用 `clampOverflowDate`: | time.Parse 结果 | 行为 | |---|---| | 成功 | 原样 | | 失败但日越界 (e.g. 2025-09-31, 9 月只有 30 日) | clamp 到当月最后一天 (`lastDayOfMonth`: 4/6/9/11→30 / 2→28-29 含 Gregorian 闰年规则 / 其余→31) + warning | | 失败非日越界 (非 YYYY-MM-DD 形态 / 月越界 / 日 0 / 日 >31) | 保留原值 + warning 让人类 triage | main_agent.md 加"日期合法性不归你管"段, 显式让 main 不为 9月31 类问题 retry. 解耦责任: LLM 不学日历, Go 一行 `time.Parse` 兜底. --- ## 3. Alternatives considered ### 3.1 sub_agent 多输出一份 LLM-friendly schema 同时落 WMS schema (rejected) 最小改动, 1 commit. 反对: schema 双轨长期不收敛 (落库存哪个 / reconcile 引擎查哪个 / LLM 出哪个). PM 显式要求"对齐 WMS", 不要双轨. 与 ADR-0004 § 4.2 同款决策原则. ### 3.2 _uncertain marker 单独顶层 schema 字段 (unresolved_rows[]) (rejected) 见 § 2.2.1. 信息分散 + join 麻烦. ### 3.3 await_human_input 走 SSE / PermissionBus 接口家族 (rejected for now) 见 § 2.3.3. rule of two 不启动 (probe 当下唯一 consumer). business logistics 接 IM 时再抽. ### 3.4 日期 LLM 教 prompt + Go fallback 双重保险 (rejected) LLM 先尝试改正, Go 兜底. 反对: LLM 教 prompt 浪费 token + 注意力 (sub agent 现已 ~16K input + 跑 thinking + tool calling 耗多). LLM 出错不保证比 Go 输入更好, 双重保险只多漂移路径不少错. Go 单点确定. ### 3.5 反射器对 _uncertain 行报"软违规" (warning 而非 violation) (rejected) 让反射器看 _uncertain=true 行**仍然校验**, 违规归类为 warning 不阻断. 反对: LLM 看到 warning 跟 violation 行为一样会"自我修正", 又把 _uncertain marker 强解掉 — marker 白挂. 选硬 skip. --- ## 4. Reverse thinking ### 4.1 _uncertain marker 不会让 sub agent 滥用 escape hatch? sub_agent.md 显式提示: > 不要为了避反射器违规而瞎挂 `_uncertain` — 数据明确时不挂, marker 是 escape hatch 不是免责盾. 配合 main agent 在 await 路径会 escalate 给人工, 人工答复后 sub agent 重抽时这行会被审视一次 — 滥用 marker = 多一次人工答复成本 (人工说"原文明确, 别挂"), 不形成滥用闭环. 兜底: 实测后若发现真在滥用, main_agent.md 加规则 "main agent 看到 _uncertain 行先质疑 _reason 是否真模糊", retry 教 sub 不要滥用 (§ 6 触发条件). ### 4.2 await_human_input 让 probe 不再"无人模式" 跑 — 业务多副本不行? 确实. probe 设计的"无人模式"假设之前主要为流水线打通验证, 加 await 后头 1 个 _uncertain 行就阻塞 stdin. 但 **probe 是 dev 工具** (单进程跑通 sub-main-反射器协议), 多副本场景 (业务 logistics platform) 用 SSE / PermissionBus / IM bot 抽象 (§ 3.3 follow-up). probe 当下 stdin 是合理 — 简单、fail-loud、能被 Claude 充当桥梁让 PM 实测协议是否走通. ### 4.3 日期 Go clamp 把"原表写错的真相"也吃掉了? 可能. 例如原表确实写 "9月31日" 是物流公司报价单 typo, clamp 到 9月30日意味着我们替对方 typo "改正". 但 PM 拍板"自动调当月最后一天 + 写 warning" — warning 输出到 stdout 让 PM 看到原值 + 修改值, 决定要不要回去问对方. 不是静默替换 — warning 是 PM-visible 信号, fallback 行为合理. ### 4.4 main agent JSON 输出加 await_human_input 第三态会不会让 LLM 漂移? `mainAgentVerdict` struct 加 `HumanQuestion` + JSON 多 1 字段. main_agent.md 加约 1/3 段说明 + 决策树 5 条. main agent (minimax-M2.7-highspeed) 要解新决策树. **实证待跑** — r26 是 2 verdict 的, r27+ 跑 await 路径才知道是否触发漂移. 触发的话 ADR-0008 § 6 加修订记录. ### 4.5 ADR-0007 capability tracking 跟 ADR-0008 是同时出还是有顺序? ADR-0007 在前, ADR-0008 在后. **顺序合理**: ADR-0007 修 wire 协议 gap (硬协议层) 让 r26+ 物流业务能跑通 round 2 收敛, **物理上能跑** 后 PM review 价格输出才暴露设计层错位. 业务跑挂时先修硬协议是必须的 — 设计层修正在硬协议跑通之上. 假如 ADR-0008 在前 ADR-0007 在后, schema 改了仍然 wire 协议 fail (regex / passback 协议), 不能落地. --- ## 5. 升华 — 业界对照 agent 间编排 + 人在环 (Human-in-the-Loop, HITL) prior art: | 框架 | HITL 机制 | 跟 ADR-0008 对照 | |---|---|---| | **LangChain HITL toolkit** | "ask_human" tool 节点, agent 调时阻塞等答复 | 同型: probe await_human_input → stdin block → 答复经 user message | | **OpenAI Assistants API** | `requires_action` 状态 + function-calling tool 路径 client side blocks, 返 tool_call 后 client 决定执行或问人 | architecturally 同型 | | **Claude Agent SDK** | `ElicitationHandler` / `PermissionHandler` 引擎层接口, agent 触发后 SDK 经 SSE 回 client side | probe stdin 是简化版, 业务 logistics 接 IM 时升级到 SDK 接口 (§ 2.3.3) | | **CrewAI / AutoGen** | per-agent `HumanInputMode` flag NEVER / TERMINATE / ALWAYS | 选 ALWAYS 时每轮 agent action 都问人 — probe 选**更细粒度**: sub agent 主动标 _uncertain → main agent 决定是否 escalate, 不是每轮都问 | **flyto 此前没有 HITL 路径** (ADR-0005 拒了 engine-level reasoning gate), 这次 await 路径是 platform/common 层独立加, **不破坏 ADR-0005 引擎中性化** (引擎不感知 await, probe 主循环自己处理). WMS-aligned schema prior art (PM 反思 1 对照): - **Stripe Products + Prices model**: Product → Price (单价 / 阶梯 / 套餐). 类似 master + details 1:N - **ShipBob / ShipStation Carrier Accounts + Service Levels**: Carrier (圆通) → ServiceLevel (派费 / 中转), 按 zone × weight 计价. 跟 WMS `ShipCostCfgMaster + ShipCostCfg` 同结构 - **Pydantic AI / Instructor schema validator+retry 模式**: schema 直绑 Python typed model, 校验失败 LLM 自纠. 类似反射器 `billcost_reflect` tool + ResponseValidator engine 闸双路径 flyto 选**直绑 WMS schema (parser.AdjustmentBundle)** 而非自创 schema 或追 EasyPost-style carrier-native 多套, 因为最终消费者 (对账引擎) 跟 WMS 共享数据, 统一 schema > 转换层 (跟 ADR-0004 § 6 同款论据). --- ## 6. 触发重新评估的条件 / Triggers for re-evaluation - **r27+ 实证发现 await 路径 main agent 漂移**: verdict=await_human_input 出现率 vs 真模糊行率不匹配 (main 把不需 escalate 的当 escalate 烧时间; 或反过来漏 escalate 强 retry 自欺) → 重新看 main_agent.md 决策树或加 ResponseValidator 端校验 - **probe stdin 桥被多 consumer 替代**: IM bot / 客服系统 / 业务 logistics web UI 都接 await 路径 → 抽象 PermissionBus 接口 family, probe stdin 路径降级为 reference 实现 (§ 2.3.3) - **sub agent _uncertain 滥用**: 实测 sub agent 在数据明确时也挂 marker 把反射器违规吞掉 → main_agent.md 加规则 "main agent 看到 _uncertain 行先质疑 _reason 是否真模糊"; 或反射器层加 _uncertain 行白名单校验 (但增加耦合, 优先 prompt 修) - **WMS schema 演进**: 新加字段 / 重命名 / 单位变更 → sub_agent.md schema 段同步, ADR-0008 加修订记录 - **日期 clamp 实测出错**: clamp 后真生产订单出现日期相关 bug → 重看 § 4.3, 评估是否回退 prompt 教 LLM 路径或加更严校验 - **bill-recon vlm 路径也直出 WMS schema**: parser/quote_to_bundle.go 当下保留 (§ 2.1.2), 触发条件 = vlm 路径 LLM 出 WMS schema 后 mapper 即死代码 → 一并退役 --- ## 7. 状态 / Status note 3 commit 落地 v0.4-dev 周期: `4616ae2` (C1 schema + reflector) + `21fdadb` (C2 verdict + 日期 Go) + 本 commit (C3 doc + ADR + TODO + CHANGELOG + CLAUDE.md). ### 7.1 PM 实证待跑 ytosample.xlsx + minimax main + deepseek-v4-flash sub 跑 r27+, 期望: 1. round 1 sub 抽出 + 标 _uncertain (深圳 / 崇明 — sample 已知 2 行模糊) 2. round 1 main 评估走决策树 § 2.3.1 第 3 条 → `verdict=await_human_input` + `human_question` 3. probe stdin 阻塞 → Claude 充当桥梁转给 PM → PM 答 → Claude 打 stdin 4. main agent 把答复包到下轮 sub feedback → round 2 sub 重抽 → ... → 收敛 ### 7.2 测试 `-race` 全绿: - 14 quote-engine-probe 单元测试 (clampOverflowDate / postProcessFinalJSON / parseMainVerdict await / ok-retry 不回归 / 未知 verdict 拒) - 7 billrecon/llm reflect 测试 (含 3 新 UncertainSkipped / PassWithUncertainAndConcrete / StripFeeRouting) - 既有 build + test 不破坏 ### 7.3 ADR-0008 跟 ADR-0007 关系 ADR-0007 = wire 层 capability tracking 决策 (硬协议) ADR-0008 = LLM/Agent 协议层产品决策 (软 prompt + JSON schema) 互相独立 — ADR-0007 r26+ 实证胜利后, PM review 触发 ADR-0008. 时序: capability OK → 业务能跑 → review 暴露设计错 → 修正. 反过来不可能 (硬协议 fail 时设计层无意义). ### 7.4 条目级状态 - (1) WMS schema 直出: ✅ C1 落地 - (2) _uncertain marker: ✅ C1 落地 (sub_agent + reflect_tool + ResponseValidator 同步) - (3) verdict=await_human_input: ✅ C2 落地 (main_agent + main.go stdin 桥) - (4) 日期 Go 后处理: ✅ C2 落地 (clampOverflowDate + postProcessFinalJSON) ## 8. 修订记录 ### v1.1 (2026-05-02): r28-r30 实证 + 引擎 NewSession 加 + sub session 跨 round 复用 (5 commit) C3 commit 后 PM 跑 r27-r30 系列实证, 暴露 3 层独立问题 + 1 个引擎 API 设计问题, 5 个 follow-up commit 落地修补: **C4 (`e7d2c09`) — reflect_tool 容忍 markdown 围栏 + prose**: r27 实测 deepseek-v4-flash sub agent 给最终 JSON 加 ```json ... ``` 围栏, 引擎 ResponseValidator 路径严格 unmarshal 撞 'å' 拒收 (block 1/3 → block 2/3), sub 重试 loop 跑超 perSessionTimeout 10m. PM 评 "比较傻 -- 人家只是加了个 json 围栏". `ReflectQuoteTool.Execute` 加 fence/prose tolerance fallback (严格 unmarshal 失败时跑 `extractJSONPayload` 剥围栏 + 截首 `{` 末 `}`), happy path 0 开销, 真 malformed 仍 IsError 不软化契约. +4 测试. **C5 (`16cf5a9`) — postProcessFinalJSON 也加 fence 容忍**: r28 实测 sub 4m33s 出 final + main verdict=ok 5m 收敛, 但 `postProcessFinalJSON` 撞同款 fence -> outPath 写 prose+围栏混合 (违反 § 2.1 "0 mapping 落库"). 入口先调 `extractJSON` 让 unmarshal 拿到干净 payload, outPath 写干净 JSON. +1 测试. **C6 (`a14a3c6`) — main_agent 见 _uncertain 必 await 强制不绕**: r29 实测 main 见 4 个 _uncertain 行自评 "标记合理 ... 均属原表模糊范畴" 直接 verdict=ok 没 escalate. PM 拍板 "如果有模糊部分得问人, 停下来, 等指示". main_agent.md 决策树第 3 条改"任何 _uncertain=true 行 → await", 删第 4 条"可由 prompt 教 sub agent 解" 留判断空间. r30 实证 main 见 _uncertain 必 escalate. **C7 (`c57bd03`) — probe sub session 跨 round 复用**: r30 实证 PM 答深圳 round 4+6+8 三次 sub 都没消化, 真因是 probe 每轮 `subEng.Session(fmt.Sprintf("sub-r%d", round))` 用 round 编号当 session id 故意每轮新建. PM 反问 "session id 字面就是会话标识不是 turn 标识" 直接戳穿概念错位. `engine.Session.Send` 本来就支持同 session 多次 Send 累积 message history (session.go:117 快照 history 喂 Run + trackEvents append). 修法: subSession 在 loop 外建一次 "sub" 单 id 跨 round 复用; round 2+ user prompt 不再 wrap "上一轮未通过, 反馈如下..." 长 prompt, 直接发 PM 答短消息 (sub history 自含上下文); verdict=retry+new_prompt 路径仍 close+rebuild engine+新 session (system prompt 漂 history 失效). prompt cache 自然受益 (Anthropic 5min TTL + DeepSeek KV cache, multi-turn 重发同 system+history 高 hit). **C8 (本 commit) — engine 加 NewSession() + Session(id) godoc 补完整 + 4 单元测试**: advisor 关键洞察 — `Session(id)` 强制要 id 把消费者推到 "loop 内造 id" 错误思考线上, 让 probe 作者顺手抓 round 编号当 session id. 加 `Engine.NewSession() *Session` 自动生成 id (`session--<8 hex>` 格式 mirror generatePlanID), 让"开新会话"无 id 钩子, 消费者自然写 loop 外建一次. `Session(id)` godoc 补完整 (双语 100+ 行, 含 multi-turn idiomatic / 反模式警告 / 何时用 vs NewSession / Close 后语义 / context window 自动压缩 (`pkg/context.Compactor + AutoCompactThreshold + CompactCircuitBreaker` 已就绪, 消费者不必管 history 大小). 4 单元测试 (TestNewAutoSessionID_FormatAndUnique 1000 iter / ConcurrentUnique 50×50 goroutine / TestEngine_NewSession_AndSessionGetOrCreate 验 NewSession 多次返不同 + s1 ID 后续 Session(id) 拿回同 ref / TestEngine_Session_SameIDReturnsSameInstance 验 get-or-create). **ADR-0008 § 5 升华补充**: agentctx 子包 (`core/pkg/context/`) 已就绪 1300+ 行 auto-compact 三件套 (`Compactor` / `CompactionPolicy` / `CompactCircuitBreaker` 防自压自杀循环 / `MicroCompact` / `EstimateTokens` / `WithTokenGap` 精确跳步) + `session_snapshot.go` 跨进程恢复 (SessionSnapshot + SnapshotStore 接口 + FileSnapshotStore + ResumeConversation) + `session_persist.go` Transcript 持久化. v0.5 周期 sub-agent dispatch 多轮对话场景这些机制全可直接受益, 引擎层不再需要新加. 本次 ADR 仅在 godoc 链接这些已就绪机制让消费者发现. **业界对照新增 (sub-agent context 管理 prior art)**: Claude Agent SDK `client.create_session()` (无 id 自动生成 uuid) / OpenAI Assistants `client.beta.threads.create()` (server-side state 累积) / LangGraph `Checkpointer + thread_id` (跨进程 resume) / CrewAI 显式 `memory=True` flag. flyto NewSession + Session(id) + SnapshotStore 三层覆盖等同 LangChain/LangGraph 同档抽象. **Tracked debt (新)**: 暂无 — agentctx auto-compact + snapshot + persist 三件套已就绪, ADR-0008 v1.1 收口. v0.5 周期物流业务 r 系列收敛后是 sub-agent dispatch 进入 staging 路径 (与 ADR-0003 staging 接通)。 **测试 -race 全绿**: core 4 NewSession + platform/common 14 quote-engine-probe + 11 billrecon/llm reflect 测试 + 既有不破坏. r31 实证待跑 (含 sub session 单 id 复用 + main 强制 await 双修生效). ### v2 (2026-05-02): r31 v1+v2+v3 实证 + 引擎层反射器契约 (StructuralValidator sealed interface + Toolset 双挂 fail-fast, 3 commit + 引用上次会话 82fa66e) **触发**: ADR-0008 v1.1 落地后 PM 跑 r31 v1+v2+v3 三次跨 model 实证 (主子组合 minimax / deepseek-flash / deepseek-pro 全排列), 同款 problem 3 (sub round 2 跨行扩散到上海) 全部出现, 真因不在 model. PM 引 MAOS commit `e9e09e463c` (cowork `doc/architecture/agent-architecture-research-2026.md` 附录) CRITIC 框架实验数据 (简单 6 参数自检 30/30 vs 复杂 9 参数 4/9), 拍板范式修法 — 工具验证信号必须来自外部确定性系统不能依赖 LLM 自报. **v1.1 范式被实证戳穿的两个设计错**: (1) 反射器作为可主动调工具 + ResponseValidator hook 双挂, sub 学会主动调反射器规避 final text 被拒, 模糊反射器 vs 工具语义; (2) ambiguity 决策让 sub 自报 _uncertain marker (LLM 自评 LLM), CRITIC 框架已证不可靠, r31 三次实证 sub round 2 都没真正消化 PM 答复, 跨行扩散到非询问城市. **r31 v1+v2+v3 实证数据** (round 2 收敛失败矩阵): | 跑次 | 主子配置 | round 2 sub | round 2 main verdict | 跨行扩散到 | |---|---|---|---|---| | v1 | main=minimax / sub=deepseek-flash | (kill 早没存) | retry | (推测同款) | | v2 | main=deepseek-flash / sub=minimax | 53s 1 turn 0 reflect | retry | 上海 | | v3 | main=deepseek-pro / sub=deepseek-flash | 4m38s 3 turn 2 reflect | retry | 上海 | 费用累计 (deepseek 服务端 dashboard): 1.19 → 1.48 CNY (r31 v3 增量 0.29). wire turn_end ↔ 服务端 token 完美一致 (Pro 增量 48082 / Flash 增量 139444 跟 wire 累加完美对上). **v2 范式重定义** (3 阶段落地): **C0** (`82fa66e`, 上次会话, 消费者层修法 — PM 戳穿症状级不是根治): 反射器变纯安检门 sub 看不到反射器存在, 引擎在背后跑 ResponseValidator hook. `reflect_tool.go` 删 reflectDetailIn 三字段 (Uncertain / RawText / Reason); `quote-engine-probe` sub engine 配 `Toolset.None()`; `sub_agent.md` 删 _uncertain 协议段 + "工具可主动调"段; `main_agent.md` 自判 ambiguity (cross-check sub 输出 vs 原表 raw text → verdict=await_human_input); `EstimateTokens` 换算对齐 PM 给真实经验值 (CJK 0.6 / word 0.3 / other 0.3, 旧 1.5 / 0.25 / 0.33 偏差大). 但 PM 戳穿: "反射器强制本应是引擎层契约保证, 不是消费者每次自觉" — 上次改的是消费者层 (probe `cfg.Toolset.None()`) + prompt 层, `engine.go` 0 行改, 下次别的消费者 (logistics platform / billrecon 业务) 用 ResponseValidator 也会重蹈覆辙. **C1** (`11112eb`, 本会话, 接口分裂奠基): 走 ADR-0006 § 3 红线 + ADR-0007 § 2.3 红线同模式 — 设计层契约 + 类型签名 + godoc + 替代路径显眼. 不靠 runtime 强制 (做不到 100%), 靠类型签名层让消费者自然分流. 新加 `validator.StructuralValidator` sealed sub-interface (嵌入 `Validator` + 包内私有 `structuralMarker()` method) + `validator.StructuralMarker` 嵌入 helper. 包外类型必须 import validator 包并嵌入 helper 才能满足接口 — 嵌入即声明契约 "无 LLM 调用 / 无业务判断 / 仅做确定性 schema/parse/单位 校验". 错误码 `flyto.ErrReflectorDoubleWired` + `engine.ErrReflectorDoubleWired` 双声明 mirror (沿用 ADR-0006 mirror 模式). **C2** (`651b504`, 本会话, 引擎层 wire 收紧 + 全消费者迁移): `cfg.ResponseValidator validator.Validator` → `cfg.ResponseReflector validator.StructuralValidator` (含 `ResponseValidatorMaxBlocks` → `ResponseReflectorMaxBlocks` 同步). godoc 第一行红线引 ADR-0008 v2 + CRITIC 框架 + 业务校验三条决策层路径 (main agent verdict cross-check / staging ML / await_human_input). 类型签名层强制: LLM-backed Validator 想挂 ResponseReflector 槽位编译期就拒. `engine.New` `buildToolRegistry` 之后加 Toolset 双挂检测 — `cfg.ResponseReflector.Name()` 跟 `cfg.Toolset.Resolve()` 撞名 → 拒构造返 `ErrReflectorDoubleWired` (装配期 fail-fast, 不 silent override 不 warning, ADR-0006 § 3 红线延续). 双挂检测依据: `validator_adapter.go` 注释明文设计意图 "Validator 与 Tool 共享 Name 让审计日志可 join" — 引擎层依此契约做检测合法. 现有消费者全迁移单 commit 保 build/test 干净: `QuoteResponseValidator` → `QuoteResponseReflector` + 嵌入 `StructuralMarker`; `JSONVerdictValidator` 同款嵌入. 编译期断言两边都改 `validator.StructuralValidator`. **C3** (本 commit, 测试 + 文档同步): 引擎双挂检测 4 单测 + ADR § 8 v2 + CHANGELOG + CLAUDE.md prepend. #### v2 反向论证 4 道 - **同名但不同 Tool 实例** — 不可能 (`validator_adapter.go` 注释明文同名设计意图, 巧合撞名违反此意图; 引擎层依此契约做检测合法) - **不同名同 Tool 实例 (alias 故意双挂)** — 显式逃避不覆盖 (ADR-0006 § 3 + ADR-0007 § 2.3 红线模式相同) - **`LLMValidator` / `RuleValidator` / `CompositeValidator` 不嵌 `StructuralMarker` 用在哪** — staging 决策层 (`pkg/staging` + `pkg/validator`), 不在 ResponseReflector 槽位. 类型签名自然分流 — staging 接口接受 `Validator` (任何), `engine.Config.ResponseReflector` 接受 `StructuralValidator` (sealed). 互不污染 - **消费者嵌 `StructuralMarker` 但内部塞 LLM 调用** — 引擎挡不住 (类型签名只验"是 structural 类型", 不验 runtime 行为), 但 godoc 红线 + ADR-0008 v2 引用 + code review 兜底, 跟 ADR-0006 / ADR-0007 红线同模式 (设计层契约不是 100% runtime 强制) #### v2 升华 — 业界对照 - **Pydantic AI** `result_validators` 跟 `tools=` 列表是装饰器 vs 参数两个名字空间, 语言层面就不能交叉 (隐式 sealed 模式, 跟 v2 StructuralValidator 显式 sealed 同精神) - **Claude Agent SDK / OpenAI Assistants / LangGraph** 没有 ResponseValidator + Tool 双 wire 形态 (function calling 都 server side, 反射器一般作为独立 graph node). Flyto 独有双 wire 形态, 检测算法跟自己设计契约 (Validator + Tool 同名 = 同身份) 对齐 - **CRITIC 框架** (MAOS commit `e9e09e463c`) 实验: 简单 6 参数自检 30/30 vs 复杂 9 参数 4/9 — LLM 自评工具复杂度临界点 ~7-8 参数, 超此点准确率断崖跌. ADR-0008 v1.1 sub agent ambiguity 决策让 sub 自评 (跨城 base_weight 蔓延 + 9 月 31 号合法性 + 段位覆盖等多维, 远超 7-8 参数临界), 不可靠是必然. v2 把 ambiguity 决策移到 main agent verdict 层 (cross-check sub 输出 vs 原表 raw text 双面证据), 业务校验三条决策层路径符合 CRITIC "工具验证信号来自外部确定性系统" 原则 #### v2 业务校验三条决策层路径 (替代 LLM-backed business reflector) - **main agent verdict cross-check** — ADR-0008 v2 § main_agent.md 已立, sub 输出 vs 原表 dump 双面证据找强猜行 → verdict=await_human_input (LLM 协议层决策当下) - **staging ML 验证** — `pkg/staging` + `pkg/validator` decision-time gate (L434 待加, 跟 main verdict 形态正交 — staging 是决策提交前 ML 二审) - **await_human_input** — ADR-0008 v2 § verdict tri-state, 多轮收敛兜底 (人工最终判断) #### v2 hook 列表方案否决 中途讨论提"升华版" `cfg.Hooks.PostResponse []validator.Validator` 跟 `PreToolUse` 同形态 hook 列表. PM 戳穿 "实践证明业务反射器不可靠, 反射器只做基础逻辑校验" — hook 列表反而**鼓励错误用法** (消费者看到能挂多个反射器自然想加 schema 反射器 + 业务反射器, 业务反射器违反 CRITIC 框架). 撤回 hook 列表方向, 走类型签名层收紧 (`StructuralValidator` sealed) + 业务校验赶决策层 — 不是放宽 (hook 列表), 而是收紧 (sealed sub-interface). #### v2 Tracked debt (调整) - 引擎层反射器契约 (P1 from v1.1) → ✅ 完成 (StructuralValidator sealed + Toolset 双挂 fail-fast + 全消费者迁移) - L434 staging ML 验证消费层 P1 — 业务校验三条决策层路径之一, 接通后让消费者真有 "业务校验该挂哪" 完整答案 - validator 内 `RuleValidator` / `CompositeValidator` / `LLMValidator` 是否也嵌 `StructuralMarker` — 现状不嵌, 用在 staging 决策层不在 ResponseReflector 槽位. 决策待物流业务驱动 #### v2 触发重新评估 - 嵌 `StructuralMarker` 但内部塞 LLM 调用 (code review 漏掉) → 加 critical warning soft 兜底 (类型签名层挡不住 runtime 行为) - 双挂检测误报 (撞名巧合不是同身份 — 实务推测不可能) → 重看检测算法 - 业务校验消费者真没合适决策层路径 → main agent verdict 形态扩展 / staging ML 接通 / 新加第四条路径 **v2 测试 -race 全绿**: core engine 5 双挂检测单测 + validator 全套含编译期断言 `cfg.ResponseReflector` 字段类型 = `StructuralValidator` + platform/common 全套 (billrecon/llm 17 测试 + responseguard 测试 + quote-engine-probe smoke). 既有不破坏 (`TestEmitCheckpointSuggested_BashDangerous` 是 pre-existing nil pointer panic 与本无关). **r31 v4 重跑期望** (PM 拍板触发, 不擅自跑): ADR-0008 v2 修法 + 引擎层契约同时生效后, 期望 sub LLM tool_use=0 (Toolset.None() 物理看不到反射器); engine ResponseReflector 兜底强制跑反射器 hook 在每个 final-text turn (内部 `Tool.Execute` 不显示成 tool_use_event); sub round 1 直接按推断填全部字段 (无 _uncertain marker), 4 个单带费段都填 base_weight=1000; main agent v4-pro cross-check sub 输出 vs 原表 dump 应识别 4 行单带费 base_weight 是 sub 强猜 → verdict=await_human_input + human_question 单行问深圳; PM 答深圳后 sub round 2 复用 session 重抽只更新深圳 (不跨行扩散因 sub 直接按字面更新), 其他 3 行保持 1000; round 2 main cross-check 应识别上海 / 苏州 / 崇明 还是强猜 → verdict=await_human_input 下一行 → 多轮收敛. 装配期双挂检测验证: 任何未来消费者把同名 Tool 既挂 Toolset 又挂 ResponseReflector → engine.New 立即拒构造返 ErrReflectorDoubleWired, 不到 runtime. ### v2.1 (2026-05-02): r31 v4 实证 round 1 通过 + 反射器 happy path 观测性 gap (P2, 3 commit) **触发**: PM 拍板跑 r31 v4 一轮 "通过就停". 实测 sub round 1 干净通过 (tool_use=0 / final text 21381 字符 / 1m55s) + main v4-pro 业务判 verdict=retry "面单首重(0kg)" base_weight 应=0 而非 sub 填的段起点 3000g (跨厂商主+子架构 + 引擎层契约 + ADR-0008 v2 范式协同实证). PM 反思反射器机制本身 — "如果 sub 不能对反射结果做任何反应, 反射的意义在哪里? 总要反射回去给会反应的人" — 戳穿 v2 描述含糊处, 拉清两条 retry 路径分工: (A) **反射器→sub turn 内修** (引擎 hook fail 时注 user message, sub 同 turn 看到反馈修, 最多 ResponseReflectorMaxBlocks=3 次, ADR-0008 v2 已立) vs (B) **main 业务判→重派 sub** (round 路径 retry, main 业务 cross-check verdict=retry 重写 sub prompt rebuild engine + 新 session 进 round 2). 两条路径独立并行, 反射器和 main 之间完全脱钩. **v2 对比 MAOS 反射器机制** (cowork claims-attribution + test-sdk-retry.mjs): - MAOS: 反射器 = LLM 视野内的 MCP tool (`claims-verify-result` 6 参数), tool 返 `isError=true` + 字符串 issues, Anthropic Agent SDK 自动塞回下一轮, LLM 主动决定调几次 verify, retry budget = SDK `query({maxTurns: 50})` 整个 agent 总 turn 数. **LLM-decided retry**, 反射器对 LLM 可见. - Flyto v2: 反射器 = 引擎 hook 不可见 (`Toolset.None()`), engine 在 `stop_reason=end_turn` 强制跑 ResponseReflector.Validate, fail 时引擎自己注 user message 给 sub 同 turn 修, retry budget = `ResponseReflectorMaxBlocks` (turn 内 cap, 不是整个 agent 总数). **engine-decided retry**, 反射器对 LLM 不可见. 跟 MAOS LLM-as-tool-caller 形态本质不同 — ADR-0008 v2 拍板纯 hook 就是否决 MAOS 那种, 防止 sub 学会"主动调反射器规避" + 防止反射器 vs ordinary tool 语义模糊. **v2.1 修法 — 反射器 happy path 观测性 gap (P2)**: PM 反问 "反射器跑了没" 时 r31 v4 log 0 行可答, 必须从代码路径推断 (probe sub engine cfg 配 ResponseReflector + sub final text 经反射器逻辑放行才到 main = 反射器 PASS). 直接证据缺失. 真因: 引擎 fail 时 emit `WarningEvent code=response_reflector_block`, **PASS 完全静默**. 跟上次会话 silent compact 5 路径同源观测性 gap (commit `7172f04` 修了 compact, 反射器没修). **C1** (`ed54e1f`, 本会话): `core/pkg/flyto/events.go` 加 `ResponseValidatedEvent` 类型 (Approved bool / ValidatorName string / Reason string / BlockCount int / MaxBlocks int / Turn int). godoc 详细说明 4 verdict 形态 (PASS first try / PASS after self-correction / Fail mid-turn / Fail at cap) + Pre-X 段说明历史 gap. `events_test.go` 加 schema 测试: `TestEventType_AllEvents` 加新 row + `TestResponseValidatedEvent_Schema` 4 case round-trip + EventType 不变 + BlockCount<=MaxBlocks 不变量. **C2** (`ddf0c2c`, 本会话): `core/pkg/engine/engine.go` runLoop 6.5 段 wire emit. 单点 emit 在 PASS / Fail 分支处理之前, BlockCount 是 post-increment 后的值 — PASS 看到本 turn 之前累计 fail 次数 (0 = 一次过), Fail 看到含本次的累计. 双通道并存 (Fail 路径双 emit ValidatedEvent + WarningEvent, PASS 路径仅 emit ValidatedEvent), 跟 ToolResultEvent + tool_executed observe 同档. 3 emit 实证测试 `response_reflector_emit_test.go` (~270 行): `OnPass` (单 emit Approved=true BlockCount=0) / `OnFailThenPass` (双 emit Fail/Pass + BlockCount 1→1 不增) / `AtCap` (MaxBlocks 个 emit BlockCount 1→2→3 + WarningEvent response_reflector_max_blocks 同时 emit). **C3** (本 commit): probe `drainSession` 加 case `*flyto.ResponseValidatedEvent` 打 banner `[reflector] approved validator=billcost_reflect block=0/3 turn=1` (Fail 时附 `reason=...`); ADR-0008 § 8 v2.1 修订记录; CHANGELOG `Unreleased (v0.5-dev)`; CLAUDE.md prepend. **v2.1 单一 emit 点设计选择**: Validate 完之后无条件 `ch <- &flyto.ResponseValidatedEvent{...}`, 然后再走 `if !approved { fail 分支 }`. 替代 (双 emit 点 — PASS 在 if-else 块内 / Fail 在 if 块内) 否决因为 (1) 单点 emit 字段语义统一 (Approved / BlockCount post-increment 一处算), (2) 后续维护新加字段一处改, (3) godoc 描述 "每次调用都 emit" 跟代码形态 1:1 对齐. **v2.1 反向论证**: - **PASS emit 噪音?** — sub 一次 turn 反射器最多跑 maxBlocks+1=4 次, 量小, 跟 turn_end / tool_use 同档不算噪音 - **复用 WarningEvent code=response_reflector_pass?** — 否, WarningEvent 语义 "出问题能继续", PASS 不是 warning, 不能滥用. 跟 CompactEvent.Kind 加 micro/full 区分同精神拒绝事件类型语义漂移 - **fail 路径双 emit 重复?** — 不重复, ValidatedEvent 是机器可读 verdict 通道 (Web UI / 监控 / audit sink 按字段消费), WarningEvent 是人类可读告警通道 (字符串模板). 双通道叠加跟 ToolResultEvent + tool_executed observe 模式同档 - **emit 时机 (Validate 前 / 后)?** — Validate 后, verdict 出才有意义, 跟 ToolResultEvent 同档 (执行后 emit 结果) **v2.1 测试 -race 全绿**: core engine 3 emit 测试 (除 3 个 pre-existing fail / panic 与本无关: TestEmitCheckpointSuggested_Bash{Dangerous,Safe} / TestRunWorkers_TaskNotification / TestSubAgent_Forward_ConcurrentSubAgents); flyto 22 schema row + 4 case Schema; platform/common 全套 (billrecon/llm 17 + responseguard) 不破坏. **r31 v5 重跑期望** (PM 拍板触发, 不擅自跑): probe 拉 ResponseValidatedEvent banner 直接打 `[reflector] approved=... block=N/3 turn=K` 让 PM 实证从 log 直接看到反射器跑了几次 / 每次 verdict 是什么, 不必再凭代码路径推断. ### v2.2 (2026-05-02): r31 v5 实证暴露 schema drift bug + agentprompt 通用包集中协议字符串 (P0, 3 commit) **触发**: ADR-0008 v2.1 反射器观测性 + TD-24 cost 真值修法落地后 PM 跑 r31 v5 full multi-round 实证, **死循环铁证**: round 1 sub 反射器 PASS 一次过 (block 0/3) 出 23030 字符. main verdict=await_human_input 问 "面单首重(0kg)" base_weight 矛盾, PM 代答 base_weight=0. round 2 sub text_len **22955** / round 3 sub text_len **22955** / round 4 sub text_len **22955** — **三轮字面完全一致**. main 反复问同一 ambiguity (崇明 1.5+1), 跑到 round 4 kill, cost 累计 ~$0.057. sub 输入 token 累积 7317→14588→21795→29009 字面递增证实 sub session 跨 round 复用 history 引擎层工作正常. 反射器在死循环里完全无关 — sub schema 一直 PASS 一次过, schema 校验路径没拦也不该拦 (它只查 monotonic / 单位 / 段位接吻不查业务忠于原表). 业务级 ambiguity 走 main verdict 路径正确, 失败的是 main verdict 出 await 后 sub 收不到答复定位信息. **真因 — schema drift**: probe `main.go:1056` 这一行 ```go prevFeedback = fmt.Sprintf( "人工答复: %s\n\n请按答复修正对应 _uncertain 行 (设 _uncertain=false 或更新字段值), 重新输出完整 JSON.", answer) ``` ADR-0008 v2 已删 sub 输出的 `_uncertain` 三件套 (`sub_agent.md` / `reflect_tool.go` 改完), sub 输出**没 _uncertain 字段了**. probe 还按 v1.1 时代措辞让 sub 找 "_uncertain 行" → 找不到 → 干脆原样重发. 加上 prevFeedback 也没附 main 的 HumanQuestion, sub 看不到问题描述无法定位 ambiguous 行. **ADR-0008 v2 follow-up 漏 grep**: 上次 commit `82fa66e` 删 _uncertain 时改了 `sub_agent.md` / `reflect_tool.go` / probe `Toolset.None()` / `engine.go` `EstimateTokens`, 漏改 probe await 路径字符串模板. PM 评 "改协议忘了改另一端的话" — 这是分布式系统 / multi-agent 系统的经典 schema drift bug, 不是低级 typo, 容易反复发生 (协议端散在 prompt 层 / 反射器层 / 引擎层 / 消费者字符串模板 4 个 layer, 字符串模板那一档没类型签名编译期 / 测试期都不挂, 改协议靠记忆维护"协议端清单"). **v2.2 修法 — 范式级 (PM 4 红线)**: PM 拍板 4 个红线驱动方案: 1. **不改引擎** — 引擎层 `Send` / `Session` / `ResponseReflector` / `PermissionHandler` 已经做对该做的, 跟 ADR-0001 / ADR-0005 引擎中性化红线一致, 不扩 HumanInputHandler / multi-agent dispatch 到引擎契约 2. **抽通用包集中协议字符串** — 跟 ADR-0006 typed ErrorCode + ADR-0008 v2 sealed StructuralValidator 同精神 (协议端集中) 3. **协议 1:1 锁** — 包跟 sub_agent.md schema 1 处定义, 协议改时改一个文件, caller 编译期 / 包测试挂掉强制 review 4. **不打补丁** — PM 评 "打地鼠", 一次到位走范式级抽象, 不要先打补丁再上抽象两步走 PM 关键反问 (拨乱反正): "正常一个 session 不都支持用户输入么? 引擎天然支持的情况下, 就这个屁事那么复杂么?" — 戳穿我前期把"接待器接口 / Dispatch loop / SubEngineRebuilder 回调 / sealed marker"全拽进来的过度设计. 引擎 `Session.Send` 早原生支持多轮用户输入, **bug 不出在多轮支持上, 只出在"Send 给 sub 的字符串内容对不上协议"这一档**. 真正的最小修法是把字符串模板搬到一处. PM 第二次反问: "都是通用的" — 戳穿包名绑业务名 (`quotedispatch`) 是错位. 物流 platform 真接同款 main↔sub dispatch 时不必另起炉灶, 包名跟内容一开始就该通用. **Commit 顺序**: - **C1** (`598c367`) 起 `platform/common/agentprompt/` 通用包. 一个 .go 文件四样: `Verdict` 三态数据类型 (ok / retry / await_human_input) + `ParseVerdict` 从 main 输出 (可能带围栏 + prose 前缀) 抽 verdict JSON + 校验三态 + retry 互斥 + await question 必填 + `BuildAwaitMessage(question, answer)` verdict=await 后给 sub 的 user message 不点名任何具体 schema 字段让 sub 用 session history 自行定位答复对应行 + `BuildRetryMessage(hint)` verdict=retry+hint 后给 sub 的 user message wrap 让 sub 知道是修正提示不是新抽取请求. 测试 11 case 含回归 `TestBuildAwaitMessage_NoUncertainPhrasing` 锁措辞不再出现 "_uncertain" 字面. - **C2** (`93161e6`) probe 改用 agentprompt. 删 `mainAgentVerdict` + `parseMainVerdict` + `snippet` (搬包内). await 路径 line 1056 改用 `agentprompt.BuildAwaitMessage(verdict.HumanQuestion, answer)`, retry 路径改用 `agentprompt.BuildRetryMessage(hint)`. `extractJSON` 保留 (postProcessFinalJSON 还在用). probe build + -race 全绿. - **C3** (本 commit) ADR § 8 v2.2 修订记录 + CHANGELOG + CLAUDE.md prepend. **v2.2 不在本包内** (rule of two — 第二个业务消费者真出现前不抽通用骨架): - 人工答复采集 (stdin / SSE / IM bot) — 消费者层 - Dispatch loop 本身 — probe 自己的 IO + 装配 + 后处理薄壳 - verdict=retry+new_prompt 路径 sub engine rebuild — 消费者层 (现状只 probe 用此路径, 物流接时是否需要待驱动) - 表 dump / 业务上下文 — 业务输入 引擎层 `Session.Send` 已原生支持多轮用户输入, 本包只负责"走在那条多轮通道上的字符串怎么拼". **v2.2 反向论证 4 道**: - **包名 agentprompt 是否过早抽象?** — 否. 触发是 r31 v5 实证 schema drift bug, 物流 platform 接同款 main↔sub dispatch 时一定复现 (main verdict 三态 + retry hint + await human input 是 multi-agent 通用模式不是 quote 业务专属). 包名通用让物流接的时候直接 import, 不必另起炉灶. 第二个业务消费者出现时不必改名 (不是 rule of two 难题, 是 PM 拍板"都是通用的"). - **Dispatch loop / HumanInputHandler 接口该不该一起抽?** — 否. 引擎已支持多轮 Send, 把 stdin/SSE/IM bot 抽象成接口 + Dispatch loop 整套搬包当下没第二个消费者验证形态, 提早抽象接口形态可能猜错返工. v2.2 范围严格限定为字符串模板 + verdict 解析, 满足"堵当前 schema drift bug + 协议改时编译期挂消费者"两个最小目标. - **sealed marker interface 防消费者绕过自拼字符串?** — Go 语言层不能 100% 防消费者强制 fmt.Sprintf 拼协议字符串塞 sub session, 跟 ADR-0008 v2 StructuralValidator 同 limitation. 实务消费者 import agentprompt 看到这套函数自然不会自拼, 跟 typed error / sealed validator 体例同档. 不靠 runtime 强制靠 godoc + code review. - **测试覆盖怎么保证?** — `TestBuildAwaitMessage_NoUncertainPhrasing` 锁住 r31 v5 真因措辞不再回归; `TestBuildAwaitMessage_GuidesSubFromHistory` 锁措辞引导 sub 用 session history 不点名 schema 字段; 协议 1:1 锁靠包测试和函数签名形态. 协议改时这两个测试自然挂. **v2.2 升华 — 业界对照**: - **业界 schema drift 经典解法**: 单一定义点 (single source of truth) + 编译期挂掉. 例 protobuf / OpenAPI 工具链生成 client+server 同源 schema. Flyto 用 LLM prompt 文本协议没法 100% 类型化, 走"通用包 + 函数签名"是工程上等价的次佳方案. - **Pydantic AI / Instructor**: 用 Python decorator + Pydantic 模型校验 LLM 输出, schema 改时所有 caller 编译挂. Flyto agentprompt 同方向不同语言 (Go 函数签名 + 测试 lock). - **LangChain BaseChatPromptTemplate / LangGraph state schema**: 同模式, prompt 模板集中包内, 业务调取参数填充. agentprompt 跟此族同档. **v2.2 触发重新评估**: - 物流 platform 真接 main↔sub dispatch 时同款字符串模板还是不一样? 一样直接复用 (验证包名通用决策); 不一样 (例如物流不用 main verdict 三态格式) 退一步包内函数签名扩展 (e.g. options / variant) 不破坏 probe 现状. - 第二个业务消费者出现需要 stdin 之外的接待器形态 (SSE / IM bot)? 现在不抽接待器接口, 物流接时再抽 (rule of two 第二个消费者真有差异时). - v2.2 范围严格收 — schema drift 修法不引入新红线不改 sub↔main 契约, 这是 v2 sealed StructuralValidator 范式延续不是新范式. 后续有新协议红线触发再立 v3. **v2.2 跟 ADR-0001 / ADR-0005 引擎中性化关系**: 完全一致. v2.2 0 行引擎层改动, 协议字符串集中在 platform/common 层, 引擎纯粹做 transport (Session.Send 多轮通道). 跟 ADR-0001 拒绝 engine-level 强制 reasoning gate + ADR-0005 删 SDK 默认 Bundle / preset 搬到 core/extra/ 同精神 — 引擎层不感知业务协议, 协议端集中在消费者 / platform 层. **v2.2 跟 v2 sealed StructuralValidator 关系**: 同精神不同 layer. v2 是引擎层 sealed sub-interface (类型签名挡 LLM-backed Validator 挂 ResponseReflector 槽位), v2.2 是 platform 层通用函数 (集中协议字符串挡 schema drift). 两者都靠"集中契约 + 编译期 / 测试期挂掉" 防消费者偏离, 不是范式重叠是范式延续. **测试 -race 全绿**: agentprompt 11 case + probe 全套 (删 3 个迁移到包内的 verdict parsing 测试) + 既有不破坏. **r31 v6 重跑期望** (PM 拍板触发, 不擅自跑): 同款 ytosample.xlsx + 同款主子组合, round 1 sub 抽 → main 出 await + question → PM 答 → round 2 sub 拿到 BuildAwaitMessage 拼的话术 (含 question + answer + 引导 sub 用上一轮自己输出定位行) → sub 改对应行字段 → 不再三轮 22955 字面一致死循环. 期望 round 2 / round 3 sub text_len 出现真实变化 (新答复消化体现在字段值差异). ### v2.3 (2026-05-15): handler 解硬编码 deepseek + Gemma 4 ADR-0007 capability 接通 + m2max staging Gemma 4 跨 model 实证 (3 commit) **触发**: PM 5-15 戳穿 — quote-dispatch handler `import pkg/providers/deepseek` + `deepseek.New()` per-request 构造 + hardcoded "deepseek-v4-pro/flash" default model 是 5-2 反复戳穿"反射器强制本应是引擎层契约保证不是消费者每次自觉"的同源问题. cmd/common 在 SSE agent run 路径已经走 `FLYTO_LLM_PROVIDER` env 选 provider (anthropic / openai-compatible 含 oMLX / deepseek), dispatch 路径完全脱钩自己 hardcoded deepseek — 切换 provider 必须改 handler 本身, 违反"引擎层 capability fallback 是产品力"原则. PM 第二个诉求 (同一天): m2max staging 切换到 m5max oMLX 跑 Gemma 4 — main `gemma4-moe-26b-a4b-q6` (26B MoE / active 4B / q6 量化) + sub `gemma4-e4b` (effective 4B). 不动 m2max 内部 `OPENAI_BASE_URL=http://10.0.0.1:8000` LAN 路径 (0.5ms RTT). 同时给 m5max 起 CF tunnel `m5max.flytoex.net` 给 LAN 外消费者用 (鉴权层走 oMLX 自身 API key, 应用层鉴权, 跟 memory `feedback_tunnel_choice_universality.md` 一致). **Commit 链路**: - **C1** (`8efe18b`): `platform/common/internal/server/quotedispatch_handler.go` 解硬编码 deepseek. 删 `import .../deepseek` + per-request `deepseek.New()` 构造段 + hardcoded "deepseek-v4-flash/pro" default model. `QuoteDispatchConfig` 字段重设: 删 `DeepSeekAPIKey`, 加 `SubProvider/MainProvider flyto.ModelProvider` + `SubModel/MainModel` 改必填 (无 hardcode fallback). handler short-circuit 改成 cfg nil 或 4 字段任一缺失 → 503 "endpoint disabled" + 诊断 (指引 deployer 配 FLYTO_LLM_PROVIDER + QUOTE_SUB_MODEL + QUOTE_MAIN_MODEL). godoc 顶部 + swagger description 更新去除 deepseek 字面. 配套 `quotedispatch_handler_test.go` 加 `fakeQuoteProvider` no-op stub 4 测试用例 cfg 字段同步改; `cmd/common/main.go` 删 `--deepseek-api-key` flag, `quoteCfg` 注入复用 SSE 路径 `FLYTO_LLM_PROVIDER` 选定的 `provider` 实例 + `QUOTE_SUB_MODEL/QUOTE_MAIN_MODEL` env 读 model id. -race 全绿 (handler 1.1s + 全 platform/common 7.5s). - **C2** (`7704264`): `cmd/common/main.go` 末尾加 `registerQuoteDispatchModels(reg *config.ModelRegistry)` helper, 显式 Register 4 条 ModelInfo (ADR-0007 capability) — `gemma4-moe-26b-a4b-q6` / `gemma4-e4b` (m5max oMLX 主子) + `deepseek-v4-pro` / `deepseek-v4-flash` (deepseek 直连回退路径). 字段 ContextWindow 131072 / MaxOutputTokens 8192 / SupportsThinking true / ToolNameRegex OpenAI-compatible / `ReasoningPassbackMode="string"` (PM 确认 Gemma 4 有 thinking, 跟 deepseek-v4 同 passback shape) / `ProviderKind="direct"` / Pricing 0 (自托管). engine.New 之前 build `*config.ModelRegistry`, 传 `engine.Config.Models = modelReg`. autoRegisterProviderModels 会叠加 openai provider 静态模型表 (OpenAI 自家 GPT 系列) 但不覆盖显式 Register 的 Gemma 4 / DeepSeek entry. - **C3** (`69a2d60`): `deploy/docker-compose.local.yml` common 段 environment 加 `QUOTE_SUB_MODEL` + `QUOTE_MAIN_MODEL` env, volumes 加 host binary bind-mount `../.bin/common:/common:ro` (跟 HK-133 prod fastpush-bin.sh 同模式 — m2max 上 image rebuild 撞 gcr.io 拉 distroless base timeout, host go build + bind-mount 让 .go 改动 ~10s 生效). m2max `.env` 同步配 `OPENAI_MODEL_ID=gemma4-moe-26b-a4b-q6` (SSE 路径 main agent 也切 Gemma 4) + `QUOTE_SUB_MODEL=gemma4-e4b` + `QUOTE_MAIN_MODEL=gemma4-moe-26b-a4b-q6`. **r2 跨 model 实证关键数据** (2026-05-15 m2max staging LAN POST localhost:8080, 绕 CF): - **结果**: HTTP 503 + 12 分 25 秒 total + 76372 bytes response body, rounds=2, verdict=await_human_input - **业务命中点**: main verdict 真识别 sub 在**深圳单带费首重重量**强猜 — 原表 "0-50kg, 首重 0.47 元/票, 续重 0.1 元/KG" 未明示首重重量字段值, sub 填了 0 / 1000. **这跟 r31 v3 deepseek-v4 实证同一行同 pattern**. - **跨 model 健壮性**: deepseek-v4 → gemma4-moe-26b-a4b-q6 + gemma4-e4b (完全不同 model family / 不同 backend / 不同量化精度) 在同一个 ambiguity 行命中 verdict=await, 证实**业务真因不在 model 选型在原表本身 ambiguity**. ADR-0008 v2 范式 (main verdict cross-check sub 强猜) 跨 model 健壮 — 协议本身的产品力不依赖底层模型选型. - **partial_final_json**: 含 master (title / start_date / end_date / volume_weight_divisor / surcharge_fee_per_ticket) + details (上海行已抽对 limit_bottom/top / base_weight / base_amount 字段), 部分数据已稳态抽取. 跟 deepseek r31 v3 partial 同形态. - **503 是预期形态**: HumanInputProvider 当前是 `UnavailableProvider` stub (P2 follow-up), dispatch loop 想 await 但 channel 没 wire, handler 翻 503. **v2.3 反向论证 (CF tunnel 100s timeout 一次纠错)**: 我前段判断"dispatch 同步 12 分钟从公网走 CF tunnel 永远撤, 必须做 async + task_id + GET poll 2-4 commit P2 真活才能上测试系统". PM 戳穿: 错了. **CF 524 是 "origin 100s 没发任何 byte"**, dispatch handler 改 chunked streaming response (每 round 结束 flush 一行 progress JSON) 就让 CF tunnel 永不撤 — SSE 能跑 hours 就是这个原理. 1 commit 不是 4 commit. v2.3 收尾不做这事, 留 v2.4 follow-up (跟 HumanInputProvider P2 wire SSE 推 + POST 答合一个 SSE 通道做更自然). **v2.3 不在本节内**: - HumanInputProvider 真接通 (SSE 推 question + POST 答) — P2 follow-up, v2.4 触发 - Dispatch endpoint chunked streaming 改 (解 CF 100s timeout 让 frontend 浏览器能用) — v2.4 跟 HumanInputProvider 合并做 - Gemma 4 跟 deepseek 实证对比 (cost / quality / latency / round 数差异) — 单独 r 序列重跑横向比较, 不在 ADR 里 **v2.3 跟 ADR-0007 关系**: 直接接通. ADR-0007 capability tracking 设计的 ModelInfo 字段 (`ReasoningPassbackMode` / `ToolNameRegex` / `ProviderKind` / `ContextWindow`) 此 commit 第一次在 dispatch 路径生效 — handler 不再硬编码任何 provider, 完全靠引擎层 cap fallback 决定 thinking passback / pre-flight tool name validation / context compaction 阈值. 切 provider 从"改 handler 1+ commit" → "改 cmd/common 注 ModelInfo + .env 改 model id 字符串 0 .go 改". 这才是 ADR-0007 capability tracking 的产品力意义. ### v2.4 (2026-05-15): dispatch endpoint 流式 NDJSON 响应 — 解 CF tunnel 100s "origin 沉默" 撤 (L907-A, 1 commit) **触发**: v2.3 r2 实证 12m25s 同步 dispatch 在 m2max LAN 内部跑通, 但走公网 labtest.flytoex.net CF tunnel 必撤 (r1 deepseek baseline 实证 HTTP 524 + 125s timeout, CF edge 100s 没看到 origin byte 就撤). PM 戳穿我之前判断: 不需要 async + task_id + GET poll 4 commit 重 P2 真活, dispatch handler 改 chunked streaming response (每 engine event flush 一行 NDJSON) 让 CF tunnel 持续看到字节就永不撤, 1 commit. SSE 能跑 hours 是同原理. **v2.4 范围**: - handler 入口预检 (cfg short-circuit / multipart parse / xlsx open / cwd 失败) 仍返单次 JSON 4xx/503/500 — ms 级失败不需要 streaming. - 预检全过后, 切 streaming mode: 设 `Content-Type: application/x-ndjson` + `Cache-Control: no-cache, no-transform` + `X-Accel-Buffering: no` + `WriteHeader(200)`, 然后 flush `{"type":"started","sub_model":...,"main_model":...,"timestamp":...}` 行让 CF tunnel 第一时间见字节. - `Request.OnEvent` (probe binary 一直在用的 per-engine-event callback hook) 在 server 端首次启用 — handler 设回调 emit `{"type":"engine_event","round":N,"source":"sub"|"main","event_type":"*engine.TextEvent"|"*engine.ThinkingEvent"|...}` 行. 每个 engine event flush 一行: (a) CF/Caddy idle 计时器一直见字节, (b) 操作员看实时进度信号 (text 流出 / thinking 中 / tool 调用) 但不导出完整 event payload — engine.Event 跨包 JSON marshal 复杂, 操作员只需进度不需要内容复刻 (终结行 `final_json` 已经全). - `quotedispatch.Run` block 跑完后, 按结果 flush 终结行: `type=final` (verdict=ok 或 MaxRounds 用尽) / `type=await_unavailable` (verdict=await_human_input 但 HumanInputProvider stub) / `type=error` (transport / engine 错). HTTP status 全 200, client 通过终结行 `type` 字段判别成功/失败. - `QuoteDispatchResponse` / `QuoteDispatchAwaitResponse` struct 保留导出 — godoc 重定义成 "type=final / type=await_unavailable NDJSON 终结行 schema mirror", 让外部 SDK 消费者直接 unmarshal 终结行. - 测试 lock-in: `TestQuoteDispatch_ConfigWired_DispatchPathReached` rename → `TestQuoteDispatch_ConfigWired_StreamingPathReached`, 验 HTTP 200 + `Content-Type: application/x-ndjson` + body 含 `"type":"started"` + `"sub_model":"fake-sub-model"` + 终结 `"type":"error"` 行 (fakeQuoteProvider 让 round 1 sub Send fail). **反向论证**: - **为什么不切 SSE (`text/event-stream` + `event:` 前缀)?** SSE 形态绑 EventSource API, EventSource 只支持 GET. dispatch 端点必须 POST + multipart xlsx (xlsx body 不能塞 query string). NDJSON 不强制特定 client API — fetch + getReader().read() / curl 流读 / Go bufio.Scanner 都能消费. 浏览器侧未来加上传 UI 时 fetch + stream reader 跟 EventSource 等价. - **为什么不加 per-round 5s heartbeat ticker?** OnEvent 频率已经够高 — engine.Send 每收 N bytes wire 层就 fire 一个 TextEvent / ThinkingEvent. 12 分钟 dispatch 期间 byte 不停, 不需要单独 ticker. 反过来如果某 engine 调用真的 ~100s 沉默 (例: provider 上游 hang), 那是更深 issue, 加 heartbeat 只是症状治标; 此 v2.4 范围限定 streaming framing, 上游 hang 留 v2.5 follow-up 触发驱动. - **OnEvent 调用并发安全?** 在同一 goroutine. `quotedispatch.Run(ctx, req)` 由 handler goroutine 同步调, 内部 engine.Stream 的 channel 由 Run 自己同步消费, OnEvent 在 Run 的 goroutine 内 fire — 跟 handler 主 flow 没并发. emit 函数无锁. - **client 中途断连 server 怎么知道?** Encode 失败被忽略, 但 `quotedispatch.Run` 拿到的是 `r.Context()`, client TCP 断 → `r.Context().Done()` 触发 → engine.Send 收到 ctx cancel → 主动 abort. 不需要 emit 函数返 error 给 dispatch. **v2.4 L907-B (同 v2.4 节追加 commit, HumanInputProvider 真接通)**: L907-A 流式响应已让 dispatch 12 分钟从公网跑得过 (r3 实证经 labtest.flytoex.net 8s 流出 131 行 NDJSON), 但 verdict=await_human_input 路径仍 emit `type=await_unavailable` (HumanInputProvider 还是 UnavailableProvider stub). L907-B 接通让多轮人机协作在浏览器跑得通. 实施: - `internal/server/server.go` Server struct 加字段 `quoteAwaitSessions sync.Map` (session_id → chan string). 用 sync.Map (不是 plain map + mutex) 因 dispatch endpoint 与 answer endpoint 不同 goroutine 并发触达, 访问 pattern 仅 Store/Load/Delete. - 路由加 `POST /api/v1/billcost/dispatch/answer`. - `quotedispatch_handler.go` dispatch handler 进 streaming 时生成 UUID session_id (`github.com/google/uuid`), echo 回 `started` 行的 `session_id` 字段让 client 收到时即可记下. cfg.HumanInput 显式覆盖 (测试 / 非 HTTP 消费者) 仍优先, 默认路径走 closure-based HumanInputProvider — 在 server map 注册一个 buffered cap=1 answer channel, defer 删, `Ask` fire 时 emit `{"type":"await_human","session_id":...,"question":...}` 行后 select 在 `answerCh <-` / `ctx.Done()` 两路 block. - 新 handler `handleQuoteDispatchAnswer` (POST /answer) cfg short-circuit 503 / JSON decode 400 / session_id 空 400 / session 不存在 404 / channel 槽已占 (重复 POST) 409 / 成功 200 `{"status":"accepted"}`. 非阻塞 channel send 防止重复 POST 或操作员双击导致 handler 自己 deadlock. - `quoteDispatchAnswerRequest` 是 JSON body 类型 (session_id + answer 两字段), 跟 await_human NDJSON 行的 session_id 对回. 测试: - `TestQuoteDispatchAnswer_NoCfg_503` — cfg nil 短路. - `TestQuoteDispatchAnswer_BadJSON_400` — body 非 JSON. - `TestQuoteDispatchAnswer_MissingSessionID_400` — session_id 字段空. - `TestQuoteDispatchAnswer_SessionNotFound_404` — sync.Map 查不到. - `TestQuoteDispatchAnswer_Accepted_200` — 模拟 in-flight dispatch (直接 Store channel 到 map, POST /answer, 验 channel 收到 "500" answer). 绕开真 dispatch.Run 因为 fakeQuoteProvider 跑不到 await_human round (closed channel 早 fail), e2e 流由生产路径 + 真 LLM 覆盖. 不在本节内 (后续 follow-up): - 浏览器 frontend 加上传 xlsx 入口 + fetch streaming UI + 出 `await_human` 行 prompt 用户答 → POST /answer — P2 UI 设计师入职后做表现层 (后端协议在 L907-A + L907-B 已就绪). - 多轮 await (同一 dispatch 内多次出 await) — 当前 channel cap=1, 答完一次后 closure 接 Ask 再次 fire 时新 emit await_human, 但同 session_id 槽还在 (defer 整体 dispatch 返回时才删). 实际生产 dispatch loop 单 await 走通后继续, 第二次 await 命中同 session_id channel 通常工作 (channel cap=1 在第一次 receive 后空出); 若发现 race / 时序问题, 触发 v2.5 重写为 per-await channel 列表 (rule of two — 第二次需求真出现). **Commit 链路**: `` L907-A streaming NDJSON + `` L907-B HumanInputProvider 接通 + POST /answer. ### v2.6 (2026-05-27): phase 0 sheet identification + db-backed session (PM 戳穿 "迁移时弄错了" 范式级修法) **触发**: PM 2026-05-27 m2max staging /billcost-test 测试现场 — 任意客户 xlsx 上传 dispatch 直接吃 Sheet1, 不识别. v2.x 迁移时把 probe (实验工具) 的 hardcoded `Sheet1` 协议照搬到 SaaS endpoint, 没加适配多样真客户 xlsx 的能力. 这是迁移层范式漏 — 不是补丁能修, 必须协议引入 phase 0 (sheet 识别). 延伸 PM 戳穿: "也不能光存在内存吧?" — 内存 session 在 CF tunnel 重连 / common 重启 / 浏览器 tab 关时全丢. v2.4 humanInput in-memory channel 是 happy path 协议, db-backed session 是 v2.6 必须配套. **v2.6 范围 (PM "先一步一步来" 收口)**: 只覆盖到 **xlsx → 结构化报价 JSON** 这一步: phase 0 sheet 识别 + db-backed session + await_human 三态 (confirm / override: / abort). 下游 (业务规则反射 / 账单 / 对账) 不碰. parser/excel.go 的 `quoteSheet = "Sheet1"` hardcode 暂不动 (它给下游 bill-recon 用, 不在 dispatch 链路). **协议层**: - 新增 phase 0: dispatch 入口 (POST /api/v1/billcost/dispatch) 接 multipart 后, 先 dump 所有 sheet 的概览 (name + row_count + 前 10 行 cell), 给一个 main-shape engine 跑 phase 0 prompt 输出 strict JSON `{selected_sheet, reasoning, candidate_sheets}`, 校验 selected_sheet 在输入列表里. - NDJSON 流新增 event: - `phase_start` — `{phase: "phase_0" | "phase_1", selected_sheet: ...}` - `phase_identify_result` — `{selected_sheet, reasoning, candidate_sheets}` - `aborted` — operator 选 abort 时终结行 - `await_human` 新字段 `phase` (区分 phase 0 三态 vs phase 1 自由文本) + `candidate_sheets` (phase 0 用) - POST `/api/v1/billcost/dispatch/answer` body 不变, 但 `answer` 在 phase 0 上下文里识别三态: `confirm` / `override:` / `abort`. unrecognized answer 视为 abort. - 新增 GET `/api/v1/billcost/dispatch/{id}` 返 session 当前 status + 已有结果 (xlsx_blob 不返). **持久化层**: - 新表 `quote_dispatch_sessions` (DDL 加 `internal/db/pool.go` platformMigrations append-only 列表): id / created_at / updated_at / status / xlsx_blob (BYTEA) / xlsx_filename / sub_model / main_model / sheets_overview (JSONB) / phase_0_result (JSONB) / human_answer / phase_1_result (JSONB) / error_msg. Index on status (启动期 MarkRunningInterrupted scan 用). - 状态枚举: `phase_0_running` | `awaiting_human` | `phase_1_running` | `done` | `aborted` | `error_interrupted`. - 新包 `flyto-platform-common/quotedispatchstore`: SessionStore interface (Create/Get/SetPhase0Result/SetHumanAnswer/SetPhase1Result/SetError/MarkRunningInterrupted 7 法), InMemoryStore (dev no-DSN 回落 + 测试), PostgresStore (生产). xlsx_blob 落库 BYTEA — postgres TOAST 自动处理 200MB 上限, 未来挪对象存储不需 schema 改. **实现层**: - `quotedispatch/sheet_overview.go` 新文件: SheetsOverview / SheetOverview 类型 + DumpSheetsOverview(f, previewRows) 函数. - `quotedispatch/phase0.go` 新文件: Phase0Result / IdentifySheetRequest 类型 + IdentifySheet(ctx, req) 函数. 用 BuildMainEngine (零工具 / 无反射器 / 字面 prompt) 跑单次 Send, parse JSON 输出. 跟 phase 1 共享 defaultEngineRunner seam 让测试 stub. - `cmd/quote-engine-probe/prompts/phase0_sheet_identify.md` 新 prompt 文件: 输入 sheets_overview JSON, 输出 strict JSON. 排除规则覆盖账单 / 对账 / 封面 / 目录 / 空 sheet. - `internal/server/quotedispatch_handler.go` 大改: handler 主体先 DumpSheetsOverview → SessionStore.Create → emit phase_start phase_0 → IdentifySheet (forward engine event 到 NDJSON) → SetPhase0Result → emit phase_identify_result → 注册 phase 0 answer channel + emit await_human (三态问题 + candidate_sheets) → 等 channel → confirm/override/abort 分流 → SetHumanAnswer (next status) → emit phase_start phase_1 → DumpSheetWithMerges 选中的 sheet → 现有 quotedispatch.Run → SetPhase1Result → emit final. 错误路径每段都 SetError(error_interrupted) 落库. - `internal/server/server.go` 注册 GET 路由. - `cmd/common/main.go` 启动期: pgPool 非空走 PostgresStore, 否则 InMemoryStore. 调 MarkRunningInterrupted 把上次崩溃残留 running 行切 error_interrupted. - `deploy/billcost-test/index.html` 前端: 加 phase_start / phase_identify_result / aborted 三 event handler. await_human 检测 phase=phase_0 时加 candidate_sheets 进 scenario.phase0Choice, UI 渲染三按钮. **当前 v2.6 不做 (followup)**: - 长连接断后的 phase 1 resume — 当前 channel 丢时 409, 重传 xlsx 重跑. db state 干净, 未来 "resume" endpoint 可秒接 phase 1 不重跑 phase 0. - parent_session_id 跨 session 复用 — 协议字段已预想, 加下游时实施. - parser/excel.go `quoteSheet = "Sheet1"` 解硬编码 — 下游 bill-recon 用, 本期不动. **反向论证**: - **phase 0 该用 sub engine 还是 main engine 跑?** Main. BuildSubEngine 需要 ReflectQuoteTool 必填, 它检查 WMS quote schema, 但 phase 0 输出形态不同 — 反射器必然 reject 强制 max-blocks retry. BuildMainEngine 零工具 / 无反射器 / 字面 prompt 正好. - **phase 0 该用独立 prompt 还是复用 sub_agent.md 多 phase 模板?** 独立 phase0_sheet_identify.md. 多 phase 单 prompt 让 sub_agent.md 复杂, 调试粒度变粗. - **session 该放 internal/server/sessionstore 还是新 quotedispatchstore?** 新包. sessionstore 是 REST/SSE 通用 session (auth/tenant 维度), quote-dispatch session 是物流业务维度. 概念混在一个 store 让两者污染. - **MarkRunningInterrupted 该处理 awaiting_human 行吗?** 不该. awaiting_human 是合法持久状态, 等的是人不是 goroutine, server 重启不破坏它的语义. running 状态才是实时 engine state 依赖, 崩了无法 resume. - **持久化失败该让请求失败吗?** 不该. agent 已收敛后 client 拿到 final 才是真源, db 写失败 log 一行但仍 emit final — 重跑可补 db, 让请求失败白烧 LLM 成本. **测试**: - `quotedispatch/sheet_overview_test.go` 4 用例 (多 sheet 顺序 / preview cap / default preview / 低于 cap 不 panic). - `quotedispatch/phase0_test.go` 6 用例 (正常 / markdown 围栏 / not in input / bad JSON / empty reasoning / 请求校验 5 路). 共享 dispatch_test.go 的 stubProvider, defaultEngineRunner seam swap. - `quotedispatchstore/inmemory_test.go` 5 用例 (full lifecycle / 重复 Create 返 ErrSessionExists / 不存在 id 全方法返 ErrSessionNotFound / SetError / MarkRunningInterrupted 仅 flip running 不动 awaiting/done/aborted). - PostgresStore 测试本期跳过 (留 follow-up). **v2.6 跟 v2.4 关系**: 协议层叠加, 不替换. v2.4 humanInput in-memory channel 仍是 phase 1 await_human_input 走的路径; v2.6 在 phase 0 也走同 channel + POST /answer endpoint, 实现复用. db-backed session 是新加层, 在 channel 之外补持久化, 让 channel 丢时 db 还能 audit + 未来 resume. **v2.6 跟引擎中性化关系 (ADR-0001 / ADR-0005)**: 完全一致. 0 行引擎层改动. phase 0 logic 全在 platform/common, 引擎纯做 transport. **Commit 链路**: `` v2.6 phase 0 sheet identification + db-backed session. ### v2.6.1 (2026-05-27): round-level 持久化 — 每轮 sub 输出 / main verdict 重写 / 反射器 PASS-Fail 序列落库 (1 commit) **触发**: v2.6 db-backed session 落地后 m2max staging 实测一次 4 轮 18m29s 收敛, PM 戳穿 "现在只存 phase_0_result + phase_1_result, 每轮中间发生什么完全没留下". 关键审计场景: - **main 重写 sub prompt 时是否塞目标值给 sub 作弊** — verdict=retry+new_prompt 路径上 main 重写整个 sub system prompt, 如果重写出来的 prompt 直接把答案 inline 进去, sub 就不是在抽取而是在抄答, 这种作弊只有看到本轮 sub 实际用的完整渲染后 system prompt 才能戳穿. - **反射器在某轮真跑了吗** — engine 每次反射器 hook 调用 emit 一个 ResponseValidatedEvent (PASS / Fail 一视同仁, ADR-0008 v2.1 修法), 但 v2.6 handler 不持久化这些事件, 事后无法回答 "sub turn N 反射器 fire 几次 / 都是 PASS 还是有 Fail-then-Pass 自纠". v2.6.1 在 v2.6 之上叠加 round-level 持久化层 — 不替换 quote_dispatch_sessions 任何字段, 仅追加新表. **协议层**: - 新表 `quote_dispatch_rounds` (DDL 加 `internal/db/pool.go` platformMigrations append-only): `id BIGSERIAL PK / session_id TEXT FK quote_dispatch_sessions(id) ON DELETE CASCADE / round_num INT / source TEXT (sub | main | reflector) / created_at TIMESTAMPTZ DEFAULT now() / text_output TEXT NULLABLE / sub_prompt_used TEXT NULLABLE / main_verdict JSONB NULLABLE / reflector_pass BOOLEAN NULLABLE / reflector_data JSONB NULLABLE / input_tokens INT DEFAULT 0 / output_tokens INT DEFAULT 0 / cost_usd DOUBLE PRECISION DEFAULT 0 / elapsed_ms BIGINT DEFAULT 0`. Index on `(session_id, round_num)`. - 一行一 (session, round, source). 单 round 最少 1 sub + 1 main = 2 行 (sub abort 的 round 只 1 sub 行); 反射器单 sub turn 可 fire 多次 → 0+ reflector 行. **实现层**: - `quotedispatch/result.go`: 退役未用的内部 `roundTrace`, 加导出 `RoundTrace` (Round / SubPromptUsed 本轮完整渲染后 system prompt / SubText / SubDone / SubElapsed / MainText / MainVerdict *agentprompt.Verdict / MainDone / MainElapsed / Reflectors []ReflectorTrace) + 导出 `ReflectorTrace` (镜像 ResponseValidatedEvent 字段). Result 加 `RoundTraces []RoundTrace`. - `quotedispatch/dispatch.go`: Run 每轮入口 snapshot 渲染后 sub system prompt 进 trace.SubPromptUsed (verdict=retry+new_prompt 在下次迭代改 currentSubTmpl, 此处冻结才是 "本轮 sub 用了什么"). 包装调用方 OnEvent 让 sub Send 上的 *flyto.ResponseValidatedEvent 落 trace.Reflectors. time.Now() delta 拿 SubElapsed / MainElapsed. verdict 解析后快照进 trace.MainVerdict, append 进 res.RoundTraces. 中途 abort (sub Send 错 / main Send 错) 也 append 部分 trace 让失败 turn 有审计行. main 不接反射器 (engine_factory.go 仅 sub engine 接 QuoteResponseReflector), 故 main Send 用原 OnEvent 不包装. - `quotedispatchstore/store.go`: 加 `Source` 枚举 + 新 `Round` struct + SessionStore interface 加 `SaveRound(ctx, sessionID, Round) error`. 未知 sessionID 返 ErrSessionNotFound, Postgres 外键失败也映射到此 sentinel 让两实现行为一致. - `quotedispatchstore/inmemory.go`: InMemoryStore 加 rounds map + SaveRound + 测试 only Rounds(sessionID) helper. - `quotedispatchstore/postgres.go`: PostgresStore.SaveRound 预检父行 + INSERT 单 SQL, 空字段 typed-nil → NULL. - `internal/server/quotedispatch_handler.go`: 新 helper `persistRoundTraces(sessionID, traces, store)` walk Result.RoundTraces 按源每行写库. handler 在 quotedispatch.Run 返回后 (任何结果) 调一次. 用 context.Background 让 streaming 中 client 断不取消审计写. 单行 insert 错 log 一行跳过. **v2.6.1 不做 (followup)**: - PostgresStore SaveRound 集成测试 — 跟 L909-E 同条 dockertest 路径合并. - UI 渲染 round 审计行 — GET /api/v1/billcost/dispatch/{id}/rounds 单独 endpoint, P2 工作. **反向论证**: - **handler 实时 per-event flush 还是 batch-at-end?** Batch. 实时落库要 quotedispatch package import quotedispatchstore, 破坏当前 quotedispatch 跟持久化解耦. 代价: dispatch 中途 server 崩溃丢未 flush 的 round (db state 干净, 审计串丢 1-2 行); 收益: quotedispatch 包持续可独立测试不依赖 db. 跟 ADR-0001 / ADR-0005 引擎中性化精神延续 — 业务包不耦合持久化. PM "先一步一步来", 真出现 mid-loop 崩溃丢审计需求再叠 store dep 接口. - **新表 vs 加 JSONB 列到 quote_dispatch_sessions?** 新表. JSONB 单列存数组随轮数膨胀, postgres TOAST 行宽限制 + 单字段 update 锁全行 = 高并发 schema lock 风险. 单独表 + 外键 + (session_id, round_num) index 既正交又支持未来按源 query (e.g. 跟踪某 validator 全历史 Fail 列表). - **main verdict parse 失败的 round 该不该存 main 行?** 该. 即使 verdict 解析失败 (probe-compat ok-and-exit 路径) main 也跑了真 LLM call 有 tokens / cost, 不存这行漏算成本审计. MainVerdict 为 nil 仍走 main 行, main_verdict 列落 NULL. - **反射器多 fire 是合并一行还是多行?** 多行. 单 sub turn 反射器 block -> block -> pass 的进度信息本身是审计信号 (模型在 turn 内自纠几次才通过), 合并行丢序丢 block_count 单调性. 每事件一行 + (session_id, round_num) index 让 dashboard 按时间序还原. **测试**: - `quotedispatch/dispatch_test.go` 加 3 用例: TestRun_RoundTraces_HappyPath (2 轮 retry-then-ok, 验 SubPromptUsed 完整渲染 + 不含 placeholder + MainVerdict 字段 + DoneEvent tokens roundtrip), TestRun_RoundTraces_ReflectorEventsCaptured (注入 block-then-pass, 验 Reflectors 切片), TestRun_RoundTraces_SubAbortStillProducesTrace (合成 sub Send 错, 验 trace 仍 append + SubPromptUsed 已 snapshot). - `quotedispatchstore/inmemory_test.go` 加 TestInMemoryStore_SaveRound (三源字段 round-trip / 调用序保持 / 防御副本). - `internal/server/quotedispatch_handler_test.go` 加 TestPersistRoundTraces_AllSources (合成 RoundTraces 驱动 helper, 验按源行布局: sub 含 sub_prompt_used / main 含 main_verdict JSON / reflector x2 含 pass bool 与 reason / round abort 时只 sub 行无 main 行) + TestPersistRoundTraces_Empty (空 trace no-op). - PostgresStore SaveRound 测试跳过 (跟 L909-E 合并). **v2.6.1 跟 v2.6 关系**: 严格叠加, 不替换. 0 行 quote_dispatch_sessions schema 改动, 0 行 phase 0 / phase 1 协议改动. RoundTraces 是新 Result 字段, 旧消费者 (probe 自身, 不读 RoundTraces) 编译 ok 运行 ok. **v2.6.1 跟引擎中性化关系 (ADR-0001 / ADR-0005 / v2.6)**: 完全一致. 0 行引擎层改动. round-level audit 全在 platform/common, quotedispatch 包不 import quotedispatchstore, handler 在两者间翻译. **Commit 链路**: `` round-level 持久化. ### v3.3 (2026-05-27): structured output via engine.Config.JSONSchema — Gemma 4 channel-marker 噪声范式级修法 (1 commit) **触发**: phase 0 IdentifySheet + phase 1 main eval 都跑 BuildMainEngine 但没用引擎层已有的 engine.Config.JSONSchema 强制 JSON 输出. m2max staging 实测 Gemma 4 26B 自由输出含 `<|channel>thought\n...{json}` 内置 reasoning channel marker, parse 失败. v3.2 兜底用 first-brace-to-last-brace 提取最外层 JSON — work 但是补丁: prompt-only "no markdown / no thinking" 指令对含内置 reasoning channel 的模型不够强, 范式级修法是 provider 层 structured output. PM 戳穿: 引擎层 (`core/pkg/engine/engine.go:4465-4469`) 已经把非 nil `engine.Config.JSONSchema` 自动包成 `flyto.ResponseFormat{Type:"json_schema", JSONSchema:...}` 透传到 provider Stream — OpenAI 兼容 (含 m5max omlx) + DeepSeek 原生支持, Anthropic 走 tool_use 强制 (跨 provider 差异引擎层已吃). quotedispatch 之前没用上这个能力, 等于自己造轮子还造瘸了. **协议层**: - `quotedispatch.EngineSpec` 加新字段 `JSONSchema json.RawMessage`. BuildMainEngine 把 spec.JSONSchema 透传到 engine.Config.JSONSchema. - 新包级 var `phase0Schema` 对应 `Phase0Result` (selected_sheet + reasoning + candidate_sheets, `additionalProperties=false` 防 LLM 多塞字段, JSON Schema 7+ 合法). - 新包级 var `verdictSchema` 对应 `agentprompt.Verdict` (verdict `enum [ok | retry | await_human_input]` + reason + feedback_to_sub_agent + new_sub_agent_prompt + human_question). 字段名以 Verdict struct tags 为单一真源. 不加 `additionalProperties:false` 因 Anthropic tool_use 回退路径对额外字段更宽容; 语义层把关由 `agentprompt.ParseVerdict` 保. **实现层**: - `quotedispatch/engine_factory.go`: EngineSpec 加 JSONSchema. BuildMainEngine engine.Config 透传 spec.JSONSchema. 新包级 seam `mainEngineBuilder = BuildMainEngine`. 生产调用方 (phase0.IdentifySheet + dispatch.Run) 经此 seam, 测试可拦截 — 跟 `defaultEngineRunner` 同款套路. - `quotedispatch/phase0.go`: 包级 var `phase0Schema`. `IdentifySheet` 调 `mainEngineBuilder` 时传 phase0Schema. v3.2 brace-extract 兜底保留 (provider 不支持 json_schema 时仍 work). - `quotedispatch/dispatch.go`: 包级 var `verdictSchema`. `Run` 构造 main engine 调 `mainEngineBuilder` 时传 verdictSchema. - `BuildSubEngine` 不动 — sub 输出含 details 数组 + master.raw_extraction 任意 key-value, JSON Schema 7 表达麻烦 (additionalProperties 不能表达 raw_extraction 的开放 key 集), 留 follow-up. **测试**: - `quotedispatch/phase0_test.go` 加 `TestIdentifySheet_WiresJSONSchema_ADR0008v33` + `withCapturedMainEngineBuilder` helper (swap mainEngineBuilder 捕 EngineSpec 后委托真 BuildMainEngine). 验 IdentifySheet 调 BuildMainEngine 时 spec.JSONSchema 非空 + schema 合法 JSON + type=object + selected_sheet 字段在. - `quotedispatch/dispatch_test.go` 加 `TestRun_WiresVerdictJSONSchema_ADR0008v33`. 验 Run 调 BuildMainEngine 时 spec.JSONSchema 非空 + schema 合法 JSON + verdict 字段 enum 锁三态 (ok | retry | await_human_input). **反向论证**: - **为什么不也把 sub 加 JSONSchema?** sub 输出形态 (`{master:{...任意 raw_extraction...}, details:[{...省字段+任意业务字段...}]}`) 含两处开放 key 集 — `master.raw_extraction` 和 `details[].*` 是 LLM 在 reflector 反馈下迭代加字段的, JSON Schema 用 `additionalProperties:true` 表达"开放"会让 schema 失去约束力,用 `false` 又会 reject 真实合法的字段. 留 follow-up: 找到能既表达 31 省必出现 + 必备字段 + 允许业务字段开放 的 schema 写法再加. - **为什么不删 v3.2 brace-extract 兜底?** Anthropic tool_use 回退路径偶尔仍含 prose 前后缀; OpenAI 兼容 + DeepSeek 原生 json_schema 输出应该是纯 JSON 但 provider 实测有 edge case. brace-extract 是廉价 (单 scan) 兜底, 留着不亏. 范式级修法 = 主路走 structured output, 旁路保 brace-extract. - **为什么不在 EngineSpec 加 ResponseFormat 字段, 而是只暴露 JSONSchema?** 引擎层 `engine.Config.JSONSchema` 是已存在的 surface, 引擎自己包 `ResponseFormat{Type:"json_schema", ...}`. quotedispatch 这层不应该决定 type 是 "json_schema" 还是未来可能的 "json_object" — 让引擎层全权决定跨 provider 差异. 简化原则跟 ADR-0005 引擎中性化精神一致. **v3.3 跟 v3.2 关系**: 严格叠加, 不替换. 0 行 v3.2 协议改动. brace-extract 兜底保留. 测试两个新 case, 旧 case 全过. **v3.3 跟引擎中性化关系 (ADR-0001 / ADR-0005)**: 完全一致. 0 行引擎层改动 — 引擎层 JSONSchema surface 早就在, quotedispatch 这次只是用上而已. **Commit 链路**: `` structured output via engine.Config.JSONSchema. ### v3.4 (2026-05-27/28): await 答案路由回 main re-eval (不直接给 sub) + grammar 上游修好后重开 (并入 prompt 单一源迁移 commit) **触发**: v3.4 前 dispatch 把操作员答案打包成 prevFeedback 直接喂下一轮 sub (`agentprompt.BuildAwaitMessage`), main 看不到答案. 单行 ambiguity 行得通; 但 sub 出**结构性错** (例如把一个省的价格抄到 22 个省) 时, 答一行只修一行, main 重 cross-check 仍见同样结构错继续问 — 退化成 "23 省 × 5 段把问题甩 PM 脸上". 同时 v3.3 因 omlx/xgrammar + Gemma 4 在 grammar mask 下 string 字段死循环重复 token 而临时关掉 JSONSchema, 上游修好后要重开. **改动**: - `dispatch.go` await 用 inner loop 包再 eval: 每次操作员答案重新喂 main eval (同 evalPrompt + 累积答案上下文), 让 main 改 sub system prompt (`verdict=retry+new_prompt`) / 继续细化问题 (`verdict=await_human_input`) / 收敛 (`verdict=ok`). sub 在此 loop 内不重跑, loop 出 `verdict=retry` 时才重跑. 每次额外 main eval append 一个 main-only RoundTrace (审计每次操作员轮). - `agentprompt.BuildMainAwaitAnswerMessage` 取代 `BuildAwaitMessage` 路径: 答案拼成 main 下一轮 eval prompt 的额外段 (给 main 不给 sub). `BuildAwaitMessage` 留为 legacy 无生产 caller (followup 删). - grammar (`engine.Config.JSONSchema`) provider-conditional 重开: 本地 omlx / Gemma 开 (上游修好), deepseek 直连等不支持 json_schema response_format 的 provider 由 handler 设 `MainDisableJSONSchema` / phase0 `DisableJSONSchema` 关. **v3.4 跟引擎中性化关系**: 完全一致. 0 行引擎层改动 — await 路由是 dispatch loop 内的事, 引擎只做 transport. **Commit 链路**: await 路由回 main + reflector 移 main + Gemma channel-marker 兜底 = `c3ce0af` (2026-05-27 prior session, "v2.6.1 → v3.4" 一带); grammar 上游修好后重开 (provider-conditional JSONSchema) 半部并入 `9cd63c1` (2026-05-28 prompt 单一源 commit, dispatch.go / phase0.go grammar re-enable 段). ### v3.5 (2026-05-28): 结构化多问 await — main 一次列出本轮所有 ambiguity (各带候选 options), 操作员一次答完, main 一次重 eval (选择题 UI, 1 commit) **触发**: PM 看 await 过程发现可观测性价值 — main 一次 cross-check 就知道**多个** ambiguity (例如多个区的首重重量原表都没明示), 但 v3.4 代码逼它**一问一答一重 eval**: `Verdict.HumanQuestion` 是单字符串, await inner loop 每收一个答案让 main 重 eval 一次. 结果 N 个 ambiguity = main 重跑 N 次 (N 倍 token) + 打断 PM N 次. PM 要 Claude Code AskUserQuestion 那种选择题界面: main 一次抛 N 个问题, 每个带 2-4 个候选 options (main 按推断生成) + "其他" 自定义输入, UI 弹成卡片, PM 一次选/填完所有, main 一次重 eval. **(走过两个弯, 入档防复发)**: 先 over (改 schema 成数组各种字段), 后 under (单 `human_question` 塞编号文本就够). PM 明确否决两者 — 要的是**结构化选择题**: `human_questions: [{question, options}]`. 不是单文本编号, 也不是过度膨胀的 schema. **改动 (协议改全消费者一起改)**: - `agentprompt`: 新 `HumanQuestion{Question string, Options []string}` 类型; `Verdict.HumanQuestion string` -> `HumanQuestions []HumanQuestion`; `ParseVerdict` await 校验改为 `len(HumanQuestions)>0` 且每项 `Question` 非空; `BuildMainAwaitAnswerMessage(questions, answers)` 拼多组 Q&A 按编号一一对应 (空答 surface 成 "未作答" 不静默丢). - `quotedispatch/dispatch.go`: `verdictSchema` `human_question` string -> `human_questions` 数组 `[{question, options}]`; await inner loop 一次 `Ask(所有 questions)` -> 收所有 answers -> 累积 -> main 重 eval **一次** (inner loop 保留: 重 eval 后若 main 又出新 ambiguity 再问一轮). 加 `HumanQuestion = agentprompt.HumanQuestion` 别名. - `quotedispatch/provider.go`: `HumanInputProvider.Ask(ctx, []HumanQuestion) ([]string, error)` — slice 契约: `len(answers)==len(questions)` 严格按位对应, 空串=跳过. 4 实现全改 (StdinProvider 逐问读同一 bufio.Reader / HumanInputProviderFunc / UnavailableProvider / server channel closure). - `internal/server/quotedispatch_handler.go`: server channel provider closure `chan string` -> `chan []string`, SSE `await_human` event 带 `questions` 数组; answer 端点 `quoteDispatchAnswerRequest` 加 `Answers []string`; `handleQuoteDispatchAnswer` 按 channel 类型分派 (chan string = phase 0 单 Answer / chan []string = phase 1 多 Answers). OnVerdict emit `human_questions`. - `deploy/billcost-test/index.html`: `AwaitQuestionsPanel` 组件 (AskUserQuestion 式卡片: 每问 question + options 可选 chip + "其他" 自定义输入, 一次提交所有答); await_human handler phase 1 走 `awaitQuestions` 卡片 (phase 0 仍走 phase0Choice 三按钮); `onSubmitMultiAnswer` POST `{session_id, answers}`. - `prompts/main_agent.md`: wire-contract 同步 (`human_question` -> `human_questions` 数组, JSON 格式块 + 互斥规则 + "一次只问一个" -> "一次列出本轮所有 ambiguity 各带 options"). schema drift 防护是 code 责任 (agentprompt 包存在理由), 不甩给非技术 PM. 深层判断 (问多少 / 主表首重是否 await) 留 PM 调. **反向论证**: - **为什么 phase 0 选 sheet 不统一到多问 channel?** phase 0 是已上线 + PM 实测过 + 在工作的三态确认 (confirm / override: / abort), 语义跟 "多问选择题" 不同. "范式纯洁" 不值得拿回归一个 working 功能换. 最小爆炸半径: phase 0 保 `chan string`, phase 1 走 `chan []string`, 同一 session 同一时刻只注册一种, answer 端点按 channel 类型分派. wire 是未导出 struct + 唯一消费者是捆绑 UI, 无外部 SDK 兼容包袱. - **为什么 Ask 返 `[]string` slice 而非 `map[question]answer`?** 按位对应最简单, prompt 侧用编号 (问题 1 / 问题 2) 配对人也好懂. map 要 question 串作 key, 重复 question / 串变化都会错位. - **为什么不删 dead `BuildAwaitMessage`?** v3.4 起无生产 caller (await 路由到 main 不到 sub), 但删它要动其测试, 与 v3.5 主题无关. 留 followup. **v3.5 跟 v3.4 关系**: 严格叠加. await 答案路由回 main re-eval 的 v3.4 结构不变, 只把 "单问单答" 扩成 "多问多答" — 一次 Ask N 问 / main 一次重 eval N 答. 0 行引擎层改动. **Commit 链路**: `` 结构化多问 await.