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

22 KiB
Raw Permalink Blame History

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.pyharness/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 的 审计管道要「不丢」,那它该包在存储接缝上,不是接在事件出口上——存储的写是运行的一部分,写 失败运行就停;事件的投递不是。

决策三:事件集只有一种取值——一步走完

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_okparse_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 决策六是同一个判据:容器的形状要一次选对,里面装什么可以以后加。

决策四:事件带的是那条步记录本身,不是它的摘要

@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_outputobservation 都没有长度上限,出口 每步都会收到一份完整副本。

工具单独的耗时拿不到。 库只记整步墙钟,里面混着模型调用、解释和动作执行三段。GovDoc 今天 的 tool_call 事件记的是工具那一段,迁过来之后这个数会变成整步的数,两批数据不可比。这一条 登记进 ../migrations/govdoc-saas.md 的缺口清单,不在本文解决——要拆开就得往步记录里加分段 计时,而那是一次持久化结构的改动,得有人先说清楚这个数拿去做什么。

只有一种事件,看不见「模型答完了、动作还在跑」。 一个动作耗时很长的项目,进度表在那段时间 里不会更新。等到有第二个消费者真的需要,加一个取值,那时 Event.step 要变成可为空并配一条 不变量——这是决策三承认的、加取值那天要付的结构成本。

留给后续的

流式的中间增量还是不透出。 0012 文末把这件事指到本文,答案是不加:一次调用之内的增量 不是一步的结果,让它进事件流就要给事件一个「一步之内多条」的形状,而那要求事件带序号和排序 保证——那正是 0003 驳回的投递保证的开头。等有消费者再说。

阶段级的抢救事件在界外。 GovDoc 的 phase_recovery 发自阶段执行器,那一层不归本库;它 要发什么事件由它自己定。本库这边它需要的是「这次运行结束了没有、结果是什么」,那是存储的 结束记录在回答。