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>
50 KiB
Design 0006 · 公共类型的英文名与五个接缝的签名
日期 2026-08-09 · 状态 已接受(2026-08-10 项目负责人确认)
回答 0003-public-api-shape.md 的「留给后续 design doc 的」里那条「公共类型的英文名与
接缝的具体签名还没定」,以及 ../explanation/architecture.md 第十四节的第一条缺口。
补充 0003 决策二、四、六,0004 决策二、三、四,0005 决策一到三:那几条定了每个
字段和方法是什么,本文只给它们英文标识符与签名。
取代 0003 四处,其余部分全部继续有效:
- 决策七里「记录集合从三类扩到五类」的那个数——日志里出现的是六种东西,少数了一种(决策七)。
- 决策三里定义那张表的第五行「参数视图」。它是一个方法不是字段(决策三)。
- 决策一给预算的描述「两个独立计数」不足以覆盖
Budget的形状:0004决策三还要求两个 别的上限,它们也住在这个类型里(决策八)。 - 决策二里「
ports装全部 Protocol 与它们的入参 / 返回结构体」这半句。照它写会让types反向依赖ports,而types是依赖图的汇点,那条箭头在 import-linter 契约下直接 违规。切法改成「进不进日志」,见决策四开头。
触及 ../explanation/architecture.md:第七节里「ports 装五个接缝的 Protocol,以及
它们的入参 / 返回结构体」那半句要按决策四改;第八、九、十一节补英文名;第十四节划掉「公共
类型的英文名与接缝的具体签名还没定」那条缺口。还要改 ../../CLAUDE.md 与 ../../README.md
开头的「一次 Agent Session」(决策一)。
(../migrations/dissect.md 的预算字段数与 architecture.md 第十节的定义 / 请求字段数原本
也在这份清单里,写本文期间已经改掉了。)
本文件超过了
../../CLAUDE.md§6 给design/定的 600 行上限。 按那一条停下来检查过, 拆掉了一块:五个接缝的行为契约——三个动作状态各自什么时候赋上、动作被拒绝时观察从哪 来、解释器能不能抛异常——移到了0007,因为那些的权威处按../../CLAUDE.md§0 本来就是tests/contract/,design doc 只记「当初为什么这么定」。剩下的仍然是一件事:公共 API 的形状叫什么、签名长什么样。那四处对
0003的更正拆不 出去,因为它们不是顺带发现的别的问题,而是「把字段类型逐个写出来」这个动作本身逼出来的: 不定下日志里到底几类记录,RunStore的方法列表就写不出来;不重新划模块归属,types里的 记录就没法标注它的字段类型。拆成两份的话,读一份的人拿不到另一份里那个让它成立的前提。
本文件写完时状态是「待确认」,2026-08-10 由项目负责人确认后转为「已接受」。 它要过
../../CLAUDE.md §2 那道人类门,因为定的是公共类型的名字与 Protocol 的签名;名字一旦发布
就受 §1.3 约束,改名会让已经在跑的下游代码直接 ImportError。所以在确认之前
src/polyloop/ 下各模块只有空的 __init__.py,现在这条阻塞解除。
读这份文档要先知道的五个词
它们的完整定义在别处,这里只给读本文够用的那一层。
接缝——库定义一个 typing.Protocol,具体实现由下游提供的那个位置。设一个接缝的判据
是「举得出两个在真实消费者身上形态明显不同的实现」(0003 决策四)。本库有五个。
ports——装全部 Protocol 的模块。名字取自「端口与适配器」那套说法:端口是库这边的
插孔,适配器是下游那边的插头。
意图与预分配结果 ID——做任何有副作用的事之前,先往日志写一条「我准备干什么」,里面
带着「这次的结果将来会以哪个 ID 存下来」。恢复时就能精确地问「这个 ID 的结果条目在不在」,
而不是靠模糊匹配去猜(0002 决策二)。一步之内有两条意图、两个不同的结果 ID:模型
调用之前一条,动作执行之前一条。
重放策略与「重放」——一个工具对「我幂等吗」这个问题的回答,两个取值。never 是「我有
不可重复的副作用」(写文件、跑 shell、调外部 API),safe 是「我幂等,重复执行一次也无害」
(读文件、grep)。「重放」指的是恢复时把那个动作再执行一次,不是把上次的结果填回去——
上次的结果正是那个「状态未知」里未知的东西,它可能根本没被写下来。默认取 never,因为两个
方向的错误代价不对称:默认 safe 而声明漏了会静默重复副作用,默认 never 而声明漏了只是
多停一次、有人会看见(0002 决策四)。
四态表——恢复时按「意图有没有 / 结果有没有」判每一次执行处在哪种状态:都没有是还没
开始(重跑),都有是做完了(跳过),有意图没结果是状态未知(按重放策略决定),有结果没
意图是日志损坏(拒绝续跑)。完整的表在 0002 决策二。
运行级与目标级——上下文按变化频率分的两段。运行级是这次运行从头到尾不变的部分(角色
说明、示例演示、能力描述),目标级是这一个目标特有的部分(题面、当前任务描述)。分开是
为了让稳定的那段排在前面:模型供应商按前缀缓存计费,把逐次变化的东西排到前面会让缓存
静默失效,而多付的幅度随注入内容的规模变化(../migrations/dissect.md 需求五)。
命名之前:有一批名字不是我们选的
dissect 的十三个步记录字段名逐字继任,一个字母都不改。 它们今天就是英文标识符
(step_idx、raw_output、call_id 这些),已经写进 dissect 的 rollouts 表。迁移验收的
硬标准是「轨迹与迁移前逐字段可比」,改名就要在 dissect 侧做一次字段映射,而那个映射本身
是一处会漂移的地方。
六个现有停止原因的字符串值同理。 它们被 dissect 的预注册判据按字符串匹配,改一个字母 那条判据会静默查到零行。预注册判据是 dissect 在跑实验之前就登记好、事后不许改的一组统计 规则,其中两条按停止原因筛数据——事后改判据等于事后挑结论,所以它们只能事先定死。
预算里三个上限也继任 dissect 的名字(见决策八)。
所以本文真正在选的名字只有:类型名、Protocol 名、方法名,以及那些没有前身的新字段。
决策一:治理单位叫 run,不叫 session
写这份文档时发现一处术语漂移。../../CLAUDE.md 与 ../../README.md 的开头写着「治理单位
是一次 Agent Session」,而 ../explanation/architecture.md 通篇是「一次运行」——
Session 在那份文件里出现零次。0003、0004、0005 也全是「一次运行」。
按 ../../CLAUDE.md §0 的裁决表,「哪些事归本库管」的权威是 scope.md 与 architecture.md,
协作文件与它们冲突时以它们为准。所以**「一次运行」是对的,「Agent Session」是漂移**。
英文名取 run 而不是 session,还有两条独立理由。
session 在业界普遍指一个长期存在、可以来回对话的东西,而本库的治理单位是有界的、
一次性的:给一个目标、跑到停止条件、返回结果,中途不接受新消息。用 session 命名会让
第一次读到的人以为可以往里追加轮次。
这个词在消费者那边已经被占了。 dissect 的环境句柄本身就叫会话(一次运行独占的容器 会话),GovDoc 那边也有工作区会话。库里再叫一次 session,两边拼在一起没法读。
模块名 polyloop.session 不受这条影响,它是 0003 决策二定的分层里那个装配层的名字,
说的是「这个模块把各部分装配到一起」,与治理单位怎么称呼是两件事。
决策二:两个入口协程
住在 polyloop.session。
async def run(definition: AgentDefinition, request: RunRequest) -> RunResult: ...
async def resume(definition: AgentDefinition, request: RunRequest) -> RunResult: ...
resume 与 run 参数完全相同,是刻意的:续跑不是另一件事,是同一次运行接着做。运行标识
在 request.run_id 里,库拿它去读回日志。
两者不合并成一个带 resume=True 开关的函数。 那个开关会掩盖两条路径失败方式的不同:
run 撞上这个运行标识已经有日志时直接报错,resume 读不到日志时直接报错。合并之后调用方
看不出自己走的是哪条,而两种错的处置完全不同。
resume 比对什么。 日志里的运行开始记录带着当次的参数快照(定义级 + 请求级合并后的
那一份),resume 把它与当前装配现算的快照逐字段比对,任何一项不一致直接报错。
这意味着续跑不能顺便改预算或换模型——那不是限制而是这条守卫的全部意义:用同一个运行
标识换一份定义续跑,前几步与后几步会来自两个不同的配置而全程零报错,这正是
../../CLAUDE.md §1.4 点名的、要到统计阶段才分不清哪些行是真的那类损坏。
决策三:定义与请求
AgentDefinition(frozen,可并发复用,住在 polyloop.session):
| 字段 | 类型 | 是什么 |
|---|---|---|
model_client |
ModelClient |
模型调用接缝 |
decision_parser |
DecisionParser |
决策解释接缝 |
store |
RunStore |
存储接缝 |
event_sink |
EventSink |
事件出口 |
synthetic_observations |
SyntheticObservations |
库自己合成的那几段观察文本 |
class SyntheticObservations: # frozen,polyloop.types
action_rejected: str # 工具不存在或参数不合法时喂回模型的那段
env_failed: str # 环境故障时喂回模型的那段
model_call_failed: str # 模型调用失败那一步喂回模型的那段
第三段是写契约测试时补上的。 模型调用失败那一步照样要留痕,而那一步没有 ParsedReply
也没有 ActionOutcome,库手上没有任何来源能产出这段观察。少了它,dissect 现有那句
「模型调用失败,这一步没有产出」在迁移后会变成空串,而验收口径是「轨迹逐字段可比」。
它挂在定义上而不是请求上,因为这三段文本跨运行不变,而且属于「模型看得见的东西」,必须
能进参数快照(0003 决策三)。不做成模板或可格式化字符串:它们是固定文本,一旦允许
按情况拼接,库就在替调用方决定模型看见什么。
定义上还有一个方法,不是字段:
def parameter_snapshot(self) -> Mapping[str, str]: ...
0003 决策三的表把「参数视图」列成第五个字段,那说不通:它的内容是「向四个接缝各问一次
参数再聚合」,而写成字段就要在构造定义之前先问一遍——那时定义还不存在。写成方法则是每次
调用现问,快照永远是从真实对象上读出来的事实而不是一份声明,这正是 0003 决策三
给的理由。表里空出来的那一行由 synthetic_observations 补上,它原本写在表外的正文里。
RunRequest(frozen,每次运行构造一个,构造廉价:无 I/O、无网络校验、无哈希计算):
| 字段 | 类型 | 是什么 |
|---|---|---|
run_id |
str |
不透明字符串,库不解析 |
budget |
Budget |
见决策八 |
action_executor |
ActionExecutor |
动作执行接缝,见下面那段 |
tools |
ToolRegistry |
本次可见的那个(子)注册表 |
context |
Context |
已渲染好的消息序列,分运行级与目标级 |
injections |
Mapping[str, tuple[Injection, ...]] |
本次要贴进上下文的 Skill 条目,按通道分组 |
model_binding |
Mapping[str, str] |
项目自己的标识,库不解释,原样透传给每次模型调用 |
model_replay_policy |
ReplayPolicy |
必填无默认 |
observation_template |
str |
观察回填历史时套的格式,含一个观察占位符 |
tool_section_template |
str |
工具清单贴进提示词的格式模板 |
cancel_grace_seconds |
float |
取消时留给「写结束记录」的秒数 |
RunRequest 住在 polyloop.session,和 AgentDefinition 同一处。
cancel_grace_seconds 是取消进来之后,库留给自己写结束记录的秒数。 取消要能穿过模型
调用与动作执行,但「这次运行结束了」这个标记必须写下去——不写的话,恢复读到的是一次没有
结束标记的运行,会被当成可以续跑,而它其实是被人主动叫停的。宽限期用完还没写完就放弃写,
不无限等待:取消的语义是尽快停下,为了留痕而卡住违背它。
它挂在请求上而不是定义上,因为「愿意为收尾等多久」随用途变而不随装配变——一次实验跑 可以多等几秒保证留痕,一个前端点了取消的交互式请求要立刻返回。
model_binding 是字符串映射而不是不透明对象,理由与 parameter_snapshot 同源:映射
能逐字段比对、能进快照,而不透明对象是公共签名上一个永久的洞,洞里的东西永远进不了参数
快照,契约测试对它也不可见。dissect 那五维项目信息全部能表达成字符串,所以不需要开这个洞。
它也有一个 parameter_snapshot() 方法。 决策二说 resume 比对的是「定义级 + 请求级
合并后的那一份」,而原来只有定义那一侧有这个方法,请求那一半没有来源——resume 的守卫
落在半空。请求这一侧的快照由它自己的字段直接产出(预算四项、重放策略、观察包装模板、工具段模板、
取消宽限、工具集的名字清单、模型绑定的全部键值),再加上向 action_executor 问一次。
模型绑定必须进快照,否则决策三给它选字符串映射的那条理由就落空了。失败场景很具体:
第一次运行绑的是 dissect 的某本账、某道题,崩溃后用同一个运行标识、换一组绑定续跑,
resume 不报错,后面每一次调用被记到另一套账目坐标上——而两段轨迹在文件里看起来是同一次
运行。
两侧的键各自带前缀,所以合并时不会撞。 定义侧的键前缀是接缝名,请求侧是 request。
不加前缀的话,「哪一侧报的这个键」要靠约定记住,而约定记不住。
action_executor 也有 parameters(),所以是五个接缝都有,不是四个。 原来只给挂在
定义上的那四个加了这个方法,而动作执行接缝挂在请求上——于是文档一边说「它的参数由它自己
上报」,一边没有给它上报的方法。
它必须上报,理由和别的接缝一样硬:dissect 的执行器是一个已经开好的容器会话,是不是 stateful 会改变跨步语义。换一个会话续跑而快照不比对,前几步的副作用留在旧会话里、后几步 在新会话上执行,全程零报错。
context 与 injections 不进快照。 它们是这次运行的输入数据不是参数,进快照会让快照
变成一份数据副本,而它们可能很大。注入内容的条目标识另行进轨迹(Injection.entry_id
原样进步记录那条路),所以「这次贴了哪几条」事后查得到,查不到的只是正文。
class Context: # frozen,polyloop.types
run_level: tuple[Message, ...] # 这次运行从头到尾不变的段
goal_level: tuple[Message, ...] # 这一个目标特有的段
class Injection: # frozen,polyloop.types
entry_id: str # 这条 Skill 条目的标识,原样进轨迹
content: str # 贴进上下文的正文
injections 的键是通道名。现在只有一个通道在用,但类型是映射不是序列,因为将来多一个通道
是加一个键、不是改类型(0003 决策六的同一条判据)。库不解释通道名,只按键分组贴。
observation_template 与 tool_section_template 是两段格式模板,不是取值集合,所以不做成
枚举。 「枚举一律 StrEnum」那条规矩管的是有穷取值,而这两个字段装的是调用方自己写的
一段带占位符的文本——dissect 与 GovDoc 的观察包装格式完全不同,做成枚举等于把两家的格式
都写进库里。
action_executor 与 tools 同时存在,这不是重复。 dissect 没有工具:它的 tools 是
空注册表,action_executor 是环境句柄,模型输出的是一整段代码。GovDoc 有工具:它的
action_executor 就是 tools.executor()。库只调用 action_executor,从不自己去
tools 里取分发器——但构造 RunRequest 时校验一件事:如果传进来的 action_executor
是注册表派生的,它必须派生自 tools 这同一个实例,否则直接报错。不校验的话,模型看见的
schema 来自一个注册表、实际分发走另一个,表现是「模型调了一个它看得见的工具却说不存在」。
这条校验怎么实现,得说清楚,否则它写不出来。 ActionExecutor 是个 Protocol,下游可以
传任何满足签名的对象,没有办法从外面判断它「是不是注册表派生的」。所以 ToolRegistry.executor()
返回的是 polyloop.tools 里一个具体类的实例,那个类持有派生它的注册表;RunRequest 构造时
只做一件事:如果 action_executor 是那个具体类的实例,就比对它持有的注册表与 tools 是不是
同一个。不是那个类的实例就一概放行——那是 dissect 那种自己写执行器的情形,库无从判断也
不该判断。
这条校验不违反「构造廉价」:它是一次 isinstance 加一次对象相等比较,没有 I/O。
决策四:五个接缝的 Protocol 名与签名
五个 Protocol 全部住在 polyloop.ports。它们的入参与返回结构体分到两个模块,判据见下面
那段。
0003 决策二写的是「ports 装全部 Protocol 与它们的入参 / 返回结构体」,那半句照着写不出来。
types 装持久化记录(同一条决策的另一半),而持久化记录里嵌着接缝的返回值——ModelCallResult
要装一个模型回复、StepCompleted 要装一个动作结果、步记录要装一个动作结算状态。三个字段的
类型如果住在 ports,types 就得 import ports,而 types 是依赖图的汇点,所有箭头指向它、
不许从它出去(0003 决策八第 1 条,由 pyproject.toml 的分层契约断言)。这个矛盾只有在
把字段类型逐个写出来的时候才浮出来。
判据是**「它是不是一个值」**:值类型与持久化记录住 types,只为一次调用打包入参或返回的
壳住 ports。
按这条:ModelReply、ActionOutcome、ActionStatus、Message、Role、TextBlock、
ContentBlock、StopReason 住 types;ModelCall、ParsedReply、RunLog 住 ports;
Decision 三分支与 ToolCall 也住 ports——它们是解释接缝的返回形状,不被任何 types
里的结构引用(步记录存的是 action、tool_name、tool_arguments 三个字符串,不是对象)。
Event 住 ports,它是事件出口的入参。
判据原本写的是「会不会被写进日志」,那条不对。 Message 不出现在任何一条日志记录里,
照那条判据要判进 ports;而 Context 住 types 且字段是消息序列,于是 types 要 import
ports——上一版刚修掉的那个反向依赖,用新判据又长回来了。「是不是一个值」不会有这个问题:
凡是被 types 里的结构引用的,本来就都是值。
Protocol 名不加 Port、Interface、Abstract 这类前后缀。 那种后缀只说明「这是个
抽象」,而模块名 ports 已经说了;名字里剩下的位置应该用来说它做什么。
class ModelClient(Protocol):
async def call(self, call: ModelCall) -> ModelReply: ...
class DecisionParser(Protocol):
def parse(self, reply: ModelReply) -> ParsedReply: ...
class ActionExecutor(Protocol):
async def execute(self, action: Action) -> ActionOutcome: ...
class RunStore(Protocol):
async def write_run_started(self, record: RunStarted) -> None: ...
async def write_intent(self, record: Intent) -> None: ...
async def write_model_call_result(self, record: ModelCallResult) -> None: ...
async def write_step_completed(self, record: StepCompleted) -> None: ...
async def read_log(self, run_id: str) -> RunLog: ...
async def write_run_finished(self, record: RunFinished) -> None: ...
class EventSink(Protocol):
async def emit(self, event: Event) -> None: ...
五个接缝各多一个同步方法:
def parameters(self) -> Mapping[str, str]: ...
AgentDefinition.parameter_snapshot() 的内容是「向定义上那四个接缝各问一次参数再聚合」,
而上面那份方法清单里没有一个成员能被问——这个方法在原来的签名集合下根本调不动。而 dissect 的 runner
在跑第一道题之前要从真实对象上读出模型标识串与网关作用域,读不到就拒绝开跑。
它同步、返回字符串映射、且不许做 I/O。 快照要能在装配之后立刻算出来,一个会发网络请求 的实现会让「构造廉价」这条承诺失效,也会让参数快照的取值依赖当时网络通不通。
键冲突直接报错,不静默覆盖。 四个接缝各报一份,键空间是平的;两个接缝报了同一个键而 取值不同时,聚合方法抛异常。静默取其中一个的话,快照里那一项记的是哪个接缝的值取决于聚合 顺序,而顺序是一次无害的重构就能改的东西。
三个名字为什么是这三个词。 ModelClient 而不是 ModelGateway——网关是 PolyGateway
那一层的事,这里只是它的客户端,叫 Gateway 会让人以为治理住在这儿。RunStore 而不是
Repository 或 Journal——Repository 在业界带着「按领域对象查询」的意味,而这个接缝只有
追加与整份读回;Journal 又太窄,它还存运行开始与结束。EventSink 而不是 Listener 或
Observer——后两个暗示库会等它回话,而这个出口是单向的、投递失败不影响循环。
DecisionParser.parse 是同步的,其余都是协程。 解释一次模型回复是纯计算,没有等待点;
写成协程会让每个只想写测试替身的下游多套一层 async def,也会诱导实现方在里面做 I/O。
五个写入方法各自只收一个记录对象,不收「运行标识 + 一堆散字段」。 运行标识住在记录里。
散着传的话,五个方法各重复一遍 run_id: str,而传错一个不会有任何地方报错——记录对象把它
和其余字段绑在一起构造,构造一次就对了。
read_log 读一个从没写过的运行标识时,返回一份空日志,不抛异常。 run 在开工前要
判断「这个标识是不是已经有日志了」,靠的就是这一条。读不存在的运行会抛异常的话,那个判断
就得写成捕获异常——而用捕获异常做流程控制会把真正的存储故障一起吞掉,于是「存储连不上」
会被读成「这是一次全新的运行」,然后覆盖式地重跑一遍。这条是写契约测试时才发现要写明的。
read_log 也是「只收一个记录对象」那条规则的例外,它收一个裸 run_id。 读的时候还没有记录对象可传,要求
先造一个只为了带运行标识的空记录,那是纯仪式。
write_step_completed 一次收下动作结果与步记录(0005 决策二)。五个写入方法
一律与它收的记录类同名,读的人不必在两套词之间做翻译,这也是本文对 content/thinking
用的同一条规矩。StepCompleted.action_outcome
可以是 None(模型调用失败的步、解析失败的步照样有步记录),但必填、无默认值——调用
方每次显式写出这次有没有动作,这件事就在调用点看得见。
ActionExecutor 挂在请求上,其余四个挂在定义上(0003 决策三),所以只有它出现在
RunRequest 里。
class ModelCall: # frozen,polyloop.ports(不落盘)
messages: tuple[Message, ...]
call_index: int # 本次运行内的第几次模型调用,从 0 递增
run_id: str
result_id: str # 库预分配的
binding: Mapping[str, str]
class ModelReply: # frozen,polyloop.types(它进 ModelCallResult)
call_id: str | None # 可为 None,绝不为空串
content: str
thinking: str
call_index 而不是 attempt_idx:0003 决策四专门写过它跟 dissect 项目绑定里那个「尝试
序号」(同一道题的第几次独立重做)撞名,这里换个词就是为了不再撞。
content 与 thinking 对应步记录的 content_chars 与 thinking_chars,名字同源,读的人
不用在两处之间做翻译。call_id 可为 None、绝不为空串——空串是个「看起来合法」的键,
join 时静默匹配不上,而 None 至少能被显式筛出来。
决策五:决策三分支与动作结果
# 以下全部 polyloop.ports:只在一次调用的往返之间存在,从不落盘
class ToolCall: name: str; arguments: Mapping[str, object]
class Action: text: str; tool_call: ToolCall | None
class FinalAnswer: text: str
class InvalidDecision: explanation: str
Decision = Action | FinalAnswer | InvalidDecision
class ParsedReply: # DecisionParser.parse 的返回值
history_text: str # 这一步回填进历史的那段 assistant 文本
decision: Decision
history_text 是必须的,不是顺手加的。 解释器有权改写进历史的文本:dissect 的解析器把
第一个代码围栏之后的内容整段丢掉,因为模型常在代码块后面编造「执行结果」。库这边只有
ModelReply.content,那是没截过的原文;照它回填,模型下一轮会看见自己编的那段执行结果,
而迁移前它看不见。库拿不到这段文本,../migrations/dissect.md 需求七就落空了。
它与 0005 决策三定的那个形状严格对称:一段文本加一个数字。模型这一侧是 history_text
(进历史的)加 content_chars(模型可见输出的全长,库自己数 ModelReply.content);观察
那一侧是 observation(进历史的)加 observation_truncated_chars。库把 history_text 原样
填进步记录的 raw_output。
它挂在 ParsedReply 上而不是三个分支各挂一份,因为三个分支都需要它——解析失败那一支
同样要往历史里回填一段 assistant 文本,否则那一步的历史缺一半。挂三份则是同一个值写三处。
Action.text 就是「这一步的动作在轨迹里长什么样」,由决策解释接缝决定内容,库原样填进步
记录的 action 字段。不叫 trace:trace 在 agent 语境里通常指整条执行轨迹,而这里
是单步的一个字符串,用它会和「逐步轨迹」撞。dissect 传那段 Python 源码、tool_call 为
None;GovDoc 传序列化后的参数并填上 tool_call。
InvalidDecision.explanation 就是回喂给模型的那段观察,不是从一个固定串里取——dissect
的解析器对五种解析失败各有一条对症说明,压成一句会改掉它的实验条件。
class ActionStatus(StrEnum): # polyloop.types,它进步记录
EXECUTED = "executed"
NOT_EXECUTED = "not_executed"
ENV_ERROR = "env_error"
class ActionOutcome: # polyloop.types,它进 StepCompleted
status: ActionStatus
observation: str
observation_is_synthetic: bool
env_reported_completion: bool
observation_truncated_chars: int
完成信号叫 env_reported_completion,不叫 completed 或 done。 它要回答的从来不是
「这次运行结束了没有」——那个问题的答案是停止原因。它回答的是「环境说目标达成了」,
而另一条完成通路(工具注册表上的完成标记)与它可信度不同。名字里带上 env_reported,
读到的人不会把 agent 自报当成环境侧信号,而那正是 dissect 的环境协议专门警告过的事。
决策六:工具类型住在 polyloop.tools
0003 决策二把「工具规格与注册表,以及由注册表派生的动作执行器」整个划给了 tools 模块,
所以下面这些不在 types 里。ReplayPolicy 是例外,它住在 types——RunRequest 也用它,
而 Intent 是持久化记录、住在 types,
它带着这一项。理由不是「请求不该为了一个枚举去 import 工具模块」——RunRequest 的字段里
本来就有 tools,它必须 import 那个模块;真正的约束是 types 不许反向依赖 tools。
class ReplayPolicy(StrEnum): # polyloop.types
NEVER = "never"
SAFE = "safe"
class ToolSpec: # polyloop.tools
name: str
description: str
parameters: Mapping[str, object] # 普通 JSON Schema 字典,不是 pydantic 模型
replay_policy: ReplayPolicy = ReplayPolicy.NEVER
completes_run: bool = False # 成功执行即代表目标达成
ToolSpec.replay_policy 默认 NEVER,而 RunRequest.model_replay_policy 必填无默认。
两处不同是刻意的:工具可能有几十个,逐个强制声明会让接入成本高到有人去写一个批量填 SAFE
的辅助函数,那比默认值更糟;模型调用一次运行只有一处,强制声明的成本是一行
(0002 决策四定了默认取 NEVER 的理由:默认 SAFE 而声明漏了会静默重复副作用,默认
NEVER 而声明漏了只是多停一次、有人会看见)。
completes_run 用动词短语而不是 is_completion_marker:它描述这个工具做什么,不是它
属于哪一类。
class ToolRegistry: # polyloop.tools,不可变值对象
def restrict_to(self, names: Collection[str]) -> "ToolRegistry": ...
def schema_for_model(self) -> Sequence[Mapping[str, object]]: ...
def validate(self, call: ToolCall) -> None: ...
def executor(self) -> ActionExecutor: ...
def spec_for(self, name: str) -> ToolSpec | None: ...
spec_for 不是可有可无的查询方法,循环少了它有两处直接失效。
停止判定那一档要问「这次执行的工具是不是被标了完成标记」(0004 决策三 G)。GovDoc 的
env_reported_completion 恒为「未完成」——它的环境状态不因为提交而改变——所以它的收尾只能
走完成标记这条。查不到 completes_run,GovDoc 的每一次运行都会一路跑到预算耗尽,而它明明
在第几步就已经提交完了。
写动作意图那一步要问「这个工具声明的重放策略是什么」(0002 决策四)。查不到的话意图里
那一项只能恒填「绝不重放」,于是声明了 safe 的工具在恢复时照样不会被重放——那个声明成了
装饰品。
没有工具的动作(dissect 那种一整段代码)取「绝不重放」。 它不在注册表里,问不出策略,
而两个方向的错误代价不对称:当成可重放而其实不是,会重复执行副作用且静默;当成绝不重放而
其实可以,只是多停一次、有人会看见(0002 决策四的同一条理由)。
返回 ToolSpec | None 而不是抛异常:问一个不在注册表里的名字是正常情形(上面那一档),
不是错误。这与 validate 抛异常不矛盾——那里问的是「这次调用合不合法」,不合法就是错误。
注册、模型可见 schema 的生成、存在性与参数校验、分发——四者必须同源(scope.md 定死的
要求)。机器保证就是这四件由同一个实例驱动、住在同一个模块里。注册表是不可变值对象,取子集
返回新实例,不能是进程级单例——同一进程里可能同时持有多份不同的窄集合。
restrict_to 而不是 subset。 subset 读起来像取一个属性,而它做的是「按名字收窄,
造一个新实例」;GovDoc 三个阶段各自可见的工具集不同,用的正是这个方法。
validate 抛异常而不返回布尔。 返回布尔的校验函数迟早有一处调用方忘了看返回值,而那处
不会报错。
schema_for_model 名字里带 for_model,是因为这个模块里还有另一份 schema——ToolSpec
的 parameters 是校验用的完整 JSON Schema,而喂给模型的那份可能裁剪过。不加限定词的
schema() 在两者之间是歧义的。
决策七:日志里出现六种东西,记录类是五个
这一节不是命名决策,它是写签名时撞出来的一个计数错误。放在这里是因为不定下来就写不出
RunStore 的方法列表。
0003 决策七写「记录集合从三类扩到五类」,它数的那五类是:运行开始、一步开始了(也就是
模型调用意图)、一个动作要执行了、逐步结果、一次运行结束了。逐一对应下来少数了一种。
「少数了一种」说的不是数量而是成员。 本文最后落到的记录类也是五个,但成员不同——两种
意图在本文里合成了一个类,空出来的位置给了模型调用结果。不把 0003 那五类列出来,读者无法
验证这个断言,只能选择相信;第三轮评审的读者正是在这里卡了三遍。0003 决策四的写入序列
是四次写,加上运行开始与运行结束,日志里一共要出现六种东西:运行开始、模型调用意图、模型
调用结果、动作意图、逐步结果、运行结束。决策七数出来的五类里没有模型调用结果。
这不能含糊过去:0002 的四态表按「意图 / 结果」判恢复状态,模型调用那一档要问的正是「这个
预分配 ID 的结果条目在不在」。没有对应的记录,这个问题问不出来。
六种东西对应五个记录类,因为两种意图合成了一个类型:
# 全部 frozen,住在 polyloop.types(它们是持久化记录)
class IntentKind(StrEnum):
MODEL_CALL = "model_call"
ACTION = "action"
class RunStarted: run_id: str; parameter_snapshot: Mapping[str, str]
schema_version: int = CURRENT_SCHEMA_VERSION
class Intent: run_id: str; kind: IntentKind; call_index: int
result_id: str; replay_policy: ReplayPolicy
class ModelCallResult: run_id: str; result_id: str
reply: ModelReply | None; failure: str | None
class StepCompleted: run_id: str; result_id: str | None
action_outcome: ActionOutcome | None; step: StepRecord
class RunFinished: run_id: str; result: RunResult
class RunLog: # polyloop.ports,read_log 的返回值
started: RunStarted | None
intents: tuple[Intent, ...]
model_results: tuple[ModelCallResult, ...]
steps: tuple[StepCompleted, ...]
finished: RunFinished | None
两种意图合成一个类型用 kind 区分,而不是 ModelCallIntent 与 ActionIntent 两个类。
它们字段完全相同,分成两个类之后恢复逻辑要把同一段「有意图没结果」的判定写两遍,而那段
判定是 ../../CLAUDE.md §3 点名要对抗审查的高危代码——同一个判断写在两处,改的时候必然有
一处漏掉。代价是类型检查器不再帮忙区分两种意图,这个补在契约测试里:断言恢复对两种 kind
的处理路径各自正确。
Intent.call_index 带的是这一步的模型调用序号(0003 决策四要求它进意图记录)。动作意图
也带同一个值——一步之内只有一次模型调用,两条意图属于同一步。
RunFinished 只带结果不另带停止原因,因为 RunResult 里已经有了;两处放同一个值,迟早
有一处被改。
StepCompleted.result_id 可为空,空表示这一步没有写过动作意图。 解析失败、模型调用
失败、最终回答这三档都在写动作意图之前就记步了,它们没有预分配的 ID。写契约测试时这条
才暴露出来:result_id 原本是必填字符串,填什么都错——随便编一个,恢复会读到一条对不上
任何意图的记录,按 0002 四态表最后一行判为「日志损坏,拒绝续跑」,而这本该是一次恢复成
llm_error 正常终止的运行。
空与非空的判据只有一条:这一步写过动作意图没有。 而这条判据要配一个不变量,否则它 会放行损坏的日志:
result_id为空当且仅当action_outcome也为空。
两者一空一有值的记录是结构上说不通的——action_outcome 有值意味着动作真的执行过,而执行
之前必定写过动作意图、必定有预分配的 ID。少了这个不变量,一条
StepCompleted(result_id=None, action_outcome=EXECUTED) 会被「空的不参与四态判定」这条规则
当成合法的完整步接受,而它正是 0002 四态表里「无意图有结果」那一档,本该判为日志损坏、
拒绝续跑。恢复读到违反这个不变量的记录直接失败,不修复也不带着它继续。
满足不变量的前提下,恢复只把非空的那些拿去和意图配对;空的那些不参与四态判定,它们本身 就是「这一步完整地发生过」的证据。
ModelCallResult 必须能表达「这次调用失败了」,所以 reply 可为空、另有一个 failure。
两者恰好一个有值:成功时 reply 有值 failure 为空,失败时反过来。
少了这一档会有一个具体的错判。模型调用抛异常时,库照 0004 记一条 call_id 为空的步、以
llm_error 收尾;如果这时只写步、不写结果条目,那么进程在写完步、还没写运行结束时崩溃,
恢复读到的是「模型调用意图有、结果条目无」——四态表判为状态未知,走重放策略。而这一次
调用的状态一点都不未知,它明确地失败过,失败这件事就记在同一份日志的步记录里。结果是一次
本该恢复成 llm_error 终止的运行,被恢复成 resume_state_unknown 或者被重放一次,而下游
按停止原因做的统计会照单收下这个错误。
failure 存的是一段说明文本不是异常对象:异常对象没法可靠地序列化成任何一种持久形态,
而恢复只需要知道「失败过」以及失败的大致形态。
StepCompleted 不叫 StepResult。 叫 StepResult 会和它里面那个 StepRecord 字段
只差一个词,读的人分不清谁装谁——第一轮评审的读者当场就读错了,把「六种东西」记成了「六个
记录类」。用过去分词与 RunStarted、RunFinished 成一族,说的是「这件事发生了」。
决策八:预算、停止原因与运行结果
class Budget: # frozen,polyloop.types,各项必填无默认
max_steps: int # 继任 dissect
max_actions: int # 新增,见下面那段
max_consecutive_parse_failures: int # 继任 dissect
max_prompt_chars: int # 继任 dissect
四个字段与 0004 决策一说的「两个独立计数」不矛盾。 那条讲的是两个计数器——步数
与已执行动作数,它们各自耗尽时撞出 step_budget 与 action_budget 两个不同的停止原因。
另外两个是阈值不是计数器:连续解析失败达到上限撞 parse_failed_repeatedly,单步提示词
超过上限撞 context_overflow(0004 决策三 B 与 D)。四个都是调用方按次设定的上限,所以
住在同一个类型里;RunRequest 因此仍然是十一个字段。
三个上限逐字继任 dissect 的 AgentConfig:max_steps、
max_consecutive_parse_failures、max_prompt_chars。最后一个同时与步记录的 prompt_chars
同源,两处指的是同一个量的上限与实测值。
max_actions 是唯一的新增项,它数的是「有效步」。 GovDoc 的无效工具调用不计有效步——
它没真的做事——但计入总迭代上界,因为模型可能一直调用不存在的工具,没有那个上界循环不会停。
这两个计数混成一个就防不住无限循环。
class StopReason(StrEnum): # 十个取值,见 0004 决策二
TASK_COMPLETED = "task_completed"
STEP_BUDGET = "step_budget"
PARSE_FAILED_REPEATEDLY = "parse_failed_repeatedly"
CONTEXT_OVERFLOW = "context_overflow"
ENV_ERROR = "env_error"
LLM_ERROR = "llm_error"
AGENT_FINISHED = "agent_finished"
ACTION_BUDGET = "action_budget"
CANCELLED = "cancelled"
RESUME_STATE_UNKNOWN = "resume_state_unknown"
task_completed 与 agent_finished 的区别要写明,否则下游会统计错。 前者是动作执行
之后目标达成——环境报告完成,或者执行的是被标了完成标记的工具;后者是模型给出最终回答,
那时环境根本没被碰过。两者都算「干完了」,但可信度完全不同:一个有环境侧证据,一个是 agent
自报。dissect 的解释器把所有非代码输出判为无效决策,所以它永远不会撞上 agent_finished。
前六个逐字继任。后四个是新名字:agent_finished 与 action_budget 跟着它们在 0004 决策二
里的中文说法直译;cancelled 用英式拼写与 asyncio.CancelledError 对齐,免得同一份代码里
两种拼法都有;resume_state_unknown 三个词各自必要——resume 说明只在续跑时出现,state
指的是那一次执行的状态,unknown 是四态表里那一档的原话。
class RunResult: # frozen,polyloop.types
run_id: str
stop_reason: StopReason
final_answer: str | None
steps: tuple[StepRecord, ...]
schema_version: int = CURRENT_SCHEMA_VERSION
event_delivery_failures: int = 0
带默认值的字段排在最后,这是 Python 的硬性要求,不是风格。
schema_version 的默认值只对构造有效,反序列化时缺它直接失败,理由与步记录那处相同,
见决策九。
event_delivery_failures 放在返回值上而不是只记日志,因为日志没人看(0003 决策四)。
哪些结构带 schema 版本,判据是「它会不会被下游单独拿出来读」。 带的有三个:
RunStarted(一份日志的头,读它才知道整份日志怎么解)、StepRecord(dissect 的分析代码
逐行读轨迹,一行就是一条)、RunResult(GovDoc 存进数据库、跨进程读回)。不带的是
Intent、ModelCallResult、StepCompleted、RunFinished——它们只在恢复时被库自己读,
而恢复读的是整份日志,版本由 RunStarted 那一条统一交代。
给每一条都带一个版本不是更安全而是更糟:同一份日志里出现四个可以各自演进的版本号, 「这份日志是哪个版本」就没有答案了。
CURRENT_SCHEMA_VERSION 是 polyloop.types 里的一个模块常量,上面那三个的默认值全取它。
放在 types 而不是 serialization,是因为要它的是那些类型的字段定义,而 types 不许
import 上层。
决策九:步记录 StepRecord 的十八个字段
住在 polyloop.types。前十三个继任 dissect,名字原样不动;后五个是新增。
| 字段 | 类型 | 来源 |
|---|---|---|
step_idx |
int |
继任 |
raw_output |
str |
继任 |
content_chars |
int |
继任 |
thinking_chars |
int |
继任 |
action |
str | None |
继任 |
parse_ok |
bool |
继任 |
parse_error |
str | None |
继任 |
observation |
str |
继任 |
observation_is_synthetic |
bool |
继任 |
observation_truncated_chars |
int |
继任 |
prompt_chars |
int |
继任 |
call_id |
str | None |
继任 |
step_wall_ms |
int |
继任 |
tool_name |
str | None |
新增,默认 None |
tool_arguments |
str | None |
新增,默认 None,序列化之后的 |
action_status |
ActionStatus | None |
新增,默认 None |
env_reported_completion |
bool |
新增,默认 False(0005 决策一) |
schema_version |
int |
新增,默认为当前版本 |
「名字原样不动」不等于「口径原样不动」。 observation 那一列的口径已经被 0005 决策三
改过——它存的是回填进历史的那段文本,不是环境返回的原文。名字继任而口径以 0005 为准,
两者不冲突:dissect 那一列存的本来就是进历史的文本,0004 决策四把口径写成「完整原文」
才是错的。
每个字段各自的口径在 0004 决策四与 0005 决策一、三。本表只给类型、名字和默认值。
五个新增字段全部带默认值,这是 ../../CLAUDE.md §1.3 的硬约束。 直接后果是一条迁移前
的 dissect 轨迹——只有那十三个字段——能被直接构造成一个 StepRecord,五个新字段各自取
默认值。
但这条路不经过 serialization。 那个模块读到没有版本字段的载荷一律直接失败,而旧轨迹
里根本没有 schema_version——它是 dissect 自己在本库存在之前写下的文件,不是本库写的记录。
两条路要分开说:读本库写的记录走 serialization,缺版本就是损坏;读迁移前的历史
文件由 dissect 自己构造 StepRecord,默认值在这条路上生效。混着说的话,「用新库读旧轨迹」
这件事看起来被承诺了,实际第一行就会失败。
schema_version 有默认值不等于反序列化时可以缺。 类型上的默认值是给构造用的:库
写一条新记录时不必每处都手填当前版本。反序列化是另一回事——serialization 读到一份没有版本
字段的载荷时直接失败,不走默认值。两者分开的理由是它们回答的问题不同:构造时「当前版本是
多少」库自己知道;读取时「这条记录是哪个版本写的」只有载荷知道,靠默认值补齐会把「这是旧
版本」和「这条没写版本」压成同一个答案,而这正是 §1.4 点名的那类静默损坏。
类名叫 StepRecord 而不是 dissect 那个 Step。 Step 在本库里已经是一个概念名(一轮
决策),一个类叫这个名字,step 这个变量到底指概念还是指记录就得靠上下文猜。
决策十:顶层导出什么
polyloop/__init__.py 只再导出五个公开模块:
__all__ = ["types", "ports", "tools", "serialization", "session"]
stores 与 adapters 不在里面,必须显式 import。这是 0003 决策八第 9 条的另一半,理由是
一个顺手提供的默认模型客户端会让每个进程在 import 时把网关连同它的 provider 目录一起拉起来。
不在顶层再导出具体的类名。 顶层每多导出一个名字就多一份永久合同,而
from polyloop.types import StepRecord 只比 from polyloop import StepRecord 多打几个字符。
polyloop.serialization 在本文里没有出现新名字,因为它装的是编解码函数与 schema major
校验,形状随记录类走。它读到缺版本或未知 major 一律直接失败。
后续新增名字时照这几条
前面各处已经用到的规矩不在这里重复,只列三条新的。
后缀分三档,别混。 *Record 是可独立持久化的结构,字段受 §1.3 与 §1.4 约束
(StepRecord);*Outcome 与 *Reply 是接缝的返回值(ActionOutcome、ModelReply);
日志条目类按它记的那件事命名,不加统一后缀(RunStarted、Intent、StepCompleted)。
三档混用的直接后果是 StepResult 那种——名字里看不出它是记录、返回值还是条目。
不用缩写,除非那个缩写本身已经是词(id、api、ms)。idx 是例外中的例外:它只
出现在继任 dissect 的字段里,新字段不许再用。
返回结构体,不返回元组。 元组的字段没有名字,加一个字段就是破坏性变更。
代价
十八个字段的步记录很宽。 这是 0004 决策四已经认下的代价,本文只是把它变成了具体的类。
宽的来源是继任——十三个字段全部来自 dissect,而它们对另外两个消费者里有相当一部分恒为
默认值。
Intent 用 kind 区分两种意图,牺牲了类型层面的区分。 见决策七。
parameter_snapshot 把所有参数压成字符串。 数值参数取回来要自己转,这是刻意的——快照要
能逐字段比对、能原样写进任何一种持久形态,而混合类型的映射在比对时会因为 1 与 1.0
这类差异产生假阳性。
Role 第一版没有工具角色。 观察以 USER 角色回填进历史,这是 dissect 今天的做法。
模型 API 原生的工具调用与工具结果消息将来靠加枚举取值承载(0003 决策六),那时要一并
回答「已有轨迹按 USER 存的观察要不要迁」。
留给后续的
事件类型集合与具名回调清单还没定,所以 Event 在本文里只有一个名字没有字段。方向已经
定了(观察走事件流、干预走具名回调),但事件集要独立成篇。EventSink.emit 的签名不会因此
改变。
多模态内容的规模度量没有答案(0003 决策六登记的缺口)。ContentBlock 现在只有文本块,
它的度量是准确的字符数:
# 全部 polyloop.types
class Role(StrEnum): SYSTEM = "system"; USER = "user"; ASSISTANT = "assistant"
class TextBlock: text: str
ContentBlock = TextBlock
class Message: role: Role; content: tuple[ContentBlock, ...]
观察以 USER 角色回填进历史,这是 dissect 今天的做法。第一版没有工具角色;模型 API
原生的工具调用与工具结果消息将来靠加枚举取值承载(0003 决策六),那时要一并回答「已有
轨迹按 USER 存的观察要不要迁」。
ContentBlock 现在只有一个成员,看起来多余。它不是预留结构而是选对类型:将来加图片块时,
ContentBlock 变成联合类型对逐块处理的代码是兼容变更,而把 content 从 str 改成联合
类型是破坏性变更(0003 决策六)。