# 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 出现真实变化 (新答复消化体现在字段值差异).