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:
2026-08-09 23:56:38 -04:00
parent a14bf8d288
commit f8e02290f4
10 changed files with 986 additions and 34 deletions
+15
View File
@@ -45,6 +45,21 @@ def records():
pytest.skip(_NOT_YET)
@pytest.fixture
def samples():
"""被测解释器认得的几段模型输出,由实现方提供。
**套件不许自己写死输入。** 库不带默认解释器实现,也就不认识任何一家的动作语言:拿
dissect 的 Python 代码围栏去喂 GovDoc 的 JSON 解析器,后者正确地返回「无效决策」,
而写死输入的套件会把这个正确行为判成失败。
实现方要提供两段:`yields_an_action`(一段能被解释成动作的模型回复)与 `yields_invalid`
(一段解释不出动作的)。这不是给套件开后门——「我这套语言里什么算合法动作」本来就只有
实现方答得出,套件断言的是**拿到之后的形状**,不是输入长什么样。
"""
pytest.skip(_NOT_YET)
@pytest.fixture
def action_executor():
"""被测的动作执行接缝实现。"""
+12 -8
View File
@@ -13,52 +13,56 @@ import pytest
pytestmark = pytest.mark.contract
def test_parse_is_synchronous(decision_parser, records):
def test_parse_is_synchronous(decision_parser, samples):
"""`parse` 是同步的,不是协程。
解释一次模型回复是纯计算,没有等待点。写成协程会让每个只想写测试替身的下游多套一层
`async def`,也会诱导实现方在里面做 I/O——而这个接缝一旦做起 I/O,「恢复时重新解释
被打断的那一步」就不再是安全操作了。
"""
parsed = decision_parser.parse(records.reply(content="anything"))
parsed = decision_parser.parse(samples.yields_an_action)
assert not hasattr(parsed, "__await__")
def test_history_text_is_what_goes_back_into_the_conversation(decision_parser, records):
def test_history_text_is_what_goes_back_into_the_conversation(decision_parser, samples):
"""`history_text` 是这一步回填进历史的那段文本,可以与模型原文不同。
解释器有权改写它:dissect 的解析器把第一个代码围栏之后的内容整段丢掉,因为模型常在
代码块后面编造「执行结果」。库这边只有模型原文,照它回填,模型下一轮会看见自己编的
那段,而迁移前它看不见。
**输入由被测实现自己提供**,不由套件写死。库不带默认实现,也就不认识任何一家的动作
语言——拿 dissect 的代码围栏去喂 GovDoc 的 JSON 解析器,它正确地返回「无效决策」,
而套件会把这个正确行为判成失败。
"""
reply = records.reply(content="```python\nprint(1)\n```\nThe command succeeded.")
reply = samples.yields_an_action
parsed = decision_parser.parse(reply)
assert isinstance(parsed.history_text, str)
assert len(parsed.history_text) <= len(reply.content)
def test_invalid_decision_explanation_is_what_is_fed_back(decision_parser, records):
def test_invalid_decision_explanation_is_what_is_fed_back(decision_parser, samples):
"""无效决策的说明文本**就是**回喂给模型的那段观察,不是从一个固定串里取。
dissect 的解析器对五种解析失败各有一条对症说明(没有代码块、空的未闭合块、闭合围栏后
跟了别的内容、多块策略下第一块为空、拼接策略下全空)。压成一句会改掉它的实验条件——
模型收到的纠错信息变了,它的纠错行为也就变了。
"""
parsed = decision_parser.parse(records.reply(content="没有任何代码块"))
parsed = decision_parser.parse(samples.yields_invalid)
assert isinstance(parsed.decision.explanation, str)
assert parsed.decision.explanation != ""
def test_action_carries_its_trace_form(decision_parser, records):
def test_action_carries_its_trace_form(decision_parser, samples):
"""动作分支要带「这一步的动作在轨迹里长什么样」,由实现方决定内容,库原样填进步记录。
dissect 传那段 Python 源码,GovDoc 传序列化后的参数。没有这个字段,dissect 轨迹里那一列
会被库改写,而那个文件是它的反思模型的唯一输入界面。
"""
parsed = decision_parser.parse(records.reply(content="```python\nprint(1)\n```"))
parsed = decision_parser.parse(samples.yields_an_action)
assert isinstance(parsed.decision.text, str)
+9 -5
View File
@@ -19,13 +19,17 @@ async def test_emit_accepts_an_event(event_sink, records):
await event_sink.emit(records.event())
async def test_delivery_failure_does_not_break_the_caller(event_sink, records):
"""投递失败由库捕获、记日志、把失败计数加一,然后继续跑。
def test_a_sink_is_allowed_to_raise_on_delivery_failure():
"""**这一层不断言「emit 不抛」——一个后端连不上时抛异常的出口是合规实现。**
一次运行不该因为进度回写的数据库连不上就终止——事件是观察通道,不是控制通道。
这条测试面对的是一个必然投递失败的出口,断言它不会把异常泄漏成循环的终止条件。
契约写的是「投递失败由**库**捕获、记日志、把失败计数加一,然后继续跑」,所以要断言的
行为在库那一侧,不在出口这一侧。原来这里写了一条 `await emit(...)` 不抛的断言,那会把
一个完全合法的审计 sink 判失败——它在日志管道不可用时抛 `ConnectionError`,而库本来就
该接住。
「库接住了失败并继续跑」属于整次运行的行为,落在驱动入口那一层的测试里,不在这个接缝的
契约里。这条留成一个说明,是为了让下一个想在这儿加断言的人先看到这段。
"""
await event_sink.emit(records.event(kind="always-fails"))
@pytest.mark.xfail(reason="事件集还没定,见 docstring", strict=True)
+1 -1
View File
@@ -141,7 +141,7 @@ async def test_action_result_and_step_land_together(store, records):
step = records.step_completed(
run_id="r1", result_id="a0", action_outcome=records.outcome(), step=records.step(step_idx=0)
)
await store.write_step(step)
await store.write_step_completed(step)
log = await store.read_log("r1")