事件流
一个帧类型、一份日志、两个纯 reducer,以及每一个都是它们的客户端的界面。
一套词汇
bingo_sdk::Event 是系统里唯一的输出词汇。终端界面、--print、JSON-RPC 和 IM 频道
全都消费它,并在渲染时导出各自的视图。没有任何界面定义一个私有的镜像 enum,而
scripts/check_discipline.sh 会拒绝任何声明了以 Event 结尾的类型名的界面 crate
(ADR-0002)。
帧
pub struct Frame {
pub seq: Seq,
pub ts: Timestamp,
pub session: SessionId,
/// The client intent this frame answers or results from, when there is one.
pub cause: Option<IntentId>,
pub event: Event,
}
seq 由会话 actor 在同一把锁下生成,对持久帧来说没有空档。持久帧在被发布之前就
已追加到存储里。
这些事件
| 分组 | 变体 |
|---|---|
| 会话 | SessionUpdated、SessionClosed |
| 回合 | TurnStarted、TurnRetrying、TurnUsage、TurnCompleted |
| 条目 | ItemStarted、ItemDelta、ItemUpdated、ItemCompleted |
| 队列 | QueueChanged |
| 提问 | InteractionOpened、InteractionResolved、InteractionCancelled |
| 意图 | IntentAck |
| 历史 | Compacted、Rewound |
| 配置 | ConfigChanged、CatalogChanged |
| 插件 | Notice、Extension、Signal |
| 传输 | Lagged |
其中四个是瞬时的:ItemDelta、Notice、Signal 和 Lagged 会占一个实时的
seq,但绝不写进日志。因此一次回放是实时流的一个子序列,而客户端要求的是单调递增的
序号,不是没有空档的序号。
这些条目
Item 是对话记录里一个持久的单位。
pub struct Item {
pub id: ItemId,
pub turn: Option<TurnId>,
pub round: u32,
pub status: ItemStatus,
pub started_at: Timestamp,
pub completed_at: Option<Timestamp>,
pub intent: Option<IntentId>,
pub body: ItemBody,
pub meta: Map<String, Value>,
}
ItemBody 是这个条目究竟是什么:User、Assistant、Reasoning、ToolCall、
Action、Compaction、Rewind、Interruption、Notice、QuestionAnswer、
PermissionReceipt 以及另外几个。一个 ToolCall 带着它的 call_id、它的输入、有的
话还有它的 ToolOutput、运行期间的进度尾巴,以及它的耗时。
一份日志之上的两个 reducer
SessionState::apply(&Frame)产出客户端视图。它就是内核为自己的快照所跑的那个 reducer,所以任何客户端的视图都等于apply(snapshot, frames since snapshot.seq)。ContextView::fold(frames)产出发给提供方的消息。它是系统里最吃重的函数,每个 日志版本都有一个 golden 测试。
两者都不会改写历史。压缩和倒回都是事件——
Compacted { boundary, kept, summary }、Rewound { to_turn, dropped }——而日志的头部
带着一个 version,所以格式变更是一个迁移器,绝不是就地编辑。
SessionState 装着按顺序排的对话记录、活跃的回合、队列、打开着的交互、上下文用量、
配置视图,以及两张插件自有状态的映射——extensions(持久,每个 kind 的最新
Extension 载荷)和 signals(瞬时,恢复之后就没了)。
写操作什么也不返回
fn submit(&self, intent: IntentId, input: Input);
fn interrupt(&self, intent: IntentId, scope: InterruptScope);
fn answer(&self, intent: IntentId, interaction: InteractionId, answer: Answer, activation: Activation);
三者都接受一个客户端生成的 IntentId,它同时也是幂等键,而且都返回 ()。结果作为
Event::IntentAck { intent, outcome } 到达。一个同步的按键处理器永远不可能等一张
回执,因为根本没有回执。
一个等过的 intent 会被确认两次——加入队列时 Queued,它的回合打开时 TurnStarted
——这样客户端不必去匹配条目就知道哪个回合是自己的。
id
SessionId、TurnId、ItemId 和 InteractionId 是 ULID,由 actor 生成一次并持久化;
它们绝不会在重启时重新生成。IntentId 由客户端来生成。
背压由内核来宣告
每个订阅者有一条有界通道。溢出时内核发出 Lagged { from, to },客户端用
events_since(seq) 重读。内核绝不会为某个客户端阻塞。
模型流不出这个循环
ModelEvent 镜像提供方那套流式分块代数——每个块的 id,文本、推理和工具输入的
start/delta/end,一个带着用量以及统一的和原始的两种结束原因的 Finish,还有按提供方
id 归类的提供方元数据。累加器在回合循环内部把它折成一个个 Item。只有 Item 和
Event 会被发布出去,所以没有任何客户端去解析某个提供方的方言。
会话、子代理与房间
子代理是带一条 parent 链接的会话;房间是没有模型的会话。没有第二个名词,也没有第二
个 reducer。
用 children: true 打开一个会话,这个附着就会从它的头部起承载每一个活着的后代的帧,
包括之后才创建的后代,每一帧都盖着它自己的 session。树里任何地方的滞后都在内核里被
治好——从最后一个转发出去的 seq 重新订阅——所以一条树流绝不会带着 Lagged 标记。在
wire 上,一个在树附着下通知出去的事件还带着 root,也就是它是经由哪个会话打开的,而
这正是客户端拿来路由的东西(ADR-0010)。
子会话的 SessionSummary.parent.item 指明是哪一次工具调用把它生出来的。那条链接在工具
调用自身上没有第二份副本:客户端从它已经持有的帧里推出来。
插件状态以数据的形式旅行
插件自有的资源——一份名册、一个房间、一张任务清单——以
Event::Extension { plugin, kind, payload } 的形式旅行,进日志,其中最新的载荷就是那个
kind 状态的全部(ADR-0011)。那些不该让日志花掉一分钱的实时状态改以 Event::Signal
旅行:绝不写下,按 (plugin, kind) 以最新载荷折进 SessionState.signals,由一个
Null 载荷移除,恢复之后不再存在。一条每秒更新十次的进度条是免费的。
内核两者都不枚举。界面拿它们做什么,是 UI 即数据。
自己去读它
bingo --print --output-format json在 stdout 上一行写一个Frame。bingo serve --stdio把每一帧原样作为一个event通知出去——见 Schema 与协议。
两者都是终端界面所折叠的同一批帧。