Files
PolyLoop/research-wiki/design/0006-public-names-and-signatures.md
T
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

50 KiB
Raw Blame History

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_idxraw_outputcall_id 这些),已经写进 dissect 的 rollouts 表。迁移验收的 硬标准是「轨迹与迁移前逐字段可比」,改名就要在 dissect 侧做一次字段映射,而那个映射本身 是一处会漂移的地方。

六个现有停止原因的字符串值同理。 它们被 dissect 的预注册判据按字符串匹配,改一个字母 那条判据会静默查到零行。预注册判据是 dissect 在跑实验之前就登记好、事后不许改的一组统计 规则,其中两条按停止原因筛数据——事后改判据等于事后挑结论,所以它们只能事先定死。

预算里三个上限也继任 dissect 的名字(见决策八)。

所以本文真正在选的名字只有:类型名、Protocol 名、方法名,以及那些没有前身的新字段。

决策一:治理单位叫 run,不叫 session

写这份文档时发现一处术语漂移。../../CLAUDE.md../../README.md 的开头写着「治理单位 是一次 Agent Session」,而 ../explanation/architecture.md 通篇是「一次运行」—— Session 在那份文件里出现零次。000300040005 也全是「一次运行」。

../../CLAUDE.md §0 的裁决表,「哪些事归本库管」的权威是 scope.mdarchitecture.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: ...

resumerun 参数完全相同,是刻意的:续跑不是另一件事,是同一次运行接着做。运行标识 在 request.run_id 里,库拿它去读回日志。

两者不合并成一个带 resume=True 开关的函数。 那个开关会掩盖两条路径失败方式的不同: run 撞上这个运行标识已经有日志时直接报错,resume 读不到日志时直接报错。合并之后调用方 看不出自己走的是哪条,而两种错的处置完全不同。

resume 比对什么。 日志里的运行开始记录带着当次的参数快照(定义级 + 请求级合并后的 那一份),resume 把它与当前装配现算的快照逐字段比对,任何一项不一致直接报错。 这意味着续跑不能顺便改预算或换模型——那不是限制而是这条守卫的全部意义:用同一个运行 标识换一份定义续跑,前几步与后几步会来自两个不同的配置而全程零报错,这正是 ../../CLAUDE.md §1.4 点名的、要到统计阶段才分不清哪些行是真的那类损坏。

决策三:定义与请求

AgentDefinitionfrozen,可并发复用,住在 polyloop.session):

字段 类型 是什么
model_client ModelClient 模型调用接缝
decision_parser DecisionParser 决策解释接缝
store RunStore 存储接缝
event_sink EventSink 事件出口
synthetic_observations SyntheticObservations 库自己合成的那几段观察文本
class SyntheticObservations:          # frozenpolyloop.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 会改变跨步语义。换一个会话续跑而快照不比对,前几步的副作用留在旧会话里、后几步 在新会话上执行,全程零报错。

contextinjections 不进快照。 它们是这次运行的输入数据不是参数,进快照会让快照 变成一份数据副本,而它们可能很大。注入内容的条目标识另行进轨迹(Injection.entry_id 原样进步记录那条路),所以「这次贴了哪几条」事后查得到,查不到的只是正文。

class Context:      # frozenpolyloop.types
    run_level: tuple[Message, ...]    # 这次运行从头到尾不变的段
    goal_level: tuple[Message, ...]   # 这一个目标特有的段

class Injection:    # frozenpolyloop.types
    entry_id: str                     # 这条 Skill 条目的标识,原样进轨迹
    content: str                      # 贴进上下文的正文

injections 的键是通道名。现在只有一个通道在用,但类型是映射不是序列,因为将来多一个通道 是加一个键、不是改类型(0003 决策六的同一条判据)。库不解释通道名,只按键分组贴。

observation_templatetool_section_template 是两段格式模板,不是取值集合,所以不做成 枚举。 「枚举一律 StrEnum」那条规矩管的是有穷取值,而这两个字段装的是调用方自己写的 一段带占位符的文本——dissect 与 GovDoc 的观察包装格式完全不同,做成枚举等于把两家的格式 都写进库里。

action_executortools 同时存在,这不是重复。 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 要装一个动作结果、步记录要装一个动作结算状态。三个字段的 类型如果住在 portstypes 就得 import ports,而 types 是依赖图的汇点,所有箭头指向它、 不许从它出去(0003 决策八第 1 条,由 pyproject.toml 的分层契约断言)。这个矛盾只有在 把字段类型逐个写出来的时候才浮出来。

判据是**「它是不是一个值」**:值类型与持久化记录住 types,只为一次调用打包入参或返回的 ports

按这条:ModelReplyActionOutcomeActionStatusMessageRoleTextBlockContentBlockStopReasontypesModelCallParsedReplyRunLogports Decision 三分支与 ToolCall 也住 ports——它们是解释接缝的返回形状,不被任何 types 里的结构引用(步记录存的是 actiontool_nametool_arguments 三个字符串,不是对象)。 Eventports,它是事件出口的入参。

判据原本写的是「会不会被写进日志」,那条不对。 Message 不出现在任何一条日志记录里, 照那条判据要判进 ports;而 Contexttypes 且字段是消息序列,于是 types 要 import ports——上一版刚修掉的那个反向依赖,用新判据又长回来了。「是不是一个值」不会有这个问题: 凡是被 types 里的结构引用的,本来就都是值。

Protocol 名不加 PortInterfaceAbstract 这类前后缀。 那种后缀只说明「这是个 抽象」,而模块名 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 而不是 RepositoryJournal——Repository 在业界带着「按领域对象查询」的意味,而这个接缝只有 追加与整份读回;Journal 又太窄,它还存运行开始与结束。EventSink 而不是 ListenerObserver——后两个暗示库会等它回话,而这个出口是单向的、投递失败不影响循环。

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:    # frozenpolyloop.ports(不落盘)
    messages: tuple[Message, ...]
    call_index: int          # 本次运行内的第几次模型调用,从 0 递增
    run_id: str
    result_id: str           # 库预分配的
    binding: Mapping[str, str]

class ModelReply:   # frozenpolyloop.types(它进 ModelCallResult
    call_id: str | None      # 可为 None,绝不为空串
    content: str
    thinking: str

call_index 而不是 attempt_idx0003 决策四专门写过它跟 dissect 项目绑定里那个「尝试 序号」(同一道题的第几次独立重做)撞名,这里换个词就是为了不再撞。

contentthinking 对应步记录的 content_charsthinking_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 字段。不叫 tracetrace 在 agent 语境里通常指整条执行轨迹,而这里 是单步的一个字符串,用它会和「逐步轨迹」撞。dissect 传那段 Python 源码、tool_callNone;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,不叫 completeddone 它要回答的从来不是 「这次运行结束了没有」——那个问题的答案是停止原因。它回答的是「环境说目标达成了」, 而另一条完成通路(工具注册表上的完成标记)与它可信度不同。名字里带上 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——ToolSpecparameters 是校验用的完整 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.portsread_log 的返回值
    started: RunStarted | None
    intents: tuple[Intent, ...]
    model_results: tuple[ModelCallResult, ...]
    steps: tuple[StepCompleted, ...]
    finished: RunFinished | None

两种意图合成一个类型用 kind 区分,而不是 ModelCallIntentActionIntent 两个类。 它们字段完全相同,分成两个类之后恢复逻辑要把同一段「有意图没结果」的判定写两遍,而那段 判定是 ../../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 不叫 StepResultStepResult 会和它里面那个 StepRecord 字段 只差一个词,读的人分不清谁装谁——第一轮评审的读者当场就读错了,把「六种东西」记成了「六个 记录类」。用过去分词与 RunStartedRunFinished 成一族,说的是「这件事发生了」。

决策八:预算、停止原因与运行结果

class Budget:              # frozenpolyloop.types,各项必填无默认
    max_steps: int                       # 继任 dissect
    max_actions: int                     # 新增,见下面那段
    max_consecutive_parse_failures: int  # 继任 dissect
    max_prompt_chars: int                # 继任 dissect

四个字段与 0004 决策一说的「两个独立计数」不矛盾。 那条讲的是两个计数器——步数 与已执行动作数,它们各自耗尽时撞出 step_budgetaction_budget 两个不同的停止原因。 另外两个是阈值不是计数器:连续解析失败达到上限撞 parse_failed_repeatedly,单步提示词 超过上限撞 context_overflow0004 决策三 B 与 D)。四个都是调用方按次设定的上限,所以 住在同一个类型里;RunRequest 因此仍然是十一个字段。

三个上限逐字继任 dissect 的 AgentConfigmax_stepsmax_consecutive_parse_failuresmax_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_completedagent_finished 的区别要写明,否则下游会统计错。 前者是动作执行 之后目标达成——环境报告完成,或者执行的是被标了完成标记的工具;后者是模型给出最终回答, 那时环境根本没被碰过。两者都算「干完了」,但可信度完全不同:一个有环境侧证据,一个是 agent 自报。dissect 的解释器把所有非代码输出判为无效决策,所以它永远不会撞上 agent_finished

前六个逐字继任。后四个是新名字:agent_finishedaction_budget 跟着它们在 0004 决策二 里的中文说法直译;cancelled 用英式拼写与 asyncio.CancelledError 对齐,免得同一份代码里 两种拼法都有;resume_state_unknown 三个词各自必要——resume 说明只在续跑时出现,state 指的是那一次执行的状态,unknown 是四态表里那一档的原话。

class RunResult:           # frozenpolyloop.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 存进数据库、跨进程读回)。不带的是 IntentModelCallResultStepCompletedRunFinished——它们只在恢复时被库自己读, 而恢复读的是整份日志,版本由 RunStarted 那一条统一交代。

给每一条都带一个版本不是更安全而是更糟:同一份日志里出现四个可以各自演进的版本号, 「这份日志是哪个版本」就没有答案了。

CURRENT_SCHEMA_VERSIONpolyloop.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 新增,默认 False0005 决策一)
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"]

storesadapters 不在里面,必须显式 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 是接缝的返回值(ActionOutcomeModelReply); 日志条目类按它记的那件事命名,不加统一后缀(RunStartedIntentStepCompleted)。 三档混用的直接后果是 StepResult 那种——名字里看不出它是记录、返回值还是条目。

不用缩写,除非那个缩写本身已经是词(idapims)。idx 是例外中的例外:它只 出现在继任 dissect 的字段里,新字段不许再用。

返回结构体,不返回元组。 元组的字段没有名字,加一个字段就是破坏性变更。

代价

十八个字段的步记录很宽。 这是 0004 决策四已经认下的代价,本文只是把它变成了具体的类。 宽的来源是继任——十三个字段全部来自 dissect,而它们对另外两个消费者里有相当一部分恒为 默认值。

Intentkind 区分两种意图,牺牲了类型层面的区分。 见决策七。

parameter_snapshot 把所有参数压成字符串。 数值参数取回来要自己转,这是刻意的——快照要 能逐字段比对、能原样写进任何一种持久形态,而混合类型的映射在比对时会因为 11.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 变成联合类型对逐块处理的代码是兼容变更,而把 contentstr 改成联合 类型是破坏性变更(0003 决策六)。