docs(design): 落成 0006 与 0007,公共 API 的名字、签名与接缝行为
0006 定「叫什么、什么形状」:五个接缝的 Protocol 名与签名、公共类型的英文名与 字段清单、类型分到 types / ports / tools 三个模块的判据。 0007 定「同一个签名下什么算对」:三个动作状态的触发条件、动作被拒绝时观察由库 合成而不取执行器那段、解释器不许抛异常、read_log 读不存在的运行返回空日志。 两份拆开是因为后者的权威处按 §0 是 tests/contract/,design doc 只记当初为什么这么定。 这两份改动了 0003 四处,全部在文首登记:记录集合是六种东西不是五类; 参数视图是方法不是字段;预算是四项不是两个计数;ports 装「Protocol 与它们的 入参/返回结构体」那半句写不出来——照它写 types 会反向依赖 ports。 四处全是「把字段类型逐个写出来」这个动作本身逼出来的,纯读文档看不见。 四轮评审:两轮硕士生冷读报了约 45 条,两轮 Codex 对抗审查报了 13 条, 逐条核实后基本全部成立并修完。最后一轮是唯一一次契约测试与文档互相抓到对方的错—— 文档改了方法名测试没跟,测试把 dissect 的动作语言写死成输入会误杀 GovDoc 的实现。 结论回写 architecture.md:第七节补类型归属判据,第八节改 ports 那一行, 第九节补五个 Protocol 的英文名,第十四节把「英文名还没定」那条缺口换成指向; 决策索引加两行。字段表刻意不回写——按 §0 那是代码的权威。 CLAUDE.md 与 README.md 开头的「一次 Agent Session」是术语漂移,改成「一次运行」。 CLAUDE.md §7 加两条工作方式:能压成一段结论的活尽量交给 subagent、 委托出去的活交证据不交判断;以及持续往下做,只在人类门和真判断不了的岔路停。 §8 那句「讲完停下来等回应」与后者打架,收窄到只管说话方式。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
# Design 0007 · 五个接缝各自的行为契约
|
||||
|
||||
**日期** 2026-08-09 · **状态** 已接受(2026-08-10 项目负责人确认)
|
||||
|
||||
**回答** `0006-public-names-and-signatures.md` 留下的三个坑:三个动作状态各自在什么条件下
|
||||
被赋上、动作被拒绝时那段观察从哪来、决策解释接缝能不能抛异常。`0006` 定的是「叫什么、
|
||||
签名长什么样」,本文定的是「同一个签名下,什么算对」。
|
||||
|
||||
**补充** `0003` 决策四。那一条定了五个接缝各是什么、返回哪几个字段,本文往下定每个字段
|
||||
在什么情况下取什么值。
|
||||
|
||||
**触及** `../../tests/contract/`。本文每一条决策都对应那套套件里的一条测试,落地后那里是
|
||||
权威(`../../CLAUDE.md` §0),本文只记「当初为什么这么定」。那套套件现在有八条标着 `xfail`,其中三条正是本文要回答的
|
||||
问题(三个动作状态的触发条件、动作被拒绝时观察的来源、解释器能不能抛异常);本文被确认之后
|
||||
把这三条改写成真的断言。**其余五条不是本文能关掉的**——两条要等事件集定下来,两条是
|
||||
原子写与前缀持久性根本没有机器兜底(见「留给后续的」),还有一条「没有动作的步」由
|
||||
`0006` 那边把 `StepCompleted.result_id` 改成可为空答掉了,等那份确认之后一并改写。
|
||||
|
||||
**本文件写完时状态是「待确认」,2026-08-10 由项目负责人确认后转为「已接受」。** 它要过
|
||||
`../../CLAUDE.md` §2 那道人类门,因为定的是公共 Protocol 的行为契约——照一个没过门的语义把
|
||||
断言写死,两个下游会按不同的理解各写一套实现,而两套都不报错。所以在确认之前那三条 `xfail`
|
||||
不许改写成断言,现在这条阻塞解除。
|
||||
|
||||
## 背景
|
||||
|
||||
这三个问题不是读文档读出来的,是写 `tests/contract/` 的时候撞出来的。它们的共同形态是:
|
||||
散文里那句话读着完全通顺——「返回状态:已执行 / 未执行 / 环境故障」——只有当你要写
|
||||
`assert outcome.status == ...` 时才发现,**等号右边填什么,没有任何一处写过**。
|
||||
|
||||
三轮文档评审(两轮硕士生阅读、一轮 Codex 对抗审查)都没报出它们,因为读者会顺着那句话
|
||||
读过去。这一点值得记下来:**签名定完不等于契约定完**,中间还隔着一层。
|
||||
|
||||
## 决策一:三个动作状态的触发条件
|
||||
|
||||
| 取值 | 什么时候 | 谁判定 |
|
||||
|---|---|---|
|
||||
| `EXECUTED` | 动作真的在环境里跑过了,不论结果对错 | 执行器 |
|
||||
| `NOT_EXECUTED` | 动作没进入执行:工具不存在、参数不合法、被执行器拒绝 | 执行器 |
|
||||
| `ENV_ERROR` | 环境自己坏了:连不上、协议不对、查询完成信号本身失败 | 执行器 |
|
||||
|
||||
**动作本身报错属于 `EXECUTED`,不属于 `ENV_ERROR`。** 代码抛异常、命令返回非零、SQL 语法
|
||||
错——这些是正常观察,要原样回喂让模型自己纠正。判成环境故障会让一次运行在模型本来能自我
|
||||
纠正的地方直接终止,而轨迹上看不出它本可以继续。分界线是**「环境还能不能接着服务」**:
|
||||
能,就是 `EXECUTED`;不能,才是 `ENV_ERROR`。
|
||||
|
||||
**`ActionStatus.ENV_ERROR` 出现必然导致 `StopReason.ENV_ERROR`。** 两者同名不同类型,这个
|
||||
对应关系要写死——环境坏了就没有下一步可走,继续跑只会产出一串同样的故障,把预算烧光而
|
||||
轨迹上全是噪声。`NOT_EXECUTED` 则不终止,它走回停止判定的开头继续下一步。
|
||||
|
||||
**为什么这三档必须写在这里,而不是留给实现自己理解。** dissect 的执行器状态恒为
|
||||
`EXECUTED`,它撞不到任何分歧;GovDoc 撞得到,但表现是停止原因的分布变了,不是异常。两个
|
||||
消费者各自理解一套而两套都不报错,这正是 `../../CLAUDE.md` §3 说的「没有失败现场」那类问题。
|
||||
|
||||
## 决策二:动作被拒绝时,观察由库合成,执行器那段进不了历史
|
||||
|
||||
状态是 `NOT_EXECUTED` 或 `ENV_ERROR` 时,回填进历史的观察取
|
||||
`AgentDefinition.synthetic_observations` 里对应的那一段,`observation_is_synthetic` 记为真。
|
||||
执行器返回的 `observation` 字段在这两档下**不进历史**。
|
||||
|
||||
**为什么不取执行器那段。** 它是「模型看得见的东西」,而模型看得见的东西必须能进参数快照
|
||||
(`0003` 决策三)。执行器每次现造一段文本的话,那段文本既不在快照里、也不受任何约束——
|
||||
两次运行之间它可以变,而变了不会有任何地方报错,于是「同一份配置跑出来的两次运行」在模型
|
||||
看来其实不同。dissect 的每一次运行是一个论文数据点,这种漂移会直接进统计。
|
||||
|
||||
**代价照实认下:执行器知道的细节丢了。** 「哪个参数不合法」这种信息只有执行器有,而合成
|
||||
文本给不出来。接受它,因为另一头的代价更大;要补的话,将来靠事件流把执行器原文送出去做
|
||||
审计,而不是让它进历史——**进历史的东西必须可复现,进审计的不必**。
|
||||
|
||||
**这一档的 `observation_truncated_chars` 恒为 0。** 合成文本是库自己写的,没有截断这回事。
|
||||
|
||||
## 决策三:决策解释接缝不许抛异常,无法解释就返回无效决策
|
||||
|
||||
`DecisionParser.parse` 对任何输入都必须返回一个 `ParsedReply`。模型输出完全没法解释时,
|
||||
返回 `InvalidDecision` 分支,说明文本就是要回喂给模型的那段。
|
||||
|
||||
**理由是「无法解释」本来就是正常路径的一部分,不是异常。** 模型输出不合格式是每天都在发生
|
||||
的事——dissect 的解析器对五种解析失败各有一条对症说明,GovDoc 的解析器还要处理 JSON 被包在
|
||||
代码围栏里、参数被平铺在动作层这两种偏差。把它表达成异常,库就得在循环里捕获它再翻译回
|
||||
「无效决策」,而那一层翻译是纯粹多余的。
|
||||
|
||||
**更硬的理由是抛异常会让恢复失去意义。** `0003` 决策七定了「被打断的那一步要重新跑一次
|
||||
解释器」,理由是那时副作用还没发生、重新解释是安全的。如果解释器可以抛异常,这条就不成立
|
||||
了——恢复会在重新解释时炸掉,而那一步的模型回复明明已经安全地存在日志里。
|
||||
|
||||
**实现方要真的做不到怎么办:让它崩。** 解释器里抛出的异常库不捕获,原样穿出去。这不是
|
||||
「支持抛异常」,是明确不接管——库接住它就得给它编一个停止原因,而任何一个编出来的原因都会
|
||||
把「解释器有 bug」伪装成「这次运行以某某原因结束」,然后进下游的统计。
|
||||
|
||||
**`CancelledError` 是唯一的例外**,它本来就不该被任何一层捕获(`../../CLAUDE.md` §1.6)。
|
||||
|
||||
## 决策四:`read_log` 读不存在的运行返回空日志
|
||||
|
||||
不抛异常。空日志的形状是:`started` 与 `finished` 为空,三个序列字段为空元组。
|
||||
|
||||
`run` 在开工前要判断「这个运行标识是不是已经有日志了」,靠的就是这条。读不存在的运行会抛
|
||||
异常的话,那个判断就得写成捕获异常——**而用捕获异常做流程控制会把真正的存储故障一起吞掉**,
|
||||
于是「存储连不上」会被读成「这是一次全新的运行」,然后覆盖式地重跑一遍,而上一次的日志还在
|
||||
那儿。
|
||||
|
||||
这一条写在 `0006` 决策四里了,这里重复一遍是因为它是本文这一族问题的同一个来源:它同样是
|
||||
写契约测试时才发现要写明的。
|
||||
|
||||
## 这三个问题为什么留到现在
|
||||
|
||||
`0003` 定接缝时给的是「返回状态(已执行 / 未执行 / 环境故障)、观察、完成信号」这一层,
|
||||
那时要回答的问题是「设不设这个接缝」,枚举取值够用了。`0006` 定名字时要回答的是「叫什么」,
|
||||
取值列出来就够。**只有当有人要写一行 `assert` 时,「什么时候取这个值」才成为一个必须回答
|
||||
的问题。**
|
||||
|
||||
所以这三个不是被遗漏的,是被推迟的——而推迟到写契约测试才发现,正好赶在写实现之前。这个
|
||||
顺序是对的:`../../README.md` 阶段清单把 ④ 测试框架排在 ⑤ 实现之前,理由就是这个。
|
||||
|
||||
## 留给后续的
|
||||
|
||||
**事件出口的行为契约只定了一半。** 「投递失败不打断循环、把失败计数加一」定了;「发出去的
|
||||
事件里有什么」定不了,因为 `Event` 还没有字段。事件集与具名回调清单要独立成一份 design doc。
|
||||
|
||||
**原子写与前缀持久性没有机器兜底。** 写契约套件时发现这两条验不了:原子性的「崩在中间时
|
||||
两者都不可见」要求在写入过程中杀掉进程,而契约测试跑在一个进程里;前缀持久性说的「已经
|
||||
持久」是掉电之后才看得出来的性质。两条都登记在契约套件里标成 `xfail`,落地时靠
|
||||
`stores` 的 unit 测试用可注入故障点覆盖前者,后者只能靠评审看实现形态。
|
||||
Reference in New Issue
Block a user