Files
iomgaa f8e02290f4 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>
2026-08-09 23:56:38 -04:00

122 lines
8.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 测试用可注入故障点覆盖前者,后者只能靠评审看实现形态。