Files
PolyLoop/research-wiki/design/0013-event-set-and-callbacks.md
iomgaa 2385da3a97 docs(design): 0013 转已接受,补进四条来自 pi 的实据
去核 reference/pi 之后补的:它同一组三份投影对不上的地方有四处不是一处(决策四);
它先发事件再落盘,反着做没问题是因为订阅者与进程同生共死,本库的出口活得比进程久
(决策五);它的回调失败处置独立收敛到同一条分界——观察型抛异常照跑、工具执行前那个
fail-closed(决策八);它一轮发十来条是被终端界面逼出来的,而它同样把实时事件流与
持久记录分成两套(决策三)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 22:43:20 -04:00

310 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Design 0013 · 事件集与具名回调清单
**日期** 2026-08-10 · **状态** 已接受(2026-08-10 项目负责人确认)
**落实** `0003-public-api-shape.md` 决策五那句「观察走事件流、干预走具名回调」,并补上
`0007-seam-behaviour.md`「留给后续的」那半个契约——事件出口的「投递失败不打断循环」已经定了,
「发出去的事件里有什么」还没有,因为 `Event` 到今天只有一个名字、零个字段。
**取代** `0007` 决策二末尾那句设想(「要补的话,将来靠事件流把执行器原文送出去做审计」)。
决策六换了另一条路,理由在那一节。
**触及** `../../src/polyloop/ports/``Event` 加字段、新增 `EventKind`)、
`../../src/polyloop/session/`(发事件的那一处)、`../../tests/contract/test_event_sink.py`
**它过了 `../../CLAUDE.md` §2 那道人类门**,因为改的是公共类型的字段:`Event` 从零字段变成有
字段,而 `EventKind` 的取值集合从此是一份对外承诺。
## 读本文需要的几个名字
**接缝**——库留给项目替换的扩展点,用 Protocol 表达。本库有五个:模型调用、决策解释、动作
执行、存储、事件出口。**事件出口**这一个的形状是一个异步的 `emit(event: Event) -> None`
加一个同步的 `parameters()`(后者交出这个实现的参数,进参数快照)。
**意图日志**——库把一次运行写成一串只追加的记录:运行开始、意图(「要做一件有副作用的事
了」)、模型调用结果、一步走完、运行结束。崩溃之后的恢复判定读这串记录,靠「有意图没结果」
这种形态判断上次停在哪一档。
**步记录(`StepRecord`)与「一步走完」记录(`StepCompleted`)是两个东西**。前者是逐步轨迹
里的一行,下游拿它做分析;后者是日志里的一条,它把动作执行接缝的**原样返回值**和那一行步
记录**一次原子写下去**。本文两处都要用到,不能互换。
**回填进历史**——历史是喂给模型的那串消息。把观察回填进历史,就是在下一次模型调用之前往
消息序列里追加一条,好让模型看见上一步的结果。
**参数快照**——装配时向每个接缝要一份字符串到字符串的映射,合并起来写进运行开始记录。续跑
时把当前装配现算的那份与记录里的逐字段比对,任何一项不一致直接报错。「模型看得见的东西必须
能进参数快照」是本库反复用到的一条判据:进不了快照,就意味着它可以在两次运行之间悄悄变化。
**`reference/pi`**——`reference/` 下六个参考仓库之一,另一个 agent 执行内核。本文引用它是当
反例,它不是本项目的设计依据。
## 需求从哪来
`../migrations/govdoc-saas.md` 的缺口登记里有一条:GovDoc 的审计出口是一个「发一条带类型和
载荷的事件」的接口,而它的业务侧有一条硬纪律——**agent 的原始输出、修复后的输出、恢复来源
全程留痕,禁止静默修复**。那条纪律迁过来之后要有地方承载,不然它迁不了。
它今天的事件模型写在 `reference/GovDoc-SaaS/research-wiki/schemas/docagent-audit-events.md`
三类事件:
| 它的事件 | 埋点 | 载荷 |
|---|---|---|
| `llm_step` | 解析模型响应之后,成功与失败都记一条 | `session_id` `iteration` `call_id` `raw_content`(模型原文,全量不截断)`repaired_content`(修复之后的文本)`parse_error` `parse_ok` |
| `tool_call` | 工具执行之后,含无效调用 | `session_id` `iteration` `tool_name` `args` `output_digest`(工具输出的截断摘要)`is_valid` `duration_ms` |
| `phase_recovery` | 阶段执行器抢救产物成功时 | `session_id` `phase_name` `recovered_outputs` `recovered_from` |
第三类在本库界外——它发自阶段编排层,而阶段编排按 `../explanation/scope.md` 不归本库
`0003` 决策五复述过这条)。前两类都在界内,而且**都是一次迭代发一条**。
dissect 那边没有对应需求:`reference/dissect/harness/agent/loop.py``harness/runner.py`
没有任何回调、钩子或进度上报。所以事件流现在只有一个真实消费者。
## 决策一:事件流是活的观察通道,它带的每一条事实在存储里另有一份
**不变量:一条事件里的任何事实,在存储里都能再找到一份。** 事件丢了,丢的是「早一点看见」,
不是那件事本身。
这条不是风格偏好,它是「投递失败不打断循环」那条契约成立的前提。出口允许抛异常、库接住并
继续跑(`0007` 已定),而这件事只有在事件不是任何事实的唯一出口时才安全——否则一个连不上的
后端会让一次运行的部分事实**静默消失**,而运行本身照常返回成功。
`0003` 驳回过反方向的那条路:给事件流加投递保证,让它承担审计。驳回的理由是一旦有投递保证,
事件流就变成持久结构,此后每加一个事件类型都要走人类门改 schema 版本。审计因此落到存储上,
而事件流的「可丢」靠上面那条不变量兑现。
## 决策二:那条审计纪律由存储承担,事件流只是让人不必轮询存储
纪律要留痕的三样东西,在意图日志里各有确定的位置:
| 纪律要的 | 在哪 |
|---|---|
| agent 的原始输出 | `ModelCallResult.reply.content`——模型调用结果记录里那段回复,库不改它 |
| 修复后的输出 | `StepRecord.raw_output`——解释器交回来的、回填进历史的那段 assistant 文本 |
| 恢复来源 | 意图日志本身:哪一步有意图没结果、按哪条重放策略处置过 |
**原文与修复后的文本落在两条不同的记录里,这不是巧合,是 `0002` 的结构决定的。** 模型调用
结算时写一条结果记录,那时还没解释;解释完、动作走完之后写一条「一步走完」,那里面的文本是
解释器交回来的。两次写之间隔着一次动作执行,所以两份文本天然分开存,而「解释器改写过什么」
正好是两者的差。
于是 `../../tests/contract/test_event_sink.py` 里那条
`test_audit_events_carry_both_raw_and_repaired_model_output` 的前提是错的:**事件不带这两样,
存储带**。它要改写成断言存储这一侧,落在驱动入口那一层的测试里——也就是跑完一次完整运行、
再把日志读回来,验里面能不能同时读出原文与修复后的文本。放在事件出口的契约套件里验不了,
因为那套件只看一个出口实现,看不到一次运行。
事件流剩下的用处只有一个:让同一个进程里的观察者不必轮询存储就能知道跑到哪儿了。GovDoc 的
审计管道要「不丢」,那它该包在存储接缝上,不是接在事件出口上——存储的写是运行的一部分,写
失败运行就停;事件的投递不是。
## 决策三:事件集只有一种取值——一步走完
```python
class EventKind(StrEnum):
STEP_FINISHED = "step_finished"
```
GovDoc 的两类界内事件都是**一次迭代发一条**,而一次迭代在本库就是一步。拆成两条只多出一个
信息:模型答完了但动作还没跑完。GovDoc 的工具是工作区里的文件操作,那段间隔可以忽略;dissect
的动作是执行一段 Python、可能很慢,但 dissect 根本不看事件。**一个真实消费者都举不出来的
分辨率,按 `0001` 决策三不实现也不预留。**
`reference/pi` 是另一个极端,一轮迭代发十来条(`agent/test/agent-loop.test.ts:1185` 把整个
序列钉死了),事件类型十种。那个粒度是被终端界面逼出来的——它要让用户看着模型一个字一个字
往外吐,所以连流式分片都发一条。本库没有这种消费者。值得注意的是 pi 也把实时事件流与持久
记录分成了两套东西:落盘那套是另外九种记录(`agent/src/harness/session/types.ts:203`),事件
流不落盘。这跟决策一是同一个分法,只是它的事件流因为要喂界面而切得极细。
一步走完时那条步记录覆盖了它两类事件载荷的大部分:`raw_output` 是修复后的文本,`parse_ok`
`parse_error` 是解析结果,`tool_name` / `tool_arguments` / `observation` / `action_status`
是工具那一侧。**没有覆盖的有两样,各自的去处写在这里,免得下一个人以为它们被漏了:**
| 它的载荷 | 步记录里没有 | 去哪拿 |
|---|---|---|
| `raw_content`(模型原文) | 步记录里那段是修复后的 | 模型调用结果记录,见决策二 |
| `duration_ms`(工具单独耗时) | 只有整步墙钟 `step_wall_ms` | 现在拿不到,登记在下面的代价里 |
**没有「运行开始」与「运行结束」两种事件。** 事件出口是在进程内被 `await` 调用的,收到事件的
那段代码和调用 `run` 的是同一个进程:它自己知道运行是什么时候开始的,也会在运行结束时拿到
`RunResult`。**用返回值交付终态比用一条可丢的事件交付更可靠**——最后一条事件投递失败时终态
就丢了,而返回值丢不了。取消那一档拿不到返回值(`CancelledError` 原样抛给调用方),但调用方
正是取消的那一方,它知道;终态在存储的结束记录里。
要把进度写进一张业务表供另一个进程轮询的实现,照这个分工:逐步的行由事件出口写,终态那一行
由拿到 `RunResult` 的调用方写。
**`EventKind` 的取值集合从此是对外承诺**,加一个取值和改停止原因的取值一样要走人类门
`../../CLAUDE.md` §2)。加取值本身在类型上是兼容的,但对一个「假定每条事件都是步事件」而
写的出口来说,新取值会被静默地当成步事件读——所以第一版就带 `kind`,让每个出口从第一天起
就得分发。这跟 `0003` 决策六是同一个判据:容器的形状要一次选对,里面装什么可以以后加。
## 决策四:事件带的是那条步记录本身,不是它的摘要
```python
@dataclass(frozen=True, slots=True, kw_only=True)
class Event:
kind: EventKind
run_id: str
model_binding: Mapping[str, str]
step: StepRecord
```
数据类的这三个开关是本库所有公共数据类的统一形状,理由在 `0009`,不在本文重复。
**不挑几个字段拼一份摘要。** 挑出来的那份是一次投影,而投影会漂移。`0003` 决策五驳回观察
投影接缝时举过参考仓库 `reference/pi` 的例子,写这一条时又去核了一遍,实际情况比那句话更糟:
它同一组三份投影对不上的地方有四处,而不是一处。
| 同一条会话条目 | `harness/session/context.ts:65` | `harness/compaction/compaction.ts:68` | `harness/compaction/branch-summarization.ts:112` |
|---|---|---|---|
| `custom` 类型的条目 | 走投影器,可见 | 落到默认分支,不可见 | 显式返回空,不可见 |
| 压缩摘要后面保留的那截尾巴 | 整段带上 | 丢掉 | 丢掉 |
| 停止原因是「推迟」的助手消息 | 显式排除 | 没有这个判断,带进去 | 没有这个判断,带进去 |
| 工具结果消息 | 保留 | 保留 | 显式丢弃 |
四处没有一处是写错的——每一份投影单独看都讲得通,它们只是在不同时间被不同的需求改过。这正是
投影这种东西的失效形态:不报错、不崩溃,只是同一条记录在两个地方长得不一样,而发现它要有人
同时读三个文件。
带整条记录就没有这个问题:`StepRecord` 加一个字段,事件里自动就有,两边不可能对不上。
**`run_id` 在场,因为一个出口可以被并发的多次运行共用。** 事件出口挂在定义上(`0003` 决策
三),而定义可以被多次运行共用;没有运行标识,两次并发运行的事件在出口那边混成一串。
**`model_binding` 在场,因为项目自己那套标识没法从运行标识倒推。** GovDoc 的审计要求三类事件
必带会话标识,而会话标识是它自己的标识、住在请求的绑定里;一次会话包含三个阶段、也就是三次
运行,所以运行标识与会话标识不是一回事。让项目把会话标识编进运行标识再解析出来,等于往一个
库明确声明「不解析」的不透明字符串里塞结构。字段名与请求上那个 `model_binding` 逐字相同,
因为它就是同一份映射原样传过来的;值只能是字符串这条约束也一并继承,理由在 `0003` 决策三
——它的全部键值都要进参数快照,而快照是一份字符串到字符串的映射。
**没有 `schema_version`。** 事件不是持久化结构,`../../CLAUDE.md` §1.4 管的是会被下游存进
数据库或实验数据集的结构。下游把事件存下来时,存的是它自己那张表的 schema。而真正需要版本
的那部分——步记录——本来就带着自己的 `schema_version` 一起进来了。
## 决策五:只有一处发事件的地方,在「一步走完」原子落地之后
一步走完、`StepCompleted` 原子落地、**然后**发事件,再进入下一次迭代的停止判定。
**先写后发,顺序不能倒过来。** 倒过来的话,进程崩在发事件与落盘之间,观察者看见了一步而
存储里没有——那正是决策一那条不变量被打破的样子,而它的表现是「进度表里有第 7 步、日志里
只到第 6 步」,谁也说不清哪个是真的。
**`reference/pi` 是反着做的**`coding-agent/src/core/agent-session.ts:633`,那一行的注释就写着
先发给扩展),而它那么做没有问题,因为它的订阅者是同一个进程里的终端界面:进程死了订阅者跟着
死,「看见了但没落盘」这件事不会留下任何痕迹。本库的出口可以是一个活得比进程久的数据库,
所以同一个顺序在这里会留下一份对不上的记录。同一件事在两个项目里答案不同,差别在订阅者的
寿命,不在哪种写法更讲究。
**发在停止判定之前,因为停止判定可能不返回。** 判定一旦决定收尾,控制流就去写结束记录、
组装 `RunResult`、返回给调用方了。把发事件排在判定之后,最后一步就没有事件——而一次运行的
最后一步恰恰是最值得看见的那一步(它是「做完了」还是「预算烧光了」)。排在判定之前,每一步
都发,没有例外要记。
**续跑时不为已经完成的步补发事件。** 事件是「这次进程里真的发生了什么」的实时投影,补发等于
宣称一件早就发生过的事刚刚发生,而接进度表的那一侧会多出一批重复行。判据是**这次进程里有没有
真的执行**:从日志里读回来直接跳过的步不发;按重放策略在这次进程里重新执行了一遍的步照发。
观察者要补全前半段,从存储里读。
**投递不排队、不并发、不缓冲。** 一条一条 `await` 过去,按发生顺序。排队要引入后台任务、
背压和关闭时的排空,而那是治理,按 `../../CLAUDE.md` §1.5 不在本库做。代价照实认下:一个慢
出口会按步拖慢整次运行。要异步就在出口实现里自己排队——它知道自己能丢多少,库不知道。
## 决策六:执行器被丢掉的那段原文已经在日志里,不必再开一条路
`0007` 决策二定了动作被拒绝或环境故障时,回填进历史的是库合成的那段文本,执行器返回的
`observation` 不进历史。它同时认下一笔代价——「哪个参数不合法」这种只有执行器知道的信息
丢了——并设想将来靠事件流把它送出去做审计。
**那笔代价没有实际发生,因为它只是没进历史,并没有没进日志。** 「一步走完」这条记录同时写
两样东西:动作执行接缝的**原样返回值**,以及那一行步记录。库替换的只是步记录里的
`observation` 那一列,接缝返回的整个结构原样落盘,其中就包括执行器自己写的那段文本。
于是那条设想不必落地,而且**不该落地**:走事件流的话,出口一失败,那段文本就再也查不出来了,
而 GovDoc 那条纪律的原话正是「禁止静默修复」——一次被库替换掉的观察,替换前的样子只能存在
于一个可丢的通道里,这本身就是一次可能静默的修复。它今天审计里那个 `output_digest` 在无效
调用那一档记的,正是它的工具调度器给出的那段拒绝说明;对应到本库,那就是动作执行接缝返回的
`observation`,而它在日志里。
**代价是拿它要读日志,读不到轨迹里。** 下游拿到的 `RunResult.steps` 是步记录序列,里面没有
这一段;要审计就得把日志读回来。接受,因为另一头是往每一条步记录上再挂一份可能上兆的文本,
而需要它的只有出错的那几步。
## 决策七:投递失败按次计数,只算这次进程的
`RunResult.event_delivery_failures` 已经在类型里了,它的口径定在这里。
**捕获 `Exception`,不捕获 `BaseException`。** `asyncio.CancelledError` 必须原样穿过
`../../CLAUDE.md` §1.6)——在发事件这一下把取消吞掉,取消就会晚一整步才生效。
**每一次抛异常的 `emit` 计一次。** 不去重、不按步合并:出口连续失败十步就是十,那个数字要
能反映「有多失败」,而不只是「失败过」。
**捕获之后记一条错误日志,不静默继续**`../../CLAUDE.md` §1.7),也**不把失败转成一条事件
从同一个出口再发一次**——`0003` 决策四已经定了这条,理由是自我喂食:一个持续失败的出口会让
失败处理路径变成递归,而递归的表现是进程卡住或栈溢出,不是一条错误日志。
**计数是每次运行的,不是每个出口的。** 出口挂在定义上、可以被并发的多次运行共用,而这个数
属于一次运行的结果,所以它住在运行自己的状态里。
**不跨进程累加。** 崩溃续跑之后,返回值里的数字是这次进程投递失败的次数,上一次进程那些不
算在内。要累加就得把它写进结束记录,而那会把一个纯观察量变成持久结构——`0003` 驳回给事件流
加投递保证时说的正是这个。
## 决策八:具名回调清单现在是空的
`0003` 决策五定了「观察走事件流、干预走具名回调」。事件流那一半上面定完了,另一半没有内容:
**本库现在一个具名回调都不提供。**
这不是遗漏。GovDoc 那套钩子有四个扩展点,逐个都已经有别的归宿:
| 它的钩子 | 在本库的归宿 |
|---|---|
| 步骤开始前注入上下文 | 上下文装配不设接缝(`0003` 决策五),注入走请求上的注入槽(`0010` |
| 工具执行后替换工具输出 | 观察投影不设接缝(`0003` 决策五),那条驳回举的反例正是这个钩子——它的文档承诺「替换」而代码做的是「追加」 |
| 步骤结束后持久化中间状态 | 存储接缝在做这件事,而且它是必填的(`0003` 的否决方案里驳回了「做成可选参数」) |
| 循环结束后做遥测与清理 | `run` 的返回值就是这个 |
**将来要加一个具名回调,判据两条**:按 `0001` 决策三得有两个真实消费者;而且它改动的东西
必须能进参数快照——回调一旦能改变模型看见的内容,那份内容就成了这次运行的参数,不进快照
就等于有一个影响行为又读不出来的默认值。
**观察通道与干预通道的分界在失败处置上:观察通道失败了运行继续,干预通道失败了运行必须停。**
一个没生效的干预和一个生效了的干预会产出两次不同的运行,而静默继续意味着事后分不出是哪一种。
事件出口按前者处置(接住、计数、继续),任何将来的回调按后者(原样抛出、终止运行)。
`reference/pi` 独立走到了同一条线上:它的扩展回调抛异常会被接住、转成一条错误事件、其余回调
照跑(`coding-agent/src/core/extensions/runner.ts:809`),唯独工具执行前那一个回调抛异常会挡住
这次工具调用(`coding-agent/src/core/agent-session.ts:491`)。它的设计文档把这条写成一句
——抛异常的回调不会让运行失败,只有工具执行前那个是 fail-closed
`agent/docs/harness-v2.md:1290`)。两边都不是从原则推出来的,是各自撞到「一个悄悄没生效的
干预事后查不出来」之后收敛到的同一处。
## 代价
**慢出口按步拖慢运行。** 决策五不排队,所以一个每步花两百毫秒写数据库的出口,在五十步的
长运行上就是十秒。缓解只能在出口实现里做。
**一条事件带着整条步记录,可能很大。** `raw_output``observation` 都没有长度上限,出口
每步都会收到一份完整副本。
**工具单独的耗时拿不到。** 库只记整步墙钟,里面混着模型调用、解释和动作执行三段。GovDoc 今天
`tool_call` 事件记的是工具那一段,迁过来之后这个数会变成整步的数,两批数据不可比。这一条
登记进 `../migrations/govdoc-saas.md` 的缺口清单,不在本文解决——要拆开就得往步记录里加分段
计时,而那是一次持久化结构的改动,得有人先说清楚这个数拿去做什么。
**只有一种事件,看不见「模型答完了、动作还在跑」。** 一个动作耗时很长的项目,进度表在那段时间
里不会更新。等到有第二个消费者真的需要,加一个取值,那时 `Event.step` 要变成可为空并配一条
不变量——这是决策三承认的、加取值那天要付的结构成本。
## 留给后续的
**流式的中间增量还是不透出。** `0012` 文末把这件事指到本文,答案是不加:一次调用之内的增量
不是一步的结果,让它进事件流就要给事件一个「一步之内多条」的形状,而那要求事件带序号和排序
保证——那正是 `0003` 驳回的投递保证的开头。等有消费者再说。
**阶段级的抢救事件在界外。** GovDoc 的 `phase_recovery` 发自阶段执行器,那一层不归本库;它
要发什么事件由它自己定。本库这边它需要的是「这次运行结束了没有、结果是什么」,那是存储的
结束记录在回答。