Changelog¶
Unreleased (v0.5-dev)¶
下一发版的累积变更, 完成后 cut.
deploy — ADR-0025 整批上生产: customer-scopes 持久卷接线 (2026-07-22)¶
- prod compose 给 common 加
/opt/flyto/data/customer-scopesbind-mount +--customer-scopes-dirflag (ADR-0025 P1 部署闸: 客户底账/档案落 host 持久卷, 容器重建不丢). host 目录预建并 chown 65532:65532 (distroless nonroot). postgres scopemirror 仍是灾备而非替代.
platform — ADR-0025 P1: 客户档案存储硬化三项 (2026-07-21)¶
per-customer 会话/记忆上线前登记的三块硬化债一次还清 (core/TODO.md ADR-0025 段):
- readLedgerTail 反向 seek 读: 原全量扫描随底账增长 O(文件); 改为从 EOF 按 64KB 块反向读, 完整行最新在先解析, 凑够 n 条合法记录即停 -- O(尾部) 与底账大小无关, 内存由最长行封顶. 契约不变 (按时间序返回 / 坏行跳过 / 读错误降级空). 测试: 3MB 多块跨界 + 尾部坏行 + 超块单行.
- 引擎闲置 TTL 回收: 容量 LRU 只守上限, 上限以下的安静舰队会永远占引擎; 新增 idle TTL (默认 30min,
EngineIdleTTL可调, 负值关) 随调度 tick 回收无人 borrow 的引擎 (active>0 永不触碰; 回收零持久损失, 下次 borrow 毫秒重建). 顺带修正 dreamSweep 刷 lastUsed 的失真 -- 调度器巡访不算客户活动, borrow 是 lastUsed 唯一写者, 否则每轮扫描后所有引擎都显得刚被用过, LRU 排序与 TTL 双双被蒙蔽. - scope 档案 FS -> postgres 镜像灾备 (
scopemirror新包): 实现 corememory.SyncAdapter接缝 (不另造平行备份契约, 未来对象存储/git 适配器可互换) -- Push = 白名单文件 (memory/ + sessions/ledger.jsonl, 引擎 session 工作态有意不镜) 按 sha256 变更检测 upsert 进customer_scope_mirror表; Pull = 只补本地缺失文件 (本地永远赢). 单写者环境刻意收窄接缝通用性: ConflictPolicy 忽略 (文档化). 挂载: 调度 tick 走 FS* (非引擎缓存 -- 已回收引擎的 scope 照样镜像) 逐 scope Push; buildEngine 缓存未命中先 Pull, 新卷在客户首次接触时自动拿回档案. 全程 fail-open. 无 postgres 部署特性关闭, 行为同前.
测试: 反向 seek 三场景 + TTL (闲置回收/在途保护/负值关闭) + scopemirror 契约 (变更检测/白名单/本地赢/越界拒绝) + 端到端灾难恢复 (镜像 -> 删卷 -> borrow 自动回复, recentWindow 带回历史). platform/common 全 -race 绿.
platform+frontend — ADR-0025 P2: 进化开关 "让它自己越用越准" (2026-07-21)¶
业务开关落地为最小可行自我改进环 (ADR-0025 §2.3), 真模型闭环验证收敛:
- 选型 (反向论证后拍):
flow.Definition.Evolve可选 bool, 不做租户级配置 -- 随 flow 走 / 画布可编辑 / 由 flow_revisions 历史免费版本化 (开关何时翻的都有据可查); 缺省 false = 现行为逐字节不变 (向后兼容). 租户配置粗粒度还要新存储, 否. - 确定性先例沉淀 (
flows_evolve.go): verdict=ok 的执行 (input 过 schema 闸 且 output 过第 3 层闸) 把 "先例: 输入 -> 确认输出" (紧凑 JSON, rune 安全截断, 输出预算 > 输入 -- 教下一次的是被确认的输出) 落进 flowmemstore 保留 scope"evolve"-- 与人工纠正的 flow 全局 scope 分离, 自动沉淀不污染人工规则清单, 仍可经既有 GET/DELETE /flows/{id}/memory?scope=evolve 查看/删除. 去重 (同款输入不堆 N 条) + cap 20 轮转 (记忆跟随近期确认行为, 不冻在头 N 次) + fail-open (记忆抖动绝不阻塞业务响应). 刻意不走 LLM 提炼 -- 沉淀可审计零成本, RL/复杂进化是 v1.x 路线. - {{.memory}} 消费闭环: injectFlowMemory 渲染两 scope, 先例排切片头部 (Render 压缩层从头部甩 -- 预算吃紧时自动沉淀先出局, 人工规则是法必须活得久且贴 recency 窗口).
- 画布/表单开关: 业务语言 "让它自己越用越准" + 副文案讲清数据去向 (本工作流自己的记忆 / 只留 20 条 / 可查可删 / 不做模型训练) -- PM 不用看见 flowmemstore 也能做知情选择. i18n 2 key x 中英.
- 真模型端到端 (m5max/gemma4, 对照真值): 第 1 次执行事实 (月货量500公斤) 在输入里 -> ok verdict 沉淀先例 (对 store 核对, 不信模型自述); 第 2 次输入不含事实, 模型经 {{.memory}} 先例答出 "500公斤" + has_precedent=true = 闭环铁证; 负对照 客户B 同问返回 unknown, 无泄漏.
flows_evolve_live_test.go(build tag live) 固化该验证, 顺手修复 bit-rot 的 flows_live_test.go (engineconfig 多租户签名改动后未跟).
测试: flows_evolve_test.go (沉淀 + 去重 + 第 2 次 prompt 携带先例 + 默认关不沉淀 + cap 轮转最旧先出 + rune 安全截断不超单条上限). platform/common 全 -race 绿, swagger 重生.
platform+frontend — ADR-0025 P2: 提示词版本/回滚, flow 修订历史落地 (2026-07-21)¶
画布留位的 "版本/回滚" 补上后端载体 (ADR-0025 §2.2/§2.3), 原 flowstore 只有 ON CONFLICT 原地覆盖的自增版本号, 历史不可见不可回:
- flow_revisions 表 (append-only): 每个 flows 版本一条快照,
flowstore.Put与 flows upsert 同事务落修订行 (flows 行锁串行化并发 Put, 每版本恰一条; 修订侧 ON CONFLICT 纯防御);Publish原地翻该版本快照状态 (唯一改写 -- definition 落表后不可变), 历史能看出哪些版本真上过线.Delete两表同删 -- 同名重建的 flow 不继承陈年历史. - Store 接口 + 双实现:
ListRevisions(新版本在前, cap 50 -- 回滚目标天然近期, 更旧留表作审计) /GetRevision; InMemoryStore 镜像同语义 (Publish 翻快照 / Delete 连带删). - 回滚不是 store 原语: REST
POST /flows/{id}/rollback/{version}=GetRevision+ 重新Put-- 旧定义逐字落为新草稿版本, 版本号只进不退, 历史永不改写, "回滚" 本身即一条可审计修订; 且落草稿不落发布 -- 运维仍要过跟任何编辑一样的审阅 + 发布闸, 不做静默上线替换.GET /flows/{id}/revisions出全量快照 (客户端将来做 diff 不用加端点). 内建 flow 无存储历史: 列表空 / 回滚 400. - 前端版本历史抽屉: /flow 详情页 (store 背书的 flow) "版本历史" 按钮开抽屉 -- 版本号 / 草稿·已发布状态 / 编辑者 / 时间 + 一键回滚 (confirm 文案明说 "另存新草稿, 发布后生效, 历史不改写"); 当前版本标记不可回滚自身. 对照 diff 刻意后置. i18n 13 key x 中英走目录, 闸绿.
测试: flows_revisions_test.go (历史快照 + 发布标记 + 回滚落 v3 草稿带 v1 定义 + 历史不被改写 + 删 flow 连带删历史 + 404/400/内建拒绝). platform/common 全 -race 绿, swagger 重生.
frontend — ADR-0025 P2: 画布工作室, flow 编辑器视觉升级 (2026-07-21)¶
React Flow (@xyflow/react 12.3.5, 依赖已在栈从未使用) 落地为 flow 编辑器的默认视图:
- 两种真节点, round-trip 保真: flow.Definition 只能表达 fan-out-then-aggregate (ADR-0025 §3 本轮否决 DAG/循环), 画布 = 一个主 agent 节点 + 0..8 个 sub 节点, 边 =
{{.sub_outputs.<name>}}引用具象化 (sub->主单向). 工具/schema/model 是节点属性不是节点 -- 硬造节点就不 round-trip. 坐标不持久化, 布局确定性生成 -- 画布加载已有 flow 再保存, Definition 零漂移 (§5 硬要求); 序列化仍走既有 buildDefinition/save/publish, flowstore/发布闸/审计零改动. - 三层抽象兑现 (§2.1): 画布属性面板业务字段在前 (名称/职责/提示词/输出字段/工具授权清单), model / provider_instance 收进折叠 "高级 (开发者/管理员)" 区 -- 搭 flow 的 PM 不必看见技术项, dev/admin 仍可触达. 表单视图保留为高级/回退编辑器 (同一 state, 视图切换零转换).
- 工具授权清单: 节点卡片显示 "{n} 个工具已授权", 面板 ToolPicker 按名勾选 (对齐 plugin 权限清单 UI 愿景方向).
- i18n: 画布全部文案走目录 (flow.canvas.* 14 key x 中英), 闸保持全绿.
诚实边界: (1) 提示词版本/回滚与进化开关无后端载体 (flowstore 只有自增版本号, 无修订历史; evolve 无 Definition 字段), 画布本轮不含, 已登记 TODO; (2) 两层租户下放的 "可见范围配置" 其读者 (flytoCall 白牌前端) 不在本仓库, 本轮不造无读者的 dead config, 登记 TODO 待 PM 决断; (3) 画布经 tsc + vite build 验证, 浏览器人工走查待 PM 打开 /flow 页 (React Flow 成熟库, 渲染风险低).
frontend — ADR-0025 P1: i18n 层, 中英参半门票级硬伤修复 (2026-07-21)¶
卖给 PM 的搭建器不能中英参半 (ADR-0025 §2.6, 从 P3 提到底座门票). 全量落地:
- 语言轴:
src/store/locale.ts-- theme/brand/preset 之外的第四条正交轴 (Zustand 同构范式), 中文默认, localStorage 持久,<html lang>首帧盖章; 顶栏 中/EN 切换器与三视觉轴并列. 与视觉轴的差别: 语言必须驱动重渲染 -- App 根key={lang}整树重挂 (低频操作, 笨但绝对正确), 命令式t()因此在任何位置都安全. - 字符串目录:
src/i18n/自写轻量 t()/useT() (两语言 / 扁平 key /{name}插值; 有意不引 react-i18next -- 这个规模换不来东西, 第三语言再评估). 目录分层: 基础 zh/en + 一页一片段 (i18n/pages/*.ts, 批量提取零冲突的结构前提). 缺 key 返回 key 本身 -- 看得见的 bug 优于静默. - 存量提取全量完成: 10 页面/组件 642 key (settings 194 / flow 85 / console 83 / home 122 营销级英文 / workspace 48 / reconcile 53 / layout 18 / docs 20 / run 17 / login 2), 4 个并行批次互斥文件集. 模块级 label 常量表改存 key 渲染时解析 (模块 init 只跑一次, 直接 t() 会冻结单一语言). mock 业务数据 / mono 技术 token / 品牌词有意不提.
- 后端错误 key 化 (§2.6 三修闭环):
i18n/errors.ts按 ADR-0006 稳定 code 出本地化标题 (error.code.*21 个引擎码 +warn.code.*5 个警告码), 后端中英混明文只作未收录 code 的 fallback, 不再是首选展示. run 页错误块/警告条已接. - i18n 闸 (
scripts/check-i18n.mjs, 挂进npm run lint): 目录 parity (zh/en 逐 key 对应 + 重复检测) + 禁内联中文 (UI 代码字符串字面量, 注释除外,i18n-ok行级豁免给 mock 数据/正则分隔符/语言自标签). 零依赖, 不引 ESLint 工具链 -- 闸的目标就是防中文内联回流, grep 级检查恰好够.
tsc + vite build + i18n 闸全绿.
core+platform — ADR-0025 P1 第二刀: Dream 服务端多 scope 调度, 老王档案自动提炼 (2026-07-21)¶
第一刀给了 "最近窗口" 的记住; 第二刀接通 Dream 后台巩固, 客户底账自动提炼成结构化事实并进记忆检索池. 全链真模型验证收敛 (m5max/gemma4):
- core 引擎接缝 (4 个 Config 位, 零值 = CLI 逐字节不变):
DisableDreamAutoFire(抑制 run 结束自动 CheckAndRun, RecordSession 仍计数 -- 让服务端中央调度器成为唯一驱动者, 关掉 ADR-0011 §2.5 全局 cap 被引擎内部 auto-fire 绕过的洞) /DreamMinHours+DreamMinSessions(门槛透传, 高流量客服 scope 要比 CLI 24h/5 更紧的节奏) /DreamTranscriptDir(巩固子 agent 读真实底账, ADR-0011 §4.4 厚模式) /DreamPromptBuilder(巩固 prompt 策略注入接缝, 见下).CheckAndRunSync同步变体 (与 CheckAndRun 共用 prepareRun, 门槛/锁/observer 逐字节一致): 信号量包住同步变体才能真正 cap 并发巩固数, 异步版在工作开始前就返回, cap 会漏. - platform 中央调度器: customerScopeManager 定时 sweep (默认 11min,
FLYTO_DREAM_SWEEP_SECONDS可调) 对每个缓存引擎驱动CheckAndRunSync, 全局信号量 cap=2, sweep 期间 active 引用计数钉住引擎防 LRU 回收中途 Close. per-customer 引擎翻开 Dream (DisableDreamAutoFire+ 门槛 envFLYTO_DREAM_MIN_HOURS/FLYTO_DREAM_MIN_SESSIONS). - 可观测性 (真跑逼出来的): per-customer 引擎接
scopeObserver把引擎内部事件桥到平台日志 (原 NoopObserver 吞掉一切, Dream 失败无声 -- 真跑第一轮就撞上这个盲区); 引擎 dream 每轮 emitdream_turn事件 (工具名列表 + 截断文本), "模型没动记忆就结束" 与成功从此可区分. - 窄巩固 prompt (真跑逼出来的, 范式级): 逐轮观测坐实 gemma4 把 10 轮烧在 Bash 探索上 / 或宣布计划后不发工具就结束 -- 引擎探索式四阶段 prompt 是给强模型设计的. 修法不是换模型:
DreamPromptBuilder注入服务端窄 prompt, 底账尾部内嵌, 模型唯一的活是一次 Write (指令层单步 + 参数层精确 frontmatter 模板 + 兜底层 MemoryDirRestrict 三层防御). 注入后 gemma4 一轮完成. - 真模型端到端验证 (对照真值): 三通真分析落底账 -> sweep 30s 准点驱动 -> gemma4 一轮 Write 产出
customer_profile.md-- 3C配件/含电池 (一通) + 月500kg/议价/大客户价 (二通) + 80kg/次日揽收 (三通) 全部事实可溯源, 零幻觉, frontmatter 合法 (进 FindRelevant 检索池); 第四通良构 verdict 且措辞与记忆画像一致 (记忆+窗口共存不破坏分析).
测试: engine TestEngine_DreamServerSeams (透传/零值默认) + TestEngine_DisableDreamAutoFire_GatesRunEndCheck (gate 双向: 抑制时计数不扫描 / 默认时真 fire) + platform TestDreamSweep_RunsAndUnpins (sweep 安全触达 + 钉子释放). core + platform 全 -race 绿.
platform — ADR-0025 P1 第一刀: /agent/run per-customer 会话/记忆 "一个客户 = 一条线" (2026-07-20)¶
现状 /agent/run 无状态: flytoCall 每次分析都当陌生人 (共享引擎全局记忆混所有客户, 且重启即失). 本增量给每个终端客户一条私有线:
- RunRequest 加可选 customer_id (租户从鉴权 requestTenant 推, 绝不取自请求体, 故一租户 id 永不能寻址另一租户 scope). 空 = 现无状态共享引擎路径逐字节不变 (向后兼容).
- per-customer 引擎缓存 (customer_scope.go): 带 customer_id 的请求落在私有引擎上, 设 ScopeRoot = <scopes>/<tenant>/<customer> (ADR-0011 单字符串锚点) + FileSessionProvider. bounded LRU (默认 256) + active 引用计数 borrow/release (回收永不 Close 在途 Run) + per-key 单飞构建. 引擎复用共享运行时权威 (Provider/Models/Executor/PermissionHandler/DefaultRunModelFunc), 故 P0 路由健康探活 failover 与设置热换一致生效.
- 永久底账 + 最近窗口注入: 每次分析追加一条 ledger.jsonl (input + output + ts) 到客户 scope, 与引擎缓存解耦直写 FS (回收后仍存活). 下次分析前把末 N 条 (字符封顶, 硬截断不上摘要 LLM) 作 "已知客户背景" 前言前置 -- 可观测的 "记住" 来自这里, 第二通电话即生效, 不需要 Dream.
- 反向坑锁死 (ADR-0025 §2.4): "同一会话" 不是全历史回放 (撑爆 context + 涨成本 + 质量降). 窗口有界: 系统提示 + 最近窗口 + 当前输入.
- DisableDream: true (本刀): 记忆注入照样接线 (buildMemory 无条件) 但读空目录 = 无害空转; 无 Run 结束自动 fire, 故几百客户引擎零 fork-storm 风险. Dream 提炼的结构化事实 (偏好/成交条件/禁忌) + 带全局 cap 的中央调度器是第二刀 (与微信客服线共用).
- fail-open: scope 构建失败回落共享无状态引擎 (本次失忆但仍跑), 底账写失败只记日志 -- 记忆特性故障不拖垮分析本身.
- 部署前须确认: --customer-scopes-dir 指向持久化 + 有备份的卷 (默认 <cwd>/customer-scopes), 否则重启丢档.
测试: customer_scope_test.go (sanitize 防路径遍历 / ledger append-read tail 环回 / 窗口格式化 + rune 安全截断 / borrow 缓存复用 + active 引用计数保护 + LRU 回收 / tee 累积-转发 + 客户断连仍 drain 不死锁) + customer_scope_integration_test.go (真驱动 handleAgentRun 两次: 第 1 通无历史 / 落底账 / 第 2 通 prompt 携带第 1 通输入+结论 = 客户被记住). platform/common 全 -race 绿, swagger 重生 (RunRequest 增 customer_id).
真模型验证 (2026-07-21, 本机直连 m5max/gemma4-moe-26b-a4b-q6, 对照真值非 LLM 自评): 本地起 common 真服务 + flytoCall 形状 lead 分析 prompt 三通实测 -- (1) 第三通转写只说 "跟上次一样的货", 模型输出 category: 3C配件 / battery_goods: yes, 该信息仅存在于第一通历史 = 窗口注入生效铁证; (2) 负对照: 无历史新客户同 prompt 答不出品类 (排除模型瞎猜); (3) verdict JSON 三通全程良构, 中文历史前言未带歪输出格式. 顺带修一真 gap: 负对照暴露消费者 prompt 引用 "已知客户背景" 而该段缺失 (新客户) 时模型卡成拒答 -- 修法范式级: 无历史时注入 "首次接触" 占位而非空串, 前言契约对新老客户对称 (消费者写一份 prompt, 平台保证该段存在); 修复后复测新客户优雅输出 unknown 良构 JSON.
platform — ADR-0025 P0: 路由级健康探活 failover + /agent/run fail-loud (2026-07-20)¶
ADR-0025 地基第一增量, 让消费者不再手填技术参数、平台替它 "本地优先 + 云端兜底":
- engineconfigstore.RoleRoute 加可选 FallbackInstance / FallbackModel (JSONB blob 免迁移, 空 = 现行为逐字节不变). GET /config/engine 回显, PUT 校验 "有 fallback_instance 必须有 fallback_model" (前置 400, 不存半配置).
- ConfigurableProvider.Stream (/run seam) 健康探活驱动 failover: RoleRun 主实例是 flyto.HealthChecker (如 m5max) 且请求时探活失败, 自动切到备选 provider + 备选 model, 并前置一条实时 provider_failover WarningEvent 让消费者当场可见. 主实例无 HealthChecker / 健康 / 无备选三者都照旧. 复用 ADR-0021 的 m5max 探活能力.
- /agent/run 对 provider_instance 参数 fail-loud (旧行为静默丢弃, 藏配置错误到故障那刻): 该端点路由驱动, 传实例名 400 并指向 settings 路由 / flows. 直接回应 flytoCall 2026-07-20 勘误第 2 条.
- 业务档 "本地优先/云端兜底" 的 UI 呈现归 studio (P2) 增量, backend 能力本增量已就绪.
docs/adr — ADR-0025 消费者自助工作流工作室 + 会话/记忆产品化 + i18n 层 (2026-07-20, Proposed)¶
flytoCall 案例 (转写正常 / 分析报错 / provider 泄漏 / 中英参半) 触发的路线级设计草案. 核心 = 三层抽象 (业务层 / 能力层 / 技术层, PM 永不见 provider_instance+model id, 根除 deepseek 事故类), 画布工作室 (React Flow 落地成既有 flow.Definition), 会话/记忆产品化 (客户档案 = 一个 ScopeRoot, 复用 ADR-0011 + SessionStore), i18n 层 (从 P3 提到底座门票), 两层租户下放. scope 限 flytoCall, 物流对账后置 (§1.4). 待 PM 定档与优先级.
core/providers/openai — 转写 pre-flight 认可 oMLX router 顶层 "ok" (2026-07-19, flytoCall 信箱报障)¶
事故: oMLX 升级多后端 router (m2max + m5max) 后 GET /health 顶层从 {"status":"healthy"} 变 {"status":"ok","backends":{...}}, CheckHealth 只认 "healthy", 健康的 m5max 被判离线, flytoCall 生产 2026-07-17 起每通转写静默降级阿里云 ASR (6 通受影响).
修法: CheckHealth 把 "ok" (router 合法顶层状态) 也认作健康, degraded/down 保持 fail-loud (ADR-0006). PR #37 (flytoCall 侧参考补丁, ccm 审后采纳) + router 形态测试. 已 fastpush prod (common v13629a4).
顺带发现并处置 (prod 运行时配置, 非代码): (1) m5max 实例 URL 曾被改指 router :9000, 但 router 对 ASR 请求错路由到无 ASR 模型的 m2max -- 已按 m5max-asr-api.md 文档态改回 :8000 直连; (2) m5max 升级后模型 id qwen3-asr 别名消失, 现为 qwen3-asr-1.7b-audio8-text4, 消费者需换新 id (已在信箱回信告知 flytoCall). 端到端真语音转写已复验逐字正确.
全链 — 消费者用户体验: 等待黑洞 / 平台掐话 / 报错裸奔三修 (2026-07-18, PM 专题)¶
问题 (探查实证): 引擎的体验素材备好了但断在出口 -- (1) transport 层 pre-stream 重试 (指数退避单次最长 32s) 发生在 event channel 存在之前, 对所有消费者完全静默, TUI 冻结 / GUI 只剩 spinner; (2) 引擎层 WarningEvent (api_retry / model_fallback / context_too_long_retrying 等中文文案) 被平台 SSE convertEvent default 分支整类丢弃, flows 同步端点同样无视, 全公司只有 dogfood TUI 能看到; (3) ErrorEvent 的 ADR-0006 结构化字段 (Code / Suggestion / Retryable) 在 SSE 与 flows 出口全部剥掉只剩 Err.Error() 中英混杂裸串, flyto.FormatErrorForDisplay 自 ADR-0006 起零消费者.
修法 (四层, 范式级):
- core: retry 包新增 ctx 携带的 ProgressSink (镜像 WithQuerySource 范式, progress.go); Retryer.Do 每次退避前回调 -- anthropic + openai-compat 两条 transport 共用 Do, 一个读位置覆盖全 provider. engine 在 BuildAndStream 前经 retry.WithProgressSink 注入 sink, 把 transport 退避转成 WarningEvent{Code:"transport_retrying"} (含 category/reason/detail) 实时进消费者事件流. 同 goroutine 发送, 与既有 emit 顺序/阻塞语义一致.
- platform SSE: convertEvent 补 warning case 整类透传 (code/message/detail); error 事件 data 增补 code/detail/suggestion/retryable 四字段 (error 串保留向后兼容).
- flows 流式模式: POST /flows/{name}/run 内容协商 -- Accept: text/event-stream 切 SSE (started / progress 每 agent turn / warning 引擎重试+failover / result 与同步同形状 / error 带等价 status), 不带头保持同步 JSON 契约零变化. 引擎可见性走 ctx 事件 tap (flows_stream.go, flowEngineRun 测试接缝签名不动); team 扇出 sub 以各自名字标注事件; provider failover 实时发 provider_failover 警告. 运行前校验仍普通 HTTP 4xx (SSE 未开始).
- 消费端渲染: 前端 agentStream 加 warning 事件 + 结构化 EngineError, run 页警告渲染成内联状态条 (退避期间可见 "引擎在扛上游故障"), 错误展示 code + 建议 + 可重试标记; flow 试跑面板改走流式客户端 (lib/flowStream.ts), spinner 旁实时状态行 + 警告列表; TUI ErrorEvent 改调 engine.FormatErrorForDisplay (该 helper 首个生产消费者).
测试: retry progress_test.go (Do 每退避回调一次 + 无 sink 不 panic); platform convert_event_test.go (warning 透传 + error 结构化字段) + flows_stream_test.go (SSE started->result / 引擎错误成终态 error 事件 / 运行前校验保持 HTTP / tap 管道 round-trip). core + platform + tui 全 -race 绿, 前端 tsc + build 绿. swagger 重生成, flows-ops.md 增流式模式节.
未做 (PM 拍不急): i18n 层 -- 面向用户文案仍硬编码中文, 行业 platform 出海前补 (core/TODO.md 登记).
core — openai-compat 路径补齐 pre-stream HTTP 重试 (2026-07-17, prod deepseek 裸奔修复)¶
缺口 (调查实证): openai-compat 家族 (openai / openrouter / ollama / lmstudio / deepseek 默认 ModeOpenAI / minimax / 自托管 fmlx) 的 HTTP 5xx/429/529 在任何一层都不重试, 直接硬失败. anthropic 路径靠 transport retryer + classifier 有完整重试; openai wire 是裸 httpClient.Do 无 retryer, 而 engine runLoop 的字符串兜底 isRetryableError 只认大写 "HTTP 429"/"HTTP 529" 且无 5xx 分支 -- openai wire 产的错误是小写 "openai_compat: http NNN", 大小写 + 前缀双重不匹配, 永不命中. 命中 prod: 香港 Anthropic 被封路由全切 deepseek, deepseek 默认走 wire, 5xx/429 硬失败不重试.
修法 (模仿 anthropic, 范式级不打补丁):
- 拆 HTTP 错误分类 (DefaultClassifier + APIError + 连接错误诊断) 从 transport (package api) 移到中立的 internal/apierror 包, 解开 wire 无法复用的循环依赖 (transport import wire -> wire 不能反向 import). transport 侧留类型别名 (type APIError = apierror.APIError 等), 7 个消费者 + AnthropicClassifier + Hinter 零改动.
- openai wire Stream 用 retryer.Do 包裹 doStreamOnce (镜像 anthropic transport CreateMessageStream): 5xx/429/529/连接错误经 apierror.DefaultClassifier 分类 + 通用 composite policy (ForegroundOnly + ServerDirective + ExponentialBackoff, MaxRetries 4) 退避重试; 4xx 不重试. pre-stream 重试天然幂等 (SSE 消费在拿到 200 + 返回 channel 之后, 重试只在握手阶段, 还没吐 token). 重试耗尽后 toEngineError 转回 flyto.EngineError 保 ErrProviderHTTPStatus typed code + Detail (ADR-0006 fail-loud 链不变). policy 由构造期显式装, nil policy = 静默不重试 (retryer.Do 首次失败即返回), 故 New 必装.
- 覆盖全 openai-compat 家族 (含 prod deepseek); 同时给 deepseek/minimax 万一切 anthropic mode 出问题 fallback 回 openai mode 保底.
HTTP 标准精化 (fmlx 服务端契约咬合, 2026-07-17 追加): DefaultClassifier 补齐两条通用 HTTP 语义 (非 fmlx 特殊化, 惠及所有 provider 含 anthropic 路径): (a) 507 Insufficient Storage 改为不可重试 (RFC 4918 资源不足语义非瞬态; 修此前 507 落 >= 500 被误当瞬态重试的反向 bug; fmlx 用 507 表"模型永久太大装不下"正好对齐); (b) 5xx 分支解析 Retry-After header (RFC 9110 "503 服务暂时不可用, 稍后重试"标准信号, 有则尊重服务端退避, 无则调用方指数退避). fmlx 根因修复 (内存满改 pre-stream 返 503 + Retry-After, 不再 200 流中部甩错) 现被引擎缺口 A 自动接住重试, 对 flytoAgent 消费者隐形. 测试 classify_retry_test.go (507 不重试 / 503 尊重 Retry-After / 503 无 header 仍重试).
缺口 B: mid-stream 死字段 ev.Retryable 接通 (2026-07-17):
- 原始 fmlx 显存 mid-stream #1 已随 fmlx 根因修消解: fmlx 内存满改 pre-stream 返 503 + Retry-After (不再 200 流中部甩错) + 缺口 A 联合, 变成 pre-stream 会重试.
- 死字段接通: openai wire consumeSSE 在 mid-stream 429/529 (openai.go:862) 和 scan error/EOF (openai.go:970) 设 ev.Retryable=true, 但 runLoop case *flyto.ErrorEvent 此前只认 stream_empty/truncated/idle_timeout 从不读该字段. 现加 ev.Retryable && !hasAnyContentBlock && midStreamRetries<max 分支 -> post-loop context-aware 退避重试; clean turn 完成后 reset (per-turn 独立预算, 防长对话偶发 blip 累积误判硬失败).
- 幂等守卫 !hasAnyContentBlock: 只在还没吐 token 时重试 (429/529 流刚开始天然满足; scan error 若已吐内容守卫拦下走硬错, 那属 L722 已吐内容 EOF replay 范畴, 需 wire 缓冲/重放, 未合并).
- 两 gate 验证 (advisor): StreamGuard.Watch 原样透传 ev.Retryable (非 drop, wire 测试实证); 970 是瞬态 scan IO. 退避读不了 Retry-After (SSE error chunk 无 HTTP header, 与 pre-stream 不对称).
- 测试: engine mid_stream_retry_test.go 数真实重试 (无内容->重试成功 / 已吐内容->守卫拦下 / 跨轮 reset 两轮各重试) + wire 透传测试. 全 engine -race 绿.
- 审核补修 (2026-07-18, 接手方复审发现): StreamGuard 合成错误 (stream_empty / stream_truncated / stream_idle_timeout) 也带 Retryable=true, 原实现在 partial-stream 预算 (2 次) 耗尽后漏进 mid-stream 分支再吃 2 次 -- 两预算叠加, 持续空流 5 次上游调用而非设计的 3 次, 多睡 2s+4s 退避, 且带误导性 mid_stream_retrying 警告 ("流中部" 实际上游从未吐过事件). 修: mid-stream 分支排除 guardSynthetic 三码, 其重试生命周期完全归 partial-stream 预算. 回归测试数真实调用 (修复前 FAIL 于 5 次实证能抓 bug, 修复后 3 次). 全 engine 测试此前无任何 stream_empty 脚本 -- 交互面盲区即漏因.
测试: openai_retry_test.go 经真 httptest server 驱动真 retryer.Do 并数真实 HTTP 尝试次数 (非分类断言): 503x2 -> 成功 (3 次) / 4xx 不重试 (1 次) / 503 耗尽 (5 次 = 1+4) / 200-非-SSE 不重试 (ErrProviderNonSSE). 全 core -race 绿.
core — 错误分类: 泛型传输码内的上下文超限升级为 context_too_long (2026-07-17, 消费者报障)¶
FlytoIM 报障: OpenAI 兼容上游把 "maximum context length" 详情装在 HTTP 400 里, wire 层包成泛型 typed code provider_http_status, ClassifyAPIErrorTyped 照单全收, forceCompact 重试永不触发. 修为 refineGenericProviderCode: 仅泛型码 (provider_http_status / provider_mid_stream_err) 且文本带超限特征时升级 context_too_long; 具体码不动 (OpenAI TPM 限流文案也含 "too many tokens", 限流必须退避不能压缩, 回归测试钉死两侧). cut core v0.5.0-alpha.29.
platform — ADR-0024 v2: httptool + 跨 provider team + 作用域记忆 (2026-07-17, flytocall.lead_tag 实战驱动)¶
首个真消费者 (flytoCall 客户标签判定) 把 team flow 从演示推到生产形态:
- httptool 包: 消费者 HTTP 接口按 spec (名/模型可读描述/URL/query 字段/bearer) 包成只读平台工具; 首个
lead.context.get(flytoCall 线索上下文, env 驱动注册). 错误对模型可读可自纠. - 跨 provider team:
SubAgentDef.ProviderInstance-- sub 钉自己的命名实例 (worker 本地 gemma@m5max + judge MiniMax 云端一次请求); 钉实例的 sub 退出请求级 failover 对. - 作用域记忆 (B/C):
flow_memories表 + GET/POST/DELETE /flows/{id}/memory + 保留模板键 {{.memory}} 每跑注入 (fail-open); 压缩三层确定性 (全量含例 -> 最旧掉例 -> 折叠合并注记), 存储永不丢条. 纠正下一跑生效, 不用重发布. - 执行器修复: 多轮文本折叠只取最后一轮 (带工具的 flow 过场白不再污染最终 JSON, 首批全挂根因); master key 改走 host secret env_file (重建容器不再依赖操作者 shell, prod crashloop 根修).
- 运维: m5max fmlx app 外部监听高载僵死 -> 装 watchdog (tailscale 地址探活 + 240s 启动宽限自动重启).
platform + frontend — R4 工具按名注册 + Team flow 编排 (2026-07-16, ADR-0024, PM 强令)¶
引擎的主子协作能力开放成自助 flow 的 team 编排, 同时补齐 INF-3 工具注册:
- 工具注册表 (INF-3/R4):
FlowConfig.Tools复用 coretools.Registry; flow 与 sub agent 的tools数组按名解析成tools.Allowlist收窄进引擎; 未知名 400 (列出已注册的), 发布闸同规则 (published = runnable); cmd/common 只登记只读安全 builtin (Read/Grep/Glob), 业务工具逐个过审.GET /api/v1/flows/tools目录端点, "tools" 成保留 flow 名. - Team flow (ADR-0024):
flow.Definition.SubAgents-- 每个 sub 并行跑独立隔离引擎, 带自己的 output_schema (= 它的反射器) + 工具 + 可选 model; 主 agent 汇总, user prompt 模板引用{{.sub_outputs.<name>}}(无模板默认{"input","sub_outputs"}JSON 信封); 主 OutputSchema 闸最终答案. 扇出上限 8. 失败语义: sub provider 级失败 502 点名 / sub 输出不合规 200+verdict=invalid_output. failover (ADR-0020 v3) 对主与每个 sub 各自生效, 抽取成runFlowEngineWithFailover. - GUI: /flow 编辑器加工具选择 chip (目录同源) + 团队区 (子 agent 卡片: 名字/模型/prompt/自己的反射器 schema/工具); 定义视图渲染工具与团队名册.
- GUI 试跑面板: /flow 详情页就地跑 flow (实例下拉/可选备选对/input 按 schema 预填骨架), 结果展示 verdict + served_instance + trace_id + 结构化输出 -- 定义/发布/验证一页闭环.
- 消费者运维手册:
platform/common/docs/flows-ops.md-- 工具注册/反射器注册 (= 写输出 schema)/会话记忆在哪 (flow 刻意无状态, 三处存储表)/记忆压缩立场/排错速查. - 测试: flow 包编译闸 (重名/非法名/超限拒) + server team 扇出聚合/sub 失败两态/工具收窄/未知工具/目录端点; 全量 -race 绿; 前端 tsc+build 过.
platform + frontend — R3 自助 flow: 界面定义流程, 发布即可调 (2026-07-16, ADR-0020 v4, PM 拍)¶
流程定义从 "焊死在代码里等发版" 变 "运维界面上定义, 发布立即可调":
- flows 表 + flowstore (租户作用域, 镜像 dispatchstore): draft -> published 生命周期, 只有已发布行对执行器可解析.
- 写面: PUT /flows/{name} 存草稿 (版本自增, 编辑已发布自动回草稿) / POST /flows/{name}/publish (发布闸 = flow.Prepare, 与执行器同源 -- 发布即可跑, 坏定义 400 带编译错误) / DELETE. 内建名不可覆盖不可删 (防覆盖).
- 执行解析链: 内建注册表优先 -> 租户已发布自助 flow; 草稿 404; 跨租户 404.
- GUI: /flow 页从只读升级为编辑器 -- 新建/编辑表单 (流程名/说明/默认模型/两段 prompt/输入输出 schema), 输出 schema 字段旁写明 "这就是反射器"; 保存草稿/发布/删除; 列表带 builtin/draft/published 徽标.
- 测试: 生命周期 E2E (存草稿->草稿不可调->发布->可调->删除->404) + 发布闸拒坏定义 + 防覆盖 + 租户隔离; 全量 -race 21 包绿.
- 边界 (诚实): 多 agent team 可视化编排不在本轮 -- 引擎有主子协作能力, 平台化编排 (节点画布) 是下一主题.
platform — flow 消费者级 failover: 本地优先, 本地挂了才云端 (2026-07-16, ADR-0020 v3, PM 拍)¶
deepseek 欠费事故暴露单实例依赖. POST /flows/{name}/run 增 fallback_instance+fallback_model (可选成对, 消费者自己的实例 -- 归属纪律不破): 主选 provider 级失败切换一次, invalid_output 不切 (质量结局重跑白花钱), 备选提前解析 (写错 400 在主选白跑前), 响应带 served_instance, 审计记真实服务方 + failover 注记. AI 小结推荐姿势: 主选 m5max/gemma4-moe-26b-a4b-q6 (本地零成本), 备选 flytocall-deepseek. 回归测试四场景 + prod 真调验证 (主选欠费实例 -> 自动切 m5max 成功).
core + platform — 转写代理参数透传修复: >270s 音频 aligner 上限可绕开 (2026-07-14, flytoCall 生产 bug)¶
flytoCall 报告 (实测矩阵齐全): >270s 录音 100% 挂 oMLX word 对齐器 270s 上限, 且报错给出的两条出路 (word_timestamps=false / on_aligner_overflow=chunk) 经代理层全部不生效. 三层根因一次修:
- 契约:
TranscriptionRequest.WordTimestamps从 bool 改 三态 *bool -- 显式 false 有实义 (关掉服务端默认开启的 word 对齐器, 正是长音频出路), 普通 bool 表达不了 "没传" 与 "传 false" 的区别. - openai provider: WordTimestamps 非 nil 即显式上线 (含 false); Extra 防撞改为 "类型化字段真赋值才优先", 空值不再遮蔽同名 Extra.
- 代理 handler: word_timestamps 三态解析 + 未知表单字段全部经 Extra 逐字透传 (on_aligner_overflow 及未来任何 oMLX 扩展参数不再等平台发版) -- 代理是代理, 不是白名单.
- 第四层 (真跑长音频才暴露): 转写 RPC 原复用 chat/vision 的 HTTP client, 秒级表头超时恰好杀掉 on_aligner_overflow=chunk 的切块重转 (服务端做完才吐首字节, 分钟级). 转写路径改用独立 client (25min 表头兜底, 真预算仍是调用方 ctx).
- 回归测试: 显式 false 到后端 / 不传保持缺省 / on_aligner_overflow 逐字透传 / 夹具跨请求残留修正. 两 module 全量 -race 绿.
- 已知边界 (真跑发现, 非本次修复范围): 长音频期间响应静默数分钟, 客户端到平台链路上的 NAT/中间盒可能按空闲切连接 (实测本地到 HK 约 4 分钟被切). 消费者侧建议部署在干净链路; 根治要把端点做成异步提交+轮询 (与 DashScope 同形), 登记 follow-up.
core + platform — 阿里云 fun-asr 转写 provider (2026-07-13, ADR-0021 v2)¶
m5max 自托管 ASR 的云端备选 (PM 调研拍板: fun-asr 异步是阿里系唯一 "准转写 + 声道分轨 + 逐句时间戳" 三者兼得). 消费方零差异: 同 POST /api/v1/audio/transcriptions, 换 provider_instance + model=paraformer-8k-v2 即切换.
- core 新
providers/aliyunasr(纯标准库): DashScope 异步任务 (提交 + 内部轮询到 SUCCEEDED, 保持同步语义) + OSS 中转 (音频落临时盘 -> V1 签名 PUT -> 限时签名 GET URL 交任务 -> 尽力删除, 真清理靠 bucket 生命周期) + 声道分轨 (双 speaker 标签时 channel_id=[0,1], 声道 0/1 -> Left/RightSpeaker, 两路 sentences 按 begin_time 合并, 毫秒转秒). Stream fail-loud (纯转写); 不实现 HealthChecker (云端 7x24, pre-flight 跳过即恒在线). - 平台: TypeAliyunASR 进 supportedTypes + factory (实例 key=DashScope key, url=业务空间 API 根, 空则公共端点) + 能力目录. OSS 凭据部署级 env (ALIBABA_CLOUD_ACCESS_KEY_ID/SECRET + FLYTO_ASR_OSS_ENDPOINT/BUCKET), 缺失 Transcribe 时 fail-loud, 实例可先注册后配 key.
- 测试: 全 fake 后端 (httptest OSS + DashScope), 零真云调用 -- 全链 (签名形状/异步头/channel_id/轮询/映射/交叉排序/FAILED/校验/删除) 覆盖.
- 待运维: OSS 子账号 AK (只授 flytocall-audio 读写) 待 PM 提供后建 prod 实例真跑一次.
frontend — 平台登录门禁上线 (2026-07-13, ADR-0022 R1 前端面)¶
- SPA 启动即登录门禁: main.tsx 渲染前 await keycloak-js
login-required, 未登录浏览器一个平台像素看不到, 跳 Keycloak 托管登录页 (/idp) 再跳回; token 只在内存不进 localStorage. - apiFetch 每请求带保鲜 Bearer (临期自动刷新); 审计行 subject 从此有人类用户真值. 顶栏显示用户名 + 退出登录.
- Keycloak 侧: SPA 公有 client
flyto-console(PKCE S256, redirect 限 hub.flytoex.net) + 同款 tenant_id/audience mapper; PM 账号 yuanwei 已建 (临时密码首登强制改). - 诚实边界: GUI 已有门, API 仍 soft 双轨 (flytoCall 迁移后切 enforced 才算锁死); 运行历史/设置页此前已接真数据 (PR #8).
deploy — IdP (Keycloak) 上线 + prod 切 OIDC soft 双轨 (2026-07-13, ADR-0022 R1 运维面)¶
- 选型 Keycloak (26.3.5): 行业标准 (企业尽调认它) / tenant claim 零代码 (内置 protocol mapper) / 机器凭据 client_credentials 开箱 / 未来 P2 前端人类 SSO 同一套. 自建 IdP 否决 (凭据管理/吊销/登录页是负资产); Zitadel 否决 (custom claim 要写脚本, 生态小).
- 部署形态: 路径式
https://hub.flytoex.net/idp(Caddy/idp/*路由, KC_HTTP_RELATIVE_PATH, 零新 DNS/证书); DB 用共享 postgres 的独立flyto_keycloak库; bootstrap admin 凭据在 host/opt/flyto/secrets/idp.env(600, git/CI 之外). - realm
flyto+ clientflytocall: 仅 service accounts (client_credentials), mapper 硬编码tenant_id=t_default(R2 解析行为不变) + 自定义 audienceflyto-platform(= common--oidc-audience). - prod 切 soft: common 带
--oidc-issuer/--oidc-audience/--auth-mode=soft重建, depends_on idp (issuer 不可达启动即 fatal 是设计). 三轨真调验证: 无 token 200 + deprecation 日志 (点名 path/来源) / 坏 token 401 / 真 token 200 且 flow_dispatches 审计行subject= flytocall 服务账号 sub -- "调用者是谁" 从此有真值. - 切强制只改
--auth-mode=enforced, 不重建不发版.
platform — flow 执行审计持久化: 每次调用可查 "谁调了哪条 flow, 结果如何" (2026-07-13, ADR-0023, INF-4)¶
PM 问 "flytoCall 调工作流有记录么? 能看到调用者么?" -- 核实答案是不能 (handler 零日志零落库, dispatches 端点是假数据 stub, subject 从未被读). 本轮落地:
- flow_dispatches 表 (db.platformMigrations): 一次执行一行 -- 身份 (tenant_id + subject) / 调用 (flow_name/version + provider_instance + model + idempotency_key) / 过程 (input + raw_output 模型原始文本) / 结果 (status: ok/invalid_output/error/rejected + output + error) / 计时. trace_id 主键 = 消费者响应里看到的那个, 拿着 id 就能查.
- dispatchstore 包 (镜像 sessionstore): Store 接口 + Postgres (prod) + 有界内存 (dev). flow handler 四个出口全记, fail-open (审计失败只记日志, 绝不打断业务响应; 脱离请求的 5s ctx, 客户端断开审计照落).
- 读面转正 (原 P2 stub 假数据):
GET /api/v1/dispatches本租户历史列表 (summary 不含 payload) +GET /api/v1/dispatches/{id}详情 (input/output/raw_output 复盘) 新增 +GET /api/v1/dispatches/{id}/eventsSSE 重放 (从落库行派生时间线). 全部租户隔离: 跨租户 trace_id 一律 404; 未接 store 503 不给假数据. - 测试: 写侧 (OK/引擎失败两出口行内容对照响应) + 读侧 (503/列表/详情/SSE 五事件/跨租户 404) + swagger 重生; platform/common 全量
-race21 包绿. - 残余 (tracked):
idempotency_key已落库未去重 (加唯一索引+窗口即成, 见 TODO R6); 数据保留/分区策略在表增长可见时定 (ADR-0023 §6).
platform — 鉴权双轨过渡 + 租户作用域 provider 解析 (2026-07-12, ADR-0022, R1+R2)¶
发布跑道清账第一包 (改语义的最先做, 抢在消费者固化之前):
- R1 鉴权双轨: 新
--auth-modeflag (空 = legacy: 有--oidc-issuer即强制;soft= 双轨过渡;enforced= 严格). soft 模式 (auth.SoftHTTPMiddleware): 带 token 必须验签通过 (401 不静默降级), 不带 token 回落t_default+ deprecation 日志 (含 method/path/远端, 供限期通知消费者点名). soft/enforced 无 issuer 启动即 fail-loud. 切 enforced 只改 flag 不改代码. - R2 租户作用域解析:
engineconfig.Manager从单租户单 snapshot 改 per-tenant snapshot map (SnapshotFor惰性加载, copy-on-write 整 map 原子换,Current()保留 = t_default 兼容视图, ConfigurableProvider/dispatch loop 等平台级 seam 零改动); 全部读写方法租户参数化 (写方法不改会互写).ConfigSnapshot增 name->type 映射 +ResolveInstance:provider_instance语义从 "全局名" 升级为 "本租户内的名或类型" -- 名精确优先 > 该类型单实例时裸类型命中 > 多条ErrAmbiguousInstance400 要求点名 (fail-loud 不猜). flow 执行 / 转写代理 / config 面全部改按请求租户解析 (requestTenant: ctx 租户, 无则 t_default). - 兼容: t_default 现有实例 (anthropic/deepseek/flytocall-deepseek/m5max) 与无 token 调用行为逐字节不变. 测试: 多租户隔离 (A 写不动 B 的 snapshot 指针) + ResolveInstance 四分支 (名精确/类型单条/类型多条歧义/名遮蔽类型) + 全量
-race绿. - 待办 (同包后续): prod 加
--oidc-issuer+--auth-mode=soft(动 prod 前单独确认) + 消费者 token 签发流程 + soft 中间件独立单测 (auth 包现无测试基建, 需 fake IdP).
core + platform — 语音转写 provider 能力 (2026-07-12, ADR-0021)¶
M5Max (oMLX) 上线 OpenAI 兼容语音转写 (长音频兜底 / 立体声说话人分离 / 词级时间戳, 接口全文 platform/common/docs/m5max-asr-api.md), 是 callcenter ASR 依赖类 flow (qa_score / violation / call.summary) 的上游. 把转写做成引擎一等可选能力 (仿 vision 先例), 不让消费者绕引擎裸调:
- core:
flyto.TranscriptionProvider可选能力契约 (Audio io.Reader流式, 数百 MB 录音不整段进内存; 字段 = OpenAI 转写 API + diarize/long_audio/word_timestamps 扩展 +Extra透传; 场景中性) +flyto.HealthChecker可选探活契约. openai provider 实现两者:Transcribeio.Pipe 流式 multipart 上线;Config.HealthCheckPath(fmlx="/health") 启用探活且Transcribe前置探活 -- 自托管 Mac 不常在线, 离线秒级 fail-loud 不灌 300 MB 进死端点. - 模型 spec:
ModelInfo.SupportsTranscription新能力位; live discovery 解析 oMLX 目录model_type(audio_stt -> transcription, vlm -> vision, 此前被忽略); platformModelSpec增supports_transcription. - 平台代理:
POST /api/v1/audio/transcriptions(multipart) 转发到provider_instance点名的实例 -- 消费者够不着 Tailscale 私网上的 ASR 机器, 且沿用 ADR-0020 v2 provider 来源纪律 (无平台默认, fail-loud). 点名无转写能力的实例返 400. 上限 320 MB, 大文件落临时盘不驻内存. - 验证: 单测全绿 (线上契约 / 探活四分支 / 前置探活挡上传 / handler 全链含 502 离线); live 测试对真 M5Max 合成语音转写逐字正确. prod 实例
m5max已建 (Tailscale IP), 容器内连通已验. - 同机 Gemma 4 chat (
gemma4-moe-26b-a4b-q6, VLM + 256K): 引擎 openai provider 原生可用 (点名实例即可, 零新代码), 接口说明入仓platform/common/docs/m5max-gemma4-api.md; prod 真跑 probe 入库 (streaming/tool_use/structured_output/schema_ref 实测 ✓, 顺带证明 prod 容器经 ts 的 chat 链路通). 256K 是声明值, probe 探不出 -- "declared 档 model spec" 记 tracked follow-up (core/TODO.md).
platform — flow provider 来源纪律: 去平台默认兜底, 请求点名消费者自有实例 (2026-07-12, ADR-0020 v2)¶
PM 拍板的架构补正: flow 功能是好的, 病在 provider 从哪来 -- v1 的 FlowConfig.DefaultProvider 兜底让 flytoCall 静默骑在平台默认 (对账在用的) key 配置上, 谁在花钱说不清 / 轮换互相误伤 / 租户隔离无从谈起. 引擎本就中性 (调用方给什么用什么, ADR-0005), 病根在平台层偷懒挂默认, 本轮拔掉:
- 去兜底: 删
FlowConfig.DefaultProvider/DefaultModel;resolveFlowProvider不再回落任何平台默认实例, 也不再优先 RoleRun 路由. - 请求点名:
FlowRunRequest增provider_instance(必填, 点名消费者自己的 ADR-0017 命名实例, 如flytocall-deepseek) +model(可选, 优先级 请求 >Definition.Model> 400). 隔离按配置条目不按 key 值: 两个消费者可持同一串 key, 但各自独立实例, 轮换/审计/归属互不影响. - fail loud 矩阵 (ADR-0006): 运行时引擎配置禁用 -> 503 (指向
FLYTO_SECRET_MASTER_KEY);provider_instance缺失/未知 -> 400 (报文指向PUT /api/v1/config/instances/{name}); model 缺失 -> 400. cmd/common 启动时配置禁用则打 INACTIVE 日志. - 测试: flow handler 增 4 case (503 配置禁用 / 400 实例缺失 / 400 实例未知 / 400 model 缺失) + OK case 断言引擎真跑在点名实例 provider + 请求 model 上; live 测试改为注册
live-anthropic实例并点名. 全-race绿. - 部署侧 follow-up (core/TODO.md): prod 注入
FLYTO_SECRET_MASTER_KEY启用运行时配置 + flytoCall 建自有 deepseek 实例 + 撤 prod 临时docker-compose.override.yml.
platform — 具名 flow 执行器: 把 "模型 B" 泛化成数据驱动的具名 flow (2026-07-12, ADR-0020, INF-1 + INF-2)¶
flytoCall (电销呼叫中心工作台) 厚接入需求: 把 AI 逻辑从薄网关 (prompt 写死 + strings.Index 抠 JSON) 升级为具名 flow (按名 HTTP 触发 + 结构化 JSON 出). 问题不在没能力, 而在模型 B 每加一个能力都要手写一个专用大文件 (quotedispatch 3751 行). 泛化成数据驱动: 一个 flow = 一份 Definition {name, version, input_schema, output_schema, system_prompt_template, tools, model}, 一个通用执行器跑任意 flow. 零 core 引擎改动 (复用现成 Config 旋钮 SystemPrompt/LiteralSystemPrompt/Toolset=tools.None()/JSONSchema/ResponseReflector).
enginefactory.BuildStructuredEngine(新中性包): 每请求模型 B 装配 (engine.New+tools.None()+ 字面 prompt + 8 个中性化 flag + JSONSchema + ResponseReflector) 提取成公共件. 提取自 quotedispatchBuildMainEngine(quotedispatch 那份暂不动, 见 follow-up).responseguard.JSONSchemaValidator(新): santhosh-tekuri/jsonschema v6 做确定性 schema 校验 (类型 / enum / 范围 / 条件必填 if-then), 嵌StructuralMarker挂ResponseReflectorin-loop 自纠. 补齐JSONVerdictValidator只查必填 key 的空档.responseguard从零消费者 helper 接到真消费者.flow包 (新):Definition+Registry接口 +BuiltinRegistry(CCM 拥有, 仓内注册) + 输入/输出 schema 校验 + prompt 渲染. 首个 flowcallcenter.wrapup(话后小结 + 判定, 迁移 flytoCall 原 prompt, 意向从旧 4 值 高/中/低/无效 升级到 3 档 tier unreachable/invalid/valid + 0-5 intent + 配置驱动标签库).POST /api/v1/flows/{name}/run(新端点, 返 JSON 非 SSE): 校验 input -> 渲染 prompt -> 每请求建引擎 -> 跑一次 -> 校验 output -> 返{flow_id, flow_version, trace_id, verdict, output}. 失败契约: 400 畸形 input / 404 未知 flow / 502 引擎错 / 200 +verdict=invalid_output(模型重试到顶仍产不出 schema-valid 输出, 消费方据以降级)./flowsCRUD stub 从此有了真执行入口 (与 node-graph draft CRUD 命名空间无冲突, 段数+方法不同).- 结构化输出 enforcement (INF-2): 两层确定性闸, 都由 output_schema 驱动 -- (1) in-loop reflector 自纠 (MaxTurns 限) + (2) REST 层最终校验 (失败报 verdict=invalid_output). provider 原生 json_schema 刻意不接 -- 真跑发现它让默认 Anthropic 发被拒的
anthropic-betaheader HTTP 400 (直接打挂非降级; quotedispatch 早有 MainDisableJSONSchema 按 provider 关它). 按 provider 能力有条件接是 P1 优化. - 真跑闭环 (对照真值):
flows_live_test.go(//go:build live, key gate) 对真 Anthropic 跑通 wrapup -- 走 scripted 测试 override seam 覆盖不到的真eng.Run+ reflector 路径. 真跑抓出并修了上述 Anthropic json_schema bug; 修后输出对照真值验证 (tier=valid / intent=3 / tags 从活口径选取未自造 / note 无编造). - 边界 (依 flytoCall §4.1): flow 定义/prompt/模型选择归 CCM 仓内拥有 (非 HTTP 自助注册); 动态 taxonomy 约束 (tags∈valid_tags / reason∈invalid_reasons) 走 prompt 指令层 + flytoCall
validateVerdict兜, output_schema 只锁静态形状. - 接线:
cmd/common经flow.DefaultRegistry()+AttachFlows接线; provider/model 优先 ADR-0017 运行时配置RunProvider/RunModel(热换) 回落启动值, flow 可Model覆盖. - 测试: responseguard/flow/enginefactory/server flow-handler 全
-race绿 (含 if-then 条件必填 / verdict 分支 / 活口径进 prompt); quotedispatch + server 全量-race绿证明加 dep 没碰坏收入路径. 加 depgithub.com/santhosh-tekuri/jsonschema/v6(platform 层). - follow-up: quotedispatch
BuildMainEngine/BuildSubEngine收敛到 enginefactory (需先补 Config 等价性 guard, 因 quotedispatch 测试短路真引擎构造); INF-3 (工具注册表 + RAG embedding/vector 栈, 支持非空 Tools 的 flow); INF-4 (dispatches 真持久化 + trace 关联 + idempotency 去重); DB 背书自助注册Registry实现 + 治理; 对真 provider 端到端跑通 wrapup 对照真值.
core/engine — 真模型验证 campaign 修复阶段 (2026-06-15, 逐个修)¶
scan 阶段坐实的引擎静默 bug 逐个修. PM 拍板权限闸接通 + 死代码接通两方向. 清单权威源 core/TODO.md campaign 段.
- Plan 队列 + 进度接通: 步进执行器 (PlanProgress 缺的消费者) + REST 端点 + 真跑修 run-model 注入 gap (桶 C: Plan 队列+进度, campaign 收尾, PM 拍 "全链+异步队列" + "永远别用没消费者搪塞" + 真跑闭环): campaign 最后一块.
EnablePlanQueue全仓无 true,NewPlanProgress/AttachProgress零非测试调用者 -- PlanQueue (UDS 异步队列, 业界对标 Celery/Temporal) + PlanProgress (5 态步骤状态机 + Kahn 拓扑序ReadySteps+ 失败连带跳过) 机器全建好, 缺的是中间那个消费者: 按 PlanStep 步进驱动 PlanProgress 的执行器整仓不存在 (不是"建好没接线", 是"缺中间件"). PM 拍 "全链+异步队列" 并明确 "永远别用没消费者搪塞" -- 真跑反推真消费者, 不接受纸面 dormant. 修 (造缺失消费者, 让审批产出步骤->异步队列->步进执行+进度合成一条流程): (1) core 步进执行器 (plan_executor.go):runPlanStepsWith按Snapshot().ReadySteps()(Kahn 消费者端) 拓扑序逐步驱动 PlanProgress (StartStep/FinishStep/SkipDependents), 每步一次e.Run; 抽成独立 Engine 的函数 (runStep 注入) 故可 stub 单测不跑 LLM;runOnePlanStep单步;normalizePlanStepIDs防空/重复 ID 折叠 PlanProgress index + 队列 StepStatuses map. (2) 升级队列进度回调全 5 态 (plan_queue.go):PlanExecFunc的onStepDone(stepID, err)(仅 done/failed 两态, 看不到 running 中间态也表达不了 skipped) ->onStep(stepID, StepExecStatus, errMsg)-- 否则 PlanProgress 接了等于没接通 (轮询只见 pending->done 跳变);executePlan回调 +engine.go initPlanQueueexecFunc 改用执行器, 原合并 prompt 方案留注释作替代. (3) platform REST 端点 (plan_handler.go): submit/status/list/cancel 4 端点直连新Engine.PlanQueue()accessor (返接口非*FilePlanQueue, 避 typed-nil 陷阱); registerRoutes 永远注册 (queue 关时 503 非 404, 同 quote-dispatch); cmd/common 默认开EnablePlanQueue(FLYTO_DISABLE_PLAN_QUEUE关) +PlanQueueDir默认/tmp/flyto-plans(distroless home 常只读). (4) 真跑暴露并修真 gap (run-model 注入): 队列e.Run用引擎启动冻结的Config.Model, 与 ADR-0017 settings 热换的 provider 失同步 -- staging 当前 RunProvider 已切 deepseek 而队列发 gemma4 -> provider 报 "supported: deepseek-v4-pro/flash, but you passed gemma4-moe-26b-a4b-q6";handleAgentRun靠WithModel(snapshot.RunModel)同步了 model 与 provider, 队列没有. 修 (范式级): core 加Config.DefaultRunModelFunc func() stringresolver 注入点 +runOnePlanStep经WithModel(resolver())应用, platform 接engineCfgMgr.Current().RunModel()使排队 Run 与/runhandler 同源 backend (nil = 单 provider 部署用 Config.Model). 真跑闭环 (memoryfeedback_verify_against_truth, 对照落盘step_statuses真值非 LLM 自评): staging REST (m2max, 运行时 provider=deepseek-v4-pro) 真提交 3 步带依赖计划 (calc-2/calc-3 dep calc-1) -> 轮询实测全程pending->running->done(running 中间态可见, 旧合并 prompt 机制根本看不到) + 拓扑序遵守 (calc-1 done 才 calc-2/3 转 running) + 10.8s 全 done; 修 gap 前同样提交实测 calc-1 1.9s failed + calc-2/3 skipped (失败连带跳过真跑验证),firstFailure暴露真因后定位 model 注入 gap. test:plan_executor_test.go(拓扑序/失败跳过/进度投影含 running/stuck 循环依赖/取消/ID 规范化, stub 单步不跑 LLM) +plan_handler_test.go(503 disabled/submit-status-list 往返/404/400). 全 engine + platform server -race 绿 + linux/arm64 交叉编译过 + staging force-recreate 部署. dormant 判断结案: 真跑反推出真消费者 (步进执行器), 这两子系统不是纸面 dormant -- 真跑暴露的 model 注入 gap 证明它们在真实链路里确实需被正确接通才能用. follow-up (非本次)*: 审批弧自动喂队列 (一条 curl 端到端串联, 销售包装非判断目的); 步进执行器并行 ready steps (当前串行, 队列 runLoop 单 goroutine 是安全默认); 跨步会话上下文串联 (当前每步独立 Run, 适合自包含步骤); QueuedPlan per-step 错误字段 (现仅 plan 级 ErrorMsg 含首个失败). - Plan 队列 follow-up: QueuedPlan per-step 错误字段 (上方 Plan 队列大条目 4 个 follow-up 之一, PM 2026-06-17 拍先做这个 -- 最廉价先清债): 上方条目修通后, step 失败只有 plan 级
ErrorMsg(firstFailure含首个失败步骤错误), 客户端GET /plans/{id}看不到每步各自的失败原因. 修: (1)QueuedPlan加StepErrors map[string]string(key=stepID, jsonstep_errors,omitempty); (2)plan_queue.go executePlanonStep 回调:StepExecFailed态把errMsglazy-init 写入StepErrors[stepID],StepExecRunning态先delete清旧错 (advisor 抓的 stale 边角 --RecoverPending重跑且这次成功的步骤不留先前失败; 起跑必报 running 含重跑, 自然清); (3) handler 不改 --handlePlanStatus已writeJSON(plan), 新字段经omitempty自动透出.firstFailure机制保留 (plan 级单一摘要点首因, 客户端不必遍历 StepErrors), 注释从"队列不持久化 per-step 错误"重写. 验证 (memoryfeedback_verify_against_truth+feedback_verify_real_wire_before_test): 单测plan_queue_test.go(注入 execFunc 调onStep(failed)断言StepErrors持久化 + 成功步骤无条目 + running 清 stale 锁 RecoverPending 重跑语义) +plan_handler_test.go(写终态 failed-plan fixture 进队列目录, GET 断言响应 JSON 含step_errorskey + 值 -- 证字段真经端点序列化非仅 struct 层). 全 engine + platform server -race 绿. staging 真跑闭环 (m2max, provider deepseek-v4-pro): 成功 2 步带依赖计划 ->done+step_errors字段 omitempty 缺席 (符合设计);timeout_secs:2短超时杠杆 -> stepfailed+step_errors: {"f1":"操作已取消: context deadline exceeded"}真落盘并经 GET 透出 -- 印证e.Run被 ctx 取消吐 ErrorEvent 走 per-step failed 路径, plan 级 (超时) 与 per-step (ctx 取消) 两粒度并存正是本字段价值. 4 follow-up 余 3 (审批弧自动喂队列 / 并行 ready steps / 跨步会话上下文). - Plan 队列 follow-up: 审批弧自动喂队列 (引擎能力 + 平台转播, 异步 opt-in) (4 follow-up 之二, PM 2026-06-17 拍做; PM 直觉纠偏 + advisor reconcile: 审批->执行的编排是引擎能力, 非平台补丁): plan 模式审批 (同步 /agent/run) 产出 steps 后是 "模型自由继续", 异步队列 (#4 那套) 是另起路径, 两条没串成一条弧. 架构定位: 审批->执行编排是引擎能力 (积木 plan 审批 / PlanQueue / runPlanSteps / PlanProgress 全在 core, 放平台等于每个消费者重拼); 只 SSE/HTTP 转发留平台 (传输概念, 引擎中性不碰). 支配性风险 (advisor 抓): 审批通过后 ExitPlanMode 返回 "你可以继续", 模型在同一 Run 内联执行计划; 再喂队列 = 同一计划跑两遍. 故核心不是 "怎么喂队列" 是 "喂队列同时让模型停手". 修 (异步, opt-in, 0 破坏): (1) core
WithPlanAutoQueue()RunOption +runConfig.planAutoQueue(默认关, 既有 plan Run 不变, 叠加非替换); (2) 开关经 ctx 传进ExitPlanMode.Execute(镜像WithEventEmitter私有 key, 锁单次 Run); (3) ExitPlanMode 审批通过 + opt-in -> 经tools.Result.Data放planQueuedSignal(现成带内通道, 同工具返图ImageResult) 而非返 "你可以继续"; (4) runLoop 收集工具结果时认出 signal -> tool_result 先 append 进历史 (advisor 抓真 bug: 否则持久 session resume 时 tool_use 缺 tool_result 被 Anthropic API 拒; one-shot /agent/run 测不到) ->submitAutoQueuePlan提交队列 -> emit 终态PlanQueuedEvent{PlanID}->goto sessionEnd结束本轮 (照抄post_tool_usehook 的 emit + goto 模式, break-复用共享收尾不 fork 第二条终止路径); 无 steps / 队列禁用 ->PlanQueuedEvent{Note}优雅终止不崩 (opt-in 语义绝不回落内联, 工作不静默跑); (5) platformRunRequest.AutoQueuePlan+ handleAgentRunWithPlanAutoQueue()+convertEventplan_queued SSE case (引擎产事件, 平台只转播). 验证 (memory feedback_verify_against_truth + verify_real_wire): coreTestPlanMode_AutoQueue_QueuesAndStopsModel承重断言 = 审批后那轮工具绝不执行 (证模型不跑第二遍; legacy 会跑 turn 4 故能区分修没修; nil-execFunc 队列只存不跑避免跟 scripted provider 无锁 call 计数器抢) + platformTestConvertEvent_PlanQueued(plan_queued 到 wire); 全 engine + server -race 绿 + 双 module build 过. staging 真跑头+尾 (头: 真模型 deepseek-v4-pro 经 ExitPlanMode 填结构化 steps -> plan_id 非空非 no-steps; 尾: 队列步骤轮询 step_statuses 真跑到 done). follow-up 余 2: 并行 ready steps (风险高); 跨步会话上下文. - Plan 队列 follow-up: 跨步会话上下文串联 (步骤共享 session 历史) (4 follow-up 之三, PM 2026-06-17 拍剩下新对话做):
runOnePlanStep此前每步一次独立e.Run无共享对话历史 -- 适合自包含步骤, 但 "step-2 用 step-1 写的函数" 这类依赖前步产出的步骤拿不到上文 (原 godoc 列为 follow-up). 修: (1)runPlanSteps每 plan 建一个 throwawaySession(用未导出newSession(planID, e)不入e.sessionState.sessionsmap -- 那个 map 无驱逐, 每 plan 注册一个会慢性泄漏; throwaway 在 plan 结束后无引用自然 GC,defer Close()best-effort 收尾), 闭包捕获喂 runStep; (2)runOnePlanStep改*Engine-independent 自由函数(ctx, sess, step, model),e.Run->sess.Send串联历史, run-model 每步由调用方经 resolver 解析 (保留 ADR-0017 热换语义; model opt 透传 Send 不覆盖历史, 因 Send 把WithMessages排最后, opts 顺序 invariant); (3) 调用点 engine.go execFunc 透传plan.ID. 正确性 (单测 dump 真透传 messages 对照非 LLM 自评, memoryfeedback_verify_against_truth):plan_executor_test.goTestRunOnePlanStep_ThreadsContextAcrossSteps用 fake EngineRef 捕获每步收到的WithMessages历史 + emit 真实 reply 事件让Session.applyTurn串联 -- 断言第 2 步 Send 历史含第 1 步 prompt (user) + reply (assistant), 第 1 步历史为空 (撤共享 session 即第 2 步空历史 FAIL). 既有 stub 路径单测不受影响 (runStep 仍注入). 串行执行保证正确性: 每步range sess.Send()跑完时trackEvents已applyTurn把该步 user+assistant 追加进历史 (close outCh 在 applyTurn 之后), 下一步 Send 快照含上一步输出. 权限行为与原e.Run一致 (Send 是 Run 薄包装, 不引入 hang). 全 engine -race 绿 + platform/common build 过. follow-up 余 1: 并行 ready steps (风险高, 队列 runLoop 单 goroutine 是文件冲突安全默认, 放开需先论证并发 e.Run 安全). - Plan 队列 follow-up: 并行 ready steps (opt-in, 隔离 fork worker, 经 -race spike 验证) (4 follow-up 之四 = 最后一个, PM 2026-06-17 拍剩下新对话做): 串行执行器
runPlanStepsWith每轮取ready[0], 无依赖分支也串行跑 (审批弧真跑里 step-a/b/c 本独立却串行耗 5 分钟). 先验证 gating 经验问题 (advisor 定调 + 否决循环论证): 同一*Engine并发Run/sess.Send不安全 -- 主 runLoop 每轮对共享文件工具调SetMessageID(engine.go), 并发即 data race + 破坏 file_history 快照键 (Explore agent 带文件:行号坐实). advisor 纠偏 "Team 已在生产跑并发 worker" 不是安全证据 (既有 sub-agent 测试全用脚本 provider, race 从没被-race抓过, 循环论证). 判别性 spike 先行 (plan_parallel_spike_test.go): N=8 fork sub-agent 经真共享 FileWriteTool 并发写不同文件,-race跑 50+ 次 (含-cpu=825x 加压) 全绿 -- 证 fork sub-agent runLoop 不调 SetMessageID (只主循环调), 故并发 worker 在共享工具上 race-free. spike 是发版闸: 过才发, 不过则退 serial-only. 修 (wave-based, opt-in, 默认关, 0 破坏): (1) corerunPlanStepsParallelWith拓扑驱动器: 每轮取整个 ready 集合 (一个 wave, 步骤互相独立) 交runWaveFunc并发执行, barrier 后按确定顺序收尾 + 失败连带跳过 (软失败同串行); 驱动器独立于 Engine/Session (stub runWave 可单测). (2)runPlanWave生产 wave runner: 每步 fork 隔离 sub-agent (SpawnSubAgent) 并发跑 (WaitGroup barrier 镜像Team.RunWorkersSync), 用共享 session 的历史快照 seed (SubAgentConfig.HistoryMessages故仍串联前面 wave 上下文), barrier 后按输入顺序applyTurnmerge 回共享 session (deterministic, 与 worker 完成顺序无关) 使后续 wave 看到产出 -- 与 #1 跨步上下文组合, 非互斥. (3)runOnePlanStepForked自己 drain channel 捕获最后 ErrorEvent (不用SubAgent.RunSync-- 它产生文本时吞错会让并行失败语义与串行失同步); 抽buildPlanStepPrompt串并行共用. (4) per-plan opt-in 全消费者贯通:PlanSubmitOptions.Parallel->QueuedPlan.Parallel(jsonparallel,omitempty持久化) -> Submit 复制 -> execFuncrunPlanSteps(... plan.Parallel ...)-> 驱动器分发; platformSubmitPlanRequest.Parallel(RESTparallelbody) -> handler 透传. 文件冲突契约 (godoc 写明): DAG 编码声明的依赖, 不编码文件级冲突; 同时 ready 的步骤不得写同一文件 -- 故默认关, 风险交回开启者 (对齐 plan_queue.go runLoop "串行是安全默认" CLEVER). 验证 (memoryfeedback_verify_against_truth: dump 真值非自评): spike (真共享工具并发 Execute, -race) + 驱动器单测 (TestRunPlanStepsParallel_WaveTopologicalOrder菱形 A->B,C->D 断言 B/C 同 wave 并行 /_FailureSkipsDependents同 wave 失败跳过 /_StuckCyclicDeps循环检测, stub runWave 不跑 LLM) + 真 fork 路径跨 wave 上下文串联 (TestRunPlanSteps_ParallelThreadsContextAcrossWaves: 脚本 provider 记录每个 worker 真收到的req.Messages, 断言 wave-2 worker 的 seed 历史含 wave-1 步骤的 prompt + reply -- 证 merge-back 真串联, 非 LLM 自评). 全 engine + platform server -race 绿 (并行测试 25x -race -cpu=8 加压无 race) + 双 module build 过. 4 follow-up 全清. 新登记 follow-up (预存, 非本次引入): 平台单 engine 跨/agent/run+ 队列共享, 并发/agent/run让主 runLoop 写 messageID 而 worker 读 -> read-write race; 此 race 当前队列 seriale.Run与并发/agent/run本就互相写 race (#2 不引入, serial 也不修), 真修需 per-worker 独立 tool registry 或主 runLoop messageID 加锁 -- 引擎级隔离, 单独立项. 顺带*: 补齐上一 commit (9edbd6eauto-queue) 漏重生的 swagger (RunRequest.auto_queue_plan入 docs). - 修预存 data race: per-turn messageID 从共享工具字段改 context 透传 (PM 2026-06-17 "直接修" 上条 #2 登记的预存 follow-up, 不留债): 主 runLoop 给 file-history 快照打键的方式是每轮经
e.tools.All()对共享的 FileEdit/FileWrite 工具实例调SetMessageID写t.messageID, 而工具 Execute 读同一字段. 一个 engine 跑并发 Run 时 (平台单 engine 同时服务/agent/run+ 计划队列, 或 #2 的并行 worker) 该写与另一 Run 的 Execute 读重叠 = data race + 破坏快照按哪一轮打键. 判别确认 (advisor gold-standard "见红才算数"): 新增TestConcurrentRun_FileWrites_NoMessageIDRace(N=8 并发Engine.Run同一 engine 各经主 runLoop 写不同文件), stash 修复跑 PRE-fix 实测WARNING: DATA RACE正在FileWriteTool/FileEditTool.SetMessageID()且 FAIL -> 坐实 race 真实存在. 修 (范式级, 非加锁打补丁): 加锁字段只是 "安全地错" (并发 Run 仍互相 clobber turn id); 正确修法 = 把 per-turn id 从共享实例状态改 per-call context 透传 --tools包加WithMessageID(ctx,id)/MessageIDFromContext(ctx)(私有 key 镜像eventEmitterKey; engine 设 / builtin 读, 无 import 环,tools包本就拥有MessageIDAware接口); runLoop 移除对e.tools.All()的 SetMessageID 写, 改在每轮toolCtx注入tools.WithMessageID(toolCtx, turnKey)(坐实ExecuteBatch->executeSingle->tool.Execute(ctx)透传到位); FileEdit/FileWrite Execute 经共享 helperresolveTurnMessageID(ctx, t.messageID)优先读 ctx, fallback 字段 (SetMessageID方法 + 字段保留供不透传 context 的直接调用者, 向后兼容). 每个 Run 的 ctx 带自己的 id, 无共享实例写 -> race-free 且逻辑正确 (不再 clobber). 双 gate 验证 (advisor: race 证安全但证不了正确; 需正确性 gate 证值没静默消失): 安全 = 上述并发 race 测试 POST-fix 绿 (25x-cpu=8加压); 正确性 = 既有TestEngineRollback_RestoresFileEndToEnd/file_history_rollback_wire_test.go(驱动真 runLoop 断言CanRollback("turn-1")-> id 仍经 ctx 流动且正确 key 快照, 非静默 no-op) 绿. 全 engine + tools + tools/builtin -race 绿 (除本机 baseline flakyTestFileEdit_SymlinkPassthroughsymlink/TMPDIR canonicalization, stash 本改后仍同样 FAIL = 与本改无关) + platform build 过. 范围精确: 消灭的是 messageID 这条 data race; 并发 file-write Run 场景实测 race-free, 未声称穷尽所有工具/所有引擎特性的并发维度. sub-agent worker 仍不设 messageID (file-history 回滚对 worker 本就未 wire, 一致). 正式归档 ADR-0019 (per-turn messageID context 透传: 范式级修法 + 否决加锁 "安全地错" 替代方案 +SetMessageID降级 LEGACY/fallback 决定 + 触发重评条件, 8 节体例). - 修预存生产 bug: symlink 沙箱检查 cwd 不对称 canonicalize (flaky test 暴露的真 bug, 修生产非补测试) (PM 2026-06-17 拍板, 本批 follow-up 收尾):
TestFileEdit_SymlinkPassthrough长期被当 "本机 baseline flaky" (macOS TMPDIR symlink), 实为生产潜藏 bug.fileedit.goapply 的 symlink 沙箱检查isPathUnder(realPath, cwd)左边realPath经EvalSymlinks解析, 右边cwd没解析 -- cwd 走过 symlink (macOS/tmp->/private/tmp//var->/private/var, 或 Linux 上 symlink 挂载点) 时, 解析后的目标会显得在未解析的 cwd 之外, 误拒沙箱内的合法 symlink 写入 (不是测试瑕疵). 修 (生产, 非补测试): 比较前把 cwd canonicalize 到与 realPath 同一 real-path namespace (EvalSymlinks, 无法解析回落 raw cwd = fail-closed 宁过拒不过放); 放在 symlink 分支内紧挨 realPath 解析处, 只在真有 symlink 时付 syscall.TestFileEdit_SymlinkPassthrough不改一行即转绿 (它一直是对的, 生产代码错). 平台无关回归TestFileEdit_SymlinkedCwdPassthrough: 显式造 symlinked cwd (linkwork->realwork), 因 Linux/tmp是真路径原测试只在 macOS 偶然命中此路径; PRE-fix 实测见红 (symlink target ... is outside working directory ...不对称) / POST-fix 绿. escape 测试TestFileEdit_SymlinkSandboxEscape仍正确拒绝 (独立 TempDir 树解析后仍越界). 全tools/builtin+tools+engine-race 绿. - 反事实/reverse-think 接通: ExitPlanMode 触发自审唱反调 + verdict 协议修复 (桶 C: 反事实, PM 拍接入 + 真跑闭环): reference hook (
ReverseThinkingHook) +reverse_think.Client+counterfactual.Deliverable全建好但完全悬空 -- 从没实例化, platform 请求路径零注册, 且全仓零工具标RequiresReverseThinking(接了也不触发). altitude (PM 拍): ADR-0001 (Accepted) 否决引擎级强制, 走平台 hook 层 opt-in, 0 引擎核心改动 (别默认反转 ADR). 修复 (三 commit): (1) core ExitPlanMode 标 flag (plan.go):ExitPlanMode.Metadata加RequiresReverseThinking-- "决定按方案 A 执行" 的时刻正是反事实检查点, 低频 (一 session 退 plan 一两次) 不撞 ADR-0001 吞吐顾虑, 与既有用户审批闸互补 (闸问 "要不要人批", 反事实问 "agent 该不该先挑战自己"); 不标 Bash/FileWrite 高频工具; core SQLCASTool 排除 (补核坐实通用/api/v1/agent/run默认 builtin 集不挂它 -- reader 假设错, feedback_verify_real_wire_before_test 抓到). (2) core verdict 协议修复 (counterfactual/deliverable.go+reverse_think/template.go): 真 MiniMax 真跑照出模型对第三态返回depends_on_<具体维度>(如depends_on_engine_requirements) 而非字面depends_on_X;Validate本就 schema-agnostic 接受 (非空即过, 其 CLEVER 已否决枚举), 真 gap = counterfactual 没给下游分类 API -> 下游硬编码== depends_on_X碰填了维度的真答案 match 不上 (live test 正是这么挂的); 加Verdict.Kind()(按depends_on_前缀收敛族, 未知报VerdictKindUnknown与 schema-agnostic 对偶) +DependsDimension()提维度数据 + prompt 把占位符 X 说清让模型填具体维度; 不碰 Validate 宽容 (不反转已否决方案, 不打补丁). (3) platform 装配层接通 (cmd/common/main.go+internal/server/server.go):MINIMAX_TOKEN_PLAN_KEY存在时构造reverse_think.Client+ReverseThinkingHook(Lookup=eng.Tools().MetadataFor现成 API) 注册到 session 级hooks.Manager, key 缺失优雅关 (同 MiniMax provider 注册条件);server.go加reverseHooks字段 +AttachReverseThinkingsetter (对齐AttachQuoteDispatch) +handleAgentRun经engine.WithSessionHooks挂每次/api/v1/agent/run; Sink 按Verdict.Kind分类 log (depends 族读DependsDimension) 给 Kind/DependsDimension 提供生产 reader + 完整 Deliverable JSON dump 审计; engine 无公开引擎级 hook 注册口,WithSessionHooks是唯一不改 core 的公开路径. 真跑闭环 (memoryfeedback_verify_against_truth, PM 指示不用 fake):reverse_thinking_live_test.go(build taglive+ key 双闸, 默认 CI 不烧 token) 真 MiniMax 真跑 -- 真 engine registry 查 ExitPlanMode flag +Manager.Execute(PreToolUse)驱动 (engine 调 hook 真实路径) + Sink 捕获 + Kind 非 Unknown 真值断言; 实跑两轮均depends_on_<维度>(engine_requirements/consistency_requirements) Kind 正确归类, 反向论证实打实挑 WithSessionHooks 方案刺 (per-request 开销 / 跨 session 不一致 / 全局 enforcement 无法表达 / "ADR-0001 可能过时"). 全 counterfactual + reverse_think + engine + tools + server + safetychain -race 绿. follow-up (非本次): basedocker-compose.yml注入MINIMAX_TOKEN_PLAN_KEY(prod; m2max staging override 已有); Sink 接staging.Record.Metadata+evolve.LogReplayer(通用路径无 Record, quote-dispatch 路径才有); 行业自定义PromptBuilder注入领域上下文. - Plan 模式接通 + 修底层 enforcement bug (死且坏) (桶 C: Plan 模式, PM 拍 A=完整修通): plan 模式跟前几项不同 -- 不是"建好没暴露", 是接了也跑不起来, 从没接通所以从没被执行过、bug 一直没暴露. 两个缺陷: (1)
PlanModeManager零调用 +EnterPlanMode/ExitPlanMode从不注册 -> 模型进不去 plan 模式; (2) 底层 ModePlan enforcement 是潜伏 bug:permission.CheckToolPermission在 ModePlan 下一刀切拒绝所有工具 (checker.go, 在 readonly 放行之前), 但 EnterPlanMode 指令明说"用 Glob/Grep/Read 探索 + 写计划文件" -- 自相矛盾, 模型一进 plan 模式连读都被拒、计划也写不了; 且主循环权限闸只在permGateEnabled时运行, 本地默认 (无 handler) 下 ModePlan 根本不强制. 修复 (完整修通, 范式级): (a) 修 enforcement (permission/checker.go): 新增PermClassPlanWrite类; CheckToolPermission 把 readonly + planwrite 工具提到 plan 判定之前放行 (safe by construction), plan 模式只拒改写/Bash/web/generic -- 模型能探索 + 起草计划但碰不了代码. (b) 加计划写入途径: 新WritePlan工具 (engine 包, PermClassPlanWrite) 写进PlanStore(对齐"计划从 store 读、防注入"原设计); EnterPlanMode/ExitPlanMode 补Metadata()声明 PermClassPlanWrite 使其在 plan 模式放行 (否则模型退不出). (c) plan 模式 enforcement 独立于 permGate: 主循环闸条件加e.planModeActive(), plan 模式激活时闸即便 permGateEnabled=false 也运行 (进 plan 模式已把 e.perms 切 ModePlan, 闸必须真跑才能兑现). (d) 构造 + 注册: engine.New 建PlanModeManager绑eng.perms(Enter/Exit 翻转闸咨询的模式), 默认中性MemoryPlanStore+NoopApprovalPolicy自动批准 (消费者经cfg.PlanStore/cfg.PlanApprovalPolicy注入 FilePlanStore/真审批); 三工具经registerBuiltinTools注册 (受 cfg.Tools 过滤, cfg.Toolset 中性化路径自动排除), manager 经setupPlanModeTools延迟注入 (镜像 SetupAgentExecutor, 工具以 nil manager 注册让 PermClassPlanWrite 自动登记, 装配后绑真 manager; nil-manager Execute 诚实失败防误配). (e) 可屏蔽: 静态cfg.DisablePlanMode(移除三工具 + 不建 manager) + 运行时权限闸 deny.Engine.PlanModeManager()accessor 供消费者 SetSessionID/AttachProgress. 验证 (feedback_verify_real_wire_before_test):plan_mode_wire_test.go(① 端到端直调三工具 Enter->Write->Exit 证 manager 绑定 + store 流 + Noop 批准 + 模式恢复; ② runLoop 无 handler 驱动 EnterPlanMode 后改写工具 danger_run 被拒 / 只读 read_safe 放行 -- 撤闸条件 planModeActive 实测 danger_run 执行 FAIL; 撤 checker readonly 放行实测 read_safe 不执行 FAIL; ③ DisablePlanMode 三工具缺席) +checker_test.go扩断言 plan 模式放行 readonly/planwrite 拒 Bash. 全 permission + engine -race 绿 (含既有 plan_test.go 不回归) + core build 过. 范围: Plan 模式 (enter/exit/write + enforcement); Plan 队列 + 进度 (EnablePlanQueue / PlanProgress, 独立子系统) 与反事实作下一批. - Team 接通: 模型可调并行小队工具 (补上缺失的创建面) (桶 C: Team, PM 拍接通 + 拍两要求 ① 模型自主调可临时屏蔽 ② 消费者可调): Agent Teams 的 5 个协调工具 (
send_message+shared_task_*四件套) 每个引擎都注册了, 但NewTeam/RunWorkers(唯一创建 Team 上下文的途径) 零非测试调用者 -> 那些工具永久空转 ("not in a Team"), 并行 worker 机器 (router / worker inbox / 共享任务板 / 权限冒泡) 全建好却根本没有创建入口, 模型无从消费. (诊断纠偏: 先误判成 "完整能力待场景, 别造假消费者", PM 指正 -- 没暴露入口当然没人能消费, 这跟 Agent/Skill 执行器没接线是同一类 dead-code bug.) 修复 (新模型可调工具, 镜像 Agent 工具执行器范式): (1) builtinTeamTool+TeamExecutor接口 +TeamWorkerSpec/TeamRunRequest/TeamWorkerResult类型 (依赖倒置, builtin 不 import engine); 模型调Team{workers:[{prompt,agent_type?,description?,model?}], shared_tasks?}并行扇出 N 个 worker, 汇总结果返回; nil-executor fallback IsError:true (诚实化, 病根 #1). (2) engineteam_executor.go:teamExecutor.RunTeam以当前引擎为 Leader, 直接构造 Team struct (绕开NewTeam对活引擎的 incomingInbox/agentName/teamRouter 变异 -- Leader 正阻塞在工具 Execute 里) + 新 router scoped 到这批 worker (worker<->worker send_message + 可选内存共享任务板可用);SetupTeamTool装配后接线 (tool-gated no-op, 同 SetupAgentExecutor). (3) 关键架构修正: worker 权限不走 Worker->Leader 冒泡 (冒泡阻塞等 Leader runLoop poll 应答, 而同步工具路径下 Leader 阻塞在 Execute 无法应答 -> 死锁到超时); 改用父引擎pe.perms同步施加 (仅permGateEnabled时, 否则 nil = 不校验对齐本地不沙盒); 为此把runWorker重构成接checkerFor func(*SubAgent) permission.Checker,RunWorkers传冒泡 checker (行为不变 + 保留 task-notification 注入), 新增 exportedRunWorkersSync(ctx, specs, checker)同步扇出 (不冒泡 / 不注入通知 / 直接返结果, 供工具路径 + 消费者). (4) PM 两要求兑现: 模型自主 = 工具默认注册; 可临时屏蔽两级 = 运行时权限闸 deny "Team" (即时, 不重建) + 静态cfg.DisableTeamTool(不注册, 模型根本看不到); 消费者可调 =NewTeam/RunWorkers/RunWorkersSync全 exported 不动. (5) 防递归: worker 拿不到 Team 工具本身 (runWorker从 allowedMap 删 "Team"), 模型无法触发无界嵌套小队爆炸; 模型驱动的嵌套小队需 spawn 深度预算 (follow-up, 参 TODO L903). 验证 (feedback_verify_real_wire_before_test): 确定性team_executor_wire_test.go(假 provider 调 Team 工具 2 worker -> 断言 provider 流 >=2 次 + 两 worker 结果回流 + description 出现非 fallback; 撤 SetupTeamTool wire 实测 FAIL provider streamed 0;DisableTeamTool=true断言工具缺席). 全 engine -race 绿 (含既有 team_test.go RunWorkers 回归, 重构未破坏) + builtin Team -race 绿 (除本机 TMPDIR symlink baseline flakyTestFileEdit_SymlinkPassthrough, 与本改无关) + core build 过. 范围: Team 接通; Plan 模式+队列 / 反事实作下一批. - Skill 工具死执行器接通 + 文件 skill 加载 (桶 B/C item: Skill 工具, PM 拍接通; 一并关掉桶 B "假成功诚实化" + 桶 C "Skills 文件加载" 两条):
builtin.SkillTool注册进每个引擎默认 registry, 但SetupSkillTool(唯一绑定其 executor + 扫描磁盘 SKILL.md 的地方) 零调用 ->SkillTool.executor恒 nil -> Execute 回落 fallback 返 "executor not configured" 且 IsError 未设 (假成功) -> 模型把没跑的 skill 当成功调用, 工作流静默蒸发; 且文件 skill 从不加载, registry 永空 (病根 #1 假成功 no-op + #2 Setup 函数零调用, 与 Agent 工具同款). 修复 (真 wire, 镜像 SetupAgentExecutor): (1)skill.gonil-executor fallback IsError false->true (诚实化, 接通后此路常态不可达, 防误配消费方看到假成功). (2) engine.New 在 skillRegistry setter 块后接线, 刻意拆两件事: 抽共享 helperbindSkillExecutor(eng)(与 SetupSkillTool 的绑定块同源) 永远绑定执行器 (tool-gated: Skill 工具未注册时如中性化 tools.None 报价流自动 no-op 安全) + 宿主 FS 目录扫描 (~/.flyto/skills~/.claude/skills<cwd>/.flyto/skills<cwd>/.claude/skills) 由新增cfg.DisableSkillFilesystemScan门控 (默认 false = 扫, 向后兼容 + 本地 CLI dogfood; SaaS 多租户设 true, 使租户引擎不读宿主 operator 的 skill 文件 -- 与DisableDream/DisableCalibrator同属 ADR-0005 跨租户 FS 隐患). 判据取舍 (原保守变体作 rejected alternative 写进注释): 把整个 SetupSkillTool 一刀切门控 (像 DisableDream 门控整个子系统) 会连执行器绑定一起丢, 对注册了 Skill 工具 + 代码 skill (SkillRegistry().RegisterBuiltin) 的 SaaS 消费方重新引入假成功 fallback; 拆开后工具在任何配置下都诚实, 只有宿主 FS 读取被门控. 验证 (feedback_verify_real_wire_before_test): 确定性skill_executor_wire_test.go(默认 config 注册 inline skill -> 断言真展开非 fallback; 撤 engine.New wire 实测确认两测试都 FAIL 返 "executor not configured"; 负对照DisableSkillFilesystemScan=true仍绑 executor -> skill 照跑, 锁定拆分设计防"简化"回退) +skill_test.goTestSkillTool_Execute_NoExecutor 翻断言 IsError:true. 全 engine + builtin Skill -race 绿 (除本机 TMPDIR symlink baseline flakyTestFileEdit_SymlinkPassthrough, 与本改无关) + core build 过. 范围: 仅 Skill 工具接通 + 文件加载; Team (NewTeam/RunWorkers) / Plan 模式+队列 / 反事实作下一批. - Agent 工具死执行器接通 (桶 B/C item 1 后半, PM Q2=接通):
builtin.AgentTool注册进每个引擎默认 registry 但SetupAgentExecutor(唯一设其 executor 处) 零调用 -> executor 恒 nil -> Execute 返假成功 fallback (IsError:false "Sub-agent execution is not available") -> 模型当委派的子 agent 工作已完成而它静默蒸发 (病根 #1 假成功 no-op + #2 Setup 函数零调用). 修复: (1) New 顶层建共享 taskStore 穿进buildToolRegistry/registerBuiltinTools(替局部创建), 使 Task 工具与 Agent 执行器共用一 store -> 后台子 agent 任务进 TaskList (RunSubAgentBackground.Create 需共享); (2) New 装配后调SetupAgentExecutor(eng, taskStore, eng.agentRegistry)-> Agent 工具经共享 SpawnSubAgent 路径真 fork+跑子 agent (cfg.Executor 保证非 nil 不 panic; Agent 工具未注册时如中性化 tools.None 报价流自动 no-op 安全); (3)fallbackResultIsError false->true (诚实化, 接通后此路常态不可达, 防误配消费方看到假成功). 叠加上一 commit (c) Tools 修复, spawn 的子 agent 现在真发工具给模型 -> Agent 工具端到端可用. 验证 (feedback_verify_real_wire_before_test): 确定性agent_executor_wire_test.go(假 provider 调 Agent 工具 sync -> 断言子 agent 真跑 provider 流了 + 返子 agent 输出非 fallback; 撤 SetupAgentExecutor 即 FAIL provider never streamed) +agent_test.go(无 executor fallback IsError:true; 撤翻转即 FAIL). 全 engine + builtin Agent/Task -race 绿 (TestFileEdit_SymlinkPassthrough 本机 TMPDIR symlink baseline flaky, stash 本改后仍失败, 无关). 范围*: 仅 Agent 工具; Skill (SetupSkillTool) / Team / Plan 作下一批. - 记忆抽取精简系统提示 + tool_calls 观测 (桶 B/C item 1b, 打通记忆抽取至 🟢): 接 item 1a (诚实化) + item 1c (子 agent Tools 修复) 后仍诊断 0 文件 (memory
feedback_diagnose_from_specific_session_data, 真模型 dump): 抽取子 agent 没传 SharedSystemPromptBytes -> SpawnSubAgent fallback 到 22KB 父编程 bundle (subagent_prompt_fallback_to_parent_bundle), 把小模型框成编程助手淹没抽取任务; 且抽取用户提示 (BuildPrompt) 从不带记忆目录绝对路径, 模型即便想写也没合法 file_path (MemoryDirRestrict 拒目录外). 修复: 引擎建精简抽取系统提示buildMemoryExtractionSystemPrompt(memDir)(确立抽取 agent 身份 + 记忆目录绝对路径 + 只写记忆约束), 经SubAgentConfig.SharedSystemPromptBytes注入覆盖 fallback. 引擎拥有此通用 frame (记忆机制本身, 非 vertical 假设, 跨场景中性); scenario-specific 的 "抽什么" 仍归 extractor.BuildPrompt. 替代方案 (扩MemoryExtractor.SystemPrompt) 作注释暂缓 (YAGNI). 顺手给 complete payload 加tool_calls计数 (观测子 agent 工具调用数) + harness 打印 payload. 验证 (真值对照): harness--check memorydump 子 agent 真行为坐实修复链 -- 修前模型干净跑完零工具 (顶 22KB bundle); item 1c 后模型真调 Write 但小模型 (e2b 2b) 路径打错被拒 / 把规则当记忆写; 精简提示 + 4B MoE (gemma4-moe-26b-a4b) -> tool_calls:1 wrote:1,production-deploy-host.md落盘含 "HK-133 ... 45.145.229.197", factFound=true -> 🟢 端到端通. 全 engine -race 绿. (剩余 dense-12b / e2b 仍 🔴 = 本地小模型工具调用/内容质量, 非引擎; 4B MoE 起即 🟢.) - 子 agent flyto.Request 漏发 Tools (重大 wire bug, 修阶段深挖发现): 诊断记忆抽取 "0 文件" 时深挖出真根因.
SubAgent.runLoop构造flyto.Request只设 Model/MaxTokens/System/Messages, 漏了 Tools 字段 -- 尽管正上方注释 (subagent.go) 声称 "工具列表传 allToolDefs(完整列表)... cache key 与父 engine 一致", 赋值从不存在. 后果: 每个子 agent (记忆抽取 / Agent 工具 / Team Worker / Dream) 发给模型零工具 -> 模型看不到任何工具 -> 永远引不出真工具调用, 只能吐散文. 即记忆抽取 "0 文件" 的真根因 (非模型限制, 险些误判). 既有子 agent 测试全用脚本化假 provider, 无视req.Tools直接发 ToolUseEvent, 故缺口隐形 (memoryfeedback_verify_real_wire_before_test). 修复:flytoReq.Tools = apiToolDefsToFlyto(sa.allToolDefs)(复用主循环同款[]api.ToolDef->[]flyto.Tool转换; 发完整 allToolDefs 做 cache parity, allowed 限制由 canUseTool 在调用时另行强制). 验证 (memoryfeedback_verify_against_truth): 确定性subagent_tools_wire_test.go(请求探测 provider 断言req.Tools携带工具定义, 模型无关 -- 修复前 req.Tools 为 nil len 0) + 真模型 harness--check memory链式坐实tool_calls0 -> N (gemma4-e2b 真调 Write wrote:1 写出文件; gemma4-moe-26b-a4b 把 fact 写进盘 -> 🟢, dump 真文件内容HK-133+ IP 对照真值 factFound=true). 全 engine -race 绿 (Tools 大改不破任何既有子 agent 测试). - 记忆抽取静默吞错诚实化 (桶 B/C item 1a, 修真静默 bug, 病根 #3):
runMemoryExtraction(engine.go) 用for range events {}排空并丢弃抽取子 agent 全部事件含*ErrorEvent, 而defer ... Event("memory_extraction_complete", nil)在每条返回路径无条件报喜, cursorlastExtractMsgIdx无条件推进 (失败窗口永久丢失),mem.Listerr 也静默 return.SilentEvents=true双重压制使子 agent 错误彻底蒸发 (病根 #3: 异步 fire-and-forget 错误进黑洞). 修复 (范式级: 不吞错, surface 真 outcome): drain loop 改 type-switch 捕获*ErrorEvent->extractErr + 数成功 Write/Edit*ToolResultEvent->wroteCount; complete 事件 payload 诚实化{turn, success, wrote, error, skipped}(不再 nil); 失败额外发memory_extraction_failed事件 (SSE/TUI/audit 可见, 不再黑洞); panic-recover 路径不 push ErrorEvent, 加锁兜底读sa.Error; cursor 仅成功时推进 (失败留窗口待下次 ShouldExtract 重试, turn-gatelastExtractTurn已在 spawn 前推进节流防 per-turn 风暴); List err 改填 extractErr 不再静默. 验证:memory_extraction_silentbug_test.go(子 agent 报错 -> failed 事件 + success=false + cursor 不进; 干净结束 -> success=true + cursor 进; 真 Write -> wrote>=1 + 文件真落盘). 真模型 harness 诊断 (memoryfeedback_diagnose_from_specific_session_data):runtime-probe --check memory打 dense-12b -> 现在排除 "引擎吞错" (事件序列无memory_extraction_failed, 子 agent 干净跑完), 坐实真因 = 嫌疑①subagent_prompt_fallback_to_parent_bundle(抽取子 agent 顶 22KB 父编程 bundle 跑, dense-12b 干净完成但从不调 Write 持久化) -> 0 文件. 本 commit 修静默机制 (病根 #3); harness 仍 🔴 (fact 未落盘) 由下一 commit 修嫌疑① 系统提示打通至 🟢. 全 engine -race 绿. - 权限闸接通主循环 (接死代码, PM 拍 Q1=接通):
e.perms(permission.Checker) 由 Config (mode + handler + timeout) 完整装配并存进 Engine, 但主 agent 工具执行路径从不调e.perms.Check-- 全 core 唯一生产 Check 调用点在subagent.go(仅 Team Worker). 后果: tui 的 y/N handler / platform/common 的 SSE 桥 / 任何 deny-all 策略全是死配置, 工具照跑 handler 零调用. 真模型 harnesscmd/runtime-probe --check toolgate已坐实 🔴 (deny-all + Default -> 工具执行, handler 0 调用). 修复 (范式级, 镜像 SubAgent 闸): (1) Engine 加permGateEnabled字段, 构造期算一次 =配了 handler || (显式设了非 bypass mode); 为 false 时 (无 handler + 空 mode = 本地 CLI 默认) 整个闸跳过, 零开销直通, 兑现 "本地默认放行" (对齐本地不沙盒立场, memoryproject_sandbox_local_vs_cloud); 有 handler 时 (tui / platform / harness 正好这 3 个想要的) 开闸真拦, 兑现 "云端真拦". (2) runLoop 在 pre_tool_use hook 闸之后、checkpoint 闸之前新增权限闸 pass, 结构镜像 checkpoint: 逐个工具发PermissionRequestEvent(观测, 点亮原本从不发射的事件类型) ->e.perms.Check-> 非 Allow 决策 (Deny, 或被 handler 解析成 Deny 的 Ask) 把调用从批次剔除并返回模型可见的 IsError tool_result -> Allow 带UpdatedInput时重写工具输入 (消费层 sanitize, 镜像 SubAgent, 补上 permission.go godoc 标注的主线程未接项) -> 全拒则同 hook/checkpoint 全拒路径 (Stop ActivityToolExec + 结果发回模型). builtin 与自定义工具共用一个 registry 汇入orchestrator.ExecuteBatch, 一处 inline 闸覆盖全部. 判据取舍 (原保守变体作 rejected alternative 写进注释): 仅判 handler 存在会让 "设了 Default mode 却无 handler" 静默直通 (本 campaign 要消灭的同类漏洞), 故额外覆盖显式 enforcing mode 并 fail-closed; 已 grep 坐实 core 包无 test 在主引擎设 PermissionMode -> 零回归. tui 零改动: 它已设 handler / 无 mode -> 闸自动开 + Check 内 mode 默认 ModeDefault -> 非只读工具走 y/N (死配置被引擎侧接线复活). platform/common 自动激活云端拦截 (它设了 SSE 桥 handler). 验证 (memoryfeedback_verify_against_truth: 对照真值非自评): 确定性 Go 测试permission_gate_wire_test.go(假 provider 吐危险工具调用 -> deny-all handler + Default -> 断言 handler 真被咨询 + 工具未执行; 负对照: 无 handler + 空 mode -> 工具执行 + handler 不调 + 无事件, 证本地直通不回归; UpdatedInput 重写路径锁定) + 真模型 harness 复跑坐实 🔴->🟢 (gemma4-e2b @ fmlx: 模型请求工具 true / 工具执行 false / handler 咨询 true / PermReqEvent true). 全 engine -race 绿 + core build 过. - 桶 A 修阶段新发现 — operationLog Write/Edit undo 占位串 clobber (修真静默 bug, 与 item 1 同属 "撤销文件编辑" 一条坏路径): 引擎执行工具走
orchestrator.ExecuteBatch-> 为 Reversible 工具 (Write/Edit) 调GenerateUndo填result.UndoInfo-> 进 OperationEntry. 但 Write/Edit 的GenerateUndo把 undo content 写成字面占位串[restored from file history backup](注释本意 "FileHistory + OperationLog 协同", 但ExecuteUndo只是裸tool.Execute, 协同从未实现).Engine.Rollback把同一 messageID 喂两子系统: 第 1 步file_history.Rollback用备份还原真内容, 第 2 步operationLog.RollbackMessage -> ExecuteUndo裸跑Write(占位串)-> 把刚还原的真内容覆盖成字面占位垃圾. 即便 item 1 死键修好, 端到端Engine.Rollback仍把文件搞成占位垃圾 (比没还原更坏). 修复 (范式级): file 操作的撤销是物理 (备份还原, 归 file_history 第 1 步), 不该进 OperationLog Saga 重执行 (第 2 步); Write/EditGenerateUndo改返nil(无 Saga 补偿, 工具仍Reversible:true经 file_history 可撤销); 撤销可见性经FileHistoryView.CanRollback暴露, 不靠 OperationLog 条目. 原占位方案作 rejected alternative 写进注释. regressionTestEngineRollback_RestoresFileEndToEnd(engine 级): 真 run loop 驱动 Write ->Engine.Rollback("turn-1")-> 断言文件还原成原字节 (死键在则没还原 / clobber 在则成占位垃圾, 任一缺陷在则 FAIL);TestFileWriteTool_GenerateUndo/TestFileEditTool_GenerateUndo改断言返 nil. 全 engine + tools/builtin -race 绿 (除 baseline 同失败的环境 flaky symlink 测试). - 桶 A critic 补扫 item 1 — file_history 回滚死键 (修真静默 bug, wire 层):
SetMessageID(FileEditTool/FileWriteTool) 零非测试调用者 -> runLoop 从不把每轮 id 传给文件工具 -> 每快照按空串""键控, 而 OperationLog 按turn-N键控 ->Engine.Rollback("turn-N")永匹配不到文件快照, 物理恢复静默跳过. 修复 (范式级): (1) 抽共享 helperturnMessageID(turnCount)-> OperationLog 记录键与文件快照键同出一处, 杜绝两键空间分叉; (2) 新增tools.MessageIDAware接口 (镜像tools.SocketAware, ADR-0005 § Bug Q 范式), runLoop 每轮turnCount++后遍历e.tools.All()类型断言 +SetMessageID(turnMessageID(turnCount))-> 不硬编 "Edit"/"Write", 子 agent 各自 runLoop 自动覆盖; (3) 修FileHistory.Rollbackgodoc "倒序" 假声明 (实现迭代 Go map 无序, 从未兑现) -> 改按路径排序确定性恢复 + godoc 诚实化. 缺口诚实登记 (不掩盖, 见 feedback_verify_real_wire_before_test): 全仓无生产代码触发Engine.Rollback-- "撤销在哪触发" (界面 undo / 失败自动回滚) 是产品决策, 作 exit criterion 登记 TODO; 故回归测试用 wire 层FileHistoryView.CanRollback证快照键=turn-N (非端到端 Rollback, 后者还撞 operationLog 占位串 clobber, 见下条). regressionfile_history_rollback_wire_test.go: 真 run loop 驱动 Write 工具调用后断言CanRollback("turn-1")真 /CanRollback("")假 (修复前相反, 严格区分). 全 engine -race 绿. (注: TMPDIR 符号链接致TestFileEdit_SymlinkPassthrough在本机 sandbox 预存失败, baseline 同失败, 与本改无关.) - 桶 A critic 补扫 item 2 — 出站规范化误删核查 (核查结论: 非 bug, 注释标清, 零逻辑改动): commit 1c7b914 的 9-normalizer pipeline 每轮跑出站消息, 疑
EmptyMessageFilter/OrphanThinkingFilter静默删 load-bearing 消息致上下文降级. 4-agent 调查 + 亲核坐实两者都安全: EmptyMessageFilter 判定式块类型感知 (default分支保留任何非文本块 -- tool_use/tool_result/thinking/image/document, 只持空字符串的 text 块算空), 删不到 "空文本但带 tool_use/tool_result" 的 load-bearing 消息 (TestEmptyMessageFilter_KeepsToolBlocks已锁); OrphanThinkingFilter 只删 100% 是 thinking 的 assistant 消息, 而引擎每轮把 thinking+tool_use 累积进同一条消息一次性 append (engine.go:5059) -> load-bearing thinking 必与其 tool_use 同处一条 -> 含 tool_use ->isThinkingOnly返回 false -> 整条保留. 处置: 给两个 filter 各加不变量注释 (块类型感知兜底 = 保留 / load-bearing thinking 与 tool_use co-locate 的证明), 防未来回归. 全 engine -race 绿. - 桶 A item 3 — 重复块检测漏近似 (修真静默 bug):
detectDuplicateTextBlocks(抓模型把同一 final 答案吐多遍 -> 拼接成 corrupted 串) 用strings.TrimSpace(只去首尾空白) + exact-match map key. 漏: 内部空白差异 / 换行缩进重排 / 少数字符漂移的近似副本. 修复 (保留精确为 fast-path, 叠加模糊层): (1) 归一化升级normalizeForDupCompare用strings.Fields把所有空白 (含内部) 压成单空格 -> 一举抓"只差排版"的重复 (CJK 无空格文本不变); (2) 精确 fast-path 在归一化形式上 map exact-match (= 原行为 + 空白变体), 廉价常见路径; (3) 模糊层仅对 ≥64 rune 的块两两算字符 8-gram Jaccard (≥0.85 且长度比 ≥0.70 判近似), 每轮块极少 O(n²) 无所谓, 阈值卡高防误报. 判断保留 (偏离 sketch): 不碰跨通道 (thinking↔text) 重复 -- 模型"先想再答"内容本相近, 拿来判重会误伤正常行为, 且 thinking 不进 final text, thinking 通道由 detectThinkingLoop 单独守; JSON 字段重排只 PARTIAL 覆盖 (靠共享 shingle), 不建 schema 假设的 JSON 规范化器. 调用点文案 "完全相同" -> "重复或高度近似". regression: 现存 5 个 exact dup 测试全保留 (fast-path 不回归) +duplicate_block_fuzzy_test.go新增 (空白变体必抓 / 长近似副本必抓 / 长不同答案不误报 / 短近似不被 fuzzy 误判 + normalize/jaccard 单测). 全 engine -race 绿 + core build 过. - 桶 A item 2 — thinking 死循环检测死区 (修真静默 bug): thinking 死循环守卫 (
detectThinkingLoop) 只在 turn 干净完成时跑. 但实测 r15 形态是: 模型 thinking 死循环 -> provider 端预算触底 cancel 流 -> wire 异常退出 (ctx-cancel / mid-stream-err / scanner-err), 累积的 thinking 不 flush 成终态ThinkingEvent-> 引擎assistantContent没 thinking 块 -> 走 ErrorEvent 硬错return或 post-loop 空流盲重试, 两条都绕过守卫 -> 无限循环到 MaxTurns/timeout, 零"死循环"信号. 修复 (engine-only, 范式级, 偏离 sketch 的 3-wire-patch): 三家 wire 断流前都已增量发ThinkingDeltaEvent, 引擎只是没存它 (4790 只转发). 改为: (1) 引擎累积thinkingDeltaBuf(断流前的 looping thinking 全在里面, provider 无关 -- 6 个 openai-compat + gemini + anthropic 自动全覆盖, wire 一行不动); (2) 抽detectAndCorrectThinkingLoophelper (emit warning + 注入纠偏 user message), 正常路径 + 两个异常出口 (ErrorEvent 硬错路径 / post-loop 空流分支) 共用; 检测到 loop -> 注入纠偏 + 重试 (计入 turn, MaxTurns 兜底, 同正常守卫), 否则维持原行为. 原 3-wire 方案作为替代方案写进注释. 已知边界 (注释标注): "先出文本再 thinking-loop" (hasAnyContentBlock=true) v1 不覆盖, 主 r15 case (纯 thinking loop / 错误流) 全覆盖. 检测算法本身 (尾部 unique-rune-ratio 数花样) 不动, "找重复节拍"升级 (抓松散循环) 留作 follow-up (桶 A item 3 同族). regression:thinking_loop_abnormal_exit_test.go-- ErrorEvent shape 自纠 + 空流 shape 自纠 (且不走盲重试) + 健康 thinking 负对照不误触发. 全 engine -race 绿 + core build 过. - 桶 A item 1 — 压缩摘要完整性闸 (修真静默 bug): 完整压缩成功路径 (
maybeCompact/forceCompact) 过去对 fast 模型回的摘要零校验就用它永久替换真历史. compressor 唯一检查是summary == ""(套 buildFallbackSummary), 非空但乱码 / 截断 / looped 的摘要直接放过, 静默污染对话 -- 零 error 零 observer. 安全兜底StrictMode.CheckCompactFailure存在却从没人调. 修复 (范式级): 新增引擎层validateCompactSummary内在质量闸 (长度 floor 20 / 乱码比 0.30 / 退化重复比 0.05, 仅 >200 字符跑) -- 只抓明显损坏的 provider 输出, 故意不做锚点重叠 / LLM 二判 (易误报会误杀合法激进摘要 -> 破坏 r31 收敛). 两条压缩路径成功后过闸: 不过则触发StrictMode.CheckCompactFailure(eval/strict 响亮 panic, 生产记 observer) + 回落 micro (拒收坏摘要). 顺手抽共享 helperemitMicroFallback消除原本 4 处近似的 micro 回落块 (改一处而非四处). 取舍: forceCompact 拒坏摘要后 micro 可能仍超限 -> 下轮 loud 400, 但响亮失败优于静默污染 (fail-loud). regression:compact_summary_validation_test.go-- 表测 (合法/CJK/最短 fallback 必过; 太短/乱码/looped 必拒) + 集成 (坏摘要 -> Kind=micro + 垃圾不落地) + strict panic + strict 合法不误报. 全 engine -race 绿 + core build 过.
core — 引擎整体 review (8-lane 多 agent + 对抗复核) + 修发送路径不规范化 bug (2026-06-15)¶
PM 让整体 review 引擎 (~11 万行非测试 Go). 结论: 核心扎实 (主循环 / 8 provider / 错误模型 ADR-0006 / 生命周期都真 wired + 防御性工程到位: stop_reason 对照真实 tool_use 块 / 断流重试 / 上下文超长重试 / max-tokens 升档 / 重复块+思维死循环检测), 无 critical. 发现 3 条主路径硬伤 (#1 normalize + #2 StreamGuard 已修; #3 token 估算器深挖后改判 = 团队故意拆分非纯 bug, 改压缩触发逻辑需先实测方向不能盲改, 登记 TODO blocked-on 实测) + 一条 ~1.5 万行 "脚手架带" (建了测了未接运行时: 权限引擎主循环零调用 / evolve / daemon+bridge / staging+shadowdb+reflector). 全部登记 core/TODO.md "引擎整体 review (2026-06-15)" 段.
- 修 #1 发送路径不规范化 (high):
NormalizeMessagesForAPIgodoc 写 "发送前清理消息列表" (修 tool_use/tool_result 配对) 但全仓零调用 -- 唯一 normalize 在 session restore (session_manager.go:232). runLoop 每轮把累积 raw messages 直接送 BuildAndStream, 长对话中途 compaction/队友注入产出的非法序列 (孤立 tool_result / 连续同角色 / 空 assistant) 照样发出去 provider 400. 修复: engine.go runLoop 在 maybeCompact 后、BuildAndStream 前用DefaultNormalizePipelineWithObserver(e.observer, e.strictMode)规范化一份副本喂 send + debug dump (raw history 不动, 每轮一次, retry 复用因 messages 各 attempt 不变). 替代方案 (否决): 只在 restore 跑 -- 中途非法序列照 400. - regression test
TestRunLoop_NormalizesOutboundMessages: 捕获 provider 断言出站req.Messages里孤立 tool_result 已剥离. 严格验证: 短路 normalize 即 FAIL (证明真抓 bug 非锁现状), 还原即 PASS. 全 engine 包 -race 绿 + core 整体 build 过. - 修 #2 StreamGuard 只包 Anthropic-compat 路径 (high): 断流看门狗 + 截断检测 + 空响应检测 (StreamGuard) 只 wrap 了 transport(api).Client (Anthropic-compat) 路径; OpenAI-compat (wire/openai.go) + gemini Stream 裸流无保护. 真实物流抽取流程跑的就是 OpenAI-compat (自托管 gemma4) -- 最该保护的路径在裸奔, LAN 静默挂死无看门狗. stream_guard.go godoc 早写明 "StreamGuard 现在对所有 provider 通用" 但没兑现. 修复 (范式级, 非补丁): StreamGuard 从 Anthropic 专有
internal/transport(package api) 移到中性新包internal/streamguard(只依赖 flyto+stdlib; api 与 wire 都 import, 无环), 在两个 wire 家族的 chokepoint (OpenAICompatClient.Stream+GeminiClient.Stream) 各包一次 -> 6 个 openai-compat provider (openai/deepseek-openai-mode/minimax-openai-mode/openrouter/lmstudio/ollama) + gemini 全自动获保护, 未来新 provider 也免费获得. 第 0 步先实测真端点排雷: curl 自托管 gemma4 流式确认末尾真发usagechunk -> StreamGuard 的 truncated 检测 (HasContent && !HasUsage) 对正常结束不误报 (否则一包就打死报价流程). - regression test
TestOpenAICompatClient_Stream_GuardedEmptyResponse: httptest 喂 200 OK + 空 SSE body, 断言 openai-compat Stream 现在冒stream_emptyErrorEvent (而非静默关闭). 严格验证: 去掉 wrap 即行为 FAIL, 还原即 PASS. build ./... + streamguard/wire/transport/8provider/engine 全 -race 绿.
core + platform/common + frontend + core/docs/adr — 引擎能力完整暴露: 三档模型知识 (静态目录 / 现场发现 / 实测能力) + capability-probe 抽包 (ADR-0018) (2026-06-06)¶
PM 拍板 "引擎能力不体现到前端就不完整, 平台必须完整暴露引擎能力, 不是只露物流够用的那部分" (引擎是产品, 领域无关). 引擎本就握三层模型知识, 但前端只露了第一层的一行标签. 本变更把三档全部接出来, 每格带来源戳.
- ADR-0018 (新): 三档模型知识. 静态目录 (provider 内置 spec 表, 直读 provider.Models()) / 现场发现 (自托管端点 /v1/models live discovery) / 实测能力 (capability-probe 实测 + 来源戳矩阵). 决策: probe 抽包落 core 不重写; 同步 + cheap-tier 默认 + 硬超时 (深度 probe 异步 = follow-up, 现无 async job 基建); 两张表分持发现/实测; provider 经 factory accessor + snapshot 构造.
- core/pkg/capability (新包, 抽自 cmd/capability-probe 2255 行): types.go (Source/Capability/ModelCapabilities, JSON tag 逐字保留) + documented.go + probe.go (全 probe + 门控顺序 + 3-way caching dispatch 逐字保留). 公共入口
ProbeModel(ctx, model, ProbeOpts) (*ModelCapabilities, error), ProbeOpts 带 4 provider 句柄 + Skip 开关 (跳贵探针) + MaxProbeTools (替换写死 128). cmd/capability-probe 退薄壳调 ProbeModel. 14 纯 helper 测试随迁. - openai 现场发现: openai.Config 加
LiveDiscovery bool(默认 false, 真 OpenAI 零回归); 为 true 时 Models() 打 {BaseURL}/v1/models (复用 OpenAICompatClient.FetchOpenAIModels, lmstudio 已验证). fmlx factory 传 LiveDiscovery:true -> fmlx 实例实时拉 MLX 真目录. - platform 持久化 + 端点: 两表 engine_discovered_models (PK tenant+instance) + engine_probed_capabilities (PK tenant+instance+model), 复合 FK CASCADE. store 4 方法 ×3 实现 (interface/postgres/inmemory, payload 存 json.RawMessage 避环). manager DiscoverInstanceModels / ProbeInstanceModel (cheap-tier, 跳 4 贵探针) / GetInstanceCapabilities + ProviderFactory() accessor (均不调 rebuildLocked, 只记展示数据). 3 端点: POST /config/instances/{name}/discover (15s 硬超时) / POST .../probe (90s, body {model}) / GET .../capabilities. swagger 重生.
- 前端三档面板: api.ts 加 6 类型 (CapabilitySource/Capability/ProbedModelCapabilities/DiscoverResponse/InstanceCapabilities) + 3 方法. settings.tsx CapabilityPanel 加 Section B (per-instance 卡): 现场发现按钮 (自托管) + per-model 实测按钮 -> 展开 ProbedMatrix (实测/文档/高级协议/schema 特性 四组, 每格 SourceBadge 标 实测/文档/未测). 零新 CSS.
- 修预存 bug: PostgresStore.PutInstance 对无 key 实例 (局域网 fmlx) 的 nil ciphertext/nonce 收敛成空切片, 避 SQL NULL 撞 NOT NULL (TestPostgresStore_KeylessInstance 由红转绿; 该 bug 自 ADR-0017 v2 0cc0d32, testcontainers 无 docker 时从未真跑过).
- 端到端验证 (labtest): 三档全 LIVE. 第一档静态规格面板; 第二档 discover deepseek 返目录 + 持久化, 不可达 fmlx-m5max 15s fail-loud 502 不 hang; 第三档 probe deepseek-v4-flash HTTP 200 18.8s 返源戳矩阵 (streaming/thinking/tool_use/structured_output/schema_ref=实测真 True, 缓存/最大工具=未测跳过, 工具名正则=strict 实测, schema 特性 4 项=True, probe_errors 诚实记 deepseek 拒点号工具名). GET capabilities round-trip 读回持久化发现+实测.
- 测试: core capability/openai/cmd + engine -race 绿; platform 全套 -race 绿 (engineconfigstore 含 keyless 转绿 + 2 新 ADR-0018 store 测试); frontend tsc+vite 绿. 4 路对抗审查 (编译测试 / 行为保真 JSON tag 逐字+门控顺序+caching dispatch+Skip* / 前后端契约字段对齐 / 安全无 key 泄漏+参数化 SQL+gated+bounded+无自动花 token) 全过.
- follow-up (登记): 深度 probe 异步 (跑贵探针, 需 async job) / discover 回灌引擎 ModelRegistry / probe 矩阵 vision 等 documented 字段从 ModelInfo 合并 (现 untested 因 capability 包 documented 表无该 model 条目) / 未保存实例 test-before-save probe (POST /config/probe).
platform/common + core/docs/adr — 运行时引擎配置: settings UI 真生效 (provider/key 热换不重启 + 密钥 encrypt-at-rest), 取代启动期 env 焊死 (ADR-0017) (2026-06-06)¶
PM 拍板 "真doc+真设置, 不然我咋用" + "八大模块尽量都接". 后端原本 provider/model/key/audit 全由进程启动时读 env 焊进引擎, 启动后无运行时改配置通路, 故 settings.tsx 全 mock. 本变更建运行时配置层让设置 UI 真生效.
- ADR-0017 (新): 运行时引擎配置. 脊椎 = 不可变
ConfigSnapshot放atomic.Pointer, 三条消费 seam 无锁只读, 应用配置 = 一次 copy-on-write 重建 + 原子 swap (取代三处 ad-hoc 锁, 范式级不打补丁). 诚实交底: "真设置" 多数是把 env/form 默认值挪进 store + 改读取点, net-new 只有 /run provider 热换 + key encrypt-at-rest. - engineconfigstore (新包):
Store双实现 (InMemory + Postgres, 镜像 sessionstore ON CONFLICT 原子 upsert). 两表engine_config(JSONB 明文路由+审计) +engine_secrets(AES-256-GCM 密文 + nonce + key_prefix + key_version). DDL 追加 platformMigrations (append-only).crypto.gostdlib AES-256-GCM (无 x/crypto), master key fail-loud, 诚实威胁模型 (防库 dump 不防活进程). - engineconfig (新包):
Manager(atomic snapshot + 从 store 重建/swap + env-seed) +ConfigurableProvider(实现 flyto.ModelProvider, 每 Stream 读当前 snapshot 委派, 只热换 provider/key/endpoint 不换 model -- model-per-role 在 engine.New 冻死) +ConfigSnapshot(不可变, 无锁读). - 4 配置端点 (REST):
GET /config/engine(脱敏 {masked,set} + 每旋钮三档 tier) /PUT /config/engine(路由+审计) /PUT /config/secrets/{name}(加密落库+重建provider+swap) /DELETE /config/secrets/{name}. secret 永远脱敏, 读回不解密 (key_prefix). 未接 master key 时 503 "config disabled". - 三档纪律 (ADR-0017 §1.3): 真生效 (anthropic/openai/deepseek/minimax key + 模型路由 + 审计) / 真存储待消费 (gemini/openrouter/ollama key 未 import) / 纯展示 (RBAC). UI 据 tier 诚实渲染绿/灰/虚.
- cmd/common 装配: master key 策略 (FLYTO_SECRET_MASTER_KEY 显式 / InMemory 临时 key 零配置真生效 / Postgres 无 key 回落旧路径). env-seed (store 优先, env 播种空字段, 现有 compose 部署不变). ConfigurableProvider 作 engine.New Provider; /run 默认 model 取 snapshot.RunModel.
- 前端: settings.tsx 的 API密钥 + 模型路由 两模块从 mock 接真 (GET/PUT /config/engine + PUT/DELETE /config/secrets/{name}), 按 tier 三档渲染 (真生效绿 / 待消费灰), secret 只显打码. lib/api.ts 加 4 方法 + 类型; vite.config 加 VITE_API_TARGET 让本地测试服务器指任意后端口.
- 端到端验证 (本地测试服务器): 新 binary :8099 (InMemory + 临时 key, openai->Gemma) + vite dev proxy -> 截图 /settings 真渲染 (openai/deepseek/minimax 真生效绿 + 打码 10jl****; gemini/openrouter/ollama 待消费灰). 改 key A->B 不重启 /run 立刻变 (前述 live smoke).
- 真 doc (godoc): docs.tsx 从 mock 换成嵌真 pkgsite godoc -- 专用子域名 godoc.labtest.flytoex.net (CF tunnel ingress + caddy block reverse_proxy godoc:8080; pkgsite 绝对 /static /fonts 路径不能挂子路径). godoc 镜像重建吃当前 /core 布局 (旧镜像是 ea217df module 改名前 3 周旧布局, core/pkg/* 子包 424; 重建后 core@v0.0.0/pkg/engine -> 200). docs.tsx iframe 协议跟随当前页 (本地 http / 公网 https). 验证: labtest.flytoex.net/docs 真渲染嵌入 godoc (Go gopher + 模块列表), 走 WARP->CF->tunnel 真路径.
- 当前生效面 + follow-up: /run engine provider/key/model 真生效 (wrapper 热换) + settings UI 接真 + 真 doc live. dispatch loop (sub/main/audit/vision) 默认仍读启动期 env -- snapshot 默认下沉 dispatch 是明确 follow-up (core/TODO). swagger 重生待做.
- 测试: store 双实现 round-trip + ON CONFLICT (testcontainers PG) + crypto encrypt/decrypt/nonce唯一/wrong-key/fail-loud + snapshot atomic swap -race + 端点 503/脱敏不泄漏/三档/400. 全绿.
- v2 升级 (2026-06-06, PM 交互敲定 + LIVE): provider 接入从单 key/类型 改多实例 (云多 key/账号 + fmlx 飞驼 Mac 推理多台各填网址) + 引擎能力暴露. 单位改命名实例
{name,type,url,加密key}(新表 engine_provider_instances, RoleRoute.Provider->Instance); 砍 v1 三档假占位, openrouter 接进引擎, 新增 fmlx 类型 (openai 兼容指实例 url); GET /config/providers 吐每类型模型 spec (来自 provider.Models(), 干掉前端硬编码假 MODEL_OPTIONS, fmlx 自托管无静态目录正常); settings.tsx 重构成 类型->多实例 UI + 模型路由从真 spec 选. 端到端验证: labtest 路由切 deepseek 实例 -> /run 立刻出 deepseek token (路由真生效, 不重启); provider 实例页 + 路由页真渲染. ADR-0017 §8 v2 修订. (fmlx-m5max 链路由 turn_start 证, m5max 当时外部掉线.)
frontend + core/docs/adr — 平台前端底座: 采纳 Claude Design 设计系统 (ADR-0015) + 引擎控制台/godoc 文档门户/物流对账明星屏, 骑既有 ADR-0009 frontend (2026-06-05)¶
PM 拍板两线走: 线 1 平台底座 (= 引擎完整前端, 含 godoc API), 线 2 物流对账作首个真实工作流 (承运商无关). 老页面级 stub 可弃 (PM), 栈 + REST/SSE/OIDC 基础设施保留 (ADR-0009 铁律 + 后端契约). Claude Design 交付 flyto-design-system bundle, 评估 (读 bundle + 两段 chat): 强在设计系统 token + Geist+苹方 字体策略 + Atlas 三视角; 否决游戏引擎宇宙 (PM 连否 "太恶心/像 20 年前"); 缺对账 hero 屏.
- ADR-0015 (新): 平台前端底座 + 设计系统采纳 + 对账首工作流 + "工作流验证底座" 纪律 (对账当试金石防空抽象) + 承运商无关工厂. 实勘修正初稿: 发现既有
frontend/(ADR-0009) -> build on 它非新建; 保留 shadcn 铁律 (初稿 "用 .fc- 当皮不套 shadcn" 与 ADR-0009 冲突, 作废), 设计系统采纳改为映进既有 shadcn/Tailwind token 系统. - 设计系统落地:
colors_and_type.css作 token 单一真相引入 (src/styles/flyto-tokens.css); shadcn 语义色全桥接到飞驼 navy#485068+ orange#F18200; Geist (拉丁) + 苹方/思源 (中文) 字体策略. 三轴正交换肤: 明暗 (data-theme) x 网络/云仓 (data-brand) x 作业/决策密度 (preset), 改<html>属性整树重皮不重渲染. - 外壳 + UI kit:
Layout(侧栏导航 + 顶栏三轴切换器 + 飞驼标) +components/flyto/kit.tsx(Panel 切角/ticks / StatTile / MonoLabel / SectionHeading / Pill / LED / Money, 承载 logo 几何). - 5 个底座界面 (React + shadcn + 飞驼 token): 工作台 / 引擎控制台 (7 provider + 18 内置工具 + 模型角色 + 运行流) / 物流对账明星屏 (三方审计省钱看板 + 错因归类 + 逐运单差异表 + 账单/WMS/报价三方对照) / godoc 开发文档门户 (core/pkg/* 包树 + 渲染 godoc API 参考) / 设置 (8 模块 + provider 密钥 + 模型路由).
- 数据为示意: 对账后端 (账单 ingest + WMS 运单查询 + 计费合并 + 坑规则集) 留 ADR-0016, 当前屏是视觉契约非 live data.
- 验证:
tsc -b && vite build绿 (1655 模块, 0 类型错误); playwright 真截图 7 张 (5 界面 + 云仓换肤 + 浅色变体) 确认设计系统 + 三轴换肤全落地. 旧孤立 stub (runtime/flow) 删. 未 commit (PM 未授权 commit, 在 main, 留工作树供 review). 页面 fan-out 用 workflow (4 agent 并行建页).
platform/common — 内嵌图抽取加审核纠错环 (审核发现问题 -> 带反馈重抽, 护栏防越纠越乱) + vision 超时 180s->300s (2026-06-04)¶
PM 实测各本地 gemma4 视觉模型 + 对真图逐字段核对后定: (1) 生产用 MiniMax (本地 gemma4 简单派费通知能抽对, 但复杂多省多重量段大表会压扁/漏行/抹空金额, 零容忍标准下 "接近正确=不正确"); (2) 审核纠错环必须做 ("没法讨巧"); (3) 超时改 300s.
- 审核纠错环 (
extractEmbeddedImageNotices): 抽取 -> LLM 审核 (已有) -> 审核发现真问题时带反馈重抽 (上一遍 bundle + 审核意见注入 prompt, 仿基础报价ParseQuoteWithFeedback). 护栏 (visionBundleRichness): 纠错 bundle 只在不比原来差 (明细/有价行/网点/日期 >=) 时才替换第一遍 -- 实测纠错被错误应用会把好结果搞乱 (如把金额挪错字段), 故更差的重抽直接拒.visionAuditFoundIssues判审核是否报真问题 (无不符/无误等哨兵 -> 跳过纠错). 被采纳的页 emitcorrected:true供 UI 标 "已按审核自动修正". 护栏不拦覆盖不变的值质量退化 -- 那交人审闸. 5 个确定性单测 (接受 richer / 拒 poorer / 审核干净跳过 / 两助手). - vision 超时 180s->300s (
defaultVisionTimeout): 实测最复杂通知 (image1: 3 重量段 x 30+ 省) 在推理重的 VLM 上单次抽取 ~196s, 180s 会切掉; 300s 给最复杂表 + 任何推理模型留余量, MiniMax 生产路径也受益. per-image 施加. - 验证 (对真图逐字段): 读真图建真值, 五模型横评 (e2b 把重量段边界当价格 / e4b-4bit 幻觉"滚装费"+错合不相干两图 / dense-31b+moe-26b 大表超时但 moe 单独跑 image5 富通知 9 网点+首重 2 元+续重 1 元全对 / e4b-8bit 简单通知全对大表半对); 纠错环实证: e4b-8bit image5 跨页日期 10-21..11-12 被准确 lift 修回, 但 4-bit 模型审核自身会幻觉 (续重 1 元说成 0.5 元) -> 纠错环上限 = 审核模型能力. 全量 -race 绿.
core + platform/common — 内嵌图 VLM provider 可配置: 视觉契约提到 flyto.VisionProvider + openai provider 加 vision + UI 选本地 gemma4 (2026-06-04)¶
PM 要测本地 VLM ("VLM provider 我也测一下本地的, 你现在是写死不让我配置"). 之前内嵌图抽取写死 MiniMax (cmd/common 硬接), UI 虽送 vision_provider/model 但服务端忽略. PM 拍板: 这不是引擎的事, 接入 provider 就行 -- 故全程不动 pkg/engine (orchestration), 只动 provider 层 + 共享契约 + 平台选 provider.
- 视觉契约提到 flyto (
flyto/vision.go):VisionProvider接口 +VisionRequest/VisionResponse从 providers/minimax promote 到 flyto (按 minimax 注释 "够两家再 promote", openai 是第二家). VisionRequest 加Model(openai 必填 / minimax 端点锁模型忽略) +MaxTokens(推理模型留余量). minimax/vision.go 适配 flyto 类型. - openai provider 加 vision (
providers/openai/vision.go):ExtractVision单次非流式 POST/v1/chat/completions带 image_url content 块 (照 minimax 直连模式), 读message.content. 不碰 Provider.Stream (其 image guard 保留) -- 视觉是独立单次抽取接缝, 不是引擎多轮 loop. 处理推理模型: gemma4 把思维链放 reasoning_content, 答案 JSON 在 content; defaultVisionMaxTokens 16384 防推理吃光 budget 切掉 JSON. - 平台按 provider 选 (
server/quotedispatch_*+cmd/common):llm.VisionClient.Provider类型 minimax.VisionClient -> flyto.VisionProvider; 抽取器MinimaxVisionExtractor泛化为VisionClientExtractor(包任意 provider + defaultModel);NewOpenAIVisionExtractor;QuoteDispatchConfig.VisionExtractors map[provider]extractor+visionExtractorFor按 per-request vision_provider 选 (没注册回 vision_unavailable, 不静默回落); model per-request 透传 (ExtractImageNotice/AuditImageNotice 加 model 参数); cmd/common 在 OPENAI_BASE_URL+KEY 设了注册 "openai" 抽取器 (模型 QUOTE_VISION_MODEL/OPENAI_MODEL_ID). UI 下拉加 "本地 gemma4 (OpenAI 兼容)" + model 框生效. - 验证 (对真值): 本地 m5max oMLX 真跑确认 gemma4-moe-26b 视觉可用 -- curl image1 读出 "中转费加收 (揽收时间)" 表格, curl image5 读出进博会加收派费通知 (finish=stop, JSON 在 content 1235 字符, reasoning 单独 7727); openai/vision.go 单测 (httptest: happy/error 外壳/空 content 露 reasoning/缺 model/空图); 全量 -race 绿 (core + common). 引擎 (pkg/engine) 零改动.
platform/common + deploy — 退役 bill-recon (物流对账前身) 整个产品, 共享包迁 internal/quotecost/ (ADR-0013 §7.0 计划清理) (2026-06-04)¶
billcost 已吸收 bill-recon 的内嵌图 overlay 能力 (ADR-0013), bill-recon 作为独立的圆通账单核对产品退役. PM 拍板彻底清除前后端 + 测试数据 + 死代码. 不能整包删: billcost 复用 bill-recon 三块共享零件 (parser 类型 / wms 字典连接 / llm 视觉抽取器), 故精确切分:
- 共享包迁出:
internal/billrecon/的根类型 (BillItem/QuoteRow/QuoteRules 等已死类型外, AdjustmentDetail/Bundle/CostType/ChinaProvinces/IsNationwide 等 billcost 用的) +parser+wms+llm迁到中性internal/quotecost/, 包名billrecon->quotecost, 全仓 18 处 import + 标识符重写.internal/billrecon/文件夹彻底删除. - bill-recon 独有删除:
internal/billrecon/{web,workflow,store,dump}(对账网页/反射循环/Postgres store/csv dump) +cmd/bill-recon+cmd/bill-recon-web(含聊天式前端 static/) +cmd/{quote-probe,reflect-probe}(被 quote-engine-probe 取代的旧探针) +Dockerfile.bill-recon-web. - 死代码删除: 迁移后零调用的 Excel/文本报价解析路径 —
parser/{excel,headers,images,rules,quote_to_bundle}.go(ParseBills/ParseQuote/QuoteToBundle 等) +llm/{quote,columns,validator_adapter}.go(QuoteParser/ColumnClassifier/QuoteResponseReflector) + 根quotecost类型包 (types.go/doc.go) +parser.InferRegions/parser.ReturnFee. 保留 billcost 真用的:parser/adjustment.go(类型+IsNationwide+ChinaProvinces) /llm/{vision,reflect_tool}.go/wms/. - 部署/CI 清除: docker-compose (base + local) 的
bill-recon-webservice + caddy depends_on + postgres-initflyto_billrecondb 初始化; CI release.yml 的 build/push step + 变更检测 + bind-mount 守卫; fastpush/fastpush-bin/check-deploy-impact 的 bill-recon-web 分支; 停删 m2max 上运行 2 周的deploy-bill-recon-web-1容器. - 测试数据: bill-recon 从未在我们 Postgres 写过数据 (6 张表 bills/ship_cost_cfg 等从未建过; WMS 连接纯只读不写外部库), 故 DB 侧无数据可清.
- 验证: 全程编译器兜底 (
go build ./...每批删除后 green); 共 36->? 个 billrecon .go 文件砍到 quotecost 仅留 billcost 真依赖; 全 Go 已无 "bill-recon"/"billrecon" 字样 (注释/示例 ID/swagger regen 一并改 billcost); common 16 包 -race 绿, core 53 包绿 (1 个 TestFileEdit_SymlinkPassthrough 是 macOS /tmp 符号链接既存环境 FAIL, 非回归).
platform/common — billcost overlay 落库时把 "全国" 展开成 31 省 (代码做非 VLM) + 省名归一到 base 短名词表 (ADR-0014 §2.0 格式统一实现) (2026-06-04)¶
PM 实测后定: "数据库里并没有全国这个省份, 实际落库的时候应该把全国拆解成每个省份, 这个没必要让 LLM 做来浪费 token" + "1.31 省" + "图片 LLM 没有必要展开". 在 confirm 落库 handler (非 VLM/prompt) 把 overlay 的 "全国"/"全境" detail 行展开成 31 个大陆省行 (expandNationwide), 同段 (省+重量段+揽收/签收口径) 被单独列出的省覆盖其展开行 (偏远 新疆/西藏 自己的价赢过全国价, band-scoped override 不跨段); 全国行一律展开无例外 (PM 实测确认 全国+网点 是矛盾口径, 全历史落库零重叠; 不开特例否则放过去会把 "全国" 漏进 DB). 顺带兑现 ADR-0014 §2.0 格式统一: explicit 省行归一到 base 短名词表 (covCanonProv: 广东省->广东 / 内蒙古自治区->内蒙古, 非已知 31 省的城市/偏远/全国标签原样透传) -- 生产实证一个 session 里 VLM 吐 上海 与 上海市 / 广东 与 广东省 混格式, base 报价统一短名, 归一后落库 overlay 与 base 同词表. base 报价不动 (PM: "报价那 138 行不是问题, 你不知道表格内部什么样, 修了就没通用性") -- 展开/归一只对 overlay. 草稿态 coverage 仍 match-time 展开 (coverageFor), 落库态已展开无需 view-time 展, 取代 ADR-0013 §2.4 store-verbatim (仅 overlay).
- 验证 (对真值非自评): 31 个省名实测等于 base phase_1_result 的 distinct province_name (ed46961f, 短名零差异); 全国+网点矛盾口径实测全历史落库零重叠 (全国行 9 / 网点行 6 / 交集 0), 故删 guard 全国一律展开; 真实 DB 端到端 (新 binary + 真 confirm 端点 + 真 Postgres): 一次性 session POST 全国(0.1)+新疆同段(0.5)+广东省异段(0.9) -> 落库 32 行 = 0-1kg 段 30 省@0.1 + 新疆@0.5 (override 赢, 无重复, 31 省完整) + 广东 3kg+@0.9 (广东省归一); 省级行残留 "全国" = 0. 单测
TestExpandNationwide(展开/band-scoped override/归一) +TestBaseProvinceSet/TestAdjustmentsConfirm更新; 全量 -race 绿. 已部署 m2max.
docs(adr) — ADR-0014: billcost base+overlay 统一抽取格式 (复用 WMS cost_type) + overlay 进引擎跨层核对 (设计 accepted, 待实现) (2026-06-04)¶
PM 实测后提的架构方向, 经两轮澄清 + design workflow (8 agent 研究 + 三视角对抗 critique) 收敛成 ADR-0014. 核心: (1) 两条抽取路 (表格->base / 图片->overlay) 输出同一套字段格式, 用现成 WMS cost_type (标准=0 / 调价=1) 做判别 (overlay 已是 1, base 补 0) -- 格式统一非数据合并, 两层永远分存不 flatten; (2) overlay 从"人审终点"升为经引擎校验: 人确认图为前置 gate (看图是人强项, 引擎不重读图), confirm 后跑跨层核对 (matched/orphan/unverifiable, 服务端读时算不持久化避 base 重抽 staleness, 全国 view-time 展开) + overlay 独立轻结构校验; (3) 计费合并 (标准+调价一起算) = 未来, cost_type 铺地基. critique 修正两个事实错误: "换 config preset 走共享反射器"不可行 (ReflectConfig 只 2 旋钮, 动 reflect.go 连累 base) -> overlay 独立校验; "持久化 verdict"会 staleness -> 读时算. 否决新造 price_kind (cost_type 已够) / 数据 merge / 时间 join / 持久化 verdict. 实现按 §7 分阶段 (format-unify -> 跨层核对服务端化 -> overlay 校验 -> 前端读服务端 verdict).
platform/common — billcost 内嵌图多页通知自动拼接 (VLM 判 page_role/page_number + 续页日期 lift 到首页表格) (2026-06-04)¶
PM 实测发现: 圆通报价表的内嵌通知一份被拆成连续多张图 (image5=上海进博会派费第1页 有抬头+网点表格但无执行时间; image6=第2页 有执行时间+条款但无表格)。当前逐图独立抽 → image5 缺日期 (三态 badge 显"缺生效日期")、image6 是没头没尾的碎片, 两页都不完整。PM 拍板: 得有"判断"出连续图 (不能靠死规则 -- 实测 image4/5/6 全 A4 同尺寸, 光看尺寸会把独立单页 image4 错并)。选项 1: VLM 自动判 (它本就在读图) + 自动合并 + 人工覆盖 (覆盖那半暂缓, 待 UI 替换一起做)。
- 检测信号交给 VLM (
cmd/quote-engine-probe/prompts/_vision.md): prompt 加两顶层字段page_role("start"=有抬头的首页/独立页 / "continuation"=无抬头从半截起的续页) +page_number(页脚 "- N -" 的数字); 加多页说明 + 续页示例 (示例4: continuation/page2/details:[] 但照常填 start_date/end_date, 续页日期是拼首页表格所必需)。实地真值验证 (真 VLM 跑 image5/6): image5 -> {page_role:start, page_number:1, 日期空, 2 行表格}; image6 -> {page_role:continuation, page_number:2, 日期 2025-10-21~11-12, details:[]}。 - billcost 侧解析 + 分组 + 合并 (
internal/server/quotedispatch_handler.go):pageMeta从bundle.Master.RawExtraction(完整原始 VLM JSON) 解 page_role/page_number -- billcost 本地, 不动共享 bill-recon parser (它忽略未知顶层键);extractEmbeddedImageNotices重构为 Phase1 抽全部 → Phase2groupPages分组 → Phase3mergeGroup合并 + 按组 emit;isContinuation= page_number>1 (主, 干净页脚信号) 或 page_role=continuation (fallback); 保守 (advisor): 只在明确续页信号才合, 绝不凭"无开头信号"去合, 抽取失败页不参与 -- 判错最多退回"分开显示"(现状), 绝不错合污染两份通知;mergeGroup把续页的空 start_date/end_date/summary 从续页 lift + concat 有意义 detail 行 (detailHasSurcharge: 全零无网点行是续页 artifact -> 丢)。 - 事件/UI:
embedded_image事件改按组 emit (name="image5.png+image6.png", bundle=合并, 新增pages数组携每页 b64+page_role+page_number 供多页预览, image_b64=首页向后兼容); 前端 adjEdits 按 combined name keying, 一卡一通知 (6 图 -> 5 卡)。 - 验证对真值: 单测
quotedispatch_multipage_test.go(parsePageMeta / isContinuation 6 例 / groupPages 连续合并+保守 (leading-continuation 独立 + errored 页不跨合) / mergeGroup lift 日期+丢 artifact / 单页透传 / detailHasSurcharge); e2e livevision_e2e_test.go对真 ytosample 跑完整路径 (6 图 -> 5 通知, image5+image6 合并出带表格+日期的完整 bundle); server 全量 -race 绿. 已部署 m2max (_vision.md bind-mount :rw 即时生效)。 - deferred: 人工覆盖 UI (手动拆/合分组) 待 UI 替换; 续页表格跨页续行 (一张表 row 1-20 在 page1, 21-40 在 page2) 当前 mergeGroup concat 两页 details 已天然支持, 但 page2 续表行无表头时 VLM 抽取质量未实测 (ytosample 此例是 section 级断点非 row 级, 未触发)。
core + platform/common — DeepSeek V4 thinking_mode 引擎契约 (sub 默认关思考解除采样 no-op) + ADR-0013 base 报价三元组 stamp (旁列) (2026-06-04)¶
两件: (1) PM 要引擎支持 DeepSeek V4 thinking_mode 可调参数 (官方 /guides/thinking_mode: 顶级 thinking:{type:enabled|disabled} + reasoning_effort:high|max, 思考开时上游忽略 temperature/top_p/penalty); (2) 兑现 ADR-0013 §2.10 之前 deferred 的 base 报价承运商三元组 stamp. thinking 是 range A core 契约改动 (协议改全消费者一起改, 走 advisor 审).
- 新 flyto 契约字段
flyto.Request.ThinkingMode *string(三态) (core/pkg/flyto/provider.go): nil=省略 (用 provider/模型默认) / "enabled" / "disabled". 比旧NeedsThinking bool严格更有表达力 — 后者只能请求思考不能关闭, 而 DeepSeek V4 默认就思考, 关掉必须显式thinking:{type:disabled}(false 只省略字段留默认开). 这个 gap 是 load-bearing: 思考开时 DeepSeek 忽略采样, 靠采样抗循环的 sub-agent 杠杆被静默 no-op.Effort string复用作 reasoning_effort (扩 doc: DeepSeek 收 high/max). ThinkingMode 与 NeedsThinking 同设时 ThinkingMode 胜; 其他 provider 忽略 (documented no-op). - wire 映射 (
core/internal/wire/openai.go):openaiReq加Thinking *openaiThinking(thinking:{type},omitempty) +ReasoningEffort *string(reasoning_effort,omitempty); StreamRequest 加ThinkingType+ReasoningEffort; buildRequest 映射. omitempty -> 其他 openai-compat provider (MiniMax/OpenRouter/Ollama/vLLM) 留 nil, body 逐字节不变. - deepseek provider (
core/pkg/providers/deepseek/provider.go): streamOpenAI 用顶级thinking:{type}+reasoning_effort替换旧reasoning:{}对象 (V4 忽略那个未知字段, 旧 enable 其实是静默 no-op). 解析: ThinkingMode 优先 -> 回退ThinkingBudget>0||NeedsThinking->enabled -> 否则省略 (V4 默认). detectFeatureWarnings 同步 (disabled 不告警). capability-probe 的 ThinkingBudget=2048 路径保留. - engine + EngineSpec + dispatch.Request + handler + UI:
engine.Config.ThinkingMode string-> buildFlytoRequest -> flyto.Request;quotedispatch.EngineSpec.{ThinkingMode,Effort};BuildSubEngine默认 ThinkingMode="disabled" (sub 是抽取 agent, 唯一抗循环杠杆是采样, V4 默认思考会让采样 no-op — 这是修 bug 的关键默认),BuildMainEngine透传 (main 要推理, ""=V4 默认开); dispatch.Request 加Sub/MainThinkingMode+Sub/MainReasoningEffort(4 处 EngineSpec 构造点: sub/main/audit/retry-rebuild); handlernormThinkingMode/normReasoningEffort白名单归一 (非法值收 "" 不透到 DeepSeek 400); UI sub/main 各两下拉 (思考开关 + effort), 默认 sub=思考关 / main=思考开. - base 报价三元组 stamp (ADR §2.10, 旁列非 mutate blob) (
db/pool.go+quotedispatchstore/+server/): quote_dispatch_sessions 加 4 idempotent ADD COLUMN (whs_id/ship_type_id/ship_type_msn/site_no TEXT);Sessionstruct + handler Create 时从 params stamp; postgres Create/Get 读写 (nullIfEmpty/string deref); /analysis envelope 加identity对象; 前端 AnalysisView 显承运商身份行. 不动 phase_1_result blob* (advisor: 能加旁列就别 mutate 存好的 result JSON). overlay master 已携相同三元组 (P2), /analysis 两层都露, 审阅者看 base+overlay 同承运商. - 验证 (对真值, 非自评): 真 DeepSeek live 跑 (走 provider->wire->真 API->解析): ThinkingMode "enabled" -> 响应带 reasoning_content (ThinkingDeltaEvent), "disabled" -> 没有, 两者 answer 都对 (17x23=391) — 证明 (a) 新 body 形态被真端点接受不 400 (b) 开关真切换非静默 no-op; wire marshal 单测锁精确 JSON 形态 (
thinking:{type:disabled}+reasoning_effort:high, advisor 要求防字段名手滑); base-stamp 真 DB: migration 4 列已应用 + 部署 /analysis 真返 identity (W02/YTO/MSN-1/BJ-CY) + base 字面 + overlay 分层; 全量 -race (core wire/deepseek/flyto/engine + common quotedispatch/store/server/db) 绿 + jsxcheck 绿. 已部署 m2max.
platform/common — billcost 内嵌临时加价图 overlay 完整弧 P1+P2+P3: 图预览/可编辑/三元组 + 落库/confirm 审核 + base+overlay 统一分析视图 (ADR-0013, 2026-06-03)¶
ADR-0013 完整实现 (P1 预览+可编辑+三元组捕获 / P2 落库+confirm 审核端点 / P3 base+overlay 统一分析视图+三态 coverage) + vision prompt 修. Range A (L719) 的内嵌图从"流式一次性只读"升为图预览+可编辑+落库+统一视图; bill-recon 作继任者被吸收 (写路径拷非 import).
- handler base64-inline 图字节 + VisionAt provenance + 三元组接住 (
internal/server/quotedispatch_handler.go):embedded_imageSSE 事件加media_type+image_b64(无条件含失败图, 失败时人审最需要图); 成功抽取后 stampbundle.Master.VisionAt = time.Now().UTC()(修 §2.7 audit 判别器反转 -- billcost 路径此前从不设, vision_extracted_at 对 VLM-读过的行也 NULL);dispatchRequestParams+parseDispatchMultipart接whs_id/ship_type_id/ship_type_msn/site_no四 multipart 字段 (ADR §2.10 承运商身份, VLM 抽不到, 上传前选定) + 可观测 log (P1 接住, P2 stamp 到 master). godoc 记 base64-inline 阈值逃生 (>2MB/图 或 >15 图 -> xlsx_blob re-extract serve 端点). - 可编辑审阅 UI (
deploy/billcost-test/index.html+main-screen.jsx+main-screen.css): 新EmbeddedAdjustmentReview/AdjustmentCard(图预览 beside 可编辑表; 三态完整度 badge §2.9 替二元 ok=!error: 全=green 已解析 / priced-无dates=amber 缺生效日期 / dated-空details=amber 无定价明细; 空-details 卡 master 仍可见可编辑; 逐行省/市/目的地网点/揽收-派费/重量段/delta 镜像 bill-recon app.js;is_target_specific派生自target_sites.length>0; 删行 + 加一行 + 逐图采用/剔除; 行 key 用稳定_rid);adjEditsApp state 按 image name keying (SSE seed 仅 absent, 防 best-effort re-deliver 覆盖编辑); 三元组 free-text 捕获区 (无 WMS 字典下拉, 默认 W02; 不含 standard_cost_sys_no = F-WMS3 deferred); 停 fmtBundle 灌聊天流改审阅面板主渲染. - P2 落库 + confirm 审核 (
db/pool.go+quotedispatchstore/+server/): DDL 两表quote_dispatch_adjustment_master/_detail(session_id TEXT FK CASCADE, image_name 键, cost_type 恒 1);AdjustmentRecord{ImageName,Bundle}billcost 自有 wrapper +SaveAdjustments/ListAdjustments(吸收 bill-reconSaveShipCostCfg写路径, 拷非 import; 去 fan-out 全国字面存 §2.4 / 去 file_site / whole-replace delete-then-insert 空仍删 §2.6 /is_target_specific服务端权威 len>0 §2.7 / 本地 nullIfEmpty); confirm 端点POST /dispatch/{id}/adjustments/confirm(store-direct 无 engine / reject-drop / 三元组顶层发 stamp 每 master / carry vision_extracted_at+raw_extraction audit 回补 bill-recon NULL gap / 无 409); GET /dispatch/{id} 加 adjustments; 前端 "确认落库 (整套替换)" 按钮. - P3 统一分析视图 (
server/+main-screen.jsx):GET /dispatch/{id}/analysisbase (phase_1_result) + overlay (ListAdjustments) 独立 labeled 层按 source table 永不 flatten (避 bill-recon C7 time-basis-join 陷阱); 前端AnalysisView渲两层 + 每 overlay detail 三态 coverage verdict (matched/orphan/unverifiable §2.8) +CHINA_PROVINCES_34/isNationwideview-time 展开全国再 match (§2.4 store 存全国字面 VIEW 展开, 否则全国加价静默 orphan = F-COV); 层身份靠 source 不 sniff is_delivery_fee (F-LAYER). - vision prompt 修 (
_vision.md): image5 target_sites 只取目的地网点, 排除三/四段码 (分拣/路由编码非目的网点, 混入污染加价范围判定). - 顺带:
parser.ChinaProvincesgodoc 31->34 改对 (实际 34 条, godoc 撒谎 :241/:245/:256, §2.4 防谎言传染). - image_name 通道决策:
parser.AdjustmentMaster无 ImageName 字段, 选 billcost 自有AdjustmentRecord{ImageName,Bundle}wrapper (image_name 一等公民不污染中性 parser), 偏离 ADR §7.2 字面签名[]parser.AdjustmentBundle= 有意取舍. deferred: ~~base 报价 master 三元组 stamp~~ (2026-06-04 已做, 用 session 旁列非 phase_1_result 注入, 见上方 06-04 段) / group-by facet 切换 (polish, 仍 deferred). - 验证 (对真值): 全量
go test -race ./...(20 包) 绿 + jsxcheck 绿; P1 真跑 ytosample 6 图 image_b64 未截断 PNG + VisionAt stamp + headless Chrome 渲 6 卡三态 badge; P2 真 DB (docker psql): master+detail 行真在 / 全国字面单行 (无 fan-out) / 三元组 stamp / is_target_specific 服务端定 (client false 被覆盖) / raw_extraction+vision_extracted_at round-trip / reject-drop / whole-replace 空 POST 清空; P3/analysis端点 live (base+overlay 分层 source=persisted) + headless Chrome 渲三态 (全国 matched 4/34 view-time 展开 / 黑龙江 orphan / target_sites unverifiable). 已部署 m2max (force-recreate). - 对抗式评审 (workflow 4 reviewer + 对抗验证): 抓 4 真 bug 已修+验, 均被验证数据巧合掩盖: (A HIGH) 前端 raw_extraction:null -> Go 4 字节 "null" -> store guard 误当 VLM-读过, 反转 §2.7 audit 判别器 (修: handler+postgres 双层收 nil, 真 DB 验手填卡 audit 列 SQL NULL); (B HIGH) base 短名 vs coverage 全名 -> 每 overlay false-orphan, P3 coverage 形同虚设 (修: normProv 两侧归一, harness 真短名重测); (C LOW) 克级 int 字段输小数 confirm 400 (修: Math.round); (D LOW) isNationwide 漏 全国主区 (修: startsWith 全国).
core + platform/common — MiniMax Code Plan provider (MiniMax-M3) + 引擎 top_k/min_p 采样能力 + per-request sub 采样 (2026-06-02)¶
billcost dispatch 测试换主力 provider: 先探索本地 ds4 (deepseek-v4-flash @ 10.0.0.1:8001) 但测试失败弃用, 改接 MiniMax Code Plan (M3 / M2.7-highspeed, 跟 vision 同一个 MINIMAX_TOKEN_PLAN_KEY). 顺带补两个引擎能力: (1) MiniMax-M3 模型 spec 入静态表; (2) flyto.Request 的 top_k/min_p 采样扩展 (抗循环). 抗循环背景: 强模型 / 非思考模式在低温 (sub 默认 0.2, 当年给弱 Gemma 调的) 偏贪心易循环, 而部分 backend 无 frequency/presence_penalty, 采样是唯一杠杆.
- MiniMax Code Plan provider + MiniMax-M3 模型 (core 引擎 spec) (
core/pkg/providers/minimax+cmd/common+index.html): minimax 静态表加MiniMax-M3ModelInfo (2026-06-01 发布的官方 launch spec: 1M 上下文 / 512K 输出 / 原生多模态 text+image+video / MSA 稀疏注意力 / list 价 $0.60-$2.40, 能力 flag 镜像 M2.7 待 capability-probe; thinking helper 数据驱动自动覆盖). cmd/common:MINIMAX_TOKEN_PLAN_KEY设了就注册minimaxprovider 进 dispatch registry (ModeOpenAI/v1/chat/completions, 实测 code-plan key 在此能跑 M3/M2.7-highspeed). 下拉默认 sub=M2.7-highspeed / main=M3. ds4/deepseek-local 探索全撤 (cmd/common 注册 + compose LOCAL_DEEPSEEK_BASE_URL + 下拉 deepseek-chat). 实测: phase 0 经 MiniMax-M3 识别 sheet 成功 (inline<think>不卡). minimax provider_test 加 M3 spec 真值断言 (1M/512K/vision/thinking/定价), 模型数 6->7. - 引擎采样能力补强 (core):
flyto.Request此前只有Temperature+TopP, 全引擎 8 个 provider 都没有 top_k / min_p (扫描确认, 无 extra-params 透传口). 新加flyto.Request.{TopK *int, MinP *float64}(扁平, 非嵌套 -- 与既有 Temperature/TopP + provider-specific 的 NeedsThinking 一致, append-only 不破坏既有消费者; 决策走了 advisor). 透传链:engine.Config.{TopK,MinP}->buildFlytoRequest->flyto.Request-> 各 provider wire. 映射 per-provider, 字段统一不等于全 provider 都认: openai-compat wire (core/internal/wire/openai.go的 StreamRequest + openaiReq + 6 个 openai-compat provider: openai/deepseek-OpenAI-mode/openrouter/ollama/lmstudio/minimax) 映射成顶层top_k/min_p; 官方 OpenAI 无此概念 (调用方留 nil 即不发); Anthropic 原生有 top_k / Gemini generationConfig 有 topK 但本轮未映射 (tracked gap, godoc + TODO 显式登记, 非静默丢); min_p 是 vLLM/本地专属 (Anthropic/OpenAI/Gemini 都没有). 指针 + omitempty: nil 省略, 显式 0 (min_p 无地板 / top_k 关闭) 仍发. 每字段精准 godoc 标谁 honor/drop. - billcost sub per-request 采样 (
quotedispatchEngineSpec/Request + handler +index.html): per-requestsub_temperature/sub_top_p/sub_top_k/sub_min_p表单字段 -> 串到 sub engine. 空 = 用现有默认 (sub 0.2 温度, 模型默认 top_p/top_k/min_p), 不动未显式设置时的行为 (Gemma 等). 设置弹窗 sub 区加四个采样输入框 + 提示 (建议 temp 0.7 / top_p 0.9 / min_p 0.05 / top_k 40; min_p 动态概率地板常最强抗循环). 只作用 sub (循环重灾区); main/audit 未加. 注: MiniMax 走 OpenAI-compat 但未必 honor min_p/top_k, temp/top_p 一定有效. - 验证对真值:
core/internal/wire/temperature_test.go加 4 测试 -- top_k/min_p 真序列化到 wire JSON (TopK=40/MinP=0.05 -> top_k:40/min_p:0.05) + nil 省略 + 显式 0 发 (omitempty 不吞) + Gemini 不映射 (锁 tracked gap, 将来接 Gemini 须同步改 godoc+测试); minimax provider_test 加 M3 spec 真值断言. core (flyto/wire/engine/providers/minimax) + common (quotedispatch/server) 全量 -race 绿. m2max 部署冒烟: minimax provider 注册 + MiniMax-M3 主代理 phase 0 识别 sheet 成功 (inline<think>不卡), ds4/deepseek-local 已撤. - tracked gap (TODO): Anthropic native top_k + Gemini generationConfig.topK 未映射 (字段在统一契约上但这俩 provider wire 暂不读 -> 设了被丢, godoc 已显式标). 接它们时同步更新 godoc + wire 测试.
- MiniMax reasoning 归一: provider 层
reasoning_split(引擎契约, 消费者无感) (core/internal/wire/openai.go+core/pkg/providers/minimax/provider.go): M3/M2.7 OpenAI 模式无条件内联<think>...</think>进 content (无独立 reasoning 字段; OpenRouter 风格reasoning参数对它无效, 实测; 只认 MiniMax 专属reasoning_split: true). dispatch 的extractJSON= 首{末}, reasoning 文本含花括号 (抽报告时常见) 就把 think 和真 JSON 黏一起破解析 — 脆弱非必破 (上条 phase 0 "不卡" 实为侥幸: 选 sheet 名的推理罕含花括号). 修法走引擎契约 (PM 2026-06-02 拍): provider 触发 reasoning 归一, 引擎路由进统一 Thinking 通道, 消费者只拿干净 content, 无感. MiniMax 上最省实现 = 发reasoning_split: true(服务端拆到 reasoning_content / reasoning_details,wire.ParseOpenAIStream已把这条通道路由成 ThinkingDeltaEvent). wire.StreamRequest + openaiReq 加ReasoningSplit *bool(reasoning_split,omitempty, 照 TopK/MinP 同款指针 + omitempty), minimax ModeOpenAI 无条件设 true (native 端点不动); 其他 openai-compat provider 留 nil 不发, 请求形态不变. temperature_test 加 2 测试 (forwarded + nil 省略). 通用归一器 (覆盖本地 Gemma omlx<|channel>/ gpt-oss harmony + 退役 phase0 消费者层stripChannelMarkers补丁) 走独立 ADR (TODO L721). - 验证对真值 (端到端 dispatch, 非组件自评): M3 main / M2.7-highspeed sub 真传 ytosample.xlsx 跑 dispatch — converged=true rounds=1 ~4.84min $0.013 (gemma 基线要多轮); 抽取对真值核对: 31 省全 / 共享一口价 (1.25/1.55/2.4/2.8 + 首重 3.5) 全套对 / 三区续重 0.6-1.5-2 全对 / 偏远 6 省 (新疆 15/12 西藏 15/15 内蒙 6/3) 全对 / 越界日期 9月31号 clamp 2025-09-30 / 三费 (买单 1.6 / 计抛 8000 / 退回 1.5) + 单带费 (京沪带 is_strip_fee, 北京 3kg 首重 1 元 + 续 0.2 精确) 全抽. reasoning_split 生效后实测: content
<think>0 次 (无 rs 时 5 次) + reasoning 全走 358/589 个 thinking_delta 通道 + sub 抽取 14/14 关键字段与无-rs 跑逐字一致 (零退化). 不循环 (PM 担心的本地循环未现). - 新发现: MiniMax-China dispatch 主循环 flaky (独立 ADR, 不阻塞本次, TODO L722): 3 跑 3 结局 — converged /
unexpected EOF(上游流掐断, 本地 omlx 也中招 = 通用 transport) /new_sensitive (1027)(输出审核误判, China-region 特有). bbc3a76 的 EOF retry 只在 vision 内嵌图路径, 核心 main<->sub 主循环裸奔无 retry. sub 抽取本身干净完整 + 对真值优秀, 是 main 端被流/审核掐断. 瞬态 EOF retry 该在 wire 层 (所有消费者白嫖) + 审核 region 切换/retry. - main max_tokens UI 可设 + 默认探因 (快修) (
quotedispatchRequest/dispatch + handler + engine_factory 探查 +index.html): main 端 EOF 真因之一查实 (PM 现场 dump main 思考流确认, 非组件推断): main(M3) 在verdict=retry轮要整段重写 ~16K 字符的 sub prompt (含 schema + 3-4 示例 + 全部规则) + 大段 cross-check 思考 = 巨型合法输出, 撞 engine_factory 写死的 64000 上限被掐断 (之前误判成"重复循环 + 韵达幻觉", 实为 main 在重写带韵达基础包裹报价示例的 prompt, 多个 example 各带 master/title 才显得"重复" — 收回误判). 修法: per-requestmain_max_tokens/sub_max_tokens(UI 数字框 -> handlerparseOptionalInt->Request.{Main,Sub}MaxTokens->EngineSpec.MaxTokens, nil=64000 默认). main 预填 200000 (MiniMax Code Plan 固定月费, 高上限零成本; 测本地 omlx 被拒可清空). 探因副产: 本想让 engine_factory 默认改 model-aware (取模型注册 MaxOutputTokens, M3=512K), 但config.ModelRegistry的DefaultModels是空表 (dispatch 路径无 auto-register),MaxOutputTokens恒返 0 -> model-aware 是 no-op, 故保留 64000 默认 + UI 杠杆兜底. 注: 放开 max_tokens 治"巨型合法输出撞顶"; 但另有一次 EOF 实测停在 ~22K token (没到 64000) = 纯 mid-stream 掉线, 仍需 L722 的 wire 层 retry. - 修 dispatch bug: retry+new_prompt 轮给 sub 发空 user 消息 (
dispatch.gosubUser 逻辑 +dispatch_test.go断言): mainverdict=retry+new_prompt重建 sub engine (新 session + 重写 system prompt) 时把prevFeedback="", 导致 round 2 sub 的 user message 是空串. 严格 provider 硬拒 — 实测 MiniMax-M2.7 sub 报2013 invalid params, chat content is empty, 砸坏整个 retry 轮; 云端 DeepSeek 实测容忍 (空回合照 system prompt 抽), 其他本地后端看严格度 = 潜伏地雷. 即便不报错, 空 user 消息也是坏习惯 (重抽靠模型对空回合自己动的未定义行为). 修:if round == 1改if round == 1 || prevFeedback == ""发开场抽取指令 (跟 round 1 同款全新开局), 杜绝空 user 消息 — provider-agnostic: 严格后端不再硬拒 + 宽容后端 new_prompt 重抽更明确.TestRun_AwaitThenRetryNewPrompt加 round-2 sub prompt 非空断言锁住 (修前 subUser="" 会触发).
platform/common — billcost 内嵌图改"结构化临时加价"抽取 + 修"看不到图片解析"真因 (ADR-0012 follow-up, range A) (2026-06-01)¶
PM 纠偏前一版 (0b27d72): 内嵌图不是旁路 markdown 转录, 是报价的重要组成部分 = 临时加价 (中转费 / 派费加收 ...), 比基础表更复杂, 要抽目的网点代码 + 揽收 vs 签收计费口径 + 重量段 + delta 金额 + 生效起止. 同时诊断出 PM "看不到图片解析" 的真因: 旧实现图片抽取坐在 20 分钟 Run 之后用 r.Context(), 代理 reap 长连接后 6 张图全 context canceled 死掉 (实测 session 665d39fe: 跑 20.5 分钟收尾时 6 图同秒全挂); 另一条死法是 dispatch 中途出错 early-return, 图片根本没机会跑. 本轮按 range A 推倒重做这块: 结构化抽取 + 可见 + 可人工 review, 合并进最终报价 JSON 留作下一步 (与 bill-recon C7 同一时间基准 join 难题).
- 内核换 structured (
internal/server/quotedispatch_vision.go重写):VisionExtractor接口从返 markdown string 改返*parser.AdjustmentBundle;MinimaxVisionExtractor复用 bill-reconbillrecon/llm.VisionClient(同一份 WMS 形态抽取器 + 解析器: limit_top 兜底 / 取整 / 去代码块), 不再另搞弱抽取. 配 billcost 单独 prompt_vision.md(复用对账抽取结构 + 报价场景重写 + 强化揽收/签收口径; 对账 prompt 假设 5 级下拉预选了承运商元数据, 报价无此表单故必须单独). per-image 超时 90s→180s (最复杂的 image1: 3 重量段 x 30+ 省份组, spike 实测 78-92s, 90s 会切掉它). - additive 复用接缝 (
billrecon/llm/vision.go): 新ExtractShipCostCfgWithPrompt(ctx, image, mediaType, prompt); 原ExtractShipCostCfg委托它带默认visionPrompt, bill-recon 行为零变化 (对账测试仍绿). billcost 传自己的 prompt 跑同一套解析. - 挪到开头并发 + 修 context (handler): 图片抽取从 final 块 (Run 之后) 挪到请求入口并发 goroutine, 跑在
context.Background()上 (脱离 client 断连), 与 phase 0/1 并行 → 操作员头一分钟在新鲜连接上就看到临时加价. deferredcancel+join保证 goroutine 在 ServeHTTP 返回前停止碰 ResponseWriter (防 emit-after-return).EmbeddedImageResult{Name, Bundle, Error}(Text→Bundle); final 行 join 后带结构化embedded_images. - 配置面板 + prompt UI 编辑 + per-request (
index.html+ handler): 设置弹窗加"内嵌图解析 (VLM)"区 — 开关 + provider 下拉 (MiniMax 已接入, Gemini/Claude 标"未接入" disabled) + model 字段 (MiniMax vlm 服务端锁模型, 占位) +_vision.mdprompt textarea (跟 _audit.md 同款 "保存为默认", prompts/defaults 返raw_vision_prompt+ prompts/save 写_vision.md+ reload, 免重启即时生效). prompt 从"烤进 extractor"改成存cfg.RawVisionPrompt、handler per-request 快照传入 (extractor 只当 transport), 这样 UI 编辑当场生效, 跟 sub/main/audit prompt 同路. per-requestvision_enabled/vision_provider/vision_model(vision_enabled默认开覆盖cfg.VisionEnabled, 同 audit_enabled 套路). 选非 minimax provider 回vision_unavailable明确提示, 不静默承诺没接的后端. UI 把embedded_image事件渲染成结构化临时加价 (省/网点/揽收-签收/delta/日期) 替掉 markdown 文本. - 接线 (
cmd/common): MINIMAX_TOKEN_PLAN_KEY 设了就注入复用 bill-recon 抽取器的 extractor (只 transport, 无烤 prompt);_vision.md经 LoadDefaultPromptsFromDir 进cfg.RawVisionPrompt(跟 _audit.md 同款可选加载 + UI 可编辑);VisionEnabled=true默认开. - 瞬态 EOF 有界 retry (handler): MiniMax vlm 端点单发无自带 retry, 生成最长的 image1 (33 行明细, ~78s) 间歇性生成中途
EOF掉链. per-image 加 3 次有界 retry + 2s backoff (父 ctx 取消即停, 不在自家关停上空耗); 实测 image1 attempt 1 EOF → attempt 2 救回完整 33 行. 常态首次即成, 3x180s 最坏只在每次都失败时命中 (罕见). - 验证靠对真值 (memory feedback_verify_against_truth): spike 对 ytosample 6 图真打 vlm + dump bundle 肉眼比对 — 揽收/签收 6/6 判对 (中转费→揽收/false, 派费→签收/true), image5 18 个网点代码全抽出, delta/重量段/日期准. 端到端真传部署路径 (curl POST → 实跑容器): 6 图结构化 bundle 请求开头并发实时流出 (不再 context-canceled); image1 EOF 经 retry 救回 33 行; UI JSX 经浏览器同款 @babel/standalone transpile 干净 (防白屏).
- 测试:
quotedispatch_vision_test.go改 bundle 形态 (best-effort 单图失败 + 无图 + 空图守卫); server + billrecon/llm 全量 -race 绿. - 范围 A 边界 + 未决 (PM 拍): 临时加价 merge 进最终报价 JSON (按揽收/签收时间基准 join, 同 bill-recon C7 难题) + 落库 embedded_images (现内存随 final 行出, 无 read-back 消费者故这轮不落库) = follow-up. 多页通知 (image5/6 跨页定价+日期) 逐图独立抽取拼不起, 已知限制. (UI
_vision.mdprompt 编辑本轮已做, 不再是 follow-up.)
platform/common — billcost xlsx 内嵌图通知解析 (VLM, 独立平行输出, ADR-0012 follow-up) (2026-05-31)¶
报价表 xlsx 里嵌的调价通知 / 加收费公告 (如 ytosample 内嵌 6 张 PNG: 内蒙古加收派费、上海发各省中转费重量段调整等) 此前被 excelize 整个漏掉 (只读单元格). 仿 bill-recon 图片解析迁过来, 但关键设计是独立输出: 内嵌图是调价公告、区别于基础报价表 (对齐 bill-recon C6 vision -> 独立 AdjustmentBundle cost_type=1), VLM 转录作为附加平行输出呈现, 绝不进 SHEET_DUMP / sub-main-验收 loop (否则污染基础抽取 + 误导验收 gate). 复用现成: core minimax.ExtractVision (billcost 已用同一 MINIMAX_TOKEN_PLAN_KEY) + archive/zip 提取.
- 提取 (
quotedispatch/embedded_images.go):ExtractEmbeddedImages(xlsxBytes)纯 archive/zip 读 xl/media/* (不用 excelize.GetPictures — 它要锚点; 接受 bytes 免临时文件; 不 import billrecon 保持中性).EmbeddedImage{Name, Data, MediaType}. - VLM 抽取 (
internal/server/quotedispatch_vision.go):VisionExtractor接口 (可配置 / 可注入 / 可 mock) +MinimaxVisionExtractor经 core vlm 端点. prompt 让 VLM 忠实转 markdown 文本 (不抽 billcost schema、不绑死), 转录非解读. per-image 90s timeout. - 集成 (handler):
Run之后提取内嵌图 -> 逐图 VLM -> 独立embedded_images字段进 final NDJSON + 流式embedded_image事件. best-effort (单图失败记 error 不致命; 无图 / 无 key 跳过).Run/ sub / main / 验收 / SHEET_DUMP 零改动. cmd/common 用 MINIMAX_TOKEN_PLAN_KEY 注入 (无 key -> 功能关; QUOTE_VISION_PROMPT 可覆盖 prompt). - UI (
index.html): 结果区流式显示每张内嵌图的 VLM 转录文本, 跟报价结果平行. - 测试:
ExtractEmbeddedImages(提取 / 无图 / 坏 zip / mediaType) +extractEmbeddedImageNotices(best-effort 单图失败不中断 + 事件流) +MinimaxVisionExtractor空图守卫. 全模块 -race 绿. - 未决 (PM 拍): 内嵌图调价数据将来是否 merge 进报价 (现独立); 是否落库 + 支持独立图片上传 (现只内嵌图、final 行出不落库). 详见 ADR-0012 follow-up.
platform/common — billcost main 跨圈进化轨迹记忆 + ADR-0012 进化机目的论归档 (2026-05-30)¶
2026-05-30 跨模型选型实证 (deepseek / gemma moe-a4b q6+bf16 / dense 31B; main↔sub 四格矩阵) 暴露两个范式级事实: (1) main 能力主导 (同一跑不动的 gemma sub 换强 deepseek main 即 8 轮收敛对真值; gemma-both 10 轮零收敛); (2) 收敛≠正确 (新表偏远 base_weight 抽成 3000 但原表明写"首重1公斤"=1000, main 说 ok + 反射器过仍错; 逐轮取证: R1 本对, R2 sub 把限 is_strip_fee 的 3kg 默认越界套到偏远). PM 澄清 dispatch loop 真目的: 产物是 main 进化出的可复用 sub prompt (按 仓库ID/快递类型ID/网点代码ID 三元组缓存), 不是这次抽取. 见 ADR-0012.
- ADR-0012 (
core/docs/adr/): billcost dispatch = sub-prompt 进化机; 按三元组缓存复用收敛 prompt; 收敛≠正确需真值晋升闸; 三层记忆模型 (圈内 turn 靠 prompt 累加 / 跨 round 加压缩轨迹 / 跨 run 靠缓存 prompt); round vs turn 词汇钉死. - §2.3 main 跨轮记忆 = 引擎 session 复用 (每轮全量 eval) + 修耗时 0s (
quotedispatch/dispatch.go): main 跨圈无状态 (每轮全新 session) 导致重复诊断 (实测"海南拆两行 ×3") + 震荡 + await 答复跨轮丢 (苏州被问两次). 初版手搓压缩轨迹 (roundSummary/buildTrajectoryBlock) 有效 (8->5 轮) 但 forget-by-default 漏 await 答复, 同日改用引擎原生 session 复用取代 (remember-by-default): main 跨所有轮 (含 await 重 eval) 复用一个 session, 历史天然带往轮 eval/verdict/await 问答 → 已答的不重问 + 没枚举到的也自动记. eval 每轮发全量 (含原表), 让 main 每轮对照原表核对 (初版曾为省 token 走 delta 删 round 2+ 原表, 削弱对照原表 cross-check, 同日撤回). 成本: main = deepseek 在线 1M 上下文装得下 + 长寿廉价 cache 吸收稳定历史前缀 (实测全 deepseek 2 轮 ds 账单 ¥0.05; 一天聚合 input cache hit 456K / miss 580K, output 占 ~74%), 比每轮 fresh session 更省. sub 不动 / prompt 不碰. 顺带修Run耗时永远 0s (未具名返回 + defer 坑 → 改具名返回 +TestRun_ElapsedPopulated回归).TestRun_MainSessionContinuity(验同一 session 复用 + 每轮全量). 全模块 -race 绿. 详见 ADR-0012 v2→v4. - ADR-0012 v5 设计深化 (审"main 进化提示词"维度后, 0 代码) + 诊断脚本
review-dispatch.py: 逐轮 dumpsub_prompt_useddiff (session 5f2a7145, 5 轮) 后 PM 拍定五条 - (1) 三元组写法对齐 PM 标准(仓库ID, 快递类型ID, 网点代码ID)(真正的修正是第三位 msnID=网点代码非商家; 中位 快递类型ID 原记录对); (2) 缓存存结构不存数值 (R5 prompt 过拟合病根: 把 base_weight=3000 + 全省价目清单腌进 prompt, 换表必灌错; 只存"按3kg首重"结构, 数值永远从新表现读); (3) 进化资产分三层 (通用规则上提基础 prompt / 三元组级结构性人答入缓存去数值 / 一次性草稿丢); (4) 复用判断硬闸 (三元组精确匹配) + 软确认 (main 回原表核对, 防"其余一致"盖章); (5) 复用验证必须双表 (改价不改结构 / 换结构换承运商, 测白问人 + 抽错两向). 附带修正: R4 标准段 bw 全 0→3000 回归根因是 main 自己写的过宽规则 "续重段=段起点" (3000=limit_bottom), 非 sub 越界; main 再加例外规则打补丁 = 打补丁非范式级修法 (早先误报"main 英雄抓回归", 据 prompt diff 修正为"自捅自堵"). 诊断脚本deploy/review-dispatch.py: 给 session-id (支持 UI 8 位前缀) 出 forensic - 逐轮字段演变标回归嫌疑 + sub_prompt_used diff + main verdict/提问, 审进化质量不判真值. 详见 ADR-0012 v5 + §2.5/§2.6/§5. - ADR-0012 v6 (§2.7): 收敛前冷眼验收 agent (硬约束, 已实现) — 防 main 记忆偏置盖章: bb34a117 (local gemma sub + deepseek main, 7 轮 "收敛" 实则 25 省标准段 base_weight 全错) 暴露 §2.3 跨轮记忆让能力够的 main 也因 "我办过了" 错觉停止复查 (R4 诊断过的错 R7 盖章放过); 软提示 (main_agent.md L136) 斗不过记忆偏置. 拆出独立第 4 个 agent (与 sub/反射器/main 并列), main 给 ok 时 dispatch 不直接收敛, 强制在零历史 session 重核最终 JSON 对原表 (同模型去记忆, 非换模型), 验收也 ok 才收敛, 验收挑出不符 -> 路由回 main 改 sub prompt 走现有 retry. 代码强制不可绕 = 硬约束. 配置:
AuditEnabled(默认开, 关=旧行为纯叠加无回归) + provider/model 默认=main + 独立prompts/_audit.md(不碰 main/sub). 可观察: 每次落库source="auditor"(verdict+不符项) + OnEvent + 日志, review-dispatch.py / UI 可见. 防死循环 (plan A):MaxAuditDisagreements=2 兜底, 超限靠 MaxRounds 终止为未收敛 (非假 ok); fail-open (验收 transport/parse 错不阻断已收敛 run). 改动:quotedispatch/dispatch.go(Request 7 audit 字段 + ok 分支 gate) +result.go(RoundTrace Audit*) +agentprompt.BuildAuditorFeedbackMessage+quotedispatchstore.SourceAuditor+ handler (persistRoundTraces auditor 行 + QuoteDispatchConfig.AuditEnabled/DefaultAuditPrompt + cmd/common QUOTE_AUDIT_ENABLED 默认开) + probe--audit*flag (顺带修 probe deepseekMainDisableJSONSchema). 4 gate 测试 + 1 persist 测试, 全模块 -race 绿. plan B (僵持 -> await_human) 是 v1.1. 详见 ADR-0012 §2.7 + §5.3 + v6.
platform/common — billcost await 人工答复结构化落库 (ADR-0012 §2.5) — 轮数调查 + 复用缓存的底料 (2026-05-31)¶
phase-1 await 的实际答复 (如"上海单带费按3kg首重") 此前只走 chan []string -> main session 历史, 没存成离散记录: (a) 无法对照 "PM 答了啥 vs Claude 答了啥" (上轮"轮数 2 vs 4-5 系统性还是随机"查不清); (b) ADR-0012 §2.5 缓存设计要的 "结构性人答" 无处存, 复用谈不上. 改成把每次 await 迭代的 (question, answer) 对落库, 跟该轮 main verdict 同行, 让事后一行读出 "操作员答 X -> main 据此判 Y".
- 数据结构 (
quotedispatch/result.go): 新HumanAnswerPair{Question, Answer};RoundTrace加HumanAnswers []HumanAnswerPair(仅 await 答复轮的 main-only trace 填, 自包含带 question 文本, 免回 join 上一轮 human_questions).buildHumanAnswerPairs按 index zip (同agentprompt.BuildMainAwaitAnswerMessage), 操作员答复逐字存 (留空存 "" 非 "(未作答)" 占位, 审计反映真实输入), 零问题返 nil 让列留 NULL. - 落库 (
quotedispatchstore+internal/db/pool.go):Round加HumanAnswers json.RawMessage;quote_dispatch_rounds加human_answers JSONB列 + 显式ALTER TABLE ... ADD COLUMN IF NOT EXISTSmigration (CREATE IF NOT EXISTS 不补已部署表的列, 见 pool.go L35 约定: 非破坏性 ADD 可直接进列表); PostgresStore SaveRound/ListRounds wire (镜像 main_verdict 的 typed-nil -> NULL); InMemoryStore 存整 struct 自动 round-trip. - 填充 (
quotedispatch/dispatch.go): await loop 两处 append main-only trace (parse-fail + 正常) 填HumanAnswers = buildHumanAnswerPairs(verdict.HumanQuestions, answers)本次迭代问答对 (同迭代配对 — 答的是本次verdict.HumanQuestions而非newVerdict的新问). - 持久化翻译 (
internal/serverpersistRoundTraces): main row 把tr.HumanAnswersmarshal 进human_answers列, 跟同行 main_verdict 并置. - 诊断脚本 (
deploy/review-dispatch.py): main/auditor verdict 段加A:行显示操作员答复 (question => answer), 跟同行 main verdict 对照看 PM-vs-Claude 答复差异. - 测试:
TestBuildHumanAnswerPairs(等长/短答/空答/nil 四态) +TestRun_AwaitMultiQuestion扩展 (端到端验三问答复 index-aligned 落 RoundTrace) +TestPersistRoundTraces_HumanAnswers(main row human_answers JSON round-trip). 全模块 -race 绿. 解锁: 轮数 2 vs 4-5 调查 (拿 PM 真实答复在 probe 复跑对比, TODO L716). 详见 ADR-0012 §2.5.
platform/common — billcost 冷眼验收 agent 可单独选 provider/model (跨模型验收 wire 完, ADR-0012 §2.7) (2026-05-31)¶
上轮 §2.7 验收 gate 落地时 handler 留了 auditProvider/auditModel/disableAuditGrammar 三个 struct 字段未 wire (后端 dispatch.Request.AuditProvider/AuditModel + dispatch.Run fallback main 早已就绪). 本次 wire 完: 验收默认跟 main 同模型 (只去跨轮记忆), 操作员可在 UI 单独选个不同 provider/model = 跨模型验收, 逃出 main 的共享盲区 (同模型可能同样看漏).
- 后端 (
quotedispatch_handler.go):parseDispatchMultipart解析audit_provider/audit_model(走跟 sub/main 同一ProviderRegistry), 未选留 nil/"" 让 dispatch.Run 回落 main;disableAuditGrammar与 main 解耦 — 按 AUDIT provider 判 (deepseek 验收即便 main 本地也关 json_schema).dispatchReq设AuditProvider/AuditModel/AuditDisableJSONSchema. - 前端 (
index.html): "冷眼验收" 区加 Provider 下拉 (首项 "跟随主代理" + availableProviders) + Model ID 输入 (留空=跟随 main), localStorage 记住, 非空才 appendaudit_provider/audit_model. - 测试:
TestParseDispatchMultipart_AuditProviderOverride(deepseek 验收 + main 本地 -> audit grammar 关 / main grammar 开 解耦; 不传则 nil + 跟随 main). 全模块 -race 绿. 详见 ADR-0012 §2.7.
platform/common — billcost 结构化多问 await (选择题 UI): main 一次列出所有 ambiguity + 一次答 + 一次重 eval (ADR-0008 v3.5) (2026-05-28)¶
PM 看 await 过程发现可观测性价值: main 一次 cross-check 就知道多个 ambiguity (多个区首重原表都没明示), 但 v3.4 代码逼它一问一答一重 eval (Verdict.HumanQuestion 单字符串 + await inner loop 每答一个重 eval 一次). N 个 ambiguity = main 重跑 N 次 (N 倍 token) + 打断 PM N 次. 改成 Claude Code AskUserQuestion 式选择题: main 一次抛 N 问各带候选 options, 操作员一次答完, main 一次重 eval. 详见 ADR-0008 v3.5.
- 协议 (
agentprompt): 新HumanQuestion{Question, Options}类型;Verdict.HumanQuestion string->HumanQuestions []HumanQuestion;ParseVerdictawait 校验改为len(HumanQuestions)>0且每项 question 非空;BuildMainAwaitAnswerMessage拼多组 Q&A 按编号对应 (空答 surface 不静默丢). - dispatch (
quotedispatch):verdictSchemahuman_question->human_questions数组[{question, options}]; await inner loop 一次Ask所有问 -> 收所有答 -> main 重 eval 一次 (inner loop 保留, 重 eval 后又出新 ambiguity 再问一轮).HumanInputProvider.Ask(ctx, []HumanQuestion) ([]string, error)slice 契约 (答数=问数, 按位对应, 空串=跳过), 4 实现全改. - handler (
internal/server): server channel providerchan string->chan []string, SSEawait_humanevent 带questions数组; answer 端点quoteDispatchAnswerRequest加Answers []string;handleQuoteDispatchAnswer按 channel 类型分派 (chan string = phase 0 选 sheet / chan []string = phase 1 多问). phase 0 路径不动 (最小爆炸半径). swagger 重生. - UI (
index.html):AwaitQuestionsPanel组件 — AskUserQuestion 式卡片 (每问 question + options chip 单选 + "其他" 自定义输入), 一次提交所有答 (POST{session_id, answers}); phase 0 仍走 phase0Choice 三按钮. - prompt (
main_agent.md): wire-contract 同步 (human_question->human_questions数组 + 格式块 + "一次只问一个" -> "一次列出所有 ambiguity 各带 options"). 深层判断 (问多少 / 主表首重是否 await) 留 PM 调. - 测试: agentprompt (多问 ParseVerdict + BuildMainAwaitAnswerMessage 配对) + quotedispatch (
TestRun_AwaitMultiQuestion: 一次 Ask N 问 + 一次重 eval + provider slice 多问) + handler (TestQuoteDispatchAnswer_MultiQuestion_Accepted_200). 全模块 -race 绿. 真验证待 staging 实跑 (需 deploy + prompt 同步, PM 本轮 deferred deploy).
platform/common — billcost prompt 单一源 + UI 保存持久化 + sub/main 通用化 + 去 per-request override (2026-05-28)¶
测试台 prompt 之前两条路并存: server .env 默认 + 浏览器 multipart per-request override, 二者易 diverge (UI 改了但没存, 下次刷新丢, 或线上跑的跟 UI 显示的不是一份). 收成单一源: prompt 只从 server 落盘文件读, UI 可编辑并 "保存为默认" 持久化, 去掉 per-request 临时 override. 同时把 sub/main prompt 里的物流硬编码 (ytosample 城市 / 数值 / 单 sheet 名) 通用化, 抽出可复用的建模约定片段单一源.
- prompt 通用化 (
prompts/sub_agent.md/main_agent.md): 去 "Sheet1" / 城市名 / 具体数值, await 例子 / human_question / 硬边界反例换成通用表述 (反例不再量化绑定 ytosample). 进化版 (*.evolved.md) 删除 (复位迁移前精简版作单一基线). - 单一源约定片段 (
prompts/_modeling_conventions.md新建 +quotedispatch/promptinclude.go): 建模约定 (续重分段 / 调价成本 await 等) 抽成一个片段文件, sub/main prompt 用{{MODELING_CONVENTIONS}}占位符引用, 加载期InlineModelingConventionssplice 进去 -- 一处改, 两个 agent 同步生效. - 去 per-request prompt override (
quotedispatch/dispatch.go+quotedispatch_handler.go):parseDispatchMultipart不再读 formsub_prompt/main_prompt/phase0_prompt, 改用cfg.Default*RLock 快照. swagger@Param去掉这三个 (顺带去掉早已不读的sheet-- phase 0 自动检测). - UI 保存持久化 (
quotedispatch_handler.go+server.go+index.html):QuoteDispatchConfig加RawSubPrompt/RawMainPrompt/ModelingConventions/PromptsDir+promptMu;LoadDefault*锁内存 raw + dir; defaults 端点返 raw 三份 +prompts_writable. 新POST /api/v1/billcost/dispatch/prompts/save(备份.backup/<ts>/-> 写三文件 -> reload re-splice, 免重启生效). UI prompt 三区编辑 (sub 主体 / main 主体 / 约定片段) + "保存为默认" 按钮. - 测试:
quotedispatch_handler_test.goMissingSubPrompt->NoDefaultSubPrompt(去 override 后语义), 加PromptsSave+LoadDefaultinline splice 测试;promptinclude_test.go新建. swagger 重生 (去三@Param+ 加 save 端点).
platform/common — billcost dispatch 测试台 per-request provider 选择 (本地 gemma vs deepseek 直连) (2026-05-28)¶
对比 gemma 与 deepseek 抽取需要切 provider, 之前 server 单 provider (FLYTO_LLM_PROVIDER env 全局选, sub/main 共用一实例). 加 provider registry 让浏览器测试台按请求切, 无需重启:
- 后端 (
main.go): 除 FLYTO_LLM_PROVIDER 选的localprovider,DEEPSEEK_API_KEY设了再用core/pkg/providers/deepseek构造deepseek(直连api.deepseek.comOpenAI 兼容), 两者放进QuoteDispatchConfig.ProviderRegistry. - handler (
quotedispatch_handler.go):ProviderRegistry字段 (additive);parseDispatchMultipart接 multipartsub_provider/main_provider名从 registry 选, 空 / 未知名回落cfg.SubProvider/MainProvider(向后兼容); 请求路径 (phase0 + dispatch spec) 用选中 provider; defaults 端点返回providers列表. - UI (
index.html): sub/main 各加 provider 下拉 (本地 gemma / deepseek 官方, 从 defaultsproviders渲染), 选 provider 自动设默认 model (gemma4-moe-26b-a4b-q6 / deepseek-v4-flash),localStorage持久化, multipart 带sub_provider/main_provider. - 新增
TestParseDispatchMultipart_ProviderOverride(registry 命中 / 回落 / 未知名回落). m2max staging 部署验证 defaults 返[local, deepseek].
platform/common — gemma JSON-grammar 假收敛诊断 + 重 eval trace + extractJSON 待修 (2026-05-28)¶
ADR-0008 v3.4 重开 verdictSchema / phase0Schema (omlx/xgrammar grammar 兼容已修, phase0 实测 12s 不再死循环). 但 gemma 在 grammar 下出完一个完整 JSON 对象后不发 stop token, 重复整个对象 (xgrammar+Gemma stop-token bug, 已报上游): main 重 eval 时吐两份相同 verdict, extractJSON 取 "第一个{到最后一个}" 把两份全包 -> invalid character '{' after top-level value -> ParseVerdict 失败 -> dispatch.go:703 "parse 失败当 ok 收" 假收敛, 吞掉 await 答复后的真实 verdict.
- 诊断: 重 eval parse 失败时 append 一条 main trace 含原始输出 (对称首次 eval), 让 db rounds 看得到 gemma 重复原文; UI
final显示last_verdict_reason, "假收敛"不再伪装成"已收敛". - 待修 (follow-up):
extractJSON(agentprompt + postprocess + dispatch sub 清理三处) 改成括号配对取第一个完整 JSON 对象, 客户端容错 gemma 重复输出, 不依赖 omlx 修 stop token.
platform/common — billcost dispatch 测试台 per-request model 可控 + UI 读 server 真值 (2026-05-28)¶
PM 实测发现测试台 (/billcost-test/) model 选择框是摆设: 后端 handler 只读 server .env (QUOTE_SUB_MODEL / QUOTE_MAIN_MODEL), 完全不接 per-request model, UI 显示的 gemma4-e4b 硬编码默认从不生效 (实际跑的是 .env 的 26B), 且刷新页面把手选 model 重置回硬编码默认. 改成 UI 真能控制 model 并持久化:
- 后端 (
quotedispatch_handler.go):parseDispatchMultipart读 multipartsub_model/main_model, 非空 override, 空回落cfg(.env), 解析进dispatchRequestParams.subModel/mainModel; handler 请求路径 (session store / started 事件 / phase0 / dispatch spec) 全改用params.subModel/mainModel, 不再直接读cfg.SubModel/MainModel(单解析点防 fallback 漂移; 不 mutate 共享 cfg). swagger@Param补 sub_model / main_model. - defaults 端点: 除三 prompt 外也返
sub_model/main_model(cfg 真值, 来自 .env), 让 UI 显示真实 model 而非硬编码猜测. - UI (
index.html): multipart 带 sub_model / main_model; model 选择存localStorage刷新恢复 (个人偏好该粘住), 没存过则显示 server 真值; 首次默认gemma4-e4b->gemma4-moe-26b-a4b-q6; 删 "model 不生效" 警告. prompt 仍 server 无条件真源不持久化 -- model 与 prompt 区别对待 (model 用户存了粘住, prompt server 永远赢, 修历史 "prompts diverged" 隐患). - 新增
TestParseDispatchMultipart_ModelOverride(override / fallback 两路). m2max staging cross-compile 重部署实测: started 事件精确回显 per-request model.
core — Dream + memory 服务端 per-scope 化 + 3 个 pre-existing bug 修 (2026-05-28)¶
新消费者 (微信客服: 一仓几百个客户微信群, 每群一独立 scope) 要 per-scope 记忆 + Dream 巩固. 引擎原 Dream + memory 是单用户单 FS (~/.flyto/), 服务端被迫 DisableDream (单一全局 FS 多 scope 跨 scope 污染 + 全局 flock 冲突). 加 additive seam 让引擎按 scope 隔离, 零破坏 CLI. 详见 ADR-0011 (core/docs/adr/).
Dream per-scope seam (ca42e45):
engine.Config加ScopeRoot(单锚点路径派生<ScopeRoot>/memory+dream.lock+dream_state.json) +SessionProvider(透传 DreamConfig, 服务端注入 DB-backed provider).buildMemoryScopeRoot 非空短路走<ScopeRoot>/memory保留 memOpts;buildDreamEngineplumb scope 路径 + SessionProvider;DreamConfig加LockPath/StatePath(空走~/.flytofallback) + per-scope 目录 MkdirAll.memory加NewFileStoreWithBaseDirAndOptions(解 baseDir + options 组合, 原两个构造各缺一半).- 全 additive optional, ScopeRoot 空 = 逐字节现行为, CLI 不破. 微信专属 wiring (DBSessionProvider / 几百群中央调度器 / scope_id 数据模型) 是消费者项目的事, 不进引擎 (中性化铁律).
- 新增
TestEngine_ScopeRoot_IsolatesMemoryAndDream(两 scope 写 disjoint 目录).
顺带修 3 个被 emitCheckpointSuggested panic 长期掩盖的 pre-existing bug (该 panic 一直 crash 整个 engine 测试套件, 掩盖其后所有测试):
- checkpoint panic (6390517):
newTestEngine()只设 observer 不设 cfg,emitCheckpointSuggested读e.cfg.DisableHardcodedCheckpointnil-deref. 修测试补cfg: &Config{}(生产 engine.New 总设 cfg, 不在生产加防御). - plan_queue 字典序 (5c86f0f): plan ID 后缀原 8 位随机 hex, 同纳秒内不保证字典序. 改进程级单调 atomic counter (
%08x). - Agent Teams race (c9d1698): Agent Teams 并发 spawn sub-agent 共享 parent Config 并发调 ModelRegistry(),
Config.Models字段 lazy-init 字段层裸奔 data race. 加modelsMu sync.Mutex护字段 (registry 内部已有 RWMutex; mutex 语义不变 SetRole 每次跑; go vet copylocks clean).
engine 套件现 -race 彻底全绿 (此前一跑就被 panic crash).
core — module path 子模块化 flyto-agent -> flyto-agent/core, 支持外部 go get (2026-05-28)¶
新消费者 (内部同 team, Codex 开发) 接入引擎. 原 module path git.flytoex.net/yuanwei/flyto-agent 在 Flyto-Agent 仓库的 core/ 子目录, 跟 repo path 对不上, 外部 go get 不通 (现有消费者 FlySafe / platform / tui 全靠 go.work + replace 本地联合绕过). 改 module path 为 git.flytoex.net/yuanwei/flyto-agent/core (Go 官方 multi-module monorepo 子目录形态): Gitea 大小写不敏感解析到 repo, go-import meta 实测对子路径 /core?go-get=1 返回正确 import-prefix flyto-agent, 外部可 go get .../flyto-agent/core@core/vX.
改动:
core/go.modmodule 行 + 288 个 .go import path 批量改 (flyto-agent/->flyto-agent/core/, 一次性脚本 sed 前缀替换, 只碰带域名+尾斜杠的标准 import, 不碰 error message 前缀 / MCP client name / 业务字符串 / 注释短形式).platform/common+tuigo.mod require + replace 跟改 (module path 变, replace 本地路径不变).go.work不动 (use 相对路径不含 module path).- 14 个 .md 文档 + README 模块路径表 + godoc pkgsite URL 同步.
consumer-onboarding.md翻转: go get 主推 (GOPRIVATE + core/vX tag), submodule 降为"改引擎源码调试 / 离线"备选.
tag scheme: go module 版本 tag 带 core/ 前缀 (core/vX.Y.Z), 跟仓库部署 tag (vX.Y.Z 触发 release.yml build+deploy) 是两套独立序列, 共存不冲突, 不改 release.yml.
验证: 三 module (core / platform/common / tui) go build + go test 全绿. core 2 个 fail (checkpoint nil deref + macOS /private/var symlink) git stash 对照确认是 pre-existing 环境问题非 rename 引起; quotedispatch 2 个 JSONSchema fail 是 v3.3 禁 wire 已知遗留.
followup:
- cut
core/vXgo module tag + 实测外部 go get 拉通. - FlySafe (跨仓, LA 机器) require + replace + import 跟改 (PM 协调).
- ADR 记录 module 子模块化决策 (8 节体例).
common — quote-dispatch structured output via engine.Config.JSONSchema (2026-05-27, ADR-0008 v3.3, 1 commit)¶
phase 0 IdentifySheet + phase 1 main eval 都跑 BuildMainEngine 但没用引擎层已有的 engine.Config.JSONSchema 强制 JSON 输出. m2max staging 实测 Gemma 4 26B 自由输出含 <|channel>thought\n...<channel|>{json}<turn|> 内置 reasoning channel marker, parse 失败. v3.2 兜底用 first-brace-to-last-brace 提取最外层 JSON object — work 但是补丁. 范式级修法是走 provider 层 structured output: 引擎层 engine.go:4465-4469 已经把非 nil engine.Config.JSONSchema 自动包成 flyto.ResponseFormat json_schema 类型透传到 provider Stream, OpenAI 兼容 + DeepSeek 原生支持, Anthropic 走 tool_use 强制 (跨 provider 差异引擎层吃).
协议层:
quotedispatch.EngineSpec新字段JSONSchema json.RawMessage. 非 nil 时 BuildMainEngine 透传到 engine.Config.JSONSchema, 引擎层接 flyto.ResponseFormat json_schema 到 provider Stream.- 新包级 var
phase0Schema(phase 0 Phase0Result 形态: selected_sheet + reasoning + candidate_sheets, additionalProperties=false 防 LLM 多塞字段). - 新包级 var
verdictSchema(phase 1 agentprompt.Verdict 形态: verdict enum [ok | retry | await_human_input] + reason + feedback_to_sub_agent + new_sub_agent_prompt + human_question, 字段名以 Verdict struct tags 为单一真源).
实现层:
quotedispatch/engine_factory.go: EngineSpec 加 JSONSchema 字段. BuildMainEngine 把 spec.JSONSchema 透传 engine.Config.JSONSchema. 新包级 seammainEngineBuilder = BuildMainEngine让生产调用方 (phase0.IdentifySheet + dispatch.Run) 经此 seam, 测试可拦截捕 EngineSpec — 跟 defaultEngineRunner 同款套路.quotedispatch/phase0.go: 包级 var phase0Schema. IdentifySheet 调 mainEngineBuilder 时传 phase0Schema.quotedispatch/dispatch.go: 包级 var verdictSchema. Run 调 mainEngineBuilder 构造 main engine 时传 verdictSchema.- BuildSubEngine 不动 (sub 输出含 details 数组 + master.raw_extraction 任意 key-value, json_schema 表达麻烦, 留 follow-up).
测试:
quotedispatch/phase0_test.go加 TestIdentifySheet_WiresJSONSchema_ADR0008v33 + withCapturedMainEngineBuilder helper. 验 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) 对齐 agentprompt.ParseVerdict.
v3.3 跟 v3.2 关系: 严格叠加. v3.2 brace-extract fallback 保留 (provider 不支持 json_schema 时仍兜底), structured output 是上游修法.
不在本 commit (followup):
- BuildSubEngine 加 JSONSchema — sub 输出 schema 复杂含 details 数组 + master.raw_extraction 任意 key-value, JSON Schema 7 表达麻烦, 留 follow-up.
- m2max staging 实证 Gemma 4 26B 跑通无 channel marker 噪声 — PM 在测.
测试 -race 全绿: quotedispatch +2 case, 全 platform/common 通过.
common — quote-dispatch round-level 持久化 (2026-05-27, ADR-0008 v2.6.1, 1 commit)¶
v2.6 db-backed session 只存 phase_0_result + phase_1_result final, 每轮 sub 输出 / main verdict 重写 (含 retry+new_prompt 路径 main 给 sub 的新 system prompt) / 反射器 PASS-Fail 序列全没落库. m2max staging 实测 4 轮 18m29s 收敛后没办法事后审计 "main 是否塞目标值给 sub 作弊" / "反射器在某轮真跑了吗". PM 拍定补 round-level 持久化, 在 v2.6 之上叠加层不替换.
协议层:
- 新表
quote_dispatch_rounds(DDL 加internal/db/pool.goplatformMigrations append-only): id BIGSERIAL / session_id FK 到 quote_dispatch_sessions ON DELETE CASCADE / round_num / source (sub|main|reflector) / created_at / text_output / sub_prompt_used / main_verdict JSONB / reflector_pass BOOL / reflector_data JSONB / input_tokens / output_tokens / cost_usd / elapsed_ms. Index on (session_id, round_num). - 行布局按 source 分: sub 行填 text_output + sub_prompt_used (本轮完整渲染后 sub system prompt — 审计 main 重写 sub prompt 是否塞目标值的铁证) + 单 sub Send 的 tokens / cost / elapsed. main 行填 text_output (主 agent 原始 text) + main_verdict (parse 后的 verdict JSON 含 new_sub_agent_prompt) + 单 main Send 的 tokens / cost / elapsed. reflector 行填 text_output (Reason) + reflector_pass + reflector_data (block_count / max_blocks / turn / validator_name 结构化). 反射器单 sub turn 可 fire 多次 (block -> block -> pass), 单 round 多 reflector 行是审计信号 "模型自纠后通过".
实现层:
quotedispatch/result.go: 新RoundTrace+ReflectorTrace导出类型 (退役未用的内部roundTrace). Result 加RoundTraces []RoundTrace. RoundTrace 含 Round / SubPromptUsed (本轮 fully rendered) / SubText / SubDone / SubElapsed / MainText / MainVerdict (*agentprompt.Verdict 指针, 解析失败为 nil) / MainDone / MainElapsed / Reflectors []ReflectorTrace. ReflectorTrace 镜像 ResponseValidatedEvent 字段.quotedispatch/dispatch.go: Run 在每轮入口 snapshot 当前渲染后 sub system prompt (currentSubTmpl经 SheetDumpPlaceholder 替换) 进 trace.SubPromptUsed, 包装调用方 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 wire QuoteResponseReflector), 故 main Send 用原 OnEvent 不包装.quotedispatchstore/store.go: 新Source枚举 (sub / main / reflector) + 新Roundstruct + SessionStore interface 加SaveRound(ctx, sessionID, Round) error. 未知 sessionID 返 ErrSessionNotFound, Postgres 外键失败也映射到此 sentinel 让两实现行为一致.quotedispatchstore/inmemory.go: InMemoryStore 加 rounds map + SaveRound 实现 + 测试 onlyRounds(sessionID)返副本 helper (并发安全).quotedispatchstore/postgres.go: PostgresStore.SaveRound INSERT 单 SQL, 预检父行存在 (映射 ErrSessionNotFound). 空 / 零字段转 typed-nil 落 NULL.internal/server/quotedispatch_handler.go: handler 在 quotedispatch.Run 返回后 (任何结果 — converged / max-rounds / error) 调新 helperpersistRoundTraceswalk Result.RoundTraces 按源每行调 store.SaveRound. 用 context.Background 让 streaming 中 client 断不取消审计写. 单行 insert 错 log 跳过不让 agent 已收敛请求失败. 工程量分布: dispatch package + store 80% 工作, handler 改动只是 walk + dispatch call site 加 helper 调用.
测试:
quotedispatch/dispatch_test.go加 3 用例: TestRun_RoundTraces_HappyPath (2 轮 retry-then-ok, 验 SubPromptUsed 完整渲染 + 不含 placeholder + MainVerdict 解析后字段 + DoneEvent tokens roundtrip), TestRun_RoundTraces_ReflectorEventsCaptured (注入 block-then-pass ResponseValidatedEvent, 验 Reflectors 切片填齐), TestRun_RoundTraces_SubAbortStillProducesTrace (合成 sub Send 错, 验 trace 仍 append + SubPromptUsed 在 abort 时也已 snapshot).quotedispatchstore/inmemory_test.go加 TestInMemoryStore_SaveRound: 未知 session 返 ErrSessionNotFound / 三源 (sub / main / reflector x2) round-trip 字段 / 调用序保持 / Rounds() 返防御副本 / 空 session 返 nil.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).
不在本 commit (followup):
- PostgresStore SaveRound 集成测试 — 跟 L909-E 同条路径, 留 dockertest. 现在 SaveRound 实现是 SQL INSERT + 预检, 概念上跟 sessionstore postgres 路径同款, 行为通过 InMemory 单测 + 手工 staging 验证 (PM 实测后 docker logs grep).
- UI 渲染 round 审计行 — 单独 endpoint (GET /api/v1/billcost/dispatch/{id}/rounds 或扩 GET /api/v1/billcost/dispatch/{id} 返 rounds[]) P2 工作.
反向论证:
- handler 实时 per-event flush 还是 batch-at-end? Batch. 实时落库要 quotedispatch package import quotedispatchstore, 破坏当前 quotedispatch 跟持久化解耦 (引擎中性化精神延续). 代价: dispatch 中途 server 崩溃丢未 flush 的 round (db state 干净, 审计串丢 1-2 行); 收益: quotedispatch 包持续可独立测试不依赖 db. 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 行该单存还是不存 main 无解析 verdict 的 round? 存. 即使 verdict parse 失败 (probe-compat ok-and-exit 路径) main 也跑了真 LLM call 有 tokens / cost, 不存这行漏算成本审计. MainVerdict 为 nil 仍走 main 行, main_verdict 列落 NULL.
测试 -race 全绿: quotedispatch +3 / quotedispatchstore +1 / internal/server +2 case, 全 platform/common 通过.
common + deploy — quote-dispatch phase 0 sheet 识别 + db-backed session (2026-05-27, ADR-0008 v2.6, 1 commit)¶
PM 2026-05-27 m2max staging /billcost-test 测试现场戳穿: dispatch 直接吃 Sheet1 不识别, 这是迁移时把 probe 实验工具的 hardcoded Sheet1 协议照搬到 SaaS endpoint, 没加适配多样真客户 xlsx 的能力 — 范式漏不是补丁. 同时拍 "session 不能光存在内存". v2.6 协议引入 phase 0 (sheet 识别) + db-backed session.
范围: 只到 xlsx → 结构化报价 JSON. 下游 (业务规则反射 / 账单 / 对账) 不碰. parser/excel.go 的 hardcode 本期暂不动.
实施:
- 新包
quotedispatchstore:SessionStoreinterface (7 法) +InMemoryStore+PostgresStore. 新表quote_dispatch_sessionsDDL 加internal/db/pool.goplatformMigrations 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. status 枚举 6 态 (phase_0_running / awaiting_human / phase_1_running / done / aborted / error_interrupted). - quotedispatch package 新增 phase 0 路径:
sheet_overview.go(DumpSheetsOverview+SheetsOverview/SheetOverview类型, 默认 preview 10 行);phase0.go(IdentifySheet+Phase0Result/IdentifySheetRequest, 用 BuildMainEngine 跑单次 Send 输出严格 JSON, 复用 defaultEngineRunner seam).cmd/quote-engine-probe/prompts/phase0_sheet_identify.md新 prompt 文件. 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 落库. - GET
/api/v1/billcost/dispatch/{id}: 新 endpoint 返 session 现状 (xlsx_blob 不返). UI 关浏览器后看 session 用. - 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 渲染三按钮 (✓ 确认 / 改选 dropdown / ✕ 取消). 文本框保留给高级 user.
测试:
quotedispatch/sheet_overview_test.go4 用例 +quotedispatch/phase0_test.go6 用例 (共享 dispatch_test.go stubProvider, swap defaultEngineRunner) +quotedispatchstore/inmemory_test.go5 用例. PostgresStore 测试跳过 (跟 sessionstore postgres_test.go 一样需要 dockertest, 留 follow-up).
不在本节内 (followup, 见 core/TODO.md):
- 长连接断后 phase 1 resume — 当前 channel 丢 (server 重启 / 浏览器关) 时 POST /answer 返 409. db 状态干净, 未来 resume endpoint 可秒接 phase 1 不重跑 phase 0.
- parent_session_id 跨 session 复用 — 协议字段已预想, 加下游 (业务规则反射 / 账单 / 对账) 时实施.
- parser/excel.go
quoteSheet = "Sheet1"解硬编码 — 下游 bill-recon 用, PM "暂时只到报价解析" 不动.
common — quote-dispatch HumanInputProvider 真接通 + POST /answer 端点 (2026-05-15, ADR-0008 v2.4 L907-B, 1 commit)¶
L907-A 流式响应已实证经公网 labtest.flytoex.net 8s 流出 131 行 NDJSON CF tunnel 永不撤, 但 verdict=await_human_input 路径仍 emit type=await_unavailable (HumanInputProvider 还是 UnavailableProvider stub). L907-B 接通让多轮人机协作在浏览器跑得通.
改文件 platform/common/internal/server/server.go:
- Server struct 加
quoteAwaitSessions sync.Map字段 (session_id → chan string). 用 sync.Map (不是 plain map + mutex) 因 dispatch endpoint 与 answer endpoint 不同 goroutine 并发触达. - 路由加
POST /api/v1/billcost/dispatch/answer → s.handleQuoteDispatchAnswer.
改文件 platform/common/internal/server/quotedispatch_handler.go:
- import
github.com/google/uuid. - dispatch handler 进 streaming 时
uuid.NewString()生成 session_id, 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":...}NDJSON 行后 select 在answerCh <-/ctx.Done()两路 block. - 新 handler
handleQuoteDispatchAnswer: cfg short-circuit 503 / JSON decode 400 / session_id 空 400 / session 不存在 404 / channel 槽已占重复 POST 409 / 成功 200{"status":"accepted"}. 非阻塞 channel send 防 handler 自己 deadlock. quoteDispatchAnswerRequestJSON body 类型 (session_id + answer 字段).
改文件 platform/common/internal/server/quotedispatch_handler_test.go:
5 个新测试 lock-in answer endpoint corner case + happy path:
- 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, e2e 流由生产 + 真 LLM 覆盖.
完整端到端流程 (浏览器 / curl 等价):
- POST
/api/v1/billcost/dispatch(multipart xlsx) → streaming NDJSON response. - Client 读
started行记下session_id. - Client 读
engine_event行看实时进度 (TurnStart / ThinkingDelta / ToolUse / etc). - 若命中 verdict=await_human_input: dispatch 端点 emit
await_human行带 session_id + question, 然后 block. - Client 提示用户答, 用户答完后 client 起另一个 HTTP 连接 POST
/api/v1/billcost/dispatch/answerbody{"session_id":...,"answer":"..."}→ 200 accepted. - 服务端 channel 收到 answer, dispatch.Ask 返, dispatch loop 拿到答继续下一 round.
- 直到 verdict=ok / MaxRounds → emit
final行 含 final_json + cost + tokens / 或多次 await 反复 4-6.
后续 (不在本 commit):
- 浏览器 frontend 加上传 xlsx 入口 + fetch streaming UI +
await_human行 prompt 用户答 → POST /answer — P2 UI 设计师入职后做表现层. - 多轮 await 同一 session 时序鲁棒性 — channel cap=1, defer 整体删, 单一 dispatch 内多次 await 在第一次 receive 后槽空出工作; 若发现 race 触发 v2.5 重写为 per-await channel 列表 (rule of two — 第二次需求真出现).
测试 -race 全 10 case 绿 (5 dispatch + 5 answer): 2.814s.
common — quote-dispatch endpoint 流式 NDJSON 响应 — 解 CF tunnel 100s "origin 沉默" 撤 (2026-05-15, ADR-0008 v2.4 L907-A, 1 commit)¶
v2.3 r2 实证 12m25s 同步 dispatch 在 m2max LAN 内部跑通, 但走公网 CF tunnel 必撤 (r1 deepseek baseline 实证 HTTP 524 + 125s timeout). PM 戳穿: 不用 async + task_id + GET poll 4 commit 重 P2 真活, dispatch handler 改 chunked streaming response (每 engine event flush 一行 NDJSON) 让 CF tunnel 持续看到字节就永不撤, 1 commit. SSE 能跑 hours 是同原理.
改文件 platform/common/internal/server/quotedispatch_handler.go:
- handler 预检 (cfg short-circuit / multipart parse / xlsx open / cwd 失败) 仍返单次 JSON 4xx/503/500. 预检过后切 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 一直用) 在 server 端首次启用 — handler 设回调 emit{"type":"engine_event","round":N,"source":"sub"|"main","event_type":"*engine.TextEvent"|...}每个 engine event 一行 NDJSON, byte 不停 CF idle 计时器一直被喂.quotedispatch.Runblock 跑完后按结果 flush 终结行:type=final(verdict=ok 或 MaxRounds 用尽, 含 final_json + cost + tokens + elapsed) /type=await_unavailable(verdict=await_human_input 但 HumanInputProvider stub) /type=error(transport / engine 错). HTTP status 全 200, client 通过终结行type判别.QuoteDispatchResponse/QuoteDispatchAwaitResponsestruct 保留导出 — godoc 重定义为 "type=final / type=await_unavailable NDJSON 终结行 schema mirror" 给 SDK 消费者用.- swagger description 更新:
@Produce application/x-ndjson+ 完整 streaming 行格式说明 (terminal-line type discriminator).
改文件 platform/common/internal/server/quotedispatch_handler_test.go:
TestQuoteDispatch_ConfigWired_DispatchPathReached→ renameTestQuoteDispatch_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).- 删 unused
encoding/jsonimport.
反向论证 (要点): SSE 形态绑 EventSource API + GET, dispatch 必须 POST + multipart, NDJSON 不强制特定 client API. OnEvent 频率已经够喂 CF idle 计时器, 不加单独 heartbeat ticker. OnEvent 在 Run goroutine 内 fire 跟 handler 主 flow 同 goroutine, emit 无锁.
v2.4 不在本节内 (L907-B 后续):
- HumanInputProvider 真接通 (SSE 推 question + POST
/api/v1/billcost/dispatch/answer收答) — Ask 接口形态没变, 实装新 SessionHumanInputProvider impl + 配套 POST endpoint + session-keyed channel store. - 浏览器 frontend 加上传 xlsx 入口 + fetch streaming UI — P2 UI 设计师入职后做表现层 (后端协议在此 commit 已就绪).
测试 -race 全 5 case 绿 (含新加 streaming 形态 lock-in 0.32s).
common + deploy — quote-dispatch handler 解硬编码 deepseek + Gemma 4 ADR-0007 capability 接通 + m2max staging Gemma 4 跨 model 实证 (2026-05-15, ADR-0008 v2.3, 3 commit)¶
PM 5-15 拍板把 m2max staging quote-dispatch 路径切到 m5max oMLX 跑 Gemma 4. 顺便戳穿 handler 之前 import pkg/providers/deepseek + per-request deepseek.New() 是 5-2 "反射器强制本应是引擎层契约保证不是消费者每次自觉" 的同源问题, 必须走 ADR-0007 capability fallback. 跟 m5max CF tunnel 公网入口 (m5max.flytoex.net) 一起落地, 应用层 API key 鉴权天然就位.
改文件 (Go):
platform/common/internal/server/quotedispatch_handler.go— 删import .../providers/deepseek, 加pkg/flyto.QuoteDispatchConfig字段重设: 删DeepSeekAPIKey, 加SubProvider/MainProvider flyto.ModelProvider+SubModel/MainModel改必填 (无 hardcoded fallback "deepseek-v4-pro/flash"). handler short-circuit 改成 cfg nil 或 4 字段任一缺失 → 503 + 诊断 (指引 deployer 配 FLYTO_LLM_PROVIDER + QUOTE_SUB_MODEL + QUOTE_MAIN_MODEL). godoc 顶部 + swagger description 同步去 deepseek 字面.platform/common/internal/server/quotedispatch_handler_test.go— 加fakeQuoteProviderno-op stub (满足 flyto.ModelProvider 接口让 short-circuit 测试 cfg 字段非空过 nil-check). 4 测试用例DeepSeekAPIKey: "fake-key"→SubProvider/MainProvider: fakeQuoteProvider{}+SubModel/MainModel: "fake-*-model".platform/common/cmd/common/main.go— 删--deepseek-api-keyflag + DEEPSEEK_API_KEY env fallback (handler 不再读).quoteCfg注入复用 SSE 路径FLYTO_LLM_PROVIDER选定的provider实例 +QUOTE_SUB_MODEL/QUOTE_MAIN_MODELenv 读 model id. 末尾加 helperregisterQuoteDispatchModels(reg *config.ModelRegistry)显式 Register 4 条 ModelInfo (gemma4-moe-26b-a4b-q6 / gemma4-e4b / deepseek-v4-pro / deepseek-v4-flash), 含 ADR-0007 capability 字段 (ContextWindow 131072 / 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 静态表但不覆盖显式 entry.
改文件 (deploy):
deploy/docker-compose.local.ymlcommon 段 — environment 加QUOTE_SUB_MODEL+QUOTE_MAIN_MODELenv, 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(不入 git) — 同步配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. Provider 仍FLYTO_LLM_PROVIDER=openai+OPENAI_BASE_URL=http://10.0.0.1:8000(LAN 0.5ms 路径不动 — m5max.flytoex.net CF tunnel 给 LAN 外消费者用).
m5max CF tunnel 公网入口 (独立部署, 不入 ccm git):
- m5max (Darwin/arm64, 上海) 走 brew install cloudflared + scp cert.pem from m2max 复用同 CF account +
cloudflared tunnel create m5max拿 UUIDd18face6-eee1-47b9-a331-6557514dfaa8+ config.yml ingressm5max.flytoex.net → http://localhost:8000+cloudflared tunnel route dns m5max m5max.flytoex.net自动加 CNAME + launchd plist (~/Library/LaunchAgents/com.cloudflared.m5max.plist) 自启 + KeepAlive. 跑 lax01 + lax05 两个 CF edge HA connection. 公网 e2e 验证curl -H "Authorization: Bearer <key>" https://m5max.flytoex.net/v1/modelsHTTP 200 720ms 拿回 oMLX 32 个 model 列表.
r2 跨 model 实证 (m2max staging LAN POST localhost:8080):
- 结果 HTTP 503 + 12m25s total + 76372 bytes, 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 健壮: 完全不同 model family / 不同 backend / 不同量化精度 在同一个 ambiguity 行命中 verdict=await, 证实业务真因不在 model 选型在原表本身 ambiguity, ADR-0008 v2 范式 (main verdict cross-check sub 强猜) 产品力不依赖底层模型选型.
- 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 当前
UnavailableProviderstub (P2 follow-up), dispatch loop 想 await 但 channel 没 wire, handler 翻 503.
反向论证 (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. v2.3 不做此事, 留 v2.4 follow-up (跟 HumanInputProvider P2 wire 合一个 SSE 通道做更自然).
Commit 链路:
- 8efe18b refactor(common): quote-dispatch handler 解硬编码 deepseek
- 7704264 feat(common): ADR-0007 capability — Gemma 4 + DeepSeek v4 ModelInfo 显式 Register
- 69a2d60 deploy(m2max staging): compose.local.yml 加 QUOTE_*_MODEL env + common binary bind-mount
测试 -race 全绿 (handler 1.1s + 全 platform/common 7.5s). m2max staging Gemma 4 dispatch r2 12m25s 跨 model 实证业务路径健壮.
deploy — fastpush-bin.sh 接通 flyto-relay + release.yml bind-mount guard + gitea secrets (2026-05-13, 上节 SOCKS5 follow-up drain)¶
drain 上一节 (SOCKS5 RFC 1929) 的 3 条 deploy / 配套 follow-up:
deploy/fastpush-bin.sh加flyto-relaycase. 从 flyto-proxy 源仓 (sibling 默认~/code/flyto-proxy,FLYTO_PROXY_REPO_PATHenv 覆盖) 走cargo zigbuild --target x86_64-unknown-linux-musl --bin flyto-relay跨编 musl static binary, 复用既有 scp + sha256 校验 + atomic symlink swap + docker restart 链路 (~50s end-to-end). macOS-only build host (cargo-zigbuild + zig via brew, PATH 显式扩/opt/homebrew/bin让 ssh non-interactive 调用也跑通). binary 名 (flyto-relay) vs compose service 名 (relay) 显式 mapping (历史命名差, 保留避免 churn compose.yml). SHA tagging 改从源 repo HEAD 取 (flyto-relay走 flyto-proxy HEAD, 不再 ccm HEAD), 让 rollback/审计能定位 build 时真实代码..gitea/workflows/release.ymldeploy job bind-mount source guard 加flyto-relay(line 407). 未来 cut tag 经 CI deploy 时, host/opt/flyto/bin/flyto-relay/current/flyto-relay不存在直接 fail loud, 不让 compose 半截死.- Gitea repo secrets 加
FLYTO_SOCKS_TOKENS+WMS_SOCKS_TOKEN两条 (走 gitea API PUT, 不经 web UI). 下次 cut tag 触发 release.yml 时这两值会被 export 进 hk-133 deploy 链路. core/TODO.md加 L906 P3 SOCKS5 token 管理 admin web UI 追踪条目 (admin 通道 list/create/revoke + relay 端 hot reload, 长期支撑多消费者 b/g 蓝绿 + 第三方接入 + 撤权回归自动化).
用法 (改 relay .rs 代码后):
billrecon/wms + deploy — SOCKS5 RFC 1929 鉴权链路 + relay 公网 48190 + binary bind-mount (2026-05-12)¶
PM 拍板 hk-133 relay SOCKS5 listener 必须公网暴露同时强鉴权, 让 m2max staging bill-recon-web 拨阿里云内网走同一个 relay (反向论证否决 ssh tunnel: 权限粒度过粗; tailscale: 同事不会装). 应用层 RFC 1929 user/pass + UUID v4 token 高熵, 是对位置的鉴权层. 配套 flyto-proxy v0.4.0-dev (df188a0) 推 SOCKS_TOKENS + RFC 1929 实现.
改文件:
platform/common/internal/billrecon/wms/client.go—parseSocksAddr函数接受两种 socks 地址形态 (向后兼容裸host:portno-auth + 新socks5://user:pass@host:portURL form 走 RFC 1929 auth).registerSocks5Dialer调用前 parse, 把*proxy.Auth传给proxy.SOCKS5. 错误信息走redactSocksAddr不泄 token.platform/common/internal/billrecon/wms/client_test.go— 6 子用例TestParseSocksAddr锁 (裸/URL 无 user/URL user only/URL user:pass/bad scheme/empty host) +TestRedactSocksAddr(URL 凭据 + 裸 host:port passthrough).platform/common/internal/billrecon/wms/doc.go— package doc 更新 SOCKS5 三种形态表 (空/裸/URL) + URL 形式让 deploy 用单一 env var 配 endpoint + token.deploy/relay-entrypoint.sh— 加SOCKS_TOKENSenv 渲 TOML 数组到socks_tokens字段 (空 →[]回落 no-auth, 非空 →["uuid", ...]).SOCKS_BIND默认0.0.0.0:1080→0.0.0.0:48190(避 well-known 1080).deploy/docker-compose.yml:- relay
ports加"48190:48190"公网映射, environment 加SOCKS_TOKENS: ${FLYTO_SOCKS_TOKENS:?}强制 .env 提供 - relay
volumes加/opt/flyto/bin/flyto-relay/current/flyto-relay:/flyto-relay:robinary bind-mount overlay (同 common/bill-recon-web 模式, 跳 cut flyto-proxy-relay image tag — PM 反复强调不要无休止发布 docker) - bill-recon-web
WMS_SOCKS_ADDR从"relay:1080"改"socks5://prod-billrecon:${WMS_SOCKS_TOKEN:?}@relay:48190"— 内部 bridge 消费者也走 RFC 1929 auth (没"内部免鉴权"特例, 协议层鉴权统一) - 顶部架构注释 :1080 段重写为 :48190 (RFC 1929 auth, host-mapped for external staging consumers)
.gitea/workflows/release.yml— deploy job export 加FLYTO_SOCKS_TOKENS+WMS_SOCKS_TOKEN两个新 secret (从 gitea secrets, future cut tag 用).
核心决策:
- 应用层鉴权 vs transport 层 (ssh tunnel / mTLS): PM 反复挑 transport 层方案权限粒度 (root ssh 过粗) / 客户端兼容性 (mTLS 多数 SOCKS5 库不支持) / 通用性 (tailscale 同事不会装). 应用层 RFC 1929 是协议层标准方案 — 所有 SOCKS5 客户端库零改动, 加同事 = 改 token 列表一行, 撤权 = 删一行.
- UUID v4 token 不用 client cert / 短 token: UUID 122 bit 熵不可暴力, 跟 SSH key 同档强度. cert 引入 CA/CRL 运维债不值; 短 token (8-16 字符) 熵不够; 长随机字符串等价于 UUID 但 UUID 是标准格式工具友好.
- port 48190 而非 1080: well-known 1080 是机会主义扫描器目标; 48190 (随机 high-port) 减少噪音命中. token 高熵端口扫到也拨不进, 但端口隐蔽是 defense-in-depth.
- binary bind-mount overlay 而非 cut tag: ccm
common+bill-recon-web已是这个模式 (memoryfeedback_bind_mount_cut_tag_ops.md). 跳 flyto-proxy-relay image tag = registry 不堆 image, 部署 ~50s vs 完整 image build ~3min. trade-off: image 内置 binary 永是老版本, 回退 image tag 不回退 binary — 这是已知模式 known issue 不是新引入. - 内部 bridge 消费者也带 token: SOCKS_TOKENS 非空时 relay 强制所有 client 走 user/pass, 无"内部免鉴权"特例. 协议层鉴权统一 — 否则 trust 网络边界又变回 bridge security boundary 模式, 跟"应用层鉴权"反方向.
部署链路:
- flyto-proxy commit df188a0 (origin/main) 不 cut tag. ccm 这边 build musl static binary out of band (rust:1-bookworm + cross-compile musl target).
- binary scp 到 hk-133
/opt/flyto/bin/flyto-relay/current/flyto-relay. - ccm commit + push main (CI 不触发 — not tag).
- ssh hk-133
cd /opt/flyto && git pull && cd deploy && export FLYTO_SOCKS_TOKENS=... WMS_SOCKS_TOKEN=... && docker compose up -d relay bill-recon-web. - e2e: m2max staging compose override 起 bill-recon-web + curl 验证拨阿里云内网 MySQL 通.
Follow-up (此 commit 不做):
deploy/fastpush-bin.sh加flyto-relay支持 + release.yml bind-mount source guard 加flyto-relay服务. 当前手动 deploy 流程保证 hk-133 上 binary 存在; future cut tag 经 CI deploy 时需要 fastpush flow.- gitea secrets 加
FLYTO_SOCKS_TOKENS+WMS_SOCKS_TOKEN两个新条目 (PM 操作, 在 hk-133 deploy 之前完成). core/TODO.md加 P3 项 "SOCKS5 token 管理 web UI" — 当前 token 列表是 .env 静态配, 未来从 web console 加/撤更顺手 (admin endpoint 路径).
platform/common/server — rate limit 默认 60/min → 6000/min (P2 UI 第一次 deploy 后撞限速教训, 2026-05-04)¶
PM 第一次打开 hub.flytoex.net (v0.5.0-alpha.23 deploy 后) 撞 429 "rate limit exceeded, retry after 54 seconds". 根因: server.go rateLimitMiddleware 默认 60 req/min/IP, 但 frontend SPA 一开页面就并发拉一堆 stub 端点 (workspace 拉 /users/me, flow 页拉 /nodes/registry + /flows, runtime 页拉 /dispatches + SSE 心跳, settings 页双层 preset 切换), 加用户正常导航和刷新 60/min 一打就爆.
改文件:
platform/common/internal/server/server.go—Config.RateLimitPerMingodoc 重写 (双语完整解释新默认 6000 = 100/s 的设计理由 + 公网防爆破仍有效的论证 + phase 2 切 per-user 的演进路径); fallback60→6000.
核心决策:
- 6000 = 100/s 而非 600 或 60000: 100/s 吸纳所有 SPA 多页面 mount 突发 + SSE 心跳稳态 + 决策者 demo 快速刷新 + 多用户 NAT 共享 IP 场景, 同时公网爆破 100 req/s/IP 真 botnet 上游 Caddy/Cloudflare 拦截就够, 应用层不必再卷紧.
- 不引 cmd/common flag: stage 1 alpha 阶段保持简单, fallback 一行改即可. 真要 per-deployment 调用 phase 2 设置面板 "API 限速" 模块 (P2 §九 模块 6 范围) 一并做.
- per-IP 不切 per-user: 当前还没真鉴权, OIDC sub claim 进 ctx 是 stage 2 的事. phase 2 切 per-user 让内部租户脱离 IP-shaped 限速 (NAT 共享 IP 多人撞同 budget 是隐藏问题).
- 不豁免业务端点: 健康检查 / OPTIONS / Swagger 已豁免, 业务端点继续受限是对的 — 公网拿到这些路径有意义.
测试: go test -race -count=1 ./internal/server/ PASS (test 用 1000/min 显式 cfg 不依赖 fallback 默认值, 改默认无回归).
对照设计文档: P2 §九 模块 6 "审计 / 日志 / 计费" 应该新增 "API 限速 / quota" 子项 phase 2 实装 — TODO.md L699 已涵盖 stage 2 设置面板.
frontend — P2 stage 1 frontend 骨架 + 部署接入 (Vite + React + Tailwind + shadcn + R3F + React Flow, 2026-05-04)¶
ADR-0009 §7 implementation 第三+四+五步. PM 拍 "go" + "把 stage1 乱序完成 自动 commit" batch 授权下一气交付 frontend 整套骨架 + HK-133 部署接入.
新文件 (28) — 全部在 frontend/ 子目录下:
工程脚手架 (8): package.json (366 deps + 23 dev deps) / package-lock.json (npm ci 重现) / vite.config.ts (dev :5173, proxy /api/v1/ + /admin/ + /swagger/* 到本地 platform/common, build target es2022) / vitest.config.ts (拆开避免 vite/vitest plugins 类型 universe 冲突 TS2769) / tsconfig.json + tsconfig.app.json + tsconfig.node.json (strict + noUnusedLocals + path alias @/*) / tailwind.config.ts (shadcn theme tokens 全 CSS variable + flyto 双层 preset tokens) / postcss.config.js / index.html
核心源码 (16): src/main.tsx + src/App.tsx (BrowserRouter + 5 路由 + QueryClient) + src/index.css (shadcn dark 默认 + .theme-worker 浅色 override) + src/test-setup.ts + 5 pages (login / workspace / flow / runtime / settings) + 4 lib (api / sse / auth / utils) + 2 store (preset / session) + src/types/api.ts (9 schema TS 类型镜像 swagger) + src/components/ui/button.tsx (shadcn vendored cva + radix slot)
部署 (4): Dockerfile (multi-stage node:20-alpine → nginx:alpine, npm ci layer cache 优化, vite build → dist 落 nginx) / nginx.conf (SPA try_files $uri /index.html, /assets/ 长缓存 immutable, index.html no-cache 让发布立即生效, /healthz 健康探测) / .dockerignore / Dockerfile
改文件 (4):
.gitignore—frontend/tsconfig.tsbuildinfo→frontend/tsconfig*.tsbuildinfo(tsc -b 给 app/node 各产一份 buildinfo).deploy/docker-compose.yml— 加frontendservice (image flyto-agent-frontend:${VERSION:-latest}, build context=../frontend, expose :80, depends_on common). 拓扑注释更新 (everything else 从 logistics:8080 → frontend:80).deploy/Caddyfile—handle {}块 reverse_proxy 从 logistics:8080 → frontend:80. 注释解释 SPA fallback 由容器内 nginx 处理, logistics 退路径理由..gitea/workflows/release.yml— changes detection 加frontend=true(path =^frontend/); release.yml self-change 兜底全 build 加 frontend; 加Build and push flyto-agent-frontendstep (context=frontend, registry cache mode=max).
5 个页面骨架 (stage 1 stub 含义: 调真实 stub 端点 + 渲染响应 shape, 不真画 React Flow canvas / 决策树 / 头像剧院, 真组件 stage 2-4 落):
/login— OIDC 起跳 (调 /auth/login + window.assign authorize_url)/— 业务员工作台 (拉 /users/me + 3 nav 卡片到 /flow /runtime /settings)/flow— 编排页占位 (拉 /nodes/registry + /flows, React Flow canvas + 节点拖拽 + flow.json 序列化 stage 2 真实装)/runtime— 实时视图占位 (拉 /dispatches + 接 SSE event 流, 决策树 + 头像剧院 + token 流式 + await 冷场 由 Claude Design 协作流 sprint 1 prototype 真画)/settings— 设置面板 P2 §九 八模块入口 + 双层 preset 切换演示
双层 preset 机制 (P2 §V + ADR-0009 §2.3):
- Tailwind theme tokens 全 CSS variable (
--background/--foreground/--primary/ ...) - Flyto preset tokens (
--particle-density/--glass-blur/--animation-duration/--metric-scale) - Zustand
usePreset切 mode 时:root.style.setProperty改 CSS variable, Tailwind class 立即响应不重渲染整树 - 三档 mode (
worker/decision/sales-walkthrough), 默认decision(玻璃感深色, 决策者视觉基线),worker切.theme-workerclass 浅色 override
核心决策:
- Tailwind 锁 v3.4 不 v4: ADR-0009 文本说"Tailwind 4+", 实战 Tailwind v4 alpha 期 ecosystem (shadcn / autoprefixer / 各 plugins) 未齐, 落 v3.4 stable. ADR-0009 §2.1 同步更新.
- vitest config 拆开 vite.config.ts: vitest 内嵌 vite 类型跟项目 vite 6 的 PluginOption 类型 universe 不一致, 同文件
defineConfig({ test: ... })报 TS2769. 拆vitest.config.ts让两套 type 不交叉 (alias 重复声明). - shadcn vendor 不 npm: ADR-0009 §3.4 拍, 主题 override 必须自由, npm 库会锁住. 当前只 vendor 1 个 Button 占位, stage 2-3 业务实装时 vendor 更多 (dialog / form / select / toast).
- frontend Docker context = frontend/ 不是仓根: 跟 common/godoc 等 image 不一样 — frontend 是独立 npm 项目, 不依赖 Go monorepo 树, build layer 薄很多.
- SPA fallback 在容器内 nginx 不在 Caddy: nginx try_files $uri /index.html. Caddy 上游只做路径分流, 不管 SPA 路由. Caddy 层简单, 容器内自治.
build 验证: npm install 366 packages 42s, npm run build PASS — dist/ 244KB JS / 12KB CSS / 0.5KB HTML, gzip 后 ~83KB. 当前 R3F/Three/React Flow tree-shake 掉未真用进 bundle, stage 2 真用进时再涨.
stage 1 不做 (留 stage 2-4):
- 鉴权 guard (登录页跳转)
- 路由权限 (角色 gate)
- 404 页 + 错误 boundary
- 真组件 test (脚手架阶段无业务组件)
- React Flow canvas + 节点拖拽 + flow.json 序列化
- 决策树 + 头像剧院 + token 流式 (Claude Design 协作流)
- await 冷场 + L697 SSE 桥接
对照设计文档: P2 综合设计 §六 Claude Design 协作流接入路径已在 /runtime 页面占位文案体现; §九 八大模块在 /settings 页面 enumerate 出来 (phase 1 alpha / phase 2 / phase 3 标签).
下次 cut tag (v0.5.0+ 含 frontend 改) release.yml 自动 build flyto-agent-frontend image push registry, deploy job 拉新 image + Caddy reload, hub.flytoex.net 根路径活到 React SPA 工作台.
platform/common/server — P2 stage 1 stub endpoints (frontend alpha 后端骨架, 2026-05-03)¶
ADR-0009 (P2 UI frontend 架构选型 — Vite/React/R3F/React Flow) §7 implementation. PM 拍 "go" 后启动 stage 1 第二步, 落 6 endpoint 类别 11 路由覆盖 frontend phase 1 alpha 必需路径. stub 含义: 路由注册 + handler 函数 + 输入/输出 JSON schema 占位 + Swagger 注解全套. 不接 DB / 不真鉴权 / 不真 dispatch event 流, 真实装 stage 2-4 落地, 每个 handler TODO 注释标 stage 2 路径.
新文件:
platform/common/internal/server/server_p2_stubs.go(530 行) — 6 endpoint 类别 11 handler 集中 1 文件, stage 2 真实装时 split per domain 并替换. 设计选择写在文件 header 双语注释 (集中放方便一次性 split + delete 不留拆迁包袱).platform/common/internal/server/server_p2_stubs_test.go(310 行) — 14 test 覆盖路由 + status + shape 三层契约. 不锁 stub 行为细节 (stage 2 swap 时 test 不需大改).
改文件:
platform/common/internal/server/server.go—registerRoutes加 11 路由 (POST /auth/login + GET /users/me + /flows GET POST + /flows/{id} GET PUT + GET /nodes/registry + GET /dispatches + GET /dispatches/{id}/events + GET POST /billcost/dispatch/{id}/answer).platform/common/docs/{swagger.json,swagger.yaml,docs.go}—make -C core docs-swag重生, 加 11 endpoint + 9 schema (LoginRequest/Response, UserMeResponse, FlowSummary, Flow, CreateFlowRequest, UpdateFlowRequest, FlowMutationResponse, NodeSchema, DispatchSummary, AnswerRequest, AnswerResponse).
6 endpoint 类别:
POST /api/v1/auth/login— OIDC 起跳, stub authorize_url. Stage 2 真实装走auth.Verifier.GET /api/v1/users/me— 当前用户身份. Stage 2 从 auth middleware ctx claims 读./api/v1/flowsGET POST +/{id}GET PUT — flow 草稿 CRUD. Stage 2 接 flows table + tenant_id 过滤 + 乐观并发版本号 CAS.GET /api/v1/nodes/registry— 4 核心节点 (main-agent/sub-agent/await/tool.exec). Stage 2 接真 registry + vertical/tenant filter. 节点 metadata 形态 (name/namespace/kind/in_schema/out_schema/idempotent) 预留 ABI 兼容性给 ADR-0010 (待起草) 跨语言节点软约束.GET /api/v1/dispatches+GET /{id}/events(SSE) — 历史 list + event 重放. Stage 2 接 dispatches table + sessionstore 持久化 events.GET POST /api/v1/billcost/dispatch/{id}/answer— await 桥接 (L697 P2). Stage 2 接 HumanInputProvider channel.
核心决策:
- 集中 1 文件 stub 而非按 domain 拆 5 文件: stage 1 stub 临时实装, stage 2 真实装时本文件每个 handler 被相应 domain 文件替换, 集中放方便一次性 split + delete 不留拆迁包袱.
- 节点 declarative metadata 形态现在落, 跨语言 ABI 推后: PM 2026-05-03 拍 "先把物流对账和客户索赔做了之后再和 C# 团队开会决定". metadata 形态 (in_schema/out_schema/idempotent) 跟节点 namespace (flyto.core. / industry. / tenant.*) 现在落不阻塞 ABI 决策, 真有跨语言节点需求时拆 declarative 层接 HTTP handler 即可.
- handler 命名
handleP2Xxx前缀: 跟既有handleHealth/handleAgentRun/handleQuoteDispatch区分, stage 2 真实装 split 后再去前缀. - test 不锁行为细节: stage 1 stub 测试只断言 (1) 路由注册, (2) status code, (3) 响应 shape 跟声明 schema 对齐. stage 2 真实装 swap 时 test 不需大改.
测试: go test -race -count=1 ./internal/server/ — 14 P2 test 全 PASS (TestP2_AuthLogin × 2 / UsersMe / Flows × 5 / NodeRegistry / Dispatches × 2 / DispatchAnswer × 3), 既有 server / agentprompt / billrecon / quotedispatch 等测试不破坏. go vet ./... clean.
红线 / 不在本范围:
- 不改 core/pkg/engine/: ADR-0001/ADR-0005 红线.
- 不接 DB / 不真鉴权 / 不真节点 registry: 留 stage 2.
- 不立新 ADR: 这是 ADR-0009 §7 implementation, 既有方向延伸. ADR-0010 节点 declarative metadata 软约束待 stage 2 起草.
- stage 1 第三步 frontend 脚手架 + 第四步登录页 + 第五步 docker-compose 接入 后续 commit 落.
对照设计文档: P2 综合设计 §四 "现有后端 endpoint 速查" 6 个待补 endpoint 全部覆盖 + ADR-0009 §2 项目结构 (frontend/lib/api.ts 接的是这套 endpoint).
platform/common/quotedispatch — main↔sub agent 转发 loop 抽包 + 接生产 REST 端点 (P1, 2026-05-03)¶
ADR-0008 v2.2 follow-up: 把 cmd/quote-engine-probe r31 v7 实证收敛的多轮主↔子 agent 转发 loop 从单 binary 抽到 platform/common/quotedispatch/ 通用包, probe 与新接的生产 REST 端点 POST /api/v1/billcost/dispatch 跑同一份代码 (跟 ADR-0008 v2.2 schema drift 单一定义点同思路 — 字符串模板集中之后, loop 编排也集中).
新文件:
platform/common/quotedispatch/doc.go— 包文档 (双语). 明确"在本包内 / 不在本包内"边界: 拿主↔子转发的 multi-round loop + HumanInputProvider 接口 + 引擎装配工厂 + xlsx dump + 日期 post-process; 协议字符串走 agentprompt 不重复.platform/common/quotedispatch/dispatch.go—Run(ctx, Request) Result主入口. round 1 sub Send → main verdict 解析 → 走 ok/retry/await 三态. retry+new_prompt 重建 sub engine + session, retry+hint 复用 session, await 调 HumanInputProvider, max-rounds 用尽给部分结果不算错.platform/common/quotedispatch/provider.go—HumanInputProvider.Ask(ctx, q) (answer, err)接口 +StdinProvider(probe 用) +UnavailableProvider{Note}(server 默认 stub, 返 ErrAwaitUnavailable, P2 接 SSE 推 + POST 答之前的占位).ErrEOFAbort(Ctrl-D 干净 abort) +ErrAwaitUnavailable两个 sentinel error 让消费者细分语义.platform/common/quotedispatch/engine_factory.go—BuildSubEngine(SubEngineSpec) / BuildMainEngine(EngineSpec) / DefaultReflectTool(). 把 ADR-0005 中性化 flag 全套 (Toolset.None / LiteralSystemPrompt / DisableX × 7 / RequireExplicitSubAgentPrompt) 收口到这里.platform/common/quotedispatch/postprocess.go—PostProcessFinalJSON(含日期 clamp + 围栏剥) +clampOverflowDate+lastDayOfMonth+extractJSON. 与原 probe 内同款逻辑, 单一定义点版本.platform/common/quotedispatch/sheetdump.go—DumpSheetWithMerges(*excelize.File, sheet) (string, error). 镜像原 probe 私有版本, server 与 probe 共一份.platform/common/quotedispatch/*_test.go— 30+ 测试: provider 7 + dispatch 12 (含 stub flyto.ModelProvider + fakeEngineRunner test seam 让 verdict 路径 ok/retry+hint/retry+new_prompt/await/exhaust/parse-fail 全覆盖) + postprocess 11 + sheetdump 4. -race 全绿.platform/common/internal/server/quotedispatch_handler.go—POST /api/v1/billcost/dispatchmultipart/form-data 端点 (xlsx + sub_prompt + main_prompt + sheet). 同步路径: 200 + final JSON + cost 统计. await 路径: 503 + 指引文案 (HumanInputProvider 是 UnavailableProvider stub 时). bad input: 4xx. 引擎 / dispatch 错: 5xx.QuoteDispatchConfig控制 DeepSeek API key + 默认 prompt + 接LoadDefaultPromptsFromDir. swag 注解走 server.go 同款体例.platform/common/internal/server/quotedispatch_handler_test.go— 6 handler 单测: 无 cfg 503 + 缺 xlsx 400 + 缺 prompt 400 + 坏 xlsx 400 + LoadDefaultPromptsFromDir + cfg wired 路径未短路.
改文件:
platform/common/internal/server/server.go—Server加quoteDispatchCfg *QuoteDispatchConfig字段;registerRoutes加POST /api/v1/billcost/dispatch路由 (无条件注册让无 cfg 时 503 而非 404, 部署诊断友好).platform/common/cmd/common/main.go— 加--deepseek-api-keyflag (回退 DEEPSEEK_API_KEY env) +--quote-prompt-dirflag, 在--rest-addr启用时调s.AttachQuoteDispatch(quoteCfg).platform/common/cmd/quote-engine-probe/main.go— 整体改成 quotedispatch.Run 的薄壳: 保留 CLI flag 解析 + 多 provider 选择 (minimax / openrouter / deepseek) + xlsx + prompt 文件 IO, 把编排让给 quotedispatch.Run.probeBanner透 OnEvent hook 实时打 stderr 镜像旧 drainSession 输出. 删原 inline 主循环 / 引擎装配 / dumpSheetWithMerges / postProcessFinalJSON / clampOverflowDate / extractJSON / mainAgentVerdict.platform/common/docs/{swagger.json,swagger.yaml,docs.go}—make -C core docs-swag自动重生, 加/billcost/dispatchendpoint +QuoteDispatchResponse/QuoteDispatchAwaitResponseschema.
核心决策:
- 抽包到
platform/common/quotedispatch/而非扩 agentprompt: agentprompt 是协议字符串单一定义点 (ADR-0008 v2.2 § scope), 包内明文 "dispatch loop 不在本包". quotedispatch 是 agentprompt 的消费者, 单向依赖. - per-request 引擎装配: engine.Config.SystemPrompt 在 engine.New 时定死 + verdict=retry+new_prompt 必须 rebuild, 共享单 engine 跨请求会让 rebuild 路径互相影响. sub engine 跑 4-7 分钟时引擎构造开销可忽略, per-request 是简单安全选择. 多副本扩展走 ADR-0003 sticky routing.
- HumanInputProvider 接口 + UnavailableProvider stub: 现状 server UI 未接通 (P2 工作), stub 返 ErrAwaitUnavailable + Note → 503, 客户端看到 "human-input wiring pointer" 文案. 接口 + Stub 让 P2 接 SSE 推 + POST 答时改 server 一行接线即可, dispatch loop 完全不动.
- probe 改用 quotedispatch.Run: ADR-0008 v2.2 schema drift 教训 — 协议层散在多处易漂. 现在 probe 与 server 字面共享同一段 loop 代码, 只在 I/O / provider 选择 / event banner 上分叉.
红线 / 不在本范围:
- 不改 core/pkg/engine/: 引擎中性化 ADR-0001 / ADR-0005 红线. 仅 import + 装配 cfg.
- 不立新 ADR: 这是 ADR-0008 v2.2 follow-up 实施 + L692 (业务 REST 通道) 既有方向的延伸, 不引入新协议红线.
- 不实装 SSE 推 + POST 答: P2 工作, 等 UI 选型. quotedispatch.HumanInputProvider 接口为该实装预留.
- 不接 SDK 自动生成: 沿 ADR-0002 § 6 立场 (5+ 客户端语言才启用).
测试: go test -count=1 -race -timeout 300s ./... 全 platform/common 模块绿. quotedispatch 34 单测 + server 6 handler 单测 + 既有 server / agentprompt / billrecon 等测试不破坏. make -C core docs-swag 重生 swagger 三件套 (CI docs drift gate 对齐).
遗留 follow-up:
- HumanInput SSE 通道实装 (P2): UI 选型后 server 端实装真 HumanInputProvider (SSE 推 question + POST 答接 channel reply 桥接). 单点改
server.AttachQuoteDispatch注入新 provider, dispatch loop 不动. - Result.HumanQuestion 字段 (P2 minor): quotedispatch.Result 暂未把 await 路径的 question 显式带出 (handler 现从 LastVerdictReason 取近似), 接 SSE 时一并补.
- 可观测性 metric / observer: round 数 / verdict 分布 / await 触发率 / cost / latency 这些指标接 OpenTelemetry, 当前仅 stderr log, P2 实装.
ADR-0008 v2.2 — agentprompt 通用包集中 main↔sub 协议字符串 (P0, 3 commit, 2026-05-02)¶
r31 v5 full multi-round 实证暴露 schema drift bug 死循环铁证: round 2 / 3 / 4 sub text_len 字面 22955 完全一致, 烧 ~$0.057 RMB. 真因是 ADR-0008 v2 删 sub 输出 _uncertain 三件套时漏改 probe 内联字符串模板 (probe main.go:1056 还在跟 sub 说 "请按答复修正对应 _uncertain 行", sub 找不到这个被删的字段干脆原样重发). PM 评 "改协议忘了改另一端的话" — 这是分布式 / multi-agent 系统经典 schema drift bug, 协议端散在 prompt 层 / 反射器层 / 引擎层 / 消费者字符串模板 4 个 layer, 字符串模板那一档没类型签名编译期 / 测试期都不挂.
Commit 顺序:
- C1 (
598c367) 起platform/common/agentprompt/通用包: 一个 .go 文件四样:Verdict三态 (ok / retry / await_human_input) +ParseVerdict(从带围栏 / prose 前缀的 main 输出抽 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)(旧措辞 "请按答复修正对应 _uncertain 行" 删除). retry 路径改用agentprompt.BuildRetryMessage(hint)(wrap 让 sub 知道是修正不是新任务).extractJSON保留 (postProcessFinalJSON 还在用; agentprompt 内有自己私有版本供 verdict 解析两者独立). probe build + -race 全绿. 删 3 个迁移到包内的 verdict parsing 测试. - C3 (本 commit) ADR-0008 § 8 v2.2 + CHANGELOG + CLAUDE.md prepend: schema drift 真因 + 范式级修法 + PM 4 红线 + 反向论证 4 道 + 业界对照 + 升级路径登记.
核心决策:
- 包名 agentprompt 通用 (不绑 quote / billcost): PM 拍板 "都是通用的", 物流 platform 真接同款 main↔sub dispatch 时直接复用. main verdict 三态 + retry hint + await human input 是 multi-agent 通用模式不是 quote 业务专属.
- 不抽 Dispatch loop / HumanInputHandler 接口: PM 反问 "正常一个 session 不都支持用户输入么? 引擎天然支持的情况下, 就这个屁事那么复杂么?" 戳穿过度设计. 引擎
Session.Send已原生支持多轮用户输入, bug 不出在多轮支持, 只出在"Send 给 sub 的字符串内容对不上协议"这一档. 真正最小修法是把字符串模板搬到一处, 接待器接口 / Dispatch loop 等待第二个消费者真出现时再抽 (rule of two). - 不立 ADR-0008 v3: 这是协议改了一端漏改另一端的 follow-up 修法, 不引入新红线、不改 sub↔main 契约. v2 sealed StructuralValidator 才算范式级. v2.2 修订记录足够, 后续有新协议红线触发再立 v3.
反向论证:
- 包名 agentprompt 是否过早抽象? — 否. 触发是 r31 v5 实证 schema drift bug, 物流 platform 接时一定复现 (multi-agent 通用模式). 包名通用让物流接时直接 import, 不必另起炉灶. PM 拍板"都是通用的"不是 rule of two 难题.
- sealed marker interface 防消费者绕过自拼字符串? — Go 语言层不能 100% 防, 跟 ADR-0008 v2 StructuralValidator 同 limitation. 靠 godoc + code review + 测试 lock. 跟 typed error / sealed validator 体例同档.
- 测试覆盖怎么保证? —
TestBuildAwaitMessage_NoUncertainPhrasing锁 r31 v5 真因措辞不回归 +TestBuildAwaitMessage_GuidesSubFromHistory锁措辞引导 sub 用 history 不点名 schema 字段. 协议改时这两个测试挂.
业界对照: 业界 schema drift 经典解法是单一定义点 (single source of truth) + 编译期挂掉 (例 protobuf / OpenAPI 生成 client+server 同源 schema). LLM prompt 文本协议没法 100% 类型化, 走"通用包 + 函数签名"是工程上等价的次佳方案. Pydantic AI / Instructor / LangChain BaseChatPromptTemplate / LangGraph state schema 同方向 — prompt 模板集中包内业务调函数填参. agentprompt 跟此族同档.
测试 -race 全绿: agentprompt 11 case + probe 全套 (删 3 个迁移到包内的 verdict parsing 测试) + 既有不破坏.
r31 v6 重跑期望 (PM 拍板触发, 不擅自跑): 同款 ytosample.xlsx + 同款主子组合, round 2 / 3 sub text_len 应出现真实变化 (新答复消化体现在字段值差异), 不再三轮 22955 字面一致死循环.
TD-24: engine.New 自动注 Provider.Models() — cost=$0 显示 bug 修 (P1, 3 commit, 2026-05-02)¶
config/models.go:63 注释明文设计意图 "各 provider 通过 RegisterModels(registry) 注册自己的模型" 跟实装从来没串起 — 0 provider 子包暴露顶级 RegisterModels, 0 消费者 (probe / cmd/common) 调过 register, EstimateCost 看到 cfg==nil 直接返 0. 这是 cost=$0.0000 显示真因, r31 v3 PM 看到 deepseek dashboard 累计 1.48 CNY (~$0.21 USD) 真实计费暴露此 gap. 同时 deepseek 子包静态表 4 个价格字段长期为 0 (注释明说 "TBD when consumers need it"), 即使 register 通了 cost 仍 0 — 双重原因都修.
Commit 顺序:
- C1 (
b13a3f4) deepseek 价格静态表 4 字段填空: 取自官方文档https://api-docs.deepseek.com/quick_start/pricing. Flash $0.14 input(miss) / $0.28 output / $0.0028 cache hit. Pro $0.435 / $0.87 / $0.003625. CacheWrite 留 0 (DeepSeek auto-cache 不需 explicit cache_control 不单独计费). Pro 当前 75% off 至 2026-05-31, 静态表填原价不入折扣 — 静态表跨时间稳定 + 5月31号自动恢复 + dashboard 已知有差异. 测试TestModelInfo_Pricing表驱动断言 + sanity check (cache hit 应远低于 miss). - C2 (
1e5776a) engine.New 自动注 Provider.Models() (PM 选 B 路径): 加 helperautoRegisterProviderModels(cfg, reg)在reg := cfg.ModelRegistry()之后 +agentctx.SetContextWindowProvider之前调一次. 内部context.WithTimeout(ctx, 200ms)调cfg.Provider.Models(ctx)→ 每个 modelInfo 注 ModelRegistry. 静态 provider (anthropic/deepseek/minimax/gemini/openai) 纳秒返回 OK; 动态 provider (openrouter HTTP fetch) 200ms 触底跳过 (消费者按需自缓存 fetch 结果再 register). 已 register model 不覆盖 (消费者手动优先, 自定义价格场景). 失败 / 超时 silent log stderr 不 panic 不 return err — 现状是 silent 0 cost, 失败模式同档非回归. 7 单测 (StaticProvider / SkipsExisting / TimeoutSkips / FailureSkips / NilProvider / EmptyIDSkipped / TestNew_AutoRegistersProviderModels 集成测试). - C3 (本 commit) CHANGELOG + CLAUDE.md prepend: TD-24 已登记债务不立新 ADR.
核心决策:
- timeout=200ms: 静态 provider 纳秒级返回 (4x 富裕), 动态 provider 真去 HTTP 必触底跳过. 跟 ADR-0007 capability tracking 同档"对静态 OK 对动态降级"模式. 不开 cfg 字段让消费者调 — 200ms 是工程合理通用值.
- silent 失败: 加自动 register 后失败仍是 silent log stderr → cost=0 fallback, 状态等价于 register 之前的 silent 0 cost, 不回归. fail-loud 会让 OpenRouter 网络抖时 engine.New panic 不能接受.
- 已 register 不覆盖: 消费者手动 register 应优先 (e.g. 自定义价格调整场景). 这是常见 "convention over configuration" 模式.
- engine.New 不加 ctx 参数: 现签名
New(cfg) (*Engine, error), 加 ctx 破坏调用方. 内部context.Background() + WithTimeout兜底, 缓存 fetch 优化留后续.
反向论证:
- Pro 折扣不入静态表 — 静态值跨时间稳定 dashboard 已知有差异, 修 cost=$0 → cost=理论价已够覆盖观测需求, 5月31号自动恢复正确状态.
- timeout 触底是否会让 OpenRouter 永远不 register? — 是, 设计如此 (动态 provider live metadata 该消费者按需 fetch 缓存; ADR-0007 § 2.2 bifurcate direct/aggregator 跟此对齐). 消费者 (probe / cmd/common) 用 OpenRouter 时自己跑一次
cfg.Provider.Models(longerCtx)把结果 register 到 cfg.Models 即可. - 是否该在 cmd/common / probe 里手动调 register 而不靠 engine.New? — 否. PM 反向论证: 设计图纸已立 (config/models.go:63 注释), "靠消费者每次记得调" 模式跟 ADR-0008 v2 反射器"靠消费者每次自觉" 同源问题, 引擎层契约自动接通才根治.
业界对照: LangChain BaseChatModel._get_pricing() 走 model registry 查表; LiteLLM litellm.model_cost PR-maintained model db 用消费者手动 register 顶级函数; Anthropic SDK / OpenAI SDK 不做 cost estimation 把责任推给消费者. Flyto 此前 ~7 维度 + 0 自动 register, 此次跟 LangChain 同档 (provider 子包静态表 + engine 层自动 wire).
测试 -race 全绿: core engine 7 auto-register 单测 (除 3 个 pre-existing fail/panic 与本无关) + deepseek 子包 1 pricing 测试; platform/common 全套 (billrecon/llm 17 + responseguard) 不破坏.
r31 v5 实证连带验证: 跟 P2 反射器观测性一起重跑时, turn_end / done event 的 cost 字段应显示真值 (不再 $0.0000). deepseek-v4-flash 8K input + 16K output 一轮 ≈ $0.0056 ($0.14 × 8 / 1M + $0.28 × 16 / 1M). 跟 dashboard 累计真实计费应在 5% 内对齐 (考虑 Pro 75% 折扣后实际计费比静态表低).
ADR-0008 v2.1 — 反射器 happy path 观测性 gap 修 (P2, 3 commit, 2026-05-02)¶
ADR-0008 v2 引擎层契约落地 + r31 v4 实证 round 1 通过 (sub tool_use=0 / final text 21381 字符 / main v4-pro 业务判 verdict=retry "面单首重(0kg)" base_weight 应=0) 后, PM 反问 "反射器跑了没" — log 0 行可答, 必须从代码路径 (probe sub engine cfg + sub final text 经反射器逻辑放行) 推断, 直接证据缺失. 真因: 引擎 fail 时 emit WarningEvent code=response_reflector_block, PASS 完全静默. 跟上次会话 silent compact 5 路径同源观测性 gap (上次修了 compact, 反射器没修).
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 之间完全脱钩.
Commit 顺序:
- C1 (
ed54e1f)flyto.ResponseValidatedEvent类型加:core/pkg/flyto/events.go加事件类型 (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_Schema4 case round-trip + EventType 不变 + BlockCount<=MaxBlocks 不变量反 false-pass. - C2 (
ddf0c2c) 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 验 happy path 不再静默) /OnFailThenPass(双 emit Fail/Pass + BlockCount 1→1 PASS 不增) /AtCap(MaxBlocks 个 emit BlockCount 1→2→3 + WarningEvent response_reflector_max_blocks 同时 emit). - C3 (本 commit) probe banner + doc 同步:
quote-engine-probe drainSession加 case*flyto.ResponseValidatedEvent打 banner[reflector] approved validator=billcost_reflect block=0/3 turn=1(Fail 时附reason=...); ADR-0008 § 8 v2.1 修订记录 (含 r31 v4 实证表 + MAOS 反射器机制对比 + 4 反向论证 + 业界对照 + 单一 emit 点设计选择 + r31 v5 重跑期望); CHANGELOG; 本 CLAUDE.md prepend.
核心决策: 单一 emit 点 (Validate 完之后无条件 emit + 然后走 fail 分支), 替代双 emit 点 (PASS 在 if-else 块内 / Fail 在 if 块内) — 单点 emit 字段语义统一 + post-increment 一处算 + 后续维护新字段一处改 + godoc 描述跟代码 1:1 对齐.
反向论证: (1) PASS emit 噪音? — sub 一次 turn 反射器最多跑 maxBlocks+1=4 次, 量小, 跟 turn_end / tool_use 同档; (2) 复用 WarningEvent code=response_reflector_pass? — 否, WarningEvent 语义"出问题能继续", PASS 不是 warning 不能滥用, 跟 CompactEvent.Kind 加 micro/full 区分同精神拒绝事件类型语义漂移; (3) fail 路径双 emit 重复? — 不重复, ValidatedEvent 是机器可读 verdict 通道 (Web UI / 监控 / audit sink 字段消费), WarningEvent 是人类可读告警通道 (字符串模板); (4) emit 时机 (Validate 前 / 后)? — 后, verdict 出才有意义.
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 语义模糊.
测试 -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 是什么, 不必再凭代码路径推断.
ADR-0008 v2 follow-up: 引擎层反射器契约 (StructuralValidator sealed interface + Toolset 双挂 fail-fast, 3 commit, 2026-05-02)¶
ADR-0008 v2 范式 (反射器纯安检门 sub 看不到反射器存在) 上次会话 commit 82fa66e 在消费者层 + prompt 层落地. PM 跑 r31 v1+v2+v3 三次跨 model 实证 (主子组合 minimax / deepseek-flash / deepseek-pro 全排列, 同款 problem 3 sub round 2 跨行扩散到上海) 后戳穿: "反射器强制本应是引擎层契约保证, 不是消费者每次自觉" — 上次改的是消费者层 (probe cfg.Toolset.None()) + prompt 层, engine.go 0 行改, 下次别的消费者 (logistics platform / billrecon 业务) 用 ResponseValidator 也会重蹈覆辙. 本 follow-up 3 commit 落地引擎层契约根治. ADR core/docs/adr/0008-quote-probe-protocol-revisions.md § 8 v2 修订记录.
Commit 顺序:
- C1 (
11112eb) 接口分裂奠基 —validator.StructuralValidatorsealed interface +ErrReflectorDoubleWired: 走 ADR-0006 § 3 红线 + ADR-0007 § 2.3 红线同模式 (设计层契约 + 类型签名 + godoc + 替代路径显眼). 不靠 runtime 强制 (做不到 100%), 靠类型签名层让消费者自然分流到对的位置. 新加validator.StructuralValidatorsealed sub-interface (嵌入Validator+ 包内私有structuralMarker()method) +validator.StructuralMarker嵌入 helper (~100 行新文件). 包外类型必须 import validator 包并嵌入 StructuralMarker 才能满足接口 — 嵌入即声明契约 "无 LLM 调用 / 无业务判断 / 仅做确定性 schema/parse/单位 校验". 错误码flyto.ErrReflectorDoubleWired+engine.ErrReflectorDoubleWired双声明 mirror (沿用 ADR-0006 mirror 模式让消费者用 errors.As 一致识别). - C2 (
651b504) engine.Config 字段重命名 + 类型收紧到StructuralValidator+ Toolset 双挂检测 + 全消费者迁移:cfg.ResponseValidator validator.Validator→cfg.ResponseReflector validator.StructuralValidator(含ResponseValidatorMaxBlocks→ResponseReflectorMaxBlocks同步,responseValidatorBlocks变量 +"response_validator_*"WarningEvent code 同步 batch rename). godoc 第一行红线引 ADR-0008 v2 + CRITIC 框架 + 业务校验三条决策层路径 (main agent verdict cross-check / staging ML / await_human_input). 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+ 编译期断言改validator.StructuralValidator;JSONVerdictValidator同款嵌入 + 编译期断言同步;quote-engine-probe+reflect_tool.go+responseguard/doc.go字段引用全 batch rename. - C3 (本 commit) doc + ADR-0008 § 8 v2 + CHANGELOG + CLAUDE.md prepend + 引擎双挂检测 5 单测:
core/pkg/engine/reflector_double_wire_test.go新文件 (TestNew_ReflectorDoubleWired_FailsFast 双挂拒返 ErrReflectorDoubleWired / TestNew_ReflectorAndDifferentNamedTool_OK 不同名通过 / TestNew_ToolsetNoneWithReflector_OK ADR-0008 v2 quote-engine-probe 典型配置 / TestNew_NoReflector_OK 向后兼容 nil 路径无侵入 / TestStructuralValidator_SealedInterface_QuoteResponseReflector 编译期断言 marker 机制) + ADR-0008 § 8 v2 修订记录 (含 r31 v1+v2+v3 实证表 + 4 反向论证 + 业界对照 + CRITIC 框架对照 + 业务校验三条决策层路径 + hook 列表方案否决理由 + tracked debt 调整) + 本 CLAUDE.md prepend + HANDOFF.md 删 (跨用户接手完成).
ADR-0008 v2 引擎层契约红线: 反射器槽位 (cfg.ResponseReflector) 仅接受 validator.StructuralValidator (sealed sub-interface, 强制嵌入 StructuralMarker 声明契约). LLM-backed 业务校验禁止挂此槽位 — 业务校验赶决策层三条路径: (1) main agent verdict cross-check (LLM 协议层决策当下) / (2) staging ML 验证 (决策提交前 ML 二审, L434 待加) / (3) await_human_input (人工最终判断). 跟 ADR-0006 § 3 + ADR-0007 § 2.3 红线同精神 — 设计层契约 + 类型签名 + godoc + 替代路径显眼.
hook 列表方案否决: 中途讨论提"升华版" cfg.Hooks.PostResponse []validator.Validator 跟 PreToolUse 同形态 hook 列表. PM 戳穿 "实践证明业务反射器不可靠, 反射器只做基础逻辑校验" — hook 列表反而鼓励错误用法 (消费者看到能挂多个反射器自然想加 schema + 业务两个反射器, 业务反射器违反 CRITIC 框架). 撤回 hook 列表方向, 走类型签名层收紧 (StructuralValidator sealed) + 业务校验赶决策层 — 不是放宽, 而是收紧.
业界对照: Pydantic AI 的 result_validators 跟 tools= 列表是装饰器 vs 参数两个名字空间, 语言层面就不能交叉 (隐式 sealed 模式). Claude Agent SDK / OpenAI Assistants / LangGraph 没有 ResponseValidator + Tool 双 wire 形态. 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 决策违反此点.
测试 -race 全绿: core engine 5 双挂检测单测 + validator 全套含编译期断言 + 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; sub round 1 直接按推断填全部字段; main agent v4-pro cross-check sub vs 原表 dump 应识别 4 行单带费 base_weight 强猜 → verdict=await_human_input + human_question 单行问深圳; PM 答深圳后 sub round 2 复用 session 重抽只更新深圳不跨行扩散; round 2 main cross-check 上海 / 苏州 / 崇明 还是强猜 → verdict=await 下一行 → 多轮收敛. 装配期双挂检测验证: 任何未来消费者把同名 Tool 既挂 Toolset 又挂 ResponseReflector → engine.New 立即拒构造返 ErrReflectorDoubleWired, 不到 runtime.
Engine: maybeCompact + forceCompact 全 5 路径 emit CompactEvent — observability bug 修复 (2 commit, 2026-05-02)¶
engine.go maybeCompact + forceCompact 5 条压缩路径历史上仅 1 条 emit *flyto.CompactEvent, 其余 4 条 silent — 消费者 (probe / orchestrator) 看到 token 预算回弹但拿不到事件, 无法在 banner 区分 micro/full compact 还是其他原因. 5 处补 emit + 加 CompactEvent.Kind 字段 ("" legacy / "micro" / "full") 让消费者根据 kind 区分压缩档位.
Commit 顺序:
- C1 (
7172f04) engine,probe: 5 silent compact 路径补 emit +CompactEvent.Kind字段; probe banner 加[compact:%s]kind 显示 - C2 (
d587cc6) test(engine):TestMaybeCompact_EmitsFullCompactEvent完整压缩 emit 实证 (236 行 mock provider 测试)
测试 -race 全绿: events_test.go schema 测试 + compact_event_test.go 完整压缩 emit 测试. 既有不破坏.
ADR-0008: quote-engine-probe LLM/Agent 协议层 4 设计修正 (3 commit, 2026-05-02)¶
ADR-0007 follow-up r26+ 物流业务 round 2 收敛后, PM review 价格输出反思 4 个我 (Claude) 之前没经讨论擅自做的设计: (1) sub_agent.md schema 跟 WMS 不对齐 / (2) 接受"无法识别"部分 (无 ambiguity marker) / (3) main agent 与人对话能力 (verdict 只 ok|retry) / (4) 日期合法性校验从 LLM prompt 移到 Go 后处理. PM 拍板"一气呵成" 4 项一起改, 走 3 commit 节奏.
Commit 顺序:
- C1 (
4616ae2) sub_agent 直出 WMS schema + _uncertain marker + reflect_tool WMS 输入: sub_agent.md 输出 schema 改{master + details[] + return_fee?}直接对齐 WMSShipCostCfgMaster + ShipCostCfg(parser/adjustment.go ADR-0004 alpha.11+ 实装). 0 in-process mapping 落库. 加单位铁律段 (g 锚: 1kg=1000) 让 LLM 不照搬 kg. 加 _uncertain ambiguity marker 三件套 (_uncertain bool + _raw_text + _reason). 删日期合法性校验段. 4 例 (5 段全功能 / 3 段无单带费 / 偏远独立 PM 强调今 sample 漏过 / _uncertain marker 示范). reflect_tool.go 输入改 WMS 形态,_uncertain=true行 skip 不构造 band. 反射器单测 +3 (UncertainSkipped / PassWithUncertainAndConcrete / StripFeeRouting). - C2 (
21fdadb) main verdict 加 await_human_input + Go 日期 clamp: mainAgentVerdict 加 HumanQuestion 字段 + parseMainVerdict 接受 "await_human_input" 第三态 + 校验 human_question 非空. main loop 加 await_human_input 分支:bufio.Reader.ReadString('\n')不用 Scanner (advisor 提示 64KB silent truncate) + EOF (Ctrl-D) 当 abort + 答复包到 prevFeedback. 加lastDayOfMonth + clampOverflowDate + postProcessFinalJSON:time.Parse失败且日越界 (4/6/9/11→30 / 2→28-29 含 Gregorian 闰年 / 其余→31) clamp + warning. main_agent.md 加决策树 5 条 (选 ok/retry/await 哪条) + 字段互斥规则 + "日期合法性不归你管"段. 单测 +14 (clampOverflowDate 4 块 / postProcessFinalJSON 5 块 / parseMainVerdict 3 块). - C3 (本 commit) doc + ADR-0008 + TODO + CLAUDE.md 同步: ADR-0008 (8 节体例对齐 ADR-0007: Status/Date/Deciders/Related/Context/Decision/Alternatives/Reverse thinking/升华/Triggers/Status note + 修订记录) + 业界 HITL prior art 对照 (LangChain ask_human / OpenAI Assistants requires_action / Claude Agent SDK ElicitationHandler / CrewAI HumanInputMode) + WMS-aligned schema 对照 (Stripe Products+Prices / ShipBob ServiceLevels / Pydantic AI schema validator+retry).
ADR-0008 § 2.3.3 红线: await 路径走 stdin 单进程, 不抽 PermissionBus / SSE / IM bot 接口 (rule of two: probe 是当下唯一 consumer, IM bot 是第二个时启动抽象). 业务 logistics 接 IM 时升级到 SDK 接口, stdin 路径作为 reference 实现保留.
ADR-0008 § 2.1.2 红线: parser/quote_to_bundle.go (441 行 mapper) 不动 — bill-recon 平行 vlm 路径在用, 跟 quote-engine-probe 不耦合. 等 vlm 路径未来也直出 WMS schema 时一起退役 (§ 6 触发条件登记).
测试 -race 全绿: 14 quote-engine-probe 单元测试 (clampOverflowDate / postProcessFinalJSON / parseMainVerdict) + 7 billrecon/llm reflect 测试 (含 3 新 _uncertain / strip routing 等) + 既有不破坏.
实证待跑: PM 用 ytosample.xlsx + minimax main + deepseek-v4-flash sub 跑 r27+, 期望 round 1 sub 标 _uncertain (深圳/崇明) → main verdict=await_human_input + human_question → probe stdin 阻塞 → Claude 充当桥梁 PM 答 → round 2 收敛.
ADR-0008 v1.1 follow-up: r27-r30 实证暴露 3 层独立问题 + 引擎 NewSession API 设计 (5 commit, 2026-05-02)¶
ADR-0008 v1.0 (3 commit) 落地后 PM 跑 r27-r30 实证, 暴露 3 层独立漏洞 + 1 个引擎 API 设计问题, 5 commit follow-up 落地修补. ADR core/docs/adr/0008-quote-probe-protocol-revisions.md § 8 v1.1 修订记录.
Commit 顺序:
- C4 (
e7d2c09) reflect_tool 容忍 markdown 围栏 + prose — r27 实测 deepseek-v4-flash sub agent 给最终 JSON 加json ...围栏, 引擎 ResponseValidator 路径 (走 QuoteResponseValidator → ReflectQuoteTool.Execute) 严格 unmarshal 撞 'å' 拒收 (block 1/3 → block 2/3), sub 重试 loop 跑超 perSessionTimeout 10m. PM 评 "比较傻 — 人家只是加了个 json 围栏". 实际数据 sub 抽对了 (31 省按一/二/三区分清续重价 0.6/1.5/2 + 偏远 6 省独立段 + 5 个 _uncertain 单带费), 只是输出形态被 strict unmarshal 拦下. 修法:ReflectQuoteTool.Execute第一次严格 unmarshal 失败时跑extractJSONPayload(剥单层json ...围栏 + 截首 '{' 末 '}' 剔 prose) 再 unmarshal. happy path 0 开销 (干净 JSON 第一次就 pass), 真 malformed (纯 prose 无 JSON) 仍 IsError 不软化契约. Tool 层鲁棒让两路径 (sub agent tool_use + engine ResponseValidator) 都受益. +4 测试 (TolerateMarkdownFences / TolerateProsePrefix / TolerateCombinedFenceAndProse / StillRejectsTrueGarbage). - C5 (
16cf5a9) postProcessFinalJSON 也加 fence 容忍 — r28 实测 sub 4m33s 出 final + main verdict=ok 5m 收敛, 但postProcessFinalJSON撞同款 fence: sub final text "反射器通过 0 违规。现在输出完整 JSON:\njson\n{...}\n", strict json.Unmarshal 撞 'å' 报错 → 跳过日期后处理 → outPath 写的是 prose+围栏混合 (消费者拿到无法 1:1 unmarshal, 违反 ADR-0008 § 2.1 "0 mapping 落库" 前提). 修法: postProcessFinalJSON 入口先调extractJSON(它本身已经先 stripFences 再截首 '{' 末 '}'), 然后 unmarshal cleaned payload. 即使没 clamp 改动, 返回 cleaned payload 让 outPath 写干净 JSON. 重命名末段 cleaned → indented 避免与新顶级 cleaned 重名. +1 测试 (TolerateFencesAndProse 验 r28 真实形态: prose 前缀 +json 围栏 + 后缀 prose + 内含 9月31日 → clamp 9月30日, outPath 不残留或 prose 字面). - C6 (
a14a3c6) main_agent 见 _uncertain 必 await 强制不绕 — r29 实测 main 见 4 个 _uncertain=true 行 (上海/深圳/苏州/崇明 单带费) 自评 "标记合理 ... 均属原表模糊范畴" 直接 verdict=ok 没 escalate. PM 拍板 "如果有模糊部分得问人, 停下来, 等指示" — main 不应自己判断 "是不是真模糊", 见 _uncertain 必走 await. main_agent.md 决策树改: 第 3 条 "歧义源于原表本身模糊→await" 改 "任何 _uncertain=true 行→await 停下问人不绕过" / 第 4 条 "_uncertain 可 prompt 教 sub agent 解→retry 教规则不问人" 删除 (即使能教也不绕过, 让人确认才上路) / 重要段补 "判不准时倾向 ok 但 _uncertain 不属于此条". 效果: 见任何 _uncertain → 必 escalate stdin → PM 答 → 下轮 sub 重抽该行落 _uncertain=false 或更新值. 即使下游可以 query _uncertain=true 看 marker, main 也不能跳过 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 答经 prevFeedback 单一字符串覆盖, round 7 PM 答上海/苏州/崇明 3 条覆盖了 round 4+6 答深圳的内容, sub round 8 session 是 fresh 看不到深圳曾被答过. PM 反问 "session id 字面就是会话标识不是 turn 标识" 直接戳穿 — 我把"会话"当成"轮次"用了, 概念错位.engine.Session.Send(session.go:117) 本来就支持: 同 session 多次 Send 自动累积 message history (快照 history 喂 Run + trackEvents 完成时 append user/assistant 回 s.messages). 修法: subSession 在 loop 外建一次 "sub" 单 id 跨 round 复用; round 1 user prompt 同原来 (开场指令), round 2+ 直接发 PM 答 / main feedback 短消息 — sub 自己 history 记得自己上轮出过什么 + main 的 question, 不再 wrap "上一轮未通过, 反馈如下..." 长 prompt 重发 (旧实现重发让 history 同信息 2 次记录浪费 token + LLM 可能误以为是新独立任务); await 路径 prevFeedback 简化"人工答复:, 请按答复修正对应 _uncertain 行" 不再重发 question + reason; verdict=retry+new_sub_agent_prompt 路径必 close+rebuild engine+新 session (system prompt 漂 history 失效); loop 退出后一次性 subSession.Close() (sync.Once 保护 retry 路径已 close 的不会重 close). prompt cache 自然受益 (Anthropic 5min TTL + DeepSeek KV cache, multi-turn 重发同 system+history 高 hit). - C8 (本 commit) engine 加 NewSession() + Session(id) godoc 补完整 + 4 单元测试 + ADR-0008 v1.1 — advisor 关键洞察:
Session(id)强制要 id 把消费者推到 "loop 内造 id" 错误思考线上, 让 probe 作者顺手抓 round 编号当 session id. 加Engine.NewSession() *Session自动生成 id (session-<unix-nano>-<8 hex>格式镜像 generatePlanID, plan_queue.go:670), 让"开新会话"无 id 钩子, 消费者自然写 loop 外建一次.Session(id)godoc 补完整 (双语 ~110 行, 含 multi-turn idiomatic 范式 + 反模式 r30 警告 + 何时用 vs NewSession + Close 后语义 + context window 自动压缩). 4 单元测试 -race 全绿 (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 § 8 修订记录 v1.1 含 5 commit + 业界对照 (Claude Agent SDK create_session / OpenAI Assistants threads.create / LangGraph Checkpointer + thread_id / CrewAI memory=True flag).
核心发现 — agentctx 三件套已就绪不必新加: PM 反问 "明明记得有会话压缩和会话记忆存档" 戳穿 sub agent 调研漏扫. 实查 core/pkg/context/ (1300+ 行) 含 Compactor / CompactionPolicy / CompactCircuitBreaker 防自压自杀循环 / MicroCompact / EstimateTokens / WithTokenGap 精确跳步; core/pkg/engine/session_snapshot.go 含 SessionSnapshot + SnapshotStore + FileSnapshotStore + ResumeConversation 跨进程恢复; core/pkg/engine/session_persist.go 含 Transcript + SaveTranscript / UpdateTranscript / LoadTranscript; engine.go runLoop 已 wire compact (engine.go:3741 tokenBudget.AutoCompactThreshold + context_calibrator.go 实测校准 + ErrContextOverflow 已分类). 本次 ADR 仅在 NewSession + Session(id) godoc 链接这些已就绪机制让消费者发现, 不新加.
关键概念错位 — session id ≠ turn id: 这是本次 follow-up 最深层教训. probe 把 round 编号当 session id 是把 "会话" 当 "轮次" 概念错位. session 是承载多轮对话的容器, 一个 session 可以 Send 多次累积 history; turn 是 session 内的单次 user/assistant 交换. PM 直接戳穿 "session id 字面就是会话标识不是 turn 标识" — 引擎层 godoc 一行 "创建一个有状态的多轮会话" 没明写 get-or-create + 多轮范式 + Close 后语义, 是 godoc 单薄但不算误导 (字面读对了就懂). 真正错的是 API 设计强制要 id 把消费者推到 "loop 内造 id" 思考线 — 加 NewSession 移除此钩子.
业界对照新增 (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 同档抽象.
测试 -race 全绿: core 4 NewSession + platform/common 14 quote-engine-probe + 11 billrecon/llm reflect 测试 + 既有不破坏.
实证待跑 (PM 拍板触发): r31 用新 probe (sub session loop 外复用) + 新 engine API. r30 因 PM 答深圳 round 4+6+8 三次 sub 都没消化触底 max rounds 已 kill; r31 期望同款 PM 答只需一次, sub session history 自然累积消化所有答复 round 1-3 内收敛.
ADR-0007 follow-up: DeepSeek 官方直连 + TD-20 实证 + Mix engine 物流胜利 (5 commit, 2026-05-01)¶
ADR-0007 Capability tracking 决策的实证 + 第 5 个 direct provider 接入. PM 反向论证否决 "openai provider + BaseURL 借壳" 工程捷径, 拍板 deepseek 走独立子包 (与 ADR-0007 § 2.1 direct provider 列表一致). TD-13 + TD-20 落地实施 + 实证, 物流业务 r 系列首次见 deepseek-v4-flash 收敛 (r22-r25 全失败 → r26+ round 2 收敛).
Commit 顺序:
- C1 (
79a5be2) wire/openai.go DeepSeek 顶级 prompt_cache_hit_tokens 字段 fallback: openaiChunk.Usage 加 PromptCacheHitTokens / PromptCacheMissTokens 顶级字段, buildUsageEvent 在嵌套 OpenAI 字段 (prompt_tokens_details.cached_tokens) 未填时用 deepseek 顶级字段 fallback. 嵌套优先保证 OpenAI 原生路径零回归; 双填假设性 provider 走嵌套防双计数. ADR-0007 § cache mapping 跟踪. 2 测试新增 (DeepSeekTopLevelCacheHit / NestedFieldWinsOverTopLevel). - C2 (
01b2022) deepseek provider 子包 (ADR-0007 § 2.1 第 5 个 direct provider):core/pkg/providers/deepseek/双模式 (ModeOpenAI 默认主路径 / ModeAnthropic 备选, 跟 minimax 同款). 2 V4 model 静态表 (deepseek-v4-flash + deepseek-v4-pro), ModelInfo ADR-0007 三字段静态填: ProviderKind="direct" + ToolNameRegex=^[a-zA-Z0-9_-]+$+ ReasoningPassbackMode="string" (r24 真因 + 官方 /guides/thinking_mode 文档双确认). resolveCapabilities provider-level fallback 让消费者忘记 RegisterModels 也能接通 reasoning passback (硬要求, 否则多轮 tool calling 必被服务端 400 拒). shared.CheckNoImageBlocks 在 Stream 入口拒 image (V4 文档无 vision 支持). 13 unit test 全绿 -race. - C3 (
a6f0c1d) capability-probe 接入 deepseek + caching ladder 路径: 加 DEEPSEEK_API_KEY env 读取/校验 + deepseek 2 targets (走 ModeOpenAI 主路径). caching 探测路径修复: 默认 generic 路径系统提示只 ~10 tokens 触发不了 deepseek auto-cache, 给 deepseek target 设 cachingProvider 走 probeCachingProvider 的 100/300/600/1200 重复 phrase ladder. 实证: 7/7 capability ✓, cache rd=1152 接通. - C4 (
02cf0b3) TD-20 capability tracking 三 prober 实测: CapabilityResult / target / ModelCapabilities 三 schema 扩 ADR-0007 三字段. probeToolNameRegex (单点探测 dotted name) + probeReasoningPassback (2 round-trip 测 reasoning_content passback 协议) + ProviderKind 静态标 (注册期人类知识). 5 target 注册点全填 providerKind. 实证完美命中: deepseek-v4-flash 服务端字面回 regex^[a-zA-Z0-9_-]+$(跟 ModelInfo 静态值一致) + passback "string" 报 r24 真因 "must be passed back" (字面). OpenRouter 经 Azure 路由 claude 4.6 系列实证 regex^[a-zA-Z0-9_-]{1,128}$(实际比 anthropic provider 静态填的 1-64 更宽, TD-26 follow-up). ADR-0007 § 2.2 bifurcate direct vs aggregator 决策得到强力实证: 同一 deepseek-v4-flash model id, direct strict + passback string vs aggregator permissive + passback none. - C5 (
ff08515) quote-engine-probe 接入 deepseek + 物流 r26+ 实证胜利: 加 deepseek 选项 (主+子可独立选 minimax/openrouter/deepseek). 跑参 main=minimax-M2.7-highspeed sub=deepseek-v4-flash 官方直连 → round 2 收敛 (7m37s). 跨厂商主+子架构验证通过, ADR-0007 C4 wire 层 reasoning_content passback 在生产场景真生效 (子 agent ~6m thinking + 多轮 tool calling 没被 deepseek 服务端 400 拒).
ADR-0007 § 2.2 bifurcate 实证矩阵 (TD-20 prober 真实数据):
| Path | model id | regex | passback | 来源证据 |
|---|---|---|---|---|
| direct (deepseek 官方) | deepseek-v4-flash | strict | string | 服务端 400 字面 ^[a-zA-Z0-9_-]+$ + "must be passed back" |
| aggregator (OpenRouter) | deepseek/v4-flash | permissive | none | 后端路由 (SiliconFlow/Together) 不预校验 + 不强制 passback |
| aggregator (OpenRouter→Anthropic) | claude-haiku-4.5 | strict | - | 服务端 400 字面 ^[a-zA-Z0-9_-]{1,128}$ |
| aggregator (OpenRouter→Azure 新发现) | claude-opus-4.6 | strict | - | Azure 400 字面 ^[a-zA-Z0-9_-]{1,128}$ (anthropic provider 静态 1-64 实际更严) |
物流 r 系列对照 (业务命门追踪): - r21 minimax-only sub agent: 0 violations 收敛 (历史 baseline ✅) - r22-r25 deepseek-v4-flash via OpenRouter: 全失败 (engine 中性化 + ADR-0007 之前) - r26+ deepseek-v4-flash 官方直连 (本次): round 2 收敛 ✅ — ADR-0005 + ADR-0006 + ADR-0007 累积修复全栈胜利
Tracked debt (ADR-0007 § 7 调整):
- TD-13 (capability-probe tool name regex 启动期校验) → drained (probe 实证完成, 数据落 capabilities.json)
- TD-20 (capability-probe 实测扩 3 项) → drained (probeToolNameRegex / probeReasoningPassback / probeProviderKind 全部实施 + 实证)
- TD-22 (OpenRouter per-model ReasoningPassbackMode 实测填) → partial (probe 已跑出 8 model 数据, 真消费者用 RegisterModels 接通留 follow-up)
- 新 TD-26: anthropic provider 静态 ToolNameRegex ^[a-zA-Z0-9_-]{1,64}$ 实际太严, OpenRouter 经 Azure/Anthropic 实证 1-128, 调研真实上限 + 调整
- 新 TD-27: OpenRouter→Azure 路径 (claude 4.6 系列实测发现) — capability 跟 Anthropic 直连可能不一致, 监控+对照
- 新 TD-28: anthropic / minimax provider 也跑 TD-20 prober (本次未跑节省配额)
测试 -race 全绿: wire/openai_test.go +2 (DeepSeek cache fallback) + deepseek 13 unit test + capability-probe 既有不破坏.
ADR-0007 Capability tracking 接入纪律 + Bug W tool_call_id dedup hotfix (6 commit, 2026-05-01)¶
物流业务命门 r22-r25 序列暴露 "修一层揭露下一层" wire 协议 gap (tool 名 regex / reasoning_content passback / tool_call_id dedup), PM 反思真因不是 5 个独立 bug 是同一底层问题 — 引擎对每个 (provider × model) 能力没真测过全走兜底假设. 3-agent 并行 review reconcile 后走方案 B' (3 字段 schema 扩 + opt-in flag 无 strict + bifurcate direct/aggregator + OpenRouter live metadata 消费扩展).
Commit 顺序:
- C1 (
09b6bc1) Bug W dedup hotfix: wire/openai.go flytoMessagesToOpenAI RoleAssistant 加 seenToolCallIDs map 去重. 单 message 内同 tool_use_id 重复 silent skip; 空 ID 不 collision 误吞. 跟 final_text_duplicate_blocks (engine.go:5039) 同源模型纪律漂移. 切出 ADR-0007 范围 (engine/wire 实现 bug 不是 capability 维度). 真因调研留 TD-19. 3 测试新增. - C2 (
dac31d6) ModelInfo 加 3 字段:ToolNameRegex string(provider 强制 regex) /ReasoningPassbackMode string(enum: ""/"none"/"string"/"details_array") /ProviderKind string(enum: ""/"direct"/"aggregator"). 零值=未知=旧行为零回归. 双语 godoc + 替代方案保留入注释. - C3 (
f77b2c3) 4 direct provider modelInfo 静态填值: anthropic (regex^[a-zA-Z0-9_-]{1,64}$/ passback "none" / kind "direct") + openai (^[a-zA-Z0-9_-]+$/ "none" / "direct") + minimax (^[a-zA-Z0-9_-]+$/ "" 暂留 / "direct") + gemini (^[a-zA-Z0-9_-]+$/ "none" / "direct"). 4 文件 batch replace_all 修改. - C4 (
17f7ba4) wire 层 capability-aware: openaiMsg 加 ReasoningContent omitempty 字段 + StreamRequest 加 ReasoningPassbackMode + ToolNameRegex 字段 + flytoMessagesToOpenAI 签名加第 4 参数 + RoleAssistant BlockThinking 累积 + mode=="string" 时 inject reasoning_content / 其他 mode 跳过 (零回归). wire/tools.go 加 ValidateToolNames(tools, regex) pre-flight 校验返 ErrModelToolUnsupported typed error. openrouter Provider Stream 接通 (req.Capabilities → wire StreamRequest 透传). 7 测试新增. - C5 (
16423ac) OpenRouter live metadata 消费扩展: FetchOpenRouterModels 扩展消费 architecture.input_modalities (auto SupportsVision) + top_provider.max_completion_tokens (实际后端上界优先 root) + pricing.input_cache_read (auto SupportsCaching) + ProviderKind="aggregator" 默认 + ToolNameRegex^[a-zA-Z0-9_-]+$默认. 1 测试 fixture 镜像 r24 实证 deepseek-v4-flash + claude-sonnet-4.6. - C6 (本 commit) ADR-0007 + 文档同步:
core/docs/adr/0007-capability-tracking-intake-discipline.md8 节体例对齐 ADR-0001/2/3/5/6 + § 2.2 bifurcate direct/aggregator + § 2.3 红线 (capability 信号驱动 wire 行为 ≠ auto-fallback) + § 物流锁 minimax sub agent 隐性约束 + 7 tracked debt (TD-19..25). TODO/CHANGELOG/CLAUDE.md 同步.
业界对照: SDK 走 string + server-validates (Anthropic/OpenAI Python); 聚合层 (LiteLLM/Aider) 走 PR-maintained model db 30+ 维度. Flyto 此前 ~7 维度 + 无 capability-aware wire 行为, ADR-0007 拉到 LiteLLM 同档.
ADR-0007 § 2.3 红线: capability code 仅供 wire 层信号化决定行为, 引擎层禁用 capability code 驱动 auto-fallback. 与 ADR-0005 引擎中性化 + ADR-0006 § 3 红线 性质相同.
与 ADR-0006 互补: ADR-0006 事后归类 (provider 4xx 暴露真因), ADR-0007 事前预防 (wire pre-flight reject + capability-aware inject). 不互斥.
§ 物流业务隐性约束: v0.5 周期物流 sub agent 锁定 minimax (r21 0 violations 收敛), ADR-0007 期间不切换其他 model 探索. escape hatch: 消费者可 RegisterModels 自填 capability 字段绕过.
Tracked debt: TD-19 engine 重复 emit 真因调研 / TD-20 capability-probe 实测扩 3 项 / TD-21 wire details_array mode 实装 / TD-22 OpenRouter per-model ReasoningPassbackMode 实测填 / TD-23 input_modalities 全 modality 字段 / TD-24 消费者 RegisterModels 接通 / TD-25 lmstudio/ollama capability 推断.
测试 -race 全绿: wire 全套 + 4 provider 全套.
实证待跑: r26 (改名 + Bug W dedup + capability-aware) 物流业务命门继续走 minimax sub 锁定不变, deepseek-v4-flash 探索由消费者 RegisterModels 接通后跑.
ADR-0006 错误分类 typed ErrorCode + wire→engine fail-loud cause 链 (6 commit, 2026-05-01)¶
物流报价表抽取业务命门 r22 (OpenRouter + deepseek-v4-flash) 3s 内 internal_error 中文兜底吞掉真因. 3-agent 并行 review reconcile 实证 task spec 假说 ("wire reasoning_details JSON tag 错") 全错 — 真因是 (1) 业务调用方 bug: 工具名 billcost.reflect 含 . 违反 OpenAI function-calling regex ^[a-zA-Z0-9_-]+$, OpenRouter 转底层 SiliconFlow HTTP 400 reject + (2) 独立的引擎 fail-loud bug: HTTP 非 200 路径不读 body 直接返 "openai_compat: http %d", parseNonSSEError 不解 OpenRouter 嵌套 metadata.raw, EngineError.Error() 不暴露 Detail, ClassifyAPIError 走字符串模式 fallback 默认 ErrInternal 折叠掉具体形态. 两件事性质独立都要修.
Commit 顺序:
- C1 (
f140eb1) 业务 fix:core/pkg/billcost/tool.goReflectTool.Name"billcost.reflect"→"billcost_reflect"+platform/common/internal/billrecon/llm/reflect_tool.goReflectQuoteTool.Name + validator_adapter.go QuoteResponseValidator.Name + PolicyVersion"billcost_reflect.v1"+ 测试断言 + sub_agent.md prompt + probe 注释全对齐. CLEVER 注释入档 ReflectTool.Name + reflect_tool.go 包注释说明 OpenAI regex 限制让以后加新 tool 不再撞. - C2 (
e009350) wire OpenRouterparseNonSSEError解嵌套 metadata.raw: 顶层 message="Provider returned error" + metadata.raw 内嵌真错误 (SiliconFlow{code,message}形态 / OpenAI passthrough{error:{message,type}}形态). 解一层 raw 抽真错误 + 标 provider_name; 形态未识别回退顶层 message; 故意不递归保 surface 小可预测. 3 测试新增 (SiliconFlow / OpenAI / unrecognized fallback). - C3 (
960445c)EngineError.Error()拼 Detail:Message: DetailGo 经典 wrapping 形态; 字符串路径让 fmt.Errorf / log.Fatalf /%v调用方零侵入看到真因, 不必 type-assert. 替代方案 (B 方案 ErrorEvent.Detail 字段不改 Error()) 入注释 — 80% caller 走字符串场景 B 不解决人类可读问题. 2 测试新增 (Message+Detail / Detail-only). - C4 (
152903d) 加 5 typed ErrorCode 词汇表: ErrProviderHTTPStatus / ErrProviderNonSSE / ErrProviderMidStreamErr / ErrWireUnmarshal / ErrModelToolUnsupported + Suggestion (actionable hint 不是 auto-action) + Retryable 默认全 false (消费方决定). ADR-0006 § 3 红线 godoc 入档: 这些 code 仅供分类, 引擎层禁用驱动 auto-fallback (如静默关 tools / 自动改 model) — 与 ADR-0005 引擎中性化精神连续. - C5 (
b3a083c) wire→engine 透传 cause 链 + ErrorEvent.Detail:core/pkg/flyto/errors.go同步加 5 typed code (wire 层只 import flyto 不 import engine, 必须镜像) +EngineError.Error()镜像拼 Detail;core/pkg/flyto/events.goErrorEvent 加 Detail 字段;core/internal/wire/openai.goHTTP 非 200 路径修真出血点 (旧实现不读 body 现统一读经 parseNonSSEError 抽消息包成 flyto.EngineError 持 ErrProviderHTTPStatus + Detail), 200+非 SSE 包成 ErrProviderNonSSE, mid-stream chunk.error 包成 ErrProviderMidStreamErr;core/internal/wire/gemini.go同款 (rule of two 第二条 wire 路径触发); anthropic 走 transport.classifier api.APIError 已 typed 不动 (engine.go:4278 已 errors.As 接), minimax 用 OpenAICompatClient 自动继承 openai.go fix;core/pkg/engine/errors.go加 ClassifyAPIErrorTyped(error) errors.As 优先 typed code 字符串 fallback, WrapError 加 flyto.EngineError 分支让 Detail 直接复用避免三层字符串叠加;core/pkg/engine/engine.go三处 retry caller (4214/4234/4406) 改用 ClassifyAPIErrorTyped + newErrorEvent errors.As 链同时识别两 sibling type 透传 Detail. 5 测试新增. - C6 (本 commit) ADR-0006 + 文档同步:
core/docs/adr/0006-error-classification-typed-errors.md8 节体例对齐 ADR-0001/2/3/5 + TODO.md / CHANGELOG.md / CLAUDE.md "最后变更" 段同步.
业界对照: Anthropic Python SDK / OpenAI Python SDK / Vercel AI SDK / LangChain 全 typed error + cause 链 + 字段访问. Flyto 早期走字符串 pattern match 是反模式, 本 ADR 对齐业界.
ADR-0006 § 3 红线: typed code 仅供分类, 引擎层禁用驱动自动行为. 例外是 ADR-0006 之前已立的 ErrContextTooLong 自动压缩重试 + ErrAPIRateLimit/Overloaded 自动 retry, 不动. 新加 5 code 都不挂 auto behavior.
Tracked debt 登记:
- TD-11: flyto.EngineError + engine.EngineError sibling 合并 (历史重复, ADR 接受 patch 不修, v1.0 release 评估)
- TD-12: anthropic 路径 transport.classifier *api.APIError 与 wire 层 typed error 合一评估
- TD-13: capability-probe 加 tool name regex ^[a-zA-Z0-9_-]+$ 启动期校验避免运行时 4xx (跟 ErrModelToolUnsupported 配套)
- TD-14: lmstudio / ollama / openrouter 等剩余 provider 是否同款 typed error 迁移 (rule of two 触发条件)
测试 (-race 全绿): core/pkg/engine/errors_test.go +5 + core/internal/wire/openai_test.go +3, 既有测试全过 (TestEmitCheckpointSuggested_BashDangerous panic 是 pre-existing 无关).
实证: r23 改名后 thinking + tool_use 路径 (不再 3s 立挂); r24 background 验证 ADR-0006 fail-loud 通路 — 跑挂时 ErrorEvent 持具体 typed code + Detail 而非泛化 ErrInternal.
Session.trackEvents 取消路径 fail-loud (1 commit, 2026-05-01)¶
core/pkg/engine/session.gotrackEvents在ctx.Done()/s.done分支 drain rawCh 之前先 best-effort 推送 (a) 当前从 rawCh 读到但未 转发的 evt + (b) 明确的*ErrorEvent(ctx 路径用WrapError(ctx.Err(), ErrInternal, "操作已取消"), session.Close 路径用WrapError(context.Canceled, ErrSessionClosed, "session closed")) 到 outCh; 两路 send 都select/default防 outCh 满时 阻塞 (饱和丢弃是设计行为, 消费方未读取是另一个问题). 抽 helperfailLoudCancel+drainRawEvents让ctx.Done与s.done两条 分支共用一个实现, 不发散.- 实证背景 — quote-engine-probe r17 round 1 真实数据: 共享
per-round
ctx, cancel := context.WithTimeout(ctx, 600s)在 sub agent 段触发 3 次final_text_duplicate_blocksretry 后 600s deadline 触底 (sub_dur=10m0s ctx_err=context deadline exceeded), trackEvents 进ctx.Done分支 drain 掉 sub 端 buffered DoneEvent — outCh silent close, 消费层 (probedrainSession) 看到range outCh退出但既无[done]也无[error], 无法区分 "引擎正常完成" vs "ctx 中途触底". main agent 用同一已 done ctx Send → engine.go:3611case <-ctx.Done()立即 emit操作已取消→ main 段也 silent close (Go select 多 case ready 伪随机, r15/r16 main 收到 ErrorEvent 而 r17 main 也 silent close — 同一 race 不同 outcome). - 修复后 — sub 端 ctx-deadline 触发会推 ErrorEvent code=internal_error
到 outCh, drainSession 收到 → return err → caller 立即
log.Fatalf暴露真因, 不会再 silent 退出当 OK; main agent 任何依赖 sub 端事件流 完整性的消费者 (orchestrator / SDK / TUI) 都得到明确的 cancel 信号. - 单元测试 5 个 (
session_failloud_test.go):failLoudCancel双 emit / outCh 饱和 nonblock /ctx.Done分支 fire ErrorEvent /s.done分支 fire ErrorEvent (code=session_closed) / drainRawEvents drain to close. Go select 多 case ready 伪随机用 12 iter 兜底 (单次 false-pass < 1/4096). 全绿 -race 3x. - probe-side 配套改动 (work-tree only, 不 commit) —
platform/common /cmd/quote-engine-probe/main.go把 per-round 共享ctx, cancel拆为subCtx/subCancel+mainCtx/mainCancel, 让 main agent 不再继承 sub 端 ctx-done 状态 + 加 timing printf 帮调试. trackEvents engine 修复 之后, 即使 probe 不拆 ctx 也只是 main 段会因同 ctx 触底立即得到清晰 ErrorEvent 而非 silent close, 数据形态可观测.
反射器 engine 强制响应闸 (3 commit, 2026-04-30)¶
- C1
core/pkg/engine:Config.ResponseValidator validator.Validator字 段 + runLoop 末端 wire — 当 LLM 出 final text 没调工具时 (stop_reason=end_turn), 引擎在 generic stop hook 之前先跑Validate(ctx, DiffInput{SourceTool:"LLMResponse", Raw: finalText}). fail (Verdict.Approved=false) 或非 nil error → 把 Reason 注入下一轮 user message, turn loop 继续; 被拒文本不作为最终答复推送. nil = 无侵入 (默认行为零开销). 复用validator.Validator接口不引入新 Response* 接口 —DiffInput.SourceTool + Raw已是 schema-agnostic opaque bytes (godoc 自述 "future HTTP-patch shapes"), LLM 文本响应正是同档情况. helperextractFinalAssistantText跳 thinking + tool_use 块, validator 看到的是消费方收到的最终答复. 5 单元测试全绿 -race (commitcc9ab20). - C2
platform/common/internal/billrecon/llm:QuoteResponseValidator把ReflectQuoteTool包成validator.Validator— 同包内复用 wrapper 的 segments+rows fan-out + summarize 逻辑, ExtraTools 工具路径 + ResponseValidator 闸路径共用一处实现.IsError→ Approved=false + Reason 透传 unmarshal 错让引擎注入 user message 时 LLM 知道哪段 JSON 错; 违规 → Approved=false + Details (violations + violation_count) 让 CompositeValidator / 审计槽按规则索引. 默认PolicyVersion="billcost.reflect.v1"供回放审计 pin. 6 单元测试全绿. 同 commit 把上轮已写未 commit 的reflect_tool.go也加进版本 (依赖) (commitfeb9c34). - C3 quote-engine-probe wire + sub_agent.md prompt 改写 — sub engine
装
ResponseValidator: &llm.QuoteResponseValidator{Tool: reflectTool}, 同一 reflectTool 实例两路径共享 Cfg + state. sub_agent.md 把 "你必须调用 billcost.reflect" 改写为"工具可主动调自查 (省一轮 retry), 引擎会强制跑同一反射器 — 不存在'绕过反射器直接出答案'路 径". r8/r9/r10 实测背景: r8 (qwen3-14B) 5 round 4 次跳反射器 / r10 (qwen3-32B) 反射器调稳 0 violations 但业务规则 (日期 / null 字段 / 重复 JSON) 反射器规则薄度漏判 — engine 强制兜住 r8 类, r10 类需 follow-upvalidator.CompositeValidator链 LLM-based 业务校验. - ADR-0001 修订: 反射器 engine wire 与反向思维 gate 不同时机 (反向思维
对应 PreToolUse, 反射器对应 stream 末端 final-text 验) 但同 hook-not-
engine 范式 — 复用
validator.Validator不开新 gate 接口 / 业界对照 Pydantic AI / Instructor schema validator+retry 模式吻合.
Hard Contract 系列 (5 commit, 2026-05-01)¶
引擎层 + tool 层对位 Claude Code 的 fail-closed 契约, 抽象层补齐让消费者 不需要手写 wire 也得到默认安全. 跟 ResponseValidator (cc9ab20) / Read-before-Edit (cc FileEditTool.ts:275-310) / SSRF / 路径黑名单同 范式 — 引擎默认开 + opt-in 替换, 不强制 / 不破坏既有 API.
e6e715acore/pkg/engine:Config.Temperature *float64 + TopP *float64wire gap 补齐. flyto.Request 字段 v0.3 L683 commitacae658已接通 7 provider passthrough, 但 engine.Config 当时只给 evolve LLMCallOpts 用没暴露 engine 消费层. r12 (Gemma 4 26B A4B MoE) probe 实测"输出纪律漂移" (JSON 多次重复 / thought 标签) 时无法降温 治理 — wire gap 跟 ResponseValidator 之前同源. buildFlytoRequest 签名 加 temperature / topP 参数, runLoop 传e.cfg.{Temperature,TopP}. 2 编译期 field type 测试.361dacecore/pkg/engine: final text 重复块 hard contract (引擎层 schema-agnostic, 不开新 Config 字段). probe r12/r13/r14 实测模型输出 纪律漂移 — 一次 final response 内输出多个内容完全相同的 ContentText 块 (r14 主 agent gpt-5.1 把同一个 verdict JSON 输出两遍 → probe 下游 JSON parse FAIL → 写空 verdict.json 还报 "converged" misleading).detectDuplicateTextBlocks块级而非拼接字符串扫描 (拼接后 schema- aware ResponseValidator 只看 corrupted 单串). runLoop 6.4 段排在 ResponseValidator (6.5) 之前, 命中 → WarningEventfinal_text_duplicate_blocks+ 注 user message 纠偏 + continue. fail-closed 类比 Read-before-Edit, 不让 corrupted 文本流给消费层. 不加独立 cap (MaxTurns 已限总 retry, 避免新 wire gap 候选). 5 单元 测试覆盖 NoDuplicate / ExactDuplicate / MixedThinkingAndDuplicate / EmptySkipped / DifferentTexts.e7c81beplatform/common/responseguard(新包):JSONVerdictValidator实现validator.Validator, 给 main agent (orchestrator) 装到engine.Config.ResponseValidator上. fail-closed 触发: 空 / parse 错 / 多 top-level JSON 拼接 (json.Decoder.More()抓 r14 主漂移: gpt-5.1 把 verdict JSON 输出两遍中间夹空白 → 解一个后 More() 仍 true) / top- level 非 object / RequiredKeys 缺字段. 与 sub agent 端业务反射器 (如QuoteResponseValidator) 形成 sub+main 双端守卫模式. doc.go 给配对 样例. 10 单元测试. 放 platform/common (非 core): schema-aware 守卫不 是引擎层 generic 结构检测, 多个 industry platform (logistics/ERP/sales) 共享"主 agent 出 JSON verdict"模式, helper 放 platform 层让业务 orchestrator 消费.041cebecore/pkg/tools/builtin: Read-before-Edit hard contract 强制, 对位 Claude Code FileEditTool.ts:275-310. 调研发现 Flyto 之前已实装 一半 —FileStateCacheRecorder(write 侧) +FileStateCacheEntry存 ContentHash/Size/LineCount/ModTime/IsPartialView, FileReadTool Execute 末尾已 RecordState. 但 FileEditTool 完全没查 (跟 Temperature/ TopP wire gap 同模式). 加FileStateCacheReader接口 (GetState 对偶 RecordState) +DefaultFileStateCache实现 both Recorder + Reader, RWMutex 线程安全, Reset() 对位 Claude Code commands/clear/conversation .ts:130. FileEditTool struct 加stateCache字段; 构造期 nil panic with friendly message (no opt-out, fail-closed by design). 删除 6 个 旧构造器 (NewFileEditTool/WithCwd/WithCache/Full/Complete/WithGuard) 替换为单一NewFileEditTool(stateCache, opts...)+ functional options (WithFileEditCache/History/Cwd/Guard). validate 在 ReadFile 后 / CRLF 转换前加三重 check: (1) 路径必须有 Read 记录 → 否则 "has not been read yet"; (2) 记录非 partial view → 否则 "partial view, read fully first"; (3) mtime 漂时走 content equality fallback (cloud sync / antivirus 改 mtime 不改字节假阳性) → hash 不等才 reject. FileReadTool collectFull 触发条件由fileCache != nil拓宽为(fileCache != nil || stateCache != nil), 让 stateCache 单独启用时 ContentHash 仍基于 raw bytes 与 Edit 端 hash 比对一致. engine.registerBuiltinTools 创建一次 共享 fileStateCache 给 FileRead + FileEdit (Run 内共享, 不跨 Run / 不跨 sub-agent 持久化). 5 cache 测试 + 6 contract 测试 + 18 旧测试 升级到 editToolForTest helper. 破坏性 API 变更 (v0.4 周期内消费者 数量小, 可破): 唯一 production caller engine.go 已升级.e522a3ecore/pkg/tools/builtin: Bash 危险路径黑名单 + WebFetch SSRF TOCTOU 加固. Bash 现状: bash_classify.go 仅做命令分类用于截断 策略 / 权限 UI 提示, 零 enforcement — rm -rf /etc 直通 execenv. 新增bash_dangerous_path.go对位 Claude Code utils/permissions/ pathValidation.ts:isDangerousRemovalPath. 黑名单: / / / 直接子目录 (/etc /usr /tmp /var /bin 等) / Windows 盘根 /$HOME本身 /*与/*通配; 子目录 (/usr/local / /tmp/build / ~/code) 不算危险开发 rm -rf dist/ 仍允许. extractRemovalTargets 简单 regex + Fields 锚定 语句边界 (^ ; && || | \n) 防 echo 'rm -rf /' 误抓; v1 不做 POSIX shell AST 留 v2. bash.go Execute 早期插入拦截 fail-loud reject. 设计差异 (vs cc): cc 走 user-in-the-loop ask (UI 弹审批可一次授权), Flyto v1 hard reject — LLM 看错误自纠. user-audit 通道 (ToolResult 加 RequireApproval 字段) 待 PM 决策驱动. 16 单元测试. WebFetch SSRF TOCTOU 微补: 已有 10 条 CIDR 黑名单 + safeDialContext- DNS 解析后 IP 逐条检查 + 字符串绕过防护非常完整. 仅
d.DialContext用原 hostname 调, Dialer 内部二次 resolve 留 DNS rebinding race 漏洞. 修: dial 用第一次 verified 的addrs[0].IP直接构造 host:port, Dialer 不再二次 resolve. HTTPS SNI 由 net/http.Transport 用原 hostname 设 ServerName 与 dial address 解耦, 此 hijack 不影响 TLS 证书校验.
调研副产品: B-9 SQL readonly 强制原计划项, 但调研发现 sql_validator.go 已完整实装 SQLValidatorTool (强制 SELECT/WITH/EXPLAIN 单条语句), 零 工作量已覆盖, 跳过编码工作仅文档同步.
新增消费者文档 core/docs/hard-contracts.md 给装 platform/industry 的
团队列引擎层全部 hard contract 总览 (类型 / 触发 / 配置 / 升级路径) 让
消费者看一份知道边界, 不用挖源码.
bill-recon C1-C10 + alpha.x 部署优化 (大头)¶
- alpha.5 → alpha.10 chat UI + workflow 链路: C5 LLM 列分类 (commit
2163d34) → C6 vision 调价图 vlm 抽取 + chat 确认 + price_adjustments 落库 (6f40c9c) → bills 列表+详情+删除 (e6f1258) → registry cache - Dockerfile mod download 单独 layer (
233baad/8db2089) → -alpha 跳过 godoc/docs build (2b97672, alpha cut 10.5min → 1.4min) → alpha.10 chat UI 显示原图 + vlm 失败标红 + 双提交 409 (44b277d). - ADR-0004 立项: PM C6 review 时拉飞驼内部 WMS 数据字典 (
CloudWM/02_Design/ CWM云仓数据库设计说明书.xlsxSheetCWMACCTShipCostCfgMaster + ShipCostCfgCostType=0/1), 指出当前 12 字段parser.Adjustment跟 WMS 现有承运商 价格表 schema 5 关键 gap (free-form 文本 vs 结构化首重续重定价 / Regions 数组 vs 一行一省 1:N / 无 StandardCostSysNo / Carrier 名字 vs ShipTypeId / 无重量段+体积重比+优先级). 拍板: 大修对齐 WMS, 不分阶段, 不落 WMS db 保留 bill-recon 自身两表镜像. 8 commit chain (S1 schema migration / S2 parser 拆 master+detail / S3 store / S4 LLM prompt + parse / S5 workflow - web / S6 review UI / S7 测试 / S8 bill detail 接新 schema).
v0.4.0 (2026-04-26)¶
v0.3.3 后的小步发版, 核心交付 platform 走 Postgres 平台化奠基 —
SessionStore 抽接口 + Postgres 后端 + db.Pool 共享 + docker-compose pg
service. ADR-0003 立 SessionStore 多副本边界 (三层进程内 pin 物理事实
+ sticky routing 必选). v0.4.0 是 v1.0 多副本部署能力的奠基, 但单副本
dev / docker-compose 行为零变化 —— cmd/common 不传 --postgres-dsn
时维持 InMemoryStore 默认, 与 v0.3.3 完全等价.
platform/common 层¶
- SessionStore 接口 + InMemoryStore + Postgres + db.Pool (L693) — 4 commit 节奏完整落地, 3-agent review reconcile 后 PM 三轮决策 (Postgres 肯定用 → 整个 platform psql → 拒绝迁移工具+sqlite 走 plain SQL + testcontainers → C3 staging Postgres 跳过待真消费者). 关闭 ADR-0002 § 4 标注的 "in-memory sessions 单实例假设" 限制
platform/common/internal/server/sessionstore/— SessionStore interface (Create/Get/Delete 三方法, 不要 Touch/List/owner_id 投机 表面) + InMemoryStore (sync.RWMutex + map) + PostgresStore (INSERT ON CONFLICT DO NOTHING + RowsAffected==0 单语句原子, 不需显式事务). SessionMetadata 仅 ID + CreatedAt 两字段 (engine.Session pointer 物 理只能在 server-local sessionCache, channel/mutex 不可序列化)platform/common/internal/db/— 共享 *pgxpool.Pool wrapper + 中央 schema 权威 (platformMigrations append-onlyCREATE TABLE IF NOT EXISTSlist, idempotent Migrate). 当前注册 sessions 表; 后续 staging / verdict-audit 真消费者落地时追加. 不引正式 migration 工具 (1 张表 schema 还在变, plain SQL 自检足够; ≥ 5 张 + 稳定再引)cmd/common --postgres-dsnflag + db.Pool 顶层 init + Migrate (10s/30s timeout) + defer Close. dsn 空时所有 Postgres 后端子系统 回落内存参考实现 (今天 sessionstore.InMemoryStore, 后续 staging 同 模式)server.go改造: sessions map + mu → sessionStore (持久 metadata, interface 注入) + sessionCache (in-process *engine.Session pointer map, sessionMu RWMutex 独立锁). 4 handler 改造 (handleCreateSession / handleGetSession / handleDeleteSession / handleSendMessage) 用 新 store + cache; handlePermissionReply 不动 (走 permCh 不直接 access sessions). TOCTOU race 折叠: RLock-then-Lock 两段 → SessionStore. Create 单语句原子- 测试: 13 个 (6 InMemoryStore + 5 testcontainers Postgres 真 pg + 2 db pool), 全模块 -race 全绿. 测试参数化契约 harness 提取留 follow-up (第三后端或 SDK 消费者落地后)
-
commit 顺序:
eec48feC1 接口 + InMemoryStore drop-in /beaff60C2 Postgres + db.Pool + docker-compose pg + release.yml POSTGRES_PASSWORD secret /96e893aC3 ADR-0003 + Caddyfile 注释 /69500dbC4 TODO + CHANGELOG + CLAUDE.md 同步 -
deploy/docker-compose.yml: 新加
postgres:16-alpineservice (健 康检查 pg_isready + 持久 volumepostgres_data+ bridge-internal 不 映射 :5432 到 host); common.depends_on.postgres.condition: service_healthy; command 加--postgres-dsn=postgres://flyto:${POSTGRES_PASSWORD}@postgres: 5432/flyto?sslmode=disable. POSTGRES_PASSWORD 走 .env :?missing fail-loud (跟 ANTHROPIC_API_KEY / FLYTO_PROXY_TOKEN 同模式) -
.gitea/workflows/release.ymldeploy step 加export POSTGRES_PASSWORD="${{ secrets.POSTGRES_PASSWORD }}"(跟 ANTHROPIC_API_KEY 同位). PM 已配 Gitea secrets -
deploy/Caddyfile/api/v1/*handle 块加单副本 vs 多副本部署区 别注释: 多副本必须lb_policy ip_hash(phase 2 改header X-Session-ID) 并列所有 replica, 或 k8sService.sessionAffinity = ClientIP. 当前 reverse_proxy 单 upstreamcommon:8080不动 (HK-133 phase 1+ 单副本 生产, sticky 是 no-op)
关键设计原则¶
- 三层进程内 pin 物理事实, 平台层只能解一层 (ADR-0003 § 2.4) —
server.permCh+Session.pendingPermissions+engine.sessionState .sessions三层都是进程内 (Go channel + mutex + goroutine 不可序列化). 后两层在 core 引擎层, 平台层即使做完整 SessionStore + PermissionBus pub/sub 也只能解 A 一层. 业界 LangGraph/Vercel/Temporal SessionStore - PermissionBus 双件套需要改 core engine API, 是 RFC 级别留给 v1.0+
- 多副本必须 LB sticky routing (ADR-0003 § 2.5) — phase 1 ip_hash / k8s ClientIP (NAT 聚集 + 跨设备脱黏可接受); phase 2 触发条件由真 投诉驱动, 改 X-Session-ID header lb_policy
- 接口三方法不要投机表面 — 5 handler 真实操作只有 Create/Get/Delete,
Touch/List/owner_id 等会被 dead-field-scanner ratchet 标为死表面.
真消费者出现 (admin UI / 多租户 gating / SLA watchdog) 时方法跟消费
者同 commit 落地 (memory
feedback_dead_fields_are_implementation_debt) - ephemeral metadata ≠ staging audit (ADR-0003 § 5.5) — staging.Store meta-pattern 复用 (InMemoryStore + SQL-later + tracked-debt), 接口 verbs 不复用 (sessions 是 ephemeral Get/Set/Delete 形态, 不是 staging 7 状态机 + 优化锁)
- engine.Session 不动, 借 SnapshotStore —
core/pkg/engine/ session_snapshot.go已有 SnapshotStore 接口 + WithMessages 注入路径, cache miss 自动恢复历史留 follow-up commit, 不在 SessionStore 重新发明 序列化 API (channel/mutex/goroutine 不可序列化是物理事实) - plain CREATE IF NOT EXISTS + testcontainers — PM 拍板拒绝过度工程: 不引 migration 工具 (1 张表 schema 还在变, 等 ≥ 5 张稳定再引), 不用 sqlite mock (与 Postgres SQL 方言差别大会掩盖真 bug). 用 testcontainers-go 启真 pg 容器跑测试 (~30s 测试启动 cost 接受)
- drop Redis + drop staging Postgres — Redis 元数据 payload 太薄 (id + created_at ~50 字节) 不值, 与 staging / verdict-audit 共享 pg 池更经济; staging Postgres 后端没真消费者 (cmd/common 没装 staging), 跳过等真消费者驱动 (ADR-0003 § 5.5)
已知限制¶
继承 v0.3.0 已知限制 (ML Validator / 熔断器 / cross-transport request-id / SSE 带宽监控 / CAP-4 自动化 / Provider 模型表 / AuditSink / WMS / 场景 化教程 / 微信 / .proto 注释), 不重复列出. v0.4.0 新增:
- 三层进程内 pin 后两层在 core 引擎层 — 真跨副本 live session 失败 转移要改 core engine API (engine.Config.PermissionBus + Session. MarshalState/RestoreState), 是 RFC 级别留给 v1.0+ (ADR-0003 § 5.2)
- NAT IP 聚集 + 跨设备脱黏 — phase 1 ip_hash / ClientIP 在企业 NAT 后多客户端共享外网 IP 会聚到同副本; 客户端切换网络 IP 会脱黏到另 一副本 → 503 (cache miss). phase 2 改 X-Session-ID header 解决, 触发 条件由真投诉驱动
- Postgres 是新单点 — 池挂 → 所有 SessionStore 操作 503. healthcheck
- restart unless-stopped + 持久 volume 是基础保护; HA Postgres (pgpool / patroni / Aurora) 是 v1.0 SLA 课题
- engine.SnapshotStore 接线 cache miss 恢复历史 — 留 follow-up commit, 触发条件: 真出现 sticky 失败导致的 503 投诉 (ADR-0003 § 2.6)
- staging Postgres 后端待真消费者 — staging 子包当前 cmd/common 没 wire, 行业 platform 也没用. 等 staging 真接入 (验证器 + ML + 审批工 作流被 logistics 或其他行业 platform 实际跑) 时同模式追加 (ADR-0003 § 5.5)
发布事实¶
- TODO: 52 → 53 done (+1: L693 SessionStore + Postgres 平台化奠基,
4 commit chain
eec48fe → beaff60 → 96e893a → 69500db), 13 → 12 open - 测试: 13 个 (6 InMemoryStore + 5 testcontainers Postgres + 2 db pool), 全模块 -race 全绿; testcontainers 启 6 个 docker pg 容器实测 通过 (~75s 总, docker 不可用 t.Skip 兜底)
- 依赖: 新增
github.com/jackc/pgx/v5(runtime, Postgres driver) +github.com/testcontainers/testcontainers-go(test-only) +github.com/testcontainers/testcontainers-go/modules/postgres(test-only). test-only dep 不进 production binary - ADR: ADR-0003 SessionStore 多副本 sticky routing + 三层 pin 物理 事实分析 (387 行 8 节体例对齐 ADR-0001/0002)
- 设计决策工作流: Agent Teams 3 角色并行 review 持续作 default, v0.4 cycle 第 1 次实战 (L693, v0.3 cycle 5 次延续)
- CI: release.yml deploy step 加 export POSTGRES_PASSWORD; Gitea secrets 已配
- dead-field-scanner baseline: 220 不变 (scanner 只扫 core/, 不扫 platform; 本版改动全在 platform/common)
- Tag: v0.4.0 一次性 cut 不走 alpha (与 v0.2.0/v0.3.0 同模式)
文档同步¶
core/docs/adr/0003-session-store-multi-replica-bounds.md新建 387 行 8 节deploy/Caddyfile加 16 行单副本 vs 多副本部署区别注释指向 ADR-0003core/TODO.mdL693 [x] + 4 commit chain + 统计 53/12/65 + 剩 12 项分组- 关键观察段加 ADR-0003 + 近期主要动作首条
CLAUDE.md关键记忆 platform 消费层 9 → 8 项 + 最后变更 L693 段 (v0.3.3 移到上一变更)CHANGELOG.mdv0.4.0 段 (本段) + 起新 Unreleased (v0.5-dev) 空段
v0.3.3 (2026-04-26)¶
L407 follow-up: 内部开发者一站式文档地图 + 双子域文档站 (godoc + docs).
PM 反馈 v0.3.0 cut 的 Swagger UI 只能展示业务 REST API, 不展示 internal Go API + 内部 markdown, 需要"内部开发者方便看文档" 的统一入口. 实现 A + B + C 三件套 (PM 拍板"都要"):
A — README 文档地图 (commit fea0d27)¶
- 升级 root
README.md加 "## 文档地图" section ~70 行 - 3 个分组: 在线入口 (always-on) / 按目标读者分组 / 5 步入门顺序
- 7 类读者: Agent / 消费层 / core 贡献者 / Provider / 部署 SRE / TUI / PM
- 进 Gitea web 看 README 一眼看全 27+ markdown 在哪
B — godoc.flytoex.net 子域 (pkgsite Go API HTML)¶
- 子域而非
hub.flytoex.net/godoc/*子路径: pkgsite 内部 href 全是绝对路径 (实测href="/",href="/git.flytoex.net/yuanwei/flyto-agent"), Caddy strip prefix 后浏览器 link 全断 (跳到 logistics:8080).pkgsite --help无--base-path/--mountflag, sub-path 部署不可行 deploy/Dockerfile.godoc多阶段: golang:1.26-alpine 装 pkgsite + 拷整 monorepo 源 +go mod download all预解析依赖 → final image 仅含 pkgsite 二进制 + 源 + module cache. ENTRYPOINTpkgsite -http=:8080 -list=false .(首页只列本地 module). cold start ~30-60s (load 472 packages), 后续 in-memory cache- 覆盖 core/ 22 子包 + platform/common 所有 exported type, 不鉴权对齐 pkg.go.dev
C — docs.flytoex.net 子域 (mkdocs-material 整合站)¶
deploy/Dockerfile.docs多阶段: python:3.12-alpine 装 mkdocs-material@9.5.31 (与 staticcheck@v0.7.0 / swag@v1.16.6 / protoc-gen-doc@v1.5.1 同 pin 版模 式) + COPY 显式列每个顶层目录 markdown (避免拉 .go/.proto/vendor 让 image 膨胀) →mkdocs build→ nginx:alpine serve/usr/share/nginx/htmlmkdocs.ymlrepo 根: docs_dir=/work(绝对路径), config 文件/config/mkdocs.yml-- mkdocs 不允许 docs_dir = config 父目录, sibling 目录绕开. nav 12 个分组 (入门 / ADR / 核心引擎 / API / Provider / 部署 / TUI / 战略 / 写作规范 / 历史) 串 README + CONSUMERS + CONTRIBUTING + FLYTO- ADR-0001/2 + CHANGELOG + TODO + 7 provider 文档 + architecture + writing-guide
- theme material 中文 + indigo + 暗色切换 + tabs/sections/search/code copy
deploy + CI 接线¶
deploy/docker-compose.yml加 godoc + docs 两 service (compose 内部 expose, Caddy 唯一入口); caddy.depends_on 加 godoc/docsdeploy/Caddyfile加godoc.flytoex.net+docs.flytoex.net两 site block (LE 自动证书 + gzip/zstd, 不鉴权; 跟 hub.flytoex.net 平级三 site block, Caddy SNI 分流).gitea/workflows/release.ymlbuild-push job 加 flyto-agent-godoc + flyto-agent-docs 两 build-push step (跟 common + logistics 同 buildx 模式)
DNS (2 条 A 记录, Cloudflare API 加)¶
godoc.flytoex.net→ 45.145.229.197 (DNS-only 灰云, 跟 hub 同模式)docs.flytoex.net→ 45.145.229.197 同上
调用 PUT /client/v4/zones/<zoneID>/dns_records 用 PM 给的 API token
(Zone:DNS:Edit scope on flytoex.net), 一次 201 Created. 不走 PM web UI.
实测¶
docker build -f deploy/Dockerfile.godoc .→ 61.8s 通过, image 跑起来 / 200, /git.flytoex.net/yuanwei/flyto-agent 200docker build -f deploy/Dockerfile.docs .→ 5.83s mkdocs build 通过, nginx serve / 200,<title>Flyto Agent 文档</title>渲染- mkdocs build INFO 警告 (不阻 build, follow-up 修):
docs/evolve-strategy.md几个章节 anchor link 不存在 (中文 heading anchor 不一致);platform/common/docs/grpc-api.md#google-protobuf-Timestampanchor 不存在
已知 follow-up¶
- pkgsite cold start 30-60s 是首次请求 load 全 module 不是启动. 生产可加 Docker HEALTHCHECK 触发预热, 当前接受首访慢
- mkdocs build 几个 anchor warning, 后续清理文档时顺手修
- Swagger UI (hub.flytoex.net/swagger/) 与 godoc 现独立 host 不交叉, docs.flytoex.net nav 可加链接到 swagger 入口 (后续)
v0.3.2 (2026-04-26)¶
Hotfix #2, v0.3.1 紧随其后.
v0.3.1 push 后 build-push job 绿 (image 成功 push 到 Gitea registry), 但
deploy job SSH 到 HK-133 跑 docker compose pull 在 parse 阶段 fail:
error while interpolating services.common.environment.ANTHROPIC_API_KEY:
required variable ANTHROPIC_API_KEY is missing a value
根因: v0.3.0 commit c35e761 给 deploy/docker-compose.yml common.environment
加了 ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:?missing ...} 强制语法, 但
.gitea/workflows/release.yml deploy script 没同步加 export ANTHROPIC_API_KEY=...
(跟 FLYTO_PROXY_TOKEN / ADMIN_BASIC_AUTH_* 同模式漏写一行). 服务器仍跑老
image (v0.2.0 时期), /api/v1/* 与 /swagger/* 没生效.
修法两处 (本 hotfix):
1. release.yml deploy script 加 export ANTHROPIC_API_KEY="${{ secrets.ANTHROPIC_API_KEY }}"
2. Gitea repo Settings → Actions → Secrets 配 ANTHROPIC_API_KEY (通过 Gitea
API PUT /api/v1/repos/{owner}/{repo}/actions/secrets/{name} 自动化完成,
201 Created)
教训登记 memory (待写): 给 docker-compose 加强制 env 必须同步三处 — compose
yaml 自身 / release.yml deploy export / Gitea secret. 漏一处部署链断.
feedback_validate_network_path_before_deploy.md v0.3 cycle 内被验证两次
(L407 commit C0 path bug + 本 hotfix env bug).
v0.3.1 (2026-04-26)¶
Hotfix, v0.3.0 紧随其后.
platform/common/go.sum 在 v0.3.0 commit 时少 golang.org/x/net@v0.50.0 的
transitive checksums (net/trace / net/http2 / net/http2/hpack), 平时
开发用 GOWORK=on 拉父 workspace 全套 sum 没踩坑, 但 Dockerfile build 走
GOWORK=off, Go 1.26 严格模式直接 missing go.sum entry 拒绝 build. 跑
GOWORK=off go mod tidy 补齐即修复. 触发原因: v0.3.0 cycle go get
github.com/swaggo/http-swagger/v2 把 golang.org/x/net v0.49.0 → v0.50.0,
后者新切了 net/trace 等 sub-package, 老 go.sum 不含.
教训登记 memory: workspace 模式下 go get 不会主动跑 go.sum tidy, 升级
间接依赖时必须 GOWORK=off go mod tidy 模拟 CI 视角再 verify; release
checklist 加 "docker build -f platform/common/Dockerfile ." 实测一次, 不要
靠 CI runner 头次发现.
platform/common/go.sum+14 / -4 (golang.org/x/net v0.50.0 transitive hash 补齐);platform/common/go.mod+4 / -2 (indirect dep 重排序)
v0.3.0 (2026-04-26)¶
v0.2.0 之后的三轮发版, 核心交付 platform/common 业务通道激活 + 消费层文档自动化全产物. v0.3 标志 Flyto Agent 从 "core 引擎就绪 + platform 静默" 过渡到 "platform 业务 REST/SSE 通路打通 + Swagger UI / gRPC markdown / CONSUMERS.md 三件套消费层一站式入口". core 引擎层新增 counterfactual + skills/reverse_think + shadowdb 三个子包, 7 provider 全接通 Temperature/TopP sampling 旋钮; ADR-0002 立 "REST 业务 / gRPC 观测" bifurcation, ADR-0001 归档反事实工作流引擎级 enforcement 否决方案.
核心新增 (core 引擎库)¶
- counterfactual 子包 + reverse_think Go skill + ReverseThinkingHook reference (L569 缩水) — 反向思维 first-class 数据结构 + Go 化 MiniMax client + platform 层一行启用入口. 6 commit 节奏落地, 原方案 A 完整版 (引擎级 8 模块 1500-2500 行) 经 3-agent review 否决, 走 Option 2-Plus 缩水方案 (4 件套 + ADR-0001 归档否决理由)
core/pkg/counterfactual/—Deliverablestruct 对齐~/.claude/skills/reverse-think/SKILL.mdJSON schema (hidden_assumptions / failure_scenarios / verdict / verdict_reason 4 核心 + ToolName / Step / DecisionID / OccurredAt 4 元数据). Verdict 常量 (A_holds/B_wins/depends_on_X) 取值开放,Validate不强制枚举允许 LLM 引入新标签 forward compat.MetadataKey = "flyto.counterfactual.deliverable"跨包 staging.Record.Metadata 落点常量core/pkg/skills/reverse_think/—Client持 APIKey + Endpoint + Model + MaxTokens (默认 8000 thinking+text 共享池) + HTTPClient + Now 注入.Run(ctx, Prompt, Annotations) (*Deliverable, error)渲染中文 prompt → POST Anthropic 兼容端点 → unmarshal → stamp metadata → Validate. 不读MINIMAX_TOKEN_PLAN_KEYenv (core 与环境解耦, 调用方注入), 刻意不 strip code-fence 包装 (LLM 遵守度 bug 应暴露非兜底). 4 sentinel error: ErrAPIKeyRequired / ErrEndpointFailed / ErrNoTextContent / ErrParseDeliverabletools.Metadata.RequiresReverseThinking— 与RequiresCheckpoint同源 opt-in flag, 默认 false. 适用非平凡设计空间 (多策略选择 / 失败模式不直观 / schema 漂移), 不适用纯机械 CRUD. 与 RequiresCheckpoint 边界: 后者问 "需要人确认?", 前者问 "agent 应不应该挑战自己的建议?", 两者可叠加 core 不规定顺序由消费 hook 链决定Deliverable.AsReplayEvent进化沉淀接线 — counterfactual import evolve 单向, evolve schema-agnostic 不感知 counterfactual. Payload 是 Clone 防 Reflector 摄入时回溯污染 staging.Record. Feedback 返 nil 对齐 evolve.ReplayEvent godoc "决策刚发生 KPI 反馈延迟中". MetaKey 4 常量 (Step/Verdict/Source) 跨包消费方按字面量匹配. 关上 "各行业自写 skill 进化结果停在哪里" 循环: 各行业触发策略可不同, 但 Deliverable schema + AsReplayEvent adapter + evolve.Reflector 学习池在 core 共享统一platform/common/safetychain/ReverseThinkingHook— Go 行业一行启用入口, 与Assemble平行 (Assemble 装 Validator+CircuitBreaker decorator, 本 hook 走 hooks.PreToolUse 不 wrap Tool). Client + Lookup + PromptBuilder + Sink 字段; fail-open advisory (LLM 错不阻断业务, ExitCode=0 + Error 挂供观测层暴露降级状态). DefaultPromptBuilder 仅合格下限, 行业按域覆盖. JSONOutput[counterfactual.MetadataKey] 携带 *Deliverable 让下游 hook / 引擎读不必二次 LLM 调用- 业界共识对齐: 调研 agent 拉的 8 框架 (Claude Agent SDK / OpenAI Assistants / Vertex ADK / LangGraph / AutoGen / CrewAI / Semantic Kernel / Cline) 全部走 "engine 提供 hook primitive, 策略由开发者写", 零框架在引擎里 hardcode "决策前必须填五步 deliverable 才能调 tool". 本缩水方案与之同形 (hook 路径 + tool metadata + 开发者自填 PromptBuilder + Sink), 没在 Flyto 引擎核心引入业界都没做的 enforcement primitive
- 35 测试 -race 全绿: counterfactual 13 + reverse_think 12 + tools 1 新增 + safetychain 9. core + platform/common 双 module build 干净. 4 件套 + reference hook 总量 ~2,131 行 (含双语 godoc + 测试)
-
3-agent 并行 review (调研业界 8 框架矩阵 + 决策前 gate 形态调查 / 质疑 agent 5 道硬质疑含
tools.Metadata.RequiresCheckpoint已实装 anchor 修正 / 设计 agent 4 备选 + 边界图 + 拍板假设矩阵). PM 拍板 Option 2-Plus (在原 Option 1 不做 + Option 2 缩水之间加进化沉淀件),core/docs/adr/0001-reverse-thinking-gate.md归档完整方案对比与否决理由 -
shadowdb 子包 (L437) — Agent 多轮推理 session 级 shadow 表. 方案 C 列标记隔离 (
session_id VARCHAR(64) NOT NULL+ filter), 规避 PG TEMP TABLE 在 pooled*sql.DB下蒸发问题 + driver 分裂. 零 DDL 纯INSERT/UPDATE/DELETE/SELECT + ?, PG/MySQL/SQLite 通吃 Openerinterface +InMemoryOpener参考实现 (开 / 关 / Reap 孤儿)Session结构 +ShadowDBnewtype (与tools/builtin.StagingDB同族意图标记)EnforceSessionFilter(sql)三层防御中层 — quote-aware 剥离字符串/注释 后正则校验session_id=?filter 存在, 不防对抗性 SQL 拼接但捕捉 LLM 随手遗漏- pull-only
Reap(ctx, olderThan)— core 不起 goroutine, 平台层从自己的 cron / watchdog 触发 - 与 staging 边界清晰: staging 决策级 pre-commit (1 decision = 1 Record), shadowdb session 级推理中 scratchpad; 单向 shadow → staging → production, shadow 永不 merge
-
24 tests 全绿含
-race+ 并发 Close/Reap 赛跑测试 (in-memory sqliteSetMaxOpenConns(1)串行化让测试验证 bookkeeping 竞态不测 driver 内部) -
Temperature / TopP cross-provider passthrough (L683) —
flyto.Request加Temperature *float64+TopP *float64, 7 provider (anthropic / openai / minimax / gemini / ollama / lmstudio / openrouter) 全部接通 sampling 旋钮. 此前 7 provider 的 Config 与 Stream 一律不传 temperature/top_p, 是 day-1 产品功能缺失而非简单 wire 问题 - 纯 passthrough + 极小特例: 越界一律不在 flyto 层 clamp, 上游 4xx 自然冒泡为 ErrorEvent (业界共识 — Vercel AI SDK / LangChain / instructor / LlamaIndex 全 passthrough; 仅 litellm 尝试 drop_params 且 bug 频发, issues #8192 / #16090 / #5884)
- 仅 1 个 wire 时已知冲突预拦: Anthropic + NeedsThinking + Temperature != 1.0 → silent override 1.0 +
parameter_overriddenWarningEvent; TopP < 0.95 同理 (服务端硬约束 [0.95, 1.0]) - 不加 OpenAI o-prefix model 检测: server 4xx 已是清晰 ErrorEvent, model-prefix 列表长期维护 burden 不值 (litellm 先例实证)
- 0 是合法 deterministic 值,
*float64+omitempty干净分离 nil 与 0 语义; helperflyto.Float(v)简化指针字段构造 - ADR rule of two: 本 commit 是 sampling knob 最小 subset (Temperature + TopP); TopK / Seed / FrequencyPenalty / PresencePenalty 走 follow-up TODO 等真消费者驱动. 业界 Vercel AI SDK / LangChain 同等对待 sampling knobs, cherry-pick 单字段是合理边界
- 7 provider wire 路径: anthropic 经
internal/transport.MessageRequest+applyThinkingSamplingConstraintshelper; openai/ollama/lmstudio/openrouter/minimax(streamOpenAI) 经internal/wire.StreamRequest+openaiReqjson:temperature/top_ptop-level; gemini 嵌generationConfig.temperature/topP; minimax(streamAnthropic) + anthropic 兼容路径同 anthropic - evolve 进化算法层串通:
LLMCallOpts.TopP+WithTopPGenOpt +genConfig.TopP+buildMeta写meta["top_p"](ParameterEvolver 与 evaluator 现可追溯候选完整 sampling 配置).FlytoLLMClientadapter 把 LLMCallOpts (float64, zero=未设) 翻译到flyto.Request(*float64, nil=未设):if v != 0 { req.X = flyto.Float(v) } - 23 tests 全绿 (17 wire/provider 层 + 6 evolve 层); baseline 220 → 219 (
LLMCallOpts.Temperature实际 drain — adapter 现在真读 selector)
platform/common 层¶
- 业务 REST/SSE 通道激活 (L692, ADR-0002) —
platform/common/internal/server/server.go1263 行已写好但未启动的业务 REST/SSE 实现接进 cmd/common, 通过新增--rest-addr=:8080启用. 6 commit 节奏完整落地, 3-agent review reconcile + PM 二次拍板. 核心决策: REST/SSE 唯一业务消费通道 + gRPC 仅观测面 (SafetyChain / Health) + 不为 C# 单独加业务 RPC + 不加 grpc-gateway (admin/server.go 已有观测面 REST handler) + Tool 级 SafetyChain 装饰留给行业 platform server.go三 API 拆分:buildHandler() http.Handler(mux + middleware chain 抽出) +Serve(ctx, ln) error(主 API, 接收外部 listener + ctx 不接管 signal) +ListenAndServe()(向后兼容 wrapper). 让 cmd/common 用一个 signal handler 统一协调 gRPC + admin HTTP + 业务 REST- OIDC 鉴权统一 (Q2.1 致命修复): 删
Config.BearerToken+authMiddlewareraw shared-secret + ConstantTimeCompare 时序防御实现; 加Config.Verifier *auth.Verifier走 auth.HTTPMiddleware. dev 模式 (Verifier=nil) 跳过鉴权与 admin/server.go 一致, 让 cmd/common 跨 gRPC + admin HTTP + 业务 REST 共用同一套 OIDC 验签 - server 不再内嵌 anthropic provider 写死:
Config删 9 字段 (APIKey / BaseURL / BearerAuth / Model / SystemPrompt / AppendPrompt / Tools / MaxTurns / PermissionMode), 不再读 ANTHROPIC_API_KEY 不再 hard-code "claude-sonnet-4-6". 新增Attach(eng *engine.Engine)+HandlePermission(ctx, req)让外部装配 engine 时把 SSE 桥接的 permission.Handler 传给 engine.Config.PermissionHandler 再 Attach cmd/common/main.go三 listener wire: 新增--rest-addrflag (默认空 = 不启动); 启用后装 anthropic provider + engine + s.Attach + net.Listen → s.Serve(restCtx, ln). signal handler 三路协调 SIGINT/SIGTERM 触发 grpcSrv.GracefulStop + httpSrv.Shutdown + restCancel (server.Serve 内部用 10s 上限做 Shutdown). errCh capacity 升 3, wantListeners 按启用 listener 数动态加- Tool 级 SafetyChain 装饰刻意不在 cmd/common 装 — 一刀切 AlwaysApprove + DefaultExtractor 会把所有 Tool 锁同一组合或 fan out 到不匹配 Tool 语义的 wrap. common 保持纯 transport, verdictStore 接线就绪等行业驱动代码 (logistics 等) 在 Tool 注册时装饰并写数据
- deploy 配置就绪:
deploy/docker-compose.ymlcommon.expose 加 8080 + command 加--rest-addr=:8080+ environment.ANTHROPIC_API_KEY${...:?...}必填;deploy/Caddyfile加handle /api/v1/* { reverse_proxy common:8080 { flush_interval -1; transport http { response_header_timeout 0 } } }让 SSE chunk 透传不被代理缓冲, 模型长思考时不被 idle timeout 切断 (客户端断线检测靠 server.go 15s 心跳); README topology + 业务 REST/SSE curl 例子同步 - ADR-0002 立项:
core/docs/adr/0002-rest-business-grpc-observation.md体例对齐 ADR-0001 八节结构 (背景 / 决策 / 评估流程 / 后果 / 替代方案保留 / 触发重评估条件 / 参考 / 修订记录). 核心定性: 与 9 天前 memoryfeedback_architecture_principle_over_rule_of_two.md的 "gRPC 优 HTTP / C# 走 gRPC" 是 bifurcation 不是 U-turn (观测仍 gRPC 沿用, 业务用 REST 是补另一面). 调研引用 11 LLM API 矩阵 (Anthropic / OpenAI / Bedrock / Cohere / Mistral / Replicate / Together / Fireworks / Ollama / LM Studio / LangServe) 全 REST/SSE 单通道, Vertex AI 是 gRPC-first 例外但 Google 内部全栈延伸不仿照 - 测试: server_test.go 既有 546 行用 httptest 直接打 handler 不动; 删 4 raw bearer TestAuth_* (auth.HTTPMiddleware 自身覆盖在 internal/auth 包测试); 新加
TestServer_Serve_RespectsCtxctx 取消干净返回 + 集成测试 listener bind/shutdown 时序. -race 全绿. cmd/common --help 验证--rest-addrflag 出现 - 4 tracked debt 登记:
L693业务 REST 多副本 SessionStore interface (P2, in-memory sessions 单实例假设);L694gRPC + REST cross-transport request-id / trace 串通 (P3, OpenTelemetry);L695SSE 1000 单/s 带宽监控 (P3, framing overhead 5-10x);L407Swagger/OpenAPI (P2, ADR-0002 直接子项) -
commit 顺序:
01f08e7C1 拆 Serve+wrapper /8189d05C2 OIDC /694bd07C3 Attach + HandlePermission /e1db327C4 cmd/common --rest-addr /c35e761C5 deploy /e330774C6 ADR-0002 + 文档 -
消费层文档自动化三件套 (L407) — 业务 REST + 观测 gRPC + 顶层 wrapper 一站式入口. ADR-0002 直接子项, 7 commit 节奏 (含 1 path bug 顺手修). queued task 锁定 swag (注解式 OpenAPI 2.0) > huma (code-first 要重写 1263 行 server.go) 路线; SDK 自动生成 (Stainless 模式) 不做 rule of two 等 5+ 语言客户端
platform/common/internal/server/server.go8 业务 handler (handleHealth/handleAgentRun/handleCreateSession/handleGetSession/handleDeleteSession/handleSendMessage/handlePermissionReply/handleListTools) 加@Summary @Description @Tags @Accept @Produce @Param @Success @Failure @Router @Security注解块, 不动业务逻辑. 新增 3 named response type (HealthResponse/StatusResponse/ListToolsResponse) 替代 ad-hoc map[string]any 让 swag schema 准 (handler 仍写 map, JSON 字段名与 struct 一致, 外部契约对齐)platform/common/internal/server/swag.goseed file 持 service-level @-block (@titleFlyto Agent Engine Business REST API /@versionv1 /@hosthub.flytoex.net /@BasePath/api/v1 /@securityDefinitions.apikey BearerAuth). 放路由旁不放 cmd/common/main.go, single-source-of-truth: 拥有路由的文件也拥有 swagger specplatform/common/docs/4 自动生成产物:swagger.json22.5K (12 schema, paths) +swagger.yaml12.3K +docs.go23.1K (Go embed, side-effect import 让 cmd/common --swagger UI 不读文件系统就嵌入) +grpc-api.md278 行 (protoc-gen-doc v1.5.1 产, HealthService + SafetyChainService 字段表)cmd/common --swaggerflag opt-in/swagger/*Swagger UI (httpSwagger v2 包内嵌). server.go authMiddleware + rateLimitMiddleware allow-list 加strings.HasPrefix("/swagger/")让 UI 在 OIDC 启用时仍可加载, 不烧 rate-limit 预算 (业界共识: Stripe / Anthropic / OpenAI 都公开 OpenAPI spec). 路径在 /api/v1 版本命名空间外, swagger 是 meta artifact 不是版本化业务端点docs/CONSUMERS.md顶层 wrapper 133 行: 端口拓扑表 (3 listener × Caddy 路径分流) / 必填环境变量表 / cmd/common 10 flag 表 / OIDC auth 流程图 (含 allow-list 例外 /api/v1/health, OPTIONS, /swagger/*) / 业务 REST 一次性 + 多轮会话 curl 例子 / 观测 gRPC SafetyChain 指引 / 进一步阅读链 (ADR-0002 + deploy/README + TODO + Dockerfile). 链 swagger.json + grpc-api.md 不重复 spec, 只放代码抽不出来的 (流程图 / 端口拓扑 / 跨 service 调用 narrative)core/Makefile加 4 个 docs target (docs-swag / docs-grpc / docs-consumers / docs-all) + 升级 docs-install 装 swag@v1.16.6 + protoc-gen-doc@v1.5.1 (与 staticcheck@v0.7.0 同 pin 版模式). 顶部export PATH := $(shell go env GOPATH)/bin:$(PATH)让 make 子 shell 找到 install 的二进制;ROOT := git rev-parse --show-toplevel让 docs-* 从任意 cwd 都能 cd 到 platform/common.gitea/workflows/release.yml加 docs drift gate (apt-get protoc + make docs-install + make docs-swag + make docs-grpc + git diff --exit-code 4 产物 swagger.json/yaml/docs.go/grpc-api.md). 与 dead-field-ratchet 平级位于 build-push job 开头. 业界对照: Stripe / Anthropic / OpenAI 都对 OpenAPI spec 跑同等 drift 闸 (PR 必须 commit 生成产物, 否则 CI 红)deploy/Caddyfile加handle /swagger/* { reverse_proxy common:8080 }(无 flush_interval, 静态资源 + JSON 不流式) +deploy/docker-compose.ymlcommon.command 加--swaggerflag (HK-133 lab 默认启用; 生产 deploy 用另份 compose 关闭)- 意外+顺手修 (C0 commit
89c38d3): 上一会话 commit 5 (c35e761) Caddyfilehandle /api/v1/*不剥前缀, server.go mux 注册/v1/*不匹配, 经 hub.flytoex.net 全 404 (内部直连 localhost:8080/v1/* 才通). L407 之前先打通真实部署链, server.go 8 mux 路由 + server_test.go ~25 处 path + main.go 注释 + ADR-0002 § 2.1 端点形态 + Caddyfile 注释 SSE 例子全部对齐/api/v1/*一致 (内外一致, BasePath=/api/v1 干净). 触发 memoryfeedback_validate_network_path_before_deploy.md警告再次成立 - smoke:
ANTHROPIC_API_KEY=fake go run ./cmd/common --rest-addr=:18080 --swagger→ /api/v1/health 200 + /swagger/index.html 200 + /swagger/doc.json 200 返回 commit 1 嵌入 spec; caddy:2-alpine validate Caddyfile = "Valid configuration"; docker compose config common.command 4 flag 全到位; build + -race 全绿 - commit 顺序:
89c38d3C0 path bug fix /26bc732C1 swag 注解 + spec 首版 /bce7670C2 --swagger flag + UI /22b6388C3 Makefile + CI drift gate + grpc-api.md /a9b04abC4 CONSUMERS.md /bf5af1dC5 deploy / 本 commit C6 同步
关键设计原则¶
- bifurcation, not U-turn (ADR-0002) — 业务 REST / 观测 gRPC 分层. 与 9 天前 memory
feedback_architecture_principle_over_rule_of_two.md的 "gRPC 优 HTTP / C# 走 gRPC" 不冲突: 观测仍 gRPC 沿用, 业务用 REST 是补另一面. 业界对照 11 LLM API (Anthropic / OpenAI / Bedrock / Cohere / Mistral / Replicate / Together / Fireworks / Ollama / LM Studio / LangServe) 全 REST/SSE 单通道, Vertex AI 是 gRPC-first 例外但 Google 内部全栈延伸不仿照 - single-source-of-truth (代码即文档) — 业务 REST swag 注解长在 server.go 旁 (拥有路由的文件拥有 swagger spec), 不放 cmd/common/main.go; 观测 gRPC 字段表由 protoc-gen-doc 直读 .proto 注释; CONSUMERS.md 链 auto 产物不重复 spec. CI drift gate 卡
git diff --exit-code4 产物, 业界对照 Stripe / Anthropic / OpenAI 都对 OpenAPI spec 跑同等闸 (PR 必须 commit 生成产物) - 顺手修部署 path bug — L407 commit 0 整体迁移 server.go (8 mux + 6 godoc + 2 middleware allow-list) + Caddyfile (注释举例) + ADR-0002 § 2.1 + main.go (3 处注释) + server_test.go (~25 处) 路径到
/api/v1/*一致 (上一会话 commit 5c35e761Caddyfilehandle /api/v1/*不剥前缀致 server.go 注册/v1/*经 hub.flytoex.net 全 404, 没真实经 Caddy 实测). 触发 memoryfeedback_validate_network_path_before_deploy.md再次成立 - fail-open advisory vs fail-closed gating —
ReverseThinkingHook对 LLM 错不阻断业务 (ExitCode=0 + Error 挂 HookResult.Error 供观测层暴露降级状态), 与 Validator / Staging / CircuitBreaker 的 fail-closed 形成边界互补 (advisory hook vs gating decorator). DefaultPromptBuilder 仅合格下限, 行业按域覆盖 - schema-agnostic 旁支 (shadowdb) — session 级 shadow 表用列标记
session_id VARCHAR(64) NOT NULL+ filter, PG / MySQL / SQLite 通吃 (规避 PG TEMP TABLE 在 pooled *sql.DB 下蒸发 + driver 分裂). 与 staging 决策级隔离边界清晰: 单向 shadow → staging → production, shadow 永不 merge - sampling knob passthrough — Temperature / TopP 越界一律不在 flyto 层 clamp, 上游 4xx 自然冒泡 (业界共识 — Vercel AI SDK / LangChain / instructor / LlamaIndex 全 passthrough; 仅 litellm 尝试 drop_params 且 bug 频发, issues #8192 / #16090 / #5884). 仅 1 个 in-Request 已知冲突预拦: anthropic + NeedsThinking + Temperature != 1.0 → silent override 1.0 +
parameter_overriddenWarningEvent - Tool 级安全链装饰是行业 platform 责任 — cmd/common 不调
safetychain.Assemble. 一刀切 AlwaysApprove + DefaultExtractor 会把所有 Tool 锁同一组合或 fan out 到不匹配 Tool 语义的 wrap; common 保持纯 transport,verdictStore接线就绪等行业驱动代码 (logistics 等) 在 Tool 注册时装饰并写数据 (ADR-0002 § Decision 第 3 条) - swag 注解 > huma code-first — L407 选型: huma (code-first) 要重写 1263 行 server.go, swag (注解式 OpenAPI 2.0) 不动业务逻辑只加注释. SDK 自动生成 (Stainless 模式) 不做 rule of two 等 5+ 语言客户端
已知限制¶
以下功能留待 v0.4+ 完成 (不影响 v0.3 使用):
- ML Validator backend 未接入 — core 接口就绪 (
validator.Validator+LLMValidator+CompositeValidator+AlwaysApprove+NewValidatedToolnil fail-fast), platform 层接外部 ML HTTP backend 实现 + 装配即启用 (TODO L434, P1) - 熔断器未在消费层启用 — core 三态 breaker +
VerdictSink桥接就绪, platform 选作用域 (NoOpScope/DestructiveOnlyScope/PerToolScope) + wireValidatedToolsink 即启用 (TODO L435, P1) - 业务 REST 多副本 SessionStore interface —
server.go当前 process-local in-memorysessions map, k8s 多副本 LB round-robin 立刻挂; v1.0 发版前必做 (TODO L693, P2; ADR-0002 § Consequences 显式标注) - gRPC + REST cross-transport request-id / trace 串通 — 单租户 dogfood 不需要, 上量后 OpenTelemetry 接 Tempo / Jaeger (TODO L694, P3)
- SSE 1000 单/s 带宽监控 — prometheus exporter 量化 framing overhead 5-10x, 真到瓶颈再优化 (压缩 / batch / fallback gRPC streaming, v1.0+ 课题; TODO L695, P3)
- CAP-4 自动化 × 3 — Flyto CLI 无头自消费 / CI/CD 集成 / WebSearch 前置 (TODO L488-490, P2; 低优工具效率)
- Provider 模型表自动更新 — Anthropic / MiniMax / OpenAI / Gemini 新模型上线需手工更
capabilities.json(TODO L454, P2) - AuditSink DB 实现 — 接口就绪, PostgreSQL 写入 + session 查询 + 批量回滚在 platform 层 (TODO L438, P3)
- WMS 波次参考实现 — "任务已建待确认" 状态机扩展在 platform 层 (TODO L439, P3)
- 场景化编排 Go 教程 —
examples/orchestration/{ssh_deploy,db_migration,system_config}三组示例等真消费者驱动 (TODO L408, P3) - 微信 ClawBot 接入 — 触发渠道扩展, 当前无业务驱动 (TODO L571, P3)
- .proto 字段注释完善 —
health.proto部分字段 description 列空,protoc-gen-doc自动反映, 后续工作
发布事实¶
- TODO: 47 → 52 done (+5: L437 shadowdb / L569 反事实缩水 4 件套 / L683 Temp+TopP / L692 业务 REST 通道 / L407 文档自动化三件套), 14 → 13 open (L693 / L694 / L695 / L407 → 仅 L407 完成出列, L693-695 自 L692 ADR-0002 tracked debt 入列)
- 测试: counterfactual 13 + reverse_think 12 + tools 1 + safetychain 9 (L569) + shadowdb 24 (L437) + Temp/TopP wire 17 + evolve 6 (L683) + L692 server_test 既有 546 行不动 + 1 ctx 测试 + L407 server smoke (curl 3 个端点) -race 全绿. core + platform/common 双 module build 干净
- 依赖: 新增
github.com/swaggo/http-swagger/v2 v2.0.2+github.com/swaggo/files/v2 v2.0.0; 升github.com/swaggo/swag v1.8.1 → v1.16.6(CLI 1.16 产 docs.go 用了 LeftDelim/RightDelim 新字段, dep schema 必须对齐) - ADR:
ADR-0001反事实工作流引擎级 enforcement 否决归档 (8 框架业界共识 + 5 套现有 gate 概念重叠 + 范畴错位 + 吞吐数学不成立);ADR-0002REST 业务 / gRPC 观测 bifurcation 立项 (11 LLM API 矩阵 + grpc-gateway/gRPC-Web 现状 + ConnectRPC 替代). 体例八节结构对齐 - 设计决策工作流: Agent Teams 3 角色并行 review 持续作 default, v0.3 cycle 实战 5 次 (L437 / L569 / L683 / L692 / L407)
- CI: 新增 docs drift gate 与 dead-field-ratchet 平级在
release.ymlbuild-push job 开头 (apt-get protoc + make docs-install + make docs-swag + make docs-grpc + git diff --exit-code 4 产物 swagger.json/yaml/docs.go/grpc-api.md) - Tag: 历史以
v0.1.0-alpha.{1..8}走 8 次 alpha 后 cut v0.1.0; v0.2.0 一次性 cut 不走 alpha; v0.3.0 同 v0.2.0 一次性 cut
文档同步¶
core/docs/data-safety.md:635原CREATE TEMP TABLE shadow_inventory_<sid>伪 SQL 回销为方案 C 列标记实现, 注明为什么不走 TEMP TABLE 路径core/TODO.mdL437 + L683 + L692 + L407 打勾, 加 L693 / L694 / L695 (P2/P3 tracked debt 自 L692 ADR-0002), v0.3 platform 消费层条目重新计数为 9 (含 L693-695)core/docs/adr/0002-rest-business-grpc-observation.md新建, 业务=REST 观测=gRPC bifurcation 决策记录core/docs/adr/0001-reverse-thinking-gate.md新建, 反事实工作流引擎级 enforcement 否决归档docs/CONSUMERS.md新建顶层 wrapper 133 行, L407 三件套唯一手写部分
v0.2.0 (2026-04-24)¶
v0.1.0 之后的二轮发版, 核心交付 "Agent → staging → Validator → ValidatedTool → CircuitBreaker → WMS API" 完整安全链 core 侧就绪. 引擎层新增 4 个子包 (validator / circuitbreaker / reflector / staging), evolve 9/9 接口矩阵参考实现全部落地, SQL 工具链 3 件套补齐 staging 一跳, platform/common 装配 + 观测 + gRPC 三面暴露就绪供 Go / C# 行业 platform 消费.
核心新增 (core 引擎库)¶
- validator 子包 — 可插拔审批契约: RuleValidator (DiffSize / TableWhitelist / Pattern 3 内置规则) / LLMValidator (Flyto provider 桥接) / CompositeValidator (AllMustApprove + Waterfall 2 模式), 显式
AlwaysApproveopt-out, nil fail-fast 构造期 panic - circuitbreaker 子包 — 三态状态机 (Closed / Open / HalfOpen) + Clock DI + VerdictSink 桥接, 订阅 ValidatedTool 的 Verdict 流水自动推进状态
- reflector umbrella 包 — 表达 "反射器" 产品抽象, 4 个跨家族 adapter (
ValidatorAsEvaluator/EvaluatorAsValidator/ValidatorAsReflector/EvaluatorAsReflector), 不引新顶层接口; 规则 / LLM / ML 后端可互换接入validator.Validator/evolve.Evaluator/evolve.Reflector任一同族接口 - staging 子包 — 决策包级 staging 表, 7 状态机 (
pending_tech → rejected_tech | pending_ml → rejected_ml | approved → executed | failed), 混合控制 Engine (前两段 arc staging 主动调 Validator, 末段approved → executed/failed外部推 MarkExecuted/MarkFailed), 可插拔DependencyGuard+InMemoryStore参考实现 + 内置TenantDenyGuard, pull-only query API - evolve v0.2+ 接口矩阵 9/9 参考实现 — Generator / Evaluator / Reflector / ApprovalFunc / ParameterStore / ParameterEvolver / LogReplayer / LogSource / FeedbackChannel / ShadowRunner 全部落地, 闭环 example 单进程跑通 (baseline 1.0 → version 2 的 1.5, divergence 0.1736, shadow delta +0.105)
- tools SQL 工具链 3 件套 — 只读校验器 (零 DB 依赖字符串解析) / CAS 乐观锁 (
maxRetries=0fail-fast 默认) / Dry-run 三路 (before + after SELECT +preview_predicate+ 100 行 truncate), 支撑 staging 一跳 - evolve FlytoLLMClient adapter —
flyto.ModelProvider → LLMClient桥接, LLMGenerator 可驱动任意 provider (anthropic / openai / minimax / gemini / ollama / lmstudio / openrouter); TextEvent 权威 + TextDeltaEvent 兜底避免 anthropic 双倍计数
platform/common 层 (SaaS 基础设施)¶
- safetychain 装配包 — 一行
Assemble(inner, v, extractor, scope, sink) tools.Tool组合 Validator + CircuitBreaker + ValidatedTool decorator, 3 breaker scope 工厂 (NoOpScope/DestructiveOnlyScope/PerToolScope) - admin HTTP 观测端点 —
GET /admin/safetychain/verdicts+GET /admin/safetychain/breakers, opt-in 挂载, auth middleware 兜底;VerdictStore接口 +RingStorebounded 内存实现 O(1) 热路径无分配 - gRPC SafetyChainService — 给 C# industry platform 原生通路, 2 RPC (
ListVerdicts/ListBreakerStates), bufconn e2e 互操作验证 proto 线格式稳定
关键设计原则¶
- fail-closed: Validator 返回 error 视 Block; CompositeValidator Waterfall 任一子 Validator Block 整体 Block; CircuitBreaker Open 直接拒绝调用; nil 依赖构造期 panic 拒启动
- schema-agnostic:
DiffInput.SourceTool作分发键,Raw []byte+Metadata map[string]any携带 opaque 载荷, 引擎层不假设任何行业 schema - 显式 opt-out 必须写出来:
AlwaysApprove{}/AllowAlwaysGuard{}/NoOpScope()/ nil Validator panic — 没有静默默认, industry 刻意关审批必须 code-level 显式动作, 消灭 "以为开了审批实际没开" 的安全假象 - 产品可替换性: 核心接口 (
Validator/Evaluator/Reflector/DependencyGuard/Store/VerdictStore/BreakerScopePolicy) 全部多态可插拔, 规则 / LLM / ML backend 互换接入任一 slot, reflector umbrella + 4 adapter 保证跨家族兼容
已知限制¶
以下功能留待 v0.3+ 完成 (不影响 v0.2 使用):
- ML Validator backend 未接入 — core 接口就绪 (
validator.Validator+LLMValidator+CompositeValidator+AlwaysApprove+NewValidatedToolnil fail-fast), platform 层接外部 ML HTTP backend 实现 + 装配即启用 (TODO L434) - 熔断器未在消费层启用 — core 三态 breaker +
VerdictSink桥接就绪, platform 选作用域 (全熔 / 只熔写 / 每工具一熔) 并 wireValidatedToolsink 即启用 (TODO L435) - 影子表生命周期管理 — Dry-run 工具已做 (模块 23 SQL Dry-run), 多轮推理临时表创建 + 会话结束清理在 platform 层 (TODO L437)
- AuditSink DB 实现 — 接口就绪, PostgreSQL 写入 + session 查询 + 批量回滚在 platform 层 (TODO L438)
- Temperature cross-provider 契约缺口 —
LLMCallOpts.Temperature被FlytoLLMClientadapter 刻意忽略 (flyto.Request最大公约数无 Temperature 字段), 温度控制仍需在 provider 工厂 Config 设置 (TODO L683, P3 设计决策, 侵入式扩flyto.Request跨所有 provider) - CAP-4 自动化 × 3 — Flyto CLI 无头自消费 / CI/CD 集成 / WebSearch 前置 (TODO L488-490, P2)
- Provider 模型表自动更新 — MiniMax / Gemini 新模型上线需手工更
capabilities.json(TODO L454, P2) - WMS 波次参考实现 — 状态机新增 "任务已建待确认" 在 platform 层 (TODO L439)
- 反事实工作流引擎级 enforcement — 1500-2500 行改造远期 2026 Q3-Q4 候选 (TODO L569, P3)
发布事实¶
- Baseline: 212 → 216 dead fields (+4 合法 tracked debt:
Record.TechVerdict / BizVerdict / ExecutionError / ExecutionProof外部 audit dashboard 消费, core 无内部 reader, 按feedback_exported_field_delete_needs_review.md) - TODO: 46 → 47 done, 15 → 14 open (L436 staging ✅ check off, 模块 23 SQL × 3 ✅, 安全链 C1-C4 ✅)
- 测试: staging 47 + reflector 25 + validator / circuitbreaker / safetychain assemble 16 + admin 11 + gRPC server 6 (bufconn e2e) 全绿, race detector 通过
- 依赖:
modernc.org/sqlitetest-only dep 落地 (core 第一条非图像处理第三方依赖, SQL CAS 测试用, 生产路径仍仅依赖database/sql标准库) - 设计决策工作流: Agent Teams 3 角色并行 review 成默认, session 实战 5 次 (SQL CAS / 候选选型 / Validator 接口 / reflector umbrella Option d / staging 5+2 决策)
详细变更¶
以下 sub-sections 保留 session-by-session 技术决策 reasoning 作为 release notes 详录, 供下游 consumer / 技术反思参考.
evolve: v0.2+ 系统级演化接口矩阵 9/9 delivered (2026-04-18)¶
core/pkg/evolve/interfaces.go 定义的 10 个核心接口 (9 interface + 1 func type) 全部落地参考实现, 战略路线见 docs/evolve-strategy.md §7.4.
| # | 接口 | 参考实现 |
|---|---|---|
| 1 | Generator |
generator_llm.go |
| 2 | Evaluator |
evaluator_impls.go |
| 3 | Reflector |
reflector_impls.go + self_reflector.go |
| 4 | ApprovalFunc |
evolve.go |
| 5 | ParameterStore |
parameter_store_file.go |
| 6 | ParameterEvolver |
parameter_evolver_default.go |
| 7 | LogReplayer |
default_log_replayer.go |
| 8 | LogSource |
log_source_file.go |
| 9 | FeedbackChannel |
feedback_channel_file.go |
| 10 | ShadowRunner |
shadow_runner_default.go |
闭环 example: core/examples/evolve_closed_loop/main.go -- 9 接口在单进程串联跑通 (baseline 1.0 → version 2 的 1.5, divergence 0.1736, shadow delta +0.105).
领域无关原则¶
所有接口用 any / string / float64 传递业务数据, 不假设任何行业 schema. 具体数据源接入由 consumer 层实现, 引擎只操作抽象接口 (见 memory project_architecture_decisions.md "数据接入边界").
evolve: FlytoLLMClient -- flyto.ModelProvider 桥接 (2026-04-18)¶
core/pkg/evolve/llm_adapter_flyto.go 提供 flyto.ModelProvider -> LLMClient adapter, 让 LLMGenerator 可直接驱动任意 provider (anthropic / openai / minimax / gemini / ollama / lmstudio / openrouter). 事件聚合以 TextEvent 为权威, TextDeltaEvent 作兜底, 避免 anthropic 同时推两者时的双倍计数. ErrorEvent 经 %w ErrLLMFailed 包装, errors.Is(err, ErrLLMFailed) 可用. 非文本事件 (ToolUse / Thinking / Usage / Done / ...) 统一忽略.
限制: flyto.Request 为跨 provider 最大公约数, 不含 Temperature; LLMCallOpts.Temperature 被 adapter 忽略 -- 温度控制仍需在 provider 工厂 Config 设置.
tools: SQL 工具链 3 件套 (2026-04-23)¶
面向 staging / 影子表的三件套, 支撑 AI Agent 写业务 DB 的 staging 一跳 (Agent -> staging -> ML 审批 -> WMS API 写生产). 2026-04-23 commit 981a2ea 分类翻盘, 从原 "消费层不属于引擎层" 挪到引擎层模块 23 -- schema-agnostic / driver-agnostic 通过 StagingDB newtype + *sql.DB DI 由客户端注入 driver, 引擎层仅 database/sql 标准库, 所有客户复用.
| 组件 | 位置 | commit |
|---|---|---|
| SQL 只读校验器 | core/pkg/tools/builtin/sql_validator.go |
79670c7 |
| SQL CAS 乐观锁 | core/pkg/tools/builtin/sql_cas.go |
bf31278 |
| SQL Dry-run 三路 | core/pkg/tools/builtin/sql_dryrun.go |
a935604 |
要点: CAS maxRetries=0 fail-fast 默认 (Agent 看 version 冲突应重 plan, 非 silent retry), version 非 int 运行时 reject, modernc.org/sqlite test-only dep 落地 (core 第一条非图像处理第三方依赖). Dry-run 方案 E (before + after 都 SELECT) + LLM 传 preview_predicate (工具不 parse SQL) + 100 行 truncate 显式提示. 只读校验器纯字符串 quote-aware 解析零 DB 依赖.
staging: 决策包级 staging 表 + 7 状态机 + 混合控制 Engine (2026-04-24)¶
core/pkg/staging/ 新子包, 管 "Agent 决策 → staging → 审批 → 生产" 链路中 staging 一环. 决策包级 Record (一次 Agent 决策 = 一条 Record, 做法 I 整体打包原子), 7 状态机:
pending_tech -> rejected_tech | pending_ml
pending_ml -> rejected_ml | approved
approved -> executed | failed
混合控制 (产品决策 Y): Engine 主动调 validator.Validator 推进前两段 arc (ValidateTech / ValidateBiz, fail-closed 语义), 终端 approved → executed/failed 由平台层外部推 (MarkExecuted(id, proof any) / MarkFailed(id, reason)). core 对生产写路径无权限, 无法自驱最后 arc.
接口契约 (产品决策 X): 技术层和业务层两个 slot 都复用 validator.Validator, 不造 TechValidator/BizValidator 新接口. Verdict.Score + Severity 能表达 "SQL 过了但 plan 差" (Warn) 与 "ML 打分 0.3" (Block). 客户用 reflector.EvaluatorAsValidator 把 Evaluator 适配为 Validator 接入任一层.
依赖可插拔 (产品决策 #3): DependencyGuard 接口, nil fail-fast, AllowAlwaysGuard{} 显式 opt-out (对齐 validator.AlwaysApprove 模式); 内置 TenantDenyGuard 示例 (metadata-driven deny-list 典型形态).
客户接入 pull-only (产品决策 #5): Store.List / ListBySession / ListStuck 查询 API, core 不向客户库推送. 客户按需查 (cron / dashboard / 事件 sink 自选).
幂等 + 审计链: MarkExecuted / MarkFailed first-write-wins 保原 proof / reason, 审计不可变性. ListStuck(state, olderThan) 给平台层 watchdog 发现 approved 卡住不推进 (外部 arc 无内置超时, AWS Step Functions task token pattern 借鉴).
| 文件 | 内容 | 行数 |
|---|---|---|
doc.go / state.go / record.go / guard.go |
7 状态 + Record + Query + DependencyGuard + AllowAlwaysGuard + TenantDenyGuard | ~350 |
store.go / inmemory.go |
Store 接口 9 方法 + InMemoryStore 参考实现 | ~350 |
engine.go |
Engine 混合控制 (NewEngine 4 参 nil fail-fast, 5 方法) | ~175 |
*_test.go |
47 test 全绿 (race detector 通过) | ~700 |
Baseline 212 → 216 (净 +4 合法 tracked debt: Record.TechVerdict / BizVerdict / ExecutionError / ExecutionProof — 外部 audit dashboard 消费, core 无内部 reader, 按 memory feedback_exported_field_delete_needs_review.md). 设计决策走 Agent Teams 3 角色 review; 产品经理 5 轮产品决策 + 7 条质疑 reconcile (4 硬挡采纳, 2 软挡采纳, 2 保留原决定含前提不成立的). TODO.md L436 check off.
reflector: 产品抽象伞形包 + 4 adapter (2026-04-24)¶
core/pkg/reflector/ 新 umbrella 包, 用于表达 "反射器" 产品抽象 — validator.Validator (sync commit 闸) / evolve.Evaluator (sync 打分) / evolve.Reflector (async 回放) 三个同族接口. 本包不引入新的顶层接口, 只提供跨家族类型安全 adapter, 兑现 "规则 / LLM / ML 后端可互换接入任一同族接口" 的可替换性, 子包代码零改动.
| Adapter | 作用 | 关键参数 |
|---|---|---|
ValidatorAsEvaluator |
Validator 包为 Evaluator | CandidateToDiff extractor; 可选 WithRejectFitness (默认 Approved=false → fitness=0 保守) |
EvaluatorAsValidator |
Evaluator 包为 Validator | DiffToCandidate extractor + threshold 必传; 可选 WithName / WithBelowThresholdSeverity / WithPolicyVersion |
ValidatorAsReflector |
Validator 包为 Reflector (旁路观察) | EventToDiff + VerdictSink 必传; OnEvent 永远返回 nil, 错误走 sink |
EvaluatorAsReflector |
Evaluator 包为 Reflector (旁路观察) | EventToCandidate + FitnessSink 必传 |
反向 Reflector → Validator/Evaluator 刻意不做 — OnEvent 无返回载荷, 无法反推同步 Verdict 或 fitness. ErrExtractFailed 统一包装 extractor 错误 (errors.Is 可识别). nil src/extract/sink 构造期 panic fail-fast.
设计决策走 Agent Teams 3 角色 review (Option d umbrella 胜出 Option a "真合并三接口") -- 合并超集接口违 Go 惯例 (io.Reader/AWS SDK step/gRPC Unary vs Stream 先例), 且 sync/async 语义不可同一接口承载 (2026-04-23 那次 review 判定的技术事实). umbrella 包以 godoc + 类型安全 adapter 达成 "反射器体系" 产品抽象, 不推翻已 ship 的 validator / evolve 代码.
safety chain: core 层装配就绪 (2026-04-23 / 24)¶
"Agent → staging → Validator → ValidatedTool → CircuitBreaker → WMS API" 安全链 core 侧 7 commit 一次到位, 加上 2026-04-24 的严格化补丁, core 层全部就绪等 platform 装配.
| 组件 | 位置 | commit |
|---|---|---|
| Validator 接口 + DiffInput / Verdict / Severity | core/pkg/validator/interfaces.go |
30639fb |
| RuleValidator + 3 内置规则 (DiffSize/TableWhitelist/Pattern) | core/pkg/validator/rule_validator.go |
e6239c2 |
| LLMValidator + FlytoLLMClient 桥接 (pkg/flyto 独立副本) | core/pkg/validator/llm_adapter_flyto.go |
ca6309e |
| CompositeValidator (AllMustApprove / Waterfall 2 模式) | core/pkg/validator/composite.go |
8b1d556 |
| ValidatedTool Decorator + VerdictSink + 3 extractor | core/pkg/tools/builtin/validated_tool.go |
6ac07b3 |
| CircuitBreaker 三态 (Closed/Open/HalfOpen) + Clock DI | core/pkg/circuitbreaker/breaker.go |
3342425 |
| AlwaysApprove 显式 opt-out + NewValidatedTool nil fail-fast | core/pkg/validator/always_approve.go + 同上 decorator |
1b0a860 |
要点:
- schema-agnostic: DiffInput.SourceTool 作分发键, Raw []byte + Metadata map[string]any 携带 opaque 载荷, 引擎不假设任何行业 schema.
- fail-closed: Validator 返回 error 视作 Block; CompositeValidator Waterfall 模式任何子 Validator Block 整体 Block.
- 熔断桥接: ValidatedTool 每次 Verdict 触发 VerdictSink(toolName, verdict), CircuitBreaker.VerdictSink() helper 订阅即接入 (reject 累积 → Open → Cooldown → HalfOpen 半开试探).
- 显式 opt-out 必须写出来: NewValidatedTool(v=nil, ...) 构造期 panic, industry 刻意不要审批必须显式传 validator.AlwaysApprove{}, code review 和审计 dashboard 过滤 ValidatorName="always-approve" 可抓到. 消灭 "以为开了审批实际没开" 的静默安全假象.
消费位点: core 侧全部就绪. 消费层 P1 L434 (ML 验证器) / L435 (熔断器) 等 platform 层装配 — 选 backend (LLM provider / 外部 ML HTTP / 自部署 ML) + 选 breaker 作用域 (全熔 / 只熔写工具 / 每工具一熔) + wire 到 Tool Registry.
safety chain: platform/common 装配层 (2026-04-24, C2)¶
platform/common/safetychain/ 新公共包 (非 internal, Go 行业 platform 可直接 import; C# logistics 走 gRPC, 见 C4). 把 core 的 Validator + CircuitBreaker + ValidatedTool decorator 组合成一行 Assemble() 调用, 产出可注册的 tools.Tool.
| 组件 | 形状 | 作用 |
|---|---|---|
Assemble(inner, v, extractor, scope, sink) tools.Tool |
装配函数 | Validator / extractor 为 nil 时由 core 构造期 panic (C1 保证); scope / sink 可 nil |
BreakerScopePolicy |
func(tools.Tool) *CircuitBreaker |
决定每 Tool 要哪个 breaker; 函数类型非 interface, 行业自定义直接写 |
NoOpScope() |
(policy, registry) | 不挂 breaker; registry 空 |
DestructiveOnlyScope(cfg) |
(policy, registry) | destructive tools 共享 1 breaker, 对齐 "写路径整体保护" 语义 |
PerToolScope(cfg) |
(policy, registry) | 每 tool 独立 lazy 分配 |
BreakerRegistry |
.Snapshot() map[string]State + .Names() []string sorted |
给 C3 admin 端点消费; 每工厂返回句柄 |
设计决策:
- 无默认: industry 不调 Assemble 就没有审批也没有熔断; 没有 safety 是显式 code-level 选择, 不是隐式回退.
- 显式 opt-out: Validator 为 nil 由 C1 的 NewValidatedTool panic 守住; industry 刻意 unchecked 必须显式传 validator.AlwaysApprove{}.
- 不再抽装配 interface: Assemble 返回的就是 core 的 *builtin.ValidatedTool. 这是接线层不是新契约. Agent Teams 3 角色 review 里"质疑"角色观察"抽 ValidatorAssembler interface 是 YAGNI", 采纳.
- fan-out 顺序: industry sink 先 (记录到 observation store 不丢) / breaker sink 后 (推进状态). 供 C3 observation store 消费时保证顺序稳定.
16 test 全绿 (TestAssemble_* × 6 + TestFanOut_AllCombinations + scope 系列 × 9). core baseline 212 不变.
消费位点: 装配层就绪. industry (logistics / erp / crm 等) 选 backend (LLM provider / 外部 ML HTTP) + 选 scope + 调 Assemble 即可启用安全链. C3 加观测端点, C4 加 gRPC 暴露给 C# 消费.
safety chain: admin 观测端点 (2026-04-24, C3)¶
安全链运维可见性: platform/common/safetychain/ 加 VerdictStore 接口 + RingStore bounded 内存实现, 把 Verdict 流水暴露给 admin HTTP 端点让运维和 dashboard 能查实时状态.
| 组件 | 形状 | 说明 |
|---|---|---|
VerdictStore 接口 |
Record(tool, v) + Snapshot() []VerdictRecord |
Record 签名对齐 builtin.VerdictSink, industry 传 store.Record 到 Assemble sink 无需 wrapper |
RingStore |
bounded 内存环形, O(1) Record 热路径无分配 | 容量显式参数, 无默认 (Warn 流量各行业差异大); clock 注入可测, nil 用 wall time |
VerdictRecord |
{Timestamp, ToolName, Verdict} 带 json tag |
导出结构, 外部观测栈 (日志收集器 / 审计管道) 可直接 decode |
admin.New(..., WithSafetyChain(store, reg)) |
variadic option 向后兼容 | 任一参数 nil 视为不启用 (配对契约); 端点仅 opt-in 时挂载, 默认 404 |
GET /admin/safetychain/verdicts |
[]VerdictRecord JSON |
空 snapshot 返回 [] 不是 null, 下游无需判 null |
GET /admin/safetychain/breakers |
[{name, state}] JSON 按 name 排序 |
轮询间 JSON diff 稳定 |
安全考虑: 两端点都走 authed() helper, verifier 非 nil 时套 auth.HTTPMiddleware (和 /admin/tenant 同级). Verdict.Reason 可能携带运营敏感信息 (为什么某 SQL 被 block), 不能未鉴权暴露. /admin/health 仍免鉴权供 Caddy / k8s 探针.
测试: 5 个 RingStore 测试 (cap panic / 未 wrap 顺序 / wrap 后保留最后 N / Snapshot 返回拷贝 / 并发 -race 无 data race / VerdictSink 签名兼容) + 6 个 admin 端点测试 (未 opt-in 返回 404 / partial opt-in 拒绝 / happy path JSON shape / 空 snapshot 返回 [] / breakers 排序 + state 字符串). core baseline 212 不变.
消费位点: core + common 装配 + common 观测都就绪. 只差 C4 gRPC 暴露给 C# logistics. 本 commit 后行业 platform (Go) 的完整消费模式是:
mp := anthropic.NewProvider(...)
v := validator.NewCompositeValidator(...) // 或 AlwaysApprove{} 显式 opt-out
scope, reg := safetychain.DestructiveOnlyScope(cfg)
store := safetychain.NewRingStore(512, nil)
admin := admin.New("v1", verifier, admin.WithSafetyChain(store, reg))
safe := safetychain.Assemble(innerTool, v, extractor, scope, store.Record)
registry.Register(safe)
safety chain: C# 消费通路 gRPC (2026-04-24, C4)¶
给 platform/industry/logistics/ (C#) 一条原生 gRPC 通路消费安全链状态, 和 C3 的 admin HTTP 形成同源双面暴露. 沿用 HealthService 已经走的 proto + genpb + grpcapi 模式, 不创造新架构.
| 组件 | 位置 | 说明 |
|---|---|---|
| proto 合约 | platform/common/internal/api/grpc/proto/safetychain.proto |
flyto.platform.common.safetychain.v1 命名, csharp_namespace Flyto.Platform.Common.SafetyChain.V1 对齐 health |
| 生成代码 | gen/safetychain.pb.go + safetychain_grpc.pb.go |
共享 package genpb, 和 health 同包 |
| server 实现 | grpcapi.SafetyChainServer{Store, Registry} |
依赖注入, 与 admin.WithSafetyChain 消费同一对 VerdictStore + BreakerRegistry (单进程单源) |
| cmd/common wire | cmd/common/main.go 启动默认 |
构造 RingStore(1024) + NoOpScope registry, 同时挂给 admin HTTP 和 gRPC, 默认端到端可访问 |
2 RPC:
- ListVerdicts(ListVerdictsRequest) -> ListVerdictsResponse — 当前 Snapshot 窗口老的在前; 空窗口返空列表不是错
- ListBreakerStates(ListBreakerStatesRequest) -> ListBreakerStatesResponse — 按 Name 排序
设计决策:
- proto Verdict 刻意省略 Details 字段 (map[string]any): proto3 无直接等价, 且没有现消费者读 per-rule breakdown. 未来需要时加 repeated DetailEntry { key, json_value }.
- Severity / State 用 string 不用 enum: 第三方 Validator / 未来新增 breaker 状态不应强迫 proto schema 升级, 前向兼容优先.
- nil Store / Registry 返空列表不报错: 观测端点不能把 "没东西看" 和 "服务挂了" 混淆. cmd/common 已 wire 空 store/registry, 但即使漏 wire 也 graceful degrade.
- 默认启用 gRPC safetychain service (和 HealthService 同级别): common 启动即可供 C# stub 对接, 哪怕数据为空. 与 "C3 admin 默认 opt-in 挂" 口径一致: cmd/common 选择了 opt-in.
生成工具链: 需要 protoc-gen-go 和 protoc-gen-go-grpc 插件在 $GOPATH/bin. 本 commit 装了一次:
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
regen 命令 (在 platform/common/internal/api/grpc/proto/ 下跑):
PATH=$GOPATH/bin:$PATH protoc \
--go_out=../gen --go_opt=paths=source_relative \
--go-grpc_out=../gen --go-grpc_opt=paths=source_relative \
safetychain.proto
测试: 6 个 server 测试 (nil-store / nil-registry / 字段 round-trip / 排序 / 空 Snapshot yields empty list / bufconn 端到端 gRPC round-trip). bufconn 测试是 C# stub 互操作的代理证据 — Go client + Go server 经 bufconn 走 proto 序列化往返成功, 说明线格式稳定, C# 用同一 proto 生成 stub 可互操作. core baseline 212 不变.
消费位点: 整条 "Agent → staging → Validator → ValidatedTool → CircuitBreaker → WMS API" 安全链在 core / common 层全部就绪. C# logistics 可用 proto + dotnet add package Grpc.Net.Client 直接消费. 剩的工作落到 industry platform 侧: 选 LLM / ML backend, 装配 Tool 到 engine 消费层, 把真实 Verdict 流水填进 common 的 VerdictStore.
v0.1.0 (2026-04-18)¶
首次公开发布。core/ 引擎库作为独立 Go module 可用。
核心能力¶
- 引擎运行时 — 多会话管理、子 Agent (fork)、Dream 记忆巩固、Plan 工作流、Token 预算
- Provider 层 — Anthropic / Gemini / MiniMax / OpenAI / OpenRouter / Ollama / LM Studio,统一接口
- Pricing — 运行时获取模型单价,ModelRegistry 集成,支持多供应商按 token 计费
- Hook 系统 — 12 种 HookType,支持 Shell / Callback / Webhook 三种执行后端,session 级隔离
- Plugin 系统 — DFS 依赖解析、manifest 验证、能力 probe
- Context 压缩 — 三层降级(部分 / 完整 / 反应式),断路器保护
- 记忆系统 — AI 相关性选择、新鲜度警告、Git/HTTP 团队同步
- 权限引擎 — 白名单 → 规则 → AI 三层级联,递归 AST 危险命令检测
- 安全 — 45 条内置 Secret 扫描规则,AuditSink 接口
- Agent Teams — Leader/Worker 多 Agent 协调,TaskList 共享任务板
- Bridge — SSE / WebSocket 传输,断线重连,串行批量上传
- Daemon — 后台守护进程,会话池 + 容量控制,崩溃恢复(指数退避),空闲超时自动关闭
- MCP 协议客户端 — JSON-RPC 2.0,多服务器并发管理,stdio / SSE / HTTP 三种传输,Elicitation 支持
- 自进化(Evolve) — 动态工具构建、Skill 学习、自我反思;接口完备,v1.0 前完善完整能力环路
已知限制¶
以下功能留待 v1.0 前完成(不影响 v0.1 使用):
- AuditSink DB 实现(platform 层)
- CAP-4 自动化测试 × 3(core 工具层)
- Provider 模型表更新(MiniMax / Gemini)