diff --git a/CLAUDE.md b/CLAUDE.md index bf845ef..be0ba2d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # PolyLoop -实验室共用的 Agent 执行内核。治理单位是**一次 Agent Session**:围绕一个目标的有界多轮「模型决策 → 动作 → 观察」循环,含预算、停止语义、取消、逐步轨迹与 Skill 注入。一次模型调用本身不归它管,那是 PolyGateway 的治理单位;PolyLoop 用 PolyGateway 的顶层公共 API,不重建一套模型治理。 +实验室共用的 Agent 执行内核。治理单位是**一次运行**:围绕一个目标的有界多轮「模型决策 → 动作 → 观察」循环,含预算、停止语义、取消、逐步轨迹与 Skill 注入。一次模型调用本身不归它管,那是 PolyGateway 的治理单位;PolyLoop 用 PolyGateway 的顶层公共 API,不重建一套模型治理。 首批消费者是 dissect 与 GovDoc-SaaS,CHSAnalyzer 是远期消费者。 @@ -149,11 +149,13 @@ Codex 是 OpenAI 的编码模型,本仓库通过 `codex` 插件调用它。** - **报告进展前,逐条对照本次会话真实的工具结果。** 只报告拿得出证据的部分;没验证的明说没验证。测试挂了就贴输出;跳过的步骤就说跳过了;做完并验证了就平实地说清楚,不要模糊其辞。 - **不做没让做的事**:不顺手重构、不为假设中的未来需求加抽象。修 bug 不需要顺带清理周边。 - **不建防御性备份分支。** 想留个后路的心情可以理解,但分支一多就没人认得出哪条还有用,最后谁都不敢删。git 本来就留着历史,需要回退随时回得去。 +- **能压成一段结论的活尽量交给 subagent,必须和别处约束咬合的活自己做。** 判据是产出的形状:「读一批材料、回来给个清单」属前者——调研某处怎么实现的、跨几份文档核对结论有没有回写、大范围搜索某个东西在哪;「写一段要同时压着十条约束的代码」属后者,交出去只会收回一段看着对、细节全错的东西,而那类错是静默的。判断一条审查发现成不成立、写 design doc、做取舍、和人对话,同样自己做。**委托出去的活要求交证据不交判断**:事实要带 `文件:行号` 或命令原始输出,并抽查两三条校准这一份可不可信——抽查错一条整份都不采纳,因为它已经证明会编。 +- **持续往下做,不要每完成一件事就停下来问「要不要继续」。** 只在两种情况停:撞上 §2 那张表里的人类门,或者不同理解会导出实质不同的工作而你判断不了。除此之外做完一件接着做下一件,做完一起报。每做完一步就问一次,等于把「决定下一步做什么」这件本该由你承担的事推回给人,而人手上的上下文比你少。 ## 8. 对话 说人话。像同事聊天那样一次说一件事,别把一轮回复写成报告。你是我的合作者,不是一个机器,不要把一大堆内容直接甩给我自己分析,这是推卸责任。我们的目标是一起通力合作开发好这个项目。 -问什么答什么,有判断直接讲,讲完停下来等回应,不要一口气把后面几步都推完。不要默认一些名词和你搜索到的内容我是一定知道的,你有讲解的义务。不要为了「扮演」专业刻意使用高信息的句子或者表述,这会显著降低可读性。 +问什么答什么,有判断直接讲。**这条管的是怎么说话,不是怎么干活**——别在一轮回复里把后面几步的推演一口气铺完,但活该往下做就往下做,什么时候停按 §7 那条。不要默认一些名词和你搜索到的内容我是一定知道的,你有讲解的义务。不要为了「扮演」专业刻意使用高信息的句子或者表述,这会显著降低可读性。 **要我做决定时,一次把决定需要的信息给全。** 具体说:总共几个问题、每个问题有哪些选项、你倾向哪个、以及哪些是你自己就能定的。**不许挤牙膏**——先讲三条、等我追问才补上剩下九条,这中间我是在信息不全的情况下做判断,等于白问。你看得到全部上下文,我看不到;你不列全,我就没有选的依据。 diff --git a/README.md b/README.md index 8ff85a1..44abc56 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # PolyLoop -实验室共用的 Agent 执行内核。治理单位是**一次 Agent Session**:围绕一个目标的有界多轮 +实验室共用的 Agent 执行内核。治理单位是**一次运行**:围绕一个目标的有界多轮 「模型决策 → 动作 → 观察」循环,含预算、停止语义、取消、逐步轨迹与 Skill 注入。 它和 PolyGateway 是叠起来的两层。PolyGateway 治理**一次模型调用**(多源、限流、重试、熔断、 -缓存、遥测),PolyLoop 治理**一次 Agent Session**,并用 PolyGateway 的顶层公共 API 拿模型。 +缓存、遥测),PolyLoop 治理**一次运行**,并用 PolyGateway 的顶层公共 API 拿模型。 任务编排、批量调度、评分、检索、Skill 的生成与进化都留在下游项目。 --- diff --git a/research-wiki/design/0006-public-names-and-signatures.md b/research-wiki/design/0006-public-names-and-signatures.md new file mode 100644 index 0000000..2dc1822 --- /dev/null +++ b/research-wiki/design/0006-public-names-and-signatures.md @@ -0,0 +1,796 @@ +# 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`。 + +```python +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` | 库自己合成的那几段观察文本 | + +```python +class SyntheticObservations: # frozen,polyloop.types + action_rejected: str # 工具不存在或参数不合法时喂回模型的那段 + env_failed: str # 环境故障时喂回模型的那段 + model_call_failed: str # 模型调用失败那一步喂回模型的那段 +``` + +**第三段是写契约测试时补上的。** 模型调用失败那一步照样要留痕,而那一步没有 `ParsedReply` +也没有 `ActionOutcome`,库手上没有任何来源能产出这段观察。少了它,dissect 现有那句 +「模型调用失败,这一步没有产出」在迁移后会变成空串,而验收口径是「轨迹逐字段可比」。 + +它挂在定义上而不是请求上,因为这三段文本跨运行不变,而且属于「模型看得见的东西」,必须 +能进参数快照(`0003` 决策三)。**不做成模板或可格式化字符串**:它们是固定文本,一旦允许 +按情况拼接,库就在替调用方决定模型看见什么。 + +**定义上还有一个方法,不是字段:** + +```python +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` +原样进步记录那条路),所以「这次贴了哪几条」事后查得到,查不到的只是正文。 + +```python +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` 已经说了;名字里剩下的位置应该用来说它做什么。 + +```python +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: ... +``` + +**五个接缝各多一个同步方法:** + +```python + 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` 里。 + +```python +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` 至少能被显式筛出来。 + +## 决策五:决策三分支与动作结果 + +```python +# 以下全部 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 +的解析器对五种解析失败各有一条对症说明,压成一句会改掉它的实验条件。 + +```python +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`。 + +```python +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`:它描述这个工具**做什么**,不是它 +属于哪一类。 + +```python +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 的结果条目在不在」。没有对应的记录,这个问题问不出来。 + +**六种东西对应五个记录类**,因为两种意图合成了一个类型: + +```python +# 全部 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` 成一族,说的是「这件事发生了」。 + +## 决策八:预算、停止原因与运行结果 + +```python +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 的无效工具调用不计有效步—— +它没真的做事——但计入总迭代上界,因为模型可能一直调用不存在的工具,没有那个上界循环不会停。 +这两个计数混成一个就防不住无限循环。 + +```python +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` 是四态表里那一档的原话。 + +```python +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` 只再导出五个公开模块: + +```python +__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` 现在只有文本块, +它的度量是准确的字符数: + +```python +# 全部 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` 决策六)。 diff --git a/research-wiki/design/0007-seam-behaviour.md b/research-wiki/design/0007-seam-behaviour.md new file mode 100644 index 0000000..5988619 --- /dev/null +++ b/research-wiki/design/0007-seam-behaviour.md @@ -0,0 +1,121 @@ +# 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 测试用可注入故障点覆盖前者,后者只能靠评审看实现形态。 diff --git a/research-wiki/explanation/architecture.md b/research-wiki/explanation/architecture.md index 4e3eefd..97b8c63 100644 --- a/research-wiki/explanation/architecture.md +++ b/research-wiki/explanation/architecture.md @@ -238,6 +238,11 @@ PolyLoop 把这个循环收成一份。它治理的单位是**一次运行**: ### 分层 +**类型分到 `types` 还是 `ports`,判据是「它是不是一个值」**:值类型与持久化记录住 `types`, +只为一次调用打包入参或返回的壳住 `ports`。判据不能写成「会不会被写进日志」——消息不出现在 +任何一条日志记录里,照那条会判进 `ports`,而上下文住 `types` 且字段就是消息序列,于是 +`types` 反向依赖 `ports`。理由见 `../design/0006-public-names-and-signatures.md` 决策四。 + **这张图讲的是谁 import 谁,不是运行顺序,也不是数据流向。** `A ──▶ B` 读作「A 的代码里写了 `from polyloop.B import ...`」,也就是 A 依赖 B。 @@ -334,8 +339,8 @@ provider 目录一起拉起来。 | 位置 | 公开 | 装什么 | |---|---|---| -| `polyloop/types/` | 是 | 公共值类型、枚举、持久化记录 | -| `polyloop/ports/` | 是 | 全部 Protocol 与它们的入参/返回结构体 | +| `polyloop/types/` | 是 | 公共值类型、枚举、持久化记录。**凡是被这里引用的都必须也在这里** | +| `polyloop/ports/` | 是 | 五个 Protocol,以及只在一次调用往返之间存在的入参 / 返回壳 | | `polyloop/tools/` | 是 | 工具规格与注册表,以及由注册表派生的动作执行器 | | `polyloop/serialization/` | 是 | 记录的编解码与 schema major 校验 | | `polyloop/session/` | 是 | 定义、请求、`run`、`resume` | @@ -374,7 +379,7 @@ GovDoc 的编排层读停止原因决定这个阶段算不算可挽救,写任 ### 五个接缝 -**模型调用**(挂定义)。把已装配好的消息序列送出去,拿回可见回复与推理段。签名里不出现 +**模型调用** `ModelClient`(挂定义)。把已装配好的消息序列送出去,拿回可见回复与推理段。签名里不出现 重试次数、退避时长、限流配额——出现即意味着库在治理一次模型调用,而那归 PolyGateway。 两个形态:一种要在调用点按多本账各记一条并自己按价格表算成本,一种要在调用外面套退避并 累加本次运行的用量。 @@ -382,11 +387,11 @@ GovDoc 的编排层读停止原因决定这个阶段算不算可挽救,写任 返回类型是库自己的,不是 re-export PolyGateway 的响应类型。理由是接缝定义模块不许有第三方 依赖(第七节规则五),而且 PolyGateway 加一个字段就等于本库的公共类型变了一次却没发过版。 -**决策解释**(挂定义)。把一次模型回复解释成三分支之一:动作、最终回答、无效决策。 +**决策解释** `DecisionParser`(挂定义)。把一次模型回复解释成三分支之一:动作、最终回答、无效决策。 库不带默认实现——带了就等于替某一家定了动作语言。两个形态:一种从代码围栏里抽 Python 源码,一种从 JSON 里抽工具名与参数。 -**动作执行**(挂请求)。结算一个动作,返回状态(已执行 / 未执行 / 环境故障)、观察、 +**动作执行** `ActionExecutor`(挂请求)。结算一个动作,返回状态(已执行 / 未执行 / 环境故障)、观察、 观察是不是库合成的、完成信号、被截断的字符数。两个形态:一种把一段代码交给已开好的容器 会话、状态恒为已执行,一种查工具注册表分发、工具不存在或参数不合法时返回未执行。 @@ -410,7 +415,7 @@ GovDoc 的编排层读停止原因决定这个阶段算不算可挽救,写任 字符数」单独记。存原文的话,恢复时得拿原文重跑一遍截断逻辑才能得到历史,而截断逻辑会随 版本变——那正是记录逐步结果要消掉的那类重算。 -**存储**(挂定义)。六个方法:写运行开始、写一条意图、写模型调用结果、写动作结果与步记录、 +**存储** `RunStore`(挂定义)。六个方法:写运行开始、写一条意图、写模型调用结果、写动作结果与步记录、 读回整份日志、写运行结束。全部带运行标识,端口不持有「当前运行」的隐式状态——一个有隐式 当前运行的端口在并发下会把 A 的意图写进 B 的日志。两个形态:一种逐行写本地 jsonl 文件, 一种写关系数据库。 @@ -428,7 +433,7 @@ GovDoc 的编排层读停止原因决定这个阶段算不算可挽救,写任 上顺序提交的事务,先提交的先持久。代价是排除了「不同记录类型写进彼此无序的多个后端」这种 形态,现在没有消费者要它。 -**事件出口**(挂定义)。投递失败由库捕获、记日志、把失败计数加一,然后继续跑。失败不再 +**事件出口** `EventSink`(挂定义)。投递失败由库捕获、记日志、把失败计数加一,然后继续跑。失败不再 转成一条事件从同一个出口发出去——那会自我喂食,一个持续失败的出口会让失败处理路径变成 递归。两个形态:一种把进度回写业务数据库,一种把审计事件送进日志管道。 @@ -449,12 +454,14 @@ GovDoc 的编排层读停止原因决定这个阶段算不算可挽救,写任 本库不部署,也不跑模型推理,没有常驻进程。它被 `pip install` 进下游项目,在下游的进程里 被装配和调用。 -装配分两步。**定义**在进程或 worker 启动时装配一次,持有跨运行不变的能力:模型调用接缝、 -决策解释接缝、存储接缝、事件出口。它是不可变的,可以被并发复用。 +装配分两步。**定义**在进程或 worker 启动时装配一次,持有跨运行不变的五样东西:模型调用 +接缝、决策解释接缝、存储接缝、事件出口,以及库在动作被拒绝或环境故障时合成的那几段观察 +文本。它是不可变的,可以被并发复用。它还有一个只读方法,把四个接缝各自上报的参数聚合成 +一份快照——那是方法不是字段,因为聚合要向接缝逐个发问,而构造定义之前定义还不存在。 -**请求**每次运行构造一个,持有这次运行独有的数据:运行标识、预算、动作执行器、本次可见的 -工具集、上下文各段、注入内容、模型绑定、模型调用的重放策略、取消收尾时限。它构造廉价—— -无 I/O、无网络校验、无哈希计算。 +**请求**每次运行构造一个,持有这次运行独有的十一样数据:运行标识、预算、动作执行器、本次 +可见的工具集、上下文各段、注入内容、模型绑定、模型调用的重放策略、观察包装模板、工具段 +渲染样式、取消收尾时限。它构造廉价——无 I/O、无网络校验、无哈希计算。 切点是「跨运行变不变」。预算只在请求这一处,不设「定义给默认值、请求可覆盖」——两处取值 意味着「这次到底跑的什么设置」要对照两个地方才答得出来。 @@ -521,9 +528,10 @@ dissect 的每一次运行都是论文数据点,这个性质对结构提了几 ## 十四、已知缺口与尚未决定的部分 -**公共类型的英文名与接缝的具体签名还没定。** 按 `../../CLAUDE.md` §2 它们要过人类门。 -本文件通篇用中文概念名指代它们,这是刻意的留白不是遗漏。它们和实现一起落地,落地时本文件 -第八、九、十一节要补上英文名。 +**公共类型的字段与枚举取值不在本文件里。** 英文名与签名定在 +`../design/0006-public-names-and-signatures.md`,行为契约定在 `../design/0007-seam-behaviour.md`; +落地之后权威转移到 `src/polyloop/` 的代码与 `tests/contract/`。本文件只给五个接缝的 +Protocol 名与模块归属,不复述字段表——按 `../../CLAUDE.md` §0,那种复述腐烂的速度和代码一样快。 **停止判定的顺序、停止原因的取值、两个预算计数的语义、步记录的字段清单不在本文件里。** 这四样已经定了,在 `../design/0004-stopping-and-step-record.md`,字段表经 @@ -552,6 +560,8 @@ dissect 的每一次运行都是论文数据点,这个性质对结构提了几 | 公共 API 分几层、五个接缝为什么是这五个、依赖规则为什么这么定 | `../design/0003-public-api-shape.md` | | 停止原因为什么是这十个、判定为什么按这个顺序、步记录为什么是这些字段 | `../design/0004-stopping-and-step-record.md` | | 存储的方法为什么这么切、为什么要前缀持久性、步记录那三处为什么改 | `../design/0005-storage-atomicity-and-record-fields.md` | +| 公共类型与接缝叫什么、字段是什么形状、类型分到哪个模块 | `../design/0006-public-names-and-signatures.md` | +| 三个动作状态什么时候赋上、动作被拒绝时观察从哪来、解释器能不能抛异常 | `../design/0007-seam-behaviour.md` | 边界的当前裁决清单(哪些在界内、哪些在界外)在 `scope.md`,那份是常青的,会随新消费者 接入而更新。每个下游要迁什么、迁完算不算数在 `../migrations/` 下对应那份。 diff --git a/research-wiki/migrations/dissect.md b/research-wiki/migrations/dissect.md index 9ad0a2c..94256a8 100644 --- a/research-wiki/migrations/dissect.md +++ b/research-wiki/migrations/dissect.md @@ -156,7 +156,7 @@ dissect 有一档实验要在同一时刻用不同的注入内容跑同一批题 | `Episode.is_done` | 动作执行接缝返回值上的完成信号字段 | **不是独立接缝**,见 architecture.md 第九节 | | `ArtifactView`(通道、条目标识、内容三字段) | Skill 注入的输入形态 | | | `build_prefix` 的段顺序约束 | **不是接缝**,是库内 `_assembly` 的纯函数 | 渲染格式留在 dissect,理由是排除条款(见 `../explanation/scope.md`) | -| `AgentConfig` 的四个预算字段 | 请求上的预算 | | +| `AgentConfig` 的三个数值上限 | 请求上的预算 | `max_steps`、`max_consecutive_parse_failures`、`max_prompt_chars`;库的预算比它多一个已执行动作数上限 | | `StopReason` | 停止原因 | dissect 现有六个取值是库的下界 | | `Step` | 逐步轨迹的一步 | dissect 现有十三个字段是库的下界 | diff --git a/tests/contract/conftest.py b/tests/contract/conftest.py index d3698ef..669b965 100644 --- a/tests/contract/conftest.py +++ b/tests/contract/conftest.py @@ -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(): """被测的动作执行接缝实现。""" diff --git a/tests/contract/test_decision_parser.py b/tests/contract/test_decision_parser.py index d27145e..9dfc1b6 100644 --- a/tests/contract/test_decision_parser.py +++ b/tests/contract/test_decision_parser.py @@ -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) diff --git a/tests/contract/test_event_sink.py b/tests/contract/test_event_sink.py index 365c274..f70cb2b 100644 --- a/tests/contract/test_event_sink.py +++ b/tests/contract/test_event_sink.py @@ -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) diff --git a/tests/contract/test_run_store.py b/tests/contract/test_run_store.py index 1aa416d..d54ed5b 100644 --- a/tests/contract/test_run_store.py +++ b/tests/contract/test_run_store.py @@ -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")