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>
8.9 KiB
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 测试用可注入故障点覆盖前者,后者只能靠评审看实现形态。