跳至正文
浏览章节

事件流

一个帧类型、一份日志、两个纯 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 与协议。

两者都是终端界面所折叠的同一批帧。