# ADR-014 确定性步骤:确定的活由引擎按声明做,不问模型 状态:**提案**(2026-08-28,待用户定) 背景出处:ACT-WORK-05 差异评估九步流程盘点(§2)、`docs/ACT-WORK-05-端到端测试记录.md` 发现表 A–AE。 关联:ADR-004(活动包 ABI:**系统不执行代码即知包为何物**)、`docs/活动开发规范.md` §4.4(自动收步)、 `PlanStep.humanWrites`(2026-08-28 立的墙)、ADR-005(工具契约与落地预检)。 --- ## 1. 问题 用户 2026-08-28 的原话: > 「这个不是一个确定的流程吗?根本不需要交给 ai,你直接查库不就知道有没有了吗?」 指的是 W05 的「逐个料号查库 → 不在库就铺骨架、发数据工单」。这一段没有任何判断余地,却写成了模型的 guide。 不是活动作者偷懒 —— **平台里没有别的落点**: | 现有机制 | 能做 | 不能做 | |---|---|---| | 纯工具(`pure`) | 算、读台账(1.2.0 起) | 写台账、发卡 | | 判定表(DMN) | 出结论 | 写台账、发卡 | | `doneWhen` | 判「办完没有」,引擎自己收步 | **做事** | | 哨兵(`entryCriteria`) | 判「能不能进这一步」 | **做事** | | 视图绑定语言 | 把台账取出来**摆到屏上** | 写回去 | 「读台账 + 写草稿 + 发人卡」跨了三样,没有一个机制能整段承接。而引擎这三样能力**全有** (`data.view` / `data.draftWrite` / `humanTool.issueExecution`)—— 缺的只是**声明面**。 `packages/activity-sdk/src/define.ts` 里 `run?` 的注释早就写着: > 留着它是为**确定性子序列(结晶片段)**将来有个落点;不写就是不写,不是缺件。 这份 ADR 就是给那个落点定形。 **代价已经付过。** W05 的事故里,至少八条落在「本来就该是确定的」那几步上(§7 逐条验算)。 --- ## 2. 盘点:W05 九步里哪些是确定的 | 步 | 确定的部分 | 需判断的部分 | |---|---|---| | **S0_bind** 建上下文 | 铸案号 · 查客户在不在库 · 写需求单(入参搬运+常量)· 逐料号查库写核对表 · 写结论行 | **零** | | **S0d_p08** 派主数据单 | 定名单 · 铺三张表骨架 · 发卡 · 人交回后改旗 | 有 datasheet 附件时从原文抽参数值 | | **S0d_p10** 派资料单 | 定名单 · 发卡 · 人交回后改旗 | **零** | | **S1** 评估输入清单 | 清单九行(四行绑定投影 + 两行固定缺口 + 三行常量默认)· 发执行单 · 把 supplement 抄进需求单 | 人补的自由文本**归纳**成技术约束 | | **S2** 标准化对照表 | 关系骨架 · 逐参数对照行(原 PN 参数集 × 候选同名行的 join)· 补封装/生命周期两行 | 单位换算;`constraint_type` 未标时归类(**应进 DMN,不进配方**) | | **S3** 逐项差异评估 | 工具串接 · 四态照抄 · 计数 · `difference_summary` · 置信取最小 | **客户原话 → 约束式** · 影响陈述 · UNKNOWN 去向与验证条件 · 推荐理由扩写 | | **S4** 结论审核 | 发决策卡(内容是计数投影)· approve 收步 | **读人的自由文本意见 → 定位到具体行 → 改实** | | **S5** 拟落账 | 分析状态推 FAE 确认 · 回写需求单五项(风险取最高档、VAL 号拼接、编号、常量三项) | 只有 summary 一句人话(可模板化) | | **T_release** 放行 | submit · 无分支去向 | 两条裁量判据 · 打回后修订 | **agent 真正不可替代的只有四类**:①自然语言 → 结构化;②读人的意见并落到具体行;③写给人看的话; ④非结构化来源抽取(datasheet 抽值、单位换算)。第四类在实跑中一次都没发生过(没传附件)。 **结论:九步里三步整个确定(S0_bind / S0d_p10 / S5),四步八成确定,只有 S3 是 agent 的主场。** 按拍数算,一单十来拍里有七八拍在做确定性的活 —— 而每一拍都是一次采样,都有编错号、抄错行、漏一列的机会。 --- ## 3. 决策 ### 3.1 声明式配方,**不执行活动包代码** `sys.activity_package.artifact`(包的 `index.js`)存了库,但**至今一次都没被执行过**。这份设计**不开这条路**: - ADR-004 的立场是「系统不执行代码即知包为何物」;配方是**数据**,装进来仍然只是数据, 可静态审、可回放、可在治理台上摆出「这一步系统会做什么」。执行代码则相反。 - 执行第三方包要配沙箱、超时、内存墙,而 `scanArtifact` 只是文本扫描,拦不住混淆。 - 配方要表达的形状**很窄**(§3.2 三个动词),不值得为它开一个能跑任意代码的面。 **代价**:表达力有上限。上限之外的**不要硬塞** —— 退回模型,或者把那一段切成独立的 agent 步(§3.4)。 ### 3.2 三个动词,复用已有的两套求值 配方只有三个动词: | 动词 | 做什么 | |---|---| | `project` | 从「某个来源的每一行」投影出台账行,`draftWrite` 落草稿层 | | `card` | 发一张人类工具的卡(执行工单 / 数据工单 / 数据录入卡),**至多一张** | | `say` | 一句给人看的交代(模板串,不是模型生成) | 条件与聚合**不发明第二套求值**: - 条件用 **S-FEEL**(`when`,对任务视图求值)—— 与哨兵、`doneWhen` 同一个求值器; - 计数/聚合用**基座读数**(`_rows` / `_missing` / `_distinct_x` / tally 计数)—— 本来就在作用域里。 ### 3.3 `auto` 是「问模型之前先把确定的事做了」,不是「整步替代」 `PlanStep.auto` 是一个属性。引擎在这一步的**每一拍拍首**先跑配方,然后: - 配方发了卡 → 这一拍到此为止(**零 token**,不问模型); - 没发卡且这一步**没有 `guide`** → 求 `doneWhen`,真了收步、进下一步(下一步若也是 auto 就接着跑); - 没发卡且这一步**有 `guide`** → 照常问模型(半 auto:确定的已经做完,模型只面对剩下的判断)。 这样老步骤一个字不改,可以一步一步搬。 ### 3.4 作用域:**一个计划项内,到交出控制权为止** 配方跑到「发卡」就结束 —— 与现有 `handsOff` 语义(ask/human/decide/listen/submit 是交出控制权)对齐。 人答回来之后,那一步若还有活,归 `guide`。 **推论(重要)**:一步要么确定,要么不是。**不做「一半 auto」的复杂机制** —— 判断点要先从那一步里切出去。 例:S0d_p08 的「datasheet 抽值」应当移出这一步(归到起单读来件),这一步才变成纯 auto。 **这条逼着活动作者把判断点想清楚,是特性不是限制。** ### 3.5 幂等 配方会被反复进入(人答卡后再 tick),必须幂等: - **写**:`draftWrite` 本来就是同 PK 覆盖。值没变 → **静默跳过,不留痕、不算空转被拒** (「写了等于没写」那条判据是拦模型空转的,配方不是模型); - **发卡**:先看 `worldTouched` —— 发过就不再发。人工激活的 auto 步,**配方发卡就是启动**。 ### 3.6 出错 = 交人,不重试、不退回模型 配方抛错(地址不存在、S-FEEL 算不出、卡骨架不合契约)是**活动包的 bug**:如实留痕 + 交人。 **不重试五遍,也不退回模型** —— 退回模型就是让它去凑,那正是这份 ADR 要消灭的东西。 ### 3.7 静态审在装的时候把住 `staticAudit` 扩到配方(与 `refersTo` 目标检查同款,写错地址 = 每一单都错): - `into` / `each` / `lookup` / `joinRows` 的台账地址必须在 `declarations.ledger` 里(与写的授权面同一份名单); - `fields` 的列名必须在该对象的列声明里; - `when` 的 S-FEEL 必须能解析,引用的名字要绑得上(`boundNames`,已有); - 卡骨架的 `view` 必须在 `viewTemplates` 里; - 同一张表既在 `auto.project` 里又在 `humanWrites` 里 → **警告**(铺骨架合法,但要作者确认列不重叠)。 ### 3.8 观测与度量 - 配方的每一笔照常进 `sys.task_memory`,`detail.auto = '<步名>#<第几笔>'`; 工作日志三色里归**系统灰**(已有档,不必新增); - 治理台的活动详情能摆出「这一步系统会做什么」(配方是数据,渲染得出来)—— 这是相对提示词的一大好处; - **自主率的分法要认第三档**:现在两档(AI / 人),配方做的事既不是 AI 判的也不是人办的,是系统做的。 --- ## 4. 声明形状 ```ts // PlanStep 新增(可选,老活动一个字不改) auto?: AutoRecipe; type AutoRecipe = { /** 配方级局部值:先算一遍,后面所有表达式都能引用({in:'evalRef'})—— 整单只有一个案号,靠它 */ readonly let?: Record; /** 按序执行;每一笔可带 when(S-FEEL),假就跳过这一笔 */ readonly do: ReadonlyArray; }; type AutoAction = | { kind: 'project'; when?: string; into: string; from: AutoSource; fields: Record } | { kind: 'card'; when?: string; workOrder: 'execution' | 'entry'; variant?: 'data'; title: AutoExpr; gateLedgers?: string[]; view?: string; /* 指导书等静态文本直接写 */ } | { kind: 'say'; when?: string; text: AutoExpr }; type AutoSource = | { once: true } // 一行 | { rows: ReadonlyArray> } // 固定几行(S1 的默认三项) | { each: string; where?: Record; // 'in:candidateInternalPns' | 'mine:域.对象' | 'ledger:域.对象' then?: { each: string; where?: Record } }; // 第二层,**最多两层** type AutoExpr = | string | number | boolean // 常量 | { in: string } // 入参 / case:* / 基座读数 / let | { self: true } // each 是数组时的本项 | { col: string } | { outer: string } // 本行的列 / 外层那一行的列 | { cat: AutoExpr[] } // 拼接 | { fmt: AutoExpr; as: 'digits' } // 只留数字(铸号) | { lookup: string; by: Record; take: string; else?: AutoExpr } // 查另一张表的一列 | { joinRows: string; where?: Record; take: string; sep?: string } // 多行一列拼成串 | { when: string; then: AutoExpr; else: AutoExpr }; // S-FEEL 条件(可嵌套) ``` **字段按声明顺序求值,已算出的字段进同一行的 S-FEEL 作用域** —— 「先 lookup 出依据,再按依据判 found/absent」 这种写法靠它成立。 ### 4.1 例:S0d_p10_document(整步确定,最小的那个) ```ts auto: { do: [ { kind: 'card', when: 'sub_eval_pn_check_exists_status_absent > 0', workOrder: 'execution', variant: 'data', view: 'p10-document-create', gateLedgers: ['parts.document'], title: { cat: ['数据工单 · 原厂技术资料创建(P-10):', { joinRows: 'mine:sub_eval.pn_check', where: { exists_status: 'absent' }, take: 'internal_pn', sep: '、' }] } }, { kind: 'say', text: '这几颗料的原厂技术资料库里查无记录,已派数据工单请你登记。' }, ], } // guide 不写。doneWhen 照旧:parts_document_rows >= parts_profile_rows and parts_document_missing = 0 // 人在件上填完确认 → 数据即入库 → 下一拍 doneWhen 成立 → 引擎收步。**模型在这一步一次都不出现。** ``` ### 4.2 例:S0_bind 的铸号与存在性核对 ```ts auto: { let: { digits: { fmt: { in: 'case:openedAt' }, as: 'digits' }, evalRef: { cat: ['W05-', { in: 'digits' }] }, requestId:{ cat: ['REQ-', { in: 'digits' }] }, }, do: [ // 客户不在库 → 发数据录入卡,这一拍到此为止 { kind: 'card', when: 'customer_profile_rows = 0', workOrder: 'entry', /* … */ }, { kind: 'project', into: 'demand.request', from: { once: true }, fields: { request_id: { in: 'requestId' }, customer_code: { in: 'customerCode' }, requirement_version: 'V1', request_type: '技术需求', request_description: { in: 'requestDescription' }, input_snapshot_ref: { cat: ['SNAP-', { in: 'evalRef' }] }, original_internal_pn: { in: 'originalInternalPn' }, request_status: 'pending', current_activity: 'ACT-WORK-05', current_stage: 'assessing' } }, { kind: 'project', into: 'sub_eval.pn_check', from: { each: 'in:candidateInternalPns' }, fields: { eval_ref: { in: 'evalRef' }, internal_pn: { self: true }, role: 'candidate', found_pn_ref: { lookup: 'ledger:parts.profile', by: { internal_pn: { self: true } }, take: 'internal_pn', else: '' }, exists_status: { when: 'found_pn_ref != ""', then: 'found', else: 'absent' } } }, // 原 PN 那一行同理(from: { once: true },role: 'original') { kind: 'project', into: 'sub_eval.verdict', from: { once: true }, fields: { /* … */ } }, ], } ``` 「在不在库」从此是一次 `lookup`,不是模型的一句断言 —— 08-27 那条「原 PN 不在库却判在库」结构性消失。 --- ## 5. 引擎行为(拍内次序) ``` tick 开始 ├─ ① 拍首自动收步(现有:doneWhen 为真的项,人工激活项需 worldTouched) ├─ ② 算 current(当下在办的那一步) ├─ ③ current.auto 存在 → 跑配方 │ ├─ 求 let │ ├─ 按序执行每一笔(when 假则跳过) │ │ · project → 逐行 draftWrite(同 PK 覆盖;值没变静默跳过) │ │ · card → 先看 worldTouched,发过就跳过;发了标 worldTouched、烧 humanCalls │ │ · say → 进对话区 │ ├─ 每一笔留痕(by=系统,detail.auto) │ └─ 抛错 → 留痕 + 交人(不重试、不退回模型) ├─ ④ 发了卡 → status awaiting,收拍(零 token) ├─ ⑤ 没发卡 → 重求 doneWhen;真了收步,回 ② (封顶 8 轮,与现有自动收步同款) ├─ ⑥ 这一步有 guide → 照常问模型(半 auto) └─ ⑦ 无 guide、doneWhen 不成立、也没有卡在等 → **交人**(配方没做到位,是包的 bug) ``` **预算**:配方不烧 token;发卡烧 `humanCalls`;配方段在同一 tick 内,不额外计 step。 --- ## 6. 与现有机制的衔接 | 机制 | 处置 | |---|---| | `doneWhen` | 不变。auto 步几乎都该有 —— 它做的事是可验的 | | 哨兵 `entryCriteria` | 不变。auto 步照样要满足入场前提才在场 | | `worldTouched` | 配方发的卡同样标 —— 它是真发了 | | `humanWrites`(2026-08-28) | **配方豁免**:那道墙防的是模型即兴,配方是审过的声明。铺骨架合法,人填内容不冲突(静态审提示列不要重叠) | | 写三分 | 不变:配方只写**草稿层**,落正式表仍靠放行 / 数据工单确认入库 | | 「一单一卡」 | 不变:配方至多一张卡 | | 熔断 | 配方不会「被拒」,不进 `stalledTurns` | | 试跑 / 回放 | 配方是纯数据求值,同输入同结果 —— 可回放。**配方里没有时钟与随机**(时刻取 `case:openedAt`) | | 记账旗 | **该删的删,不是该 auto** —— `input_confirmed_total` 这类基座本就数得出来 | --- ## 7. 回溯验算:历史事故有几条会结构性消失 | # | 事故 | 落在哪 | auto 后 | |---|---|---|---| | 1 | 编号从正式表里**别单的案号**里挑 | S0_bind 铸号 | 消失(模板铸号) | | 2 | 原 PN 不在库却判「在库」(发现 I) | S0_bind 查库 | 消失(lookup 现查) | | 3 | 数据工单派给了**在库**的那颗(发现 J) | S0d_p08 名单 | 消失(where absent) | | 4 | 压拍没写骨架就发卡,人打开是空表(发现 Q) | S0d_p08/p10 | 消失(配方按序) | | 5 | S1 清单 11 行全漏 `input_item`,人无处可填 | S1 前半 | 消失(字段声明齐全) | | 6 | 库里有值的参数被 S1 当缺口再问一遍(发现 AB,三次复现) | S1 前半 | 消失(清单是投影) | | 7 | P-10 抄**原 PN 的规格书**安在候选头上(发现 AE) | S0d_p10 | 消失(模型不出现) | | 8 | `finish` 撞 worldTouched 墙五拍熔断(发现 AE 同单) | S0d_p10 | 消失(那一步不问模型) | **八条落在会被 auto 掉的三步半上。** 剩下的事故(工具没调、比对入参抄错、验收卡面孔、重做循环) 不在这个范围内 —— 它们该由别的机制治,这份 ADR 不承诺。 --- ## 8. 不做什么 - **不调工具**:工具有副作用面与预算面,先不给(S3 的 `compare → recommend` 串接留作待定) - **不 `finish`、不 `submit`、不发决策卡**:收步归 `doneWhen`,终局归 submit,岔路口本就是判断 - **不写正式表**:仍走草稿层 - **不做三层以上的嵌套**:两层(关系 × 参数)是 W05 的上限;超出就别 auto - **不在治理台上编辑配方**:那就成了低代码平台;配方随包走,改配方 = 升版本重装 - **不承诺消灭 agent**:§2 的四类判断照旧归它 --- ## 9. 代价与风险 1. **活动作者要写结构化声明,不能再靠散文。** 但那些散文本来就在(而且更长、还不能测); 配方**能单测**(纯数据求值,包内自测钉死)。 2. **代码错是必然的错,模型错是概率的错。** 配方写错 = 每一单都错。 对策:静态审(§3.7)+ 包内自测 + 安装两审的回放审拿样例入参跑一遍配方。 **但必然的错比概率的错好查** —— 这正是把它从模型手里拿走的理由。 3. **立场要改口。** 「组织的工作定义为可编排的工作流……活动是完成一项任务的 Agent 机制」—— 加一句:**agent 只在真需要判断的地方出现,与「人只在责任点与放行点出现」是同一条原则。** 确定的事交系统,判断交 AI,责任交人。 4. **把有判断的地方硬写成规则的风险。** `constraint_type` 的 hard/soft 归类按品类会变 —— 这类**进 DMN 判定表,不进配方**;配方里只许出现「取值、拼装、投影、发卡」。 5. **现在的「确定」有一半是靠强制命名一致换来的。** S2 能纯 join,前提是「候选与原 PN 参数名一模一样」 (P-08 建骨架时用中文名硬压 + 门上的行数对账)。**一旦料号来自外部导入**,同义归一就回来了 —— 那是判断,是 agent 的活。**auto 化不等于永远不需要 agent。** --- ## 10. 迁移次序(每搬一步都要五案例 e2e:released + 被拒 0 才算数) 1. **`S0d_p10_document`** —— 最小、纯粹、刚出过事;拿它验证机制(配方 + 静态审 + 留痕 + 观测) 2. **`S5_prepare_landing`** —— 整步确定、不发卡;验证「不发卡时接着走 doneWhen 收步」那条路 3. **`S0_bind`** —— 整步确定、含一张录入卡的分岔;验证 `let` 铸号与 `lookup` 存在性 4. **`S1_input_confirm` 前半** —— 清单投影 + 发卡;半 auto(收口仍归 guide) 5. **`S0d_p08_master`** —— **先把 datasheet 抽值切出去**,再 auto 6. **`S2_normalize`** —— 两层嵌套,最难;搬之前先确认它与比对工具的 join 是否重复(§11) --- ## 11. 待定(要用户定的) 1. **决策卡要不要给配方**:S4 第一张卡的 ask 内容是计数投影,确定;但决策卡是岔路口,给了会不会滑坡? 2. **工具串接要不要做第四个动词**:S3 的 `compare → recommend` 之间模型只是转手 counts,却占掉整整一拍。 3. **`humanWrites` 该不该细到列**:现在是表级。auto 铺骨架 + 人填内容之后,列级更准。 4. **S2 与比对工具的 join 是否重复**:`compare-parameters` 1.2.0 起自己从台账取参数对, 而 S2 让模型抄的 `difference_item` 对照表是**同一份 join**。一份事实做了两遍,一遍还是手抄的 —— 要么 S2 auto 化,要么让工具的出参直接落 `difference_item`(那是另一个机制:工具出参绑台账)。 5. **`T_release` 第三条裁量判据**「推荐等级与差异明细一致」就是工具的定级规则本身, 让模型再裁一次是给自己盖章 —— 该删(与 08-01 在 A1 上拆掉 L4 同款)。