跳转到内容

Tape 与 context

本页解释 Bub 使用的 context 模型:每个 session 一条 append-only tape、标记重建点的 anchor、改变阶段的 handoff,以及把 tape entry 转成模型消息列表的 context selector。

更深入的模型见 tape.systems。本页只覆盖 Bub 的具体实现;需要完整理论时请查阅上游模型。

Bub 复用了 tape 模型的四个原语:

  • Tape — 单个 session 的事实序列,按时间排列。
  • Entry — tape 上的一条不可变记录(message、tool call、tool result、event、anchor)。
  • Anchor — 携带结构化 state 负载的检查点。可以从 anchor 重建 context,无需重扫整条 tape。
  • View — 由 entry 派生的、面向具体任务的 context window。

恒守三条不变式:

  1. 历史是 append-only —— entry 永不被覆写。
  2. 派生物从不替换原始事实 —— 摘要是派生 view,而非编辑。
  3. context 是构造出来的,而不是整体继承 —— 每次 turn 都派生一份新的 view。

anchor 不是 删除点。tape 完整保留 anchor 之前的所有内容;anchor 只告诉 context 构造从哪里开始重建。anchor 的 state 负载提供下一阶段所需的最小继承状态。

handoff 是受约束的过渡:写入新 anchor、附上最小继承 state、把执行起点推过新 anchor。在 Bub 中,调用 handoff 时给出名字与可选 state dict;结果是同一条 tape 上新增一条 anchor entry。

每个 session 对应一条 tape。Bub 用 workspace 路径与 session id 计算 tape 名(TapeService.session_tape):

workspace_hash = md5(str(workspace.resolve()).encode()).hexdigest()[:16]
session_hash   = md5(session_id.encode()).hexdigest()[:16]
tape_name      = f"{workspace_hash}__{session_hash}"

这意味着同一个 session_id 在不同 workspace 会得到不同 tape,两个仓库不会互相串 state。

默认的 provide_tape_store 返回根目录在 ~/.bub/tapes/FileTapeStore。插件通过提供自己的 provide_tape_store(例如 SQLite 或 HTTP 后端)来替换它。

builtin spill 插件通过 provide_tape_sidecars 挂载 SpillStore、注册 spill.read,并通过现有 after_tool_call hook 限制大结果。sidecar 本身没有工具结果拦截约定。较大的结果存放在名为 <session-tape>__spill 的 sibling tape 中。sidecar 与 session tape 使用同一个 TapeStore,因此现有存储插件不需要实现 spill 专用接口。Bub 依次写入 UTF-8 安全的 chunk,最后写入 manifest;manifest 是该结果已完整存储的提交标记。

主 tape 只保留有界预览和 opaque handle,不保存完整结果。spill.read 工具按 handle 与 cursor 有界读取,也支持从末尾开始读取。由 spill.read 明确返回的内容会作为普通的有界 tool result 记录。

sidecar 与 session tape 一起参与 fork、merge、archive 和 reset,但始终是独立 tape,构造主 context 时不会扫描它。也可以通过 Tape.archive_sidecar("spill")Tape.reset_sidecar("spill") 单独 archive 或 reset sidecar。当 reset 要求先 archive 时,如果 archive 失败,Bub 会保留 sidecar。

spill 配置归 sidecar 插件自己所有:在 config.yml 使用 spill.threshold,或设置环境变量 BUB_SPILL_THRESHOLD。设为 0 只停止新写入,不会卸载 sidecar,因此已有 handle 仍可读取,tape 生命周期操作也仍会包含它。

spill 写入以 spill.write event 记录。框架管理的生命周期结果使用 sidecar.archivesidecar.resetsidecar.merge,其数据会标明受影响的 sidecar 或物理 tape。它们会在任何 context selector 运行前被排除,因此可用于运维和审计,同时不会改变模型消息或 prompt cache 前缀。sidecar 持久化失败会被记录,但不会阻止主 tape merge 或 reset。

在某条 tape 的第一次 turn 之前,TapeService.ensure_bootstrap_anchor 会检查是否存在 anchor entry。如果没有,则写入一条 session/start handoff,state={"owner": "human"}。这保证每条 tape 在 context 重建时都有起始 anchor。

default_tape_context:entry → OpenAI 消息

Section titled “default_tape_context:entry → OpenAI 消息”

default_tape_context() 构造一个 TapeContext,其 select 函数(_select_messages)遍历 tape entry 并产出 OpenAI 兼容的消息。映射规则如下:

  • anchor → 形如 [Anchor created: <name>]: <state-as-json>assistant 消息。
  • message → 直接以 entry payload 作为 chat 消息 dict。
  • tool_call → 带空 content 与 tool_calls 数组的 assistant 消息。
  • tool_result → 每个 result 一条 tool role 消息;若前面有 pending tool_call,Bub 会按 result 位置复制对应 call 的 idtool_call_id

context selector 本身是个 hook(build_tape_context),插件可以用其他策略(压缩、摘要、检索)替换它,而无需触动 pipeline 其余部分。

带有 context=False 标记的 entry 会在 selector 运行前被移除。spill 运维 event 因此仍可在 tape 上查询,但不会进入默认 context 或插件自定义 context。

TapeService.fork_tape(tape_name, merge_back=True) 是由 ForkTapeStore.fork 支撑的 async context manager。块内的写入发生在 fork 后的 tape 上;退出时合并回父 tape(若 merge_back=False 则丢弃)。可以用它跑子任务,避免污染父 session 的历史,直到决定保留结果。

内置 Agent 循环会捕捉匹配 _is_context_length_error 的模型错误(如 context lengthmaximum contexttoken limitprompt too long)。当此类错误触发且 MAX_AUTO_HANDOFF_RETRIES(当前为 1)尚未耗尽时,循环会:

  1. 写入名为 auto_handoff/context_overflow 的 handoff,state={"reason": "context_length_exceeded", "error": <message>}
  2. 记录 loop.step 事件,status="auto_handoff"
  3. 用同一 prompt 重试 —— 默认 TapeContext 会选择最新 anchor 之后的 entry,因此重试使用更短的重建历史。

每次 turn 仅重试一次。如果 prompt 或保留 state 仍然过大,重试仍可能失败;此时错误会正常抛出。

  • Surfaces — channel 与 tool 与 tape 的关系。
  • Turn pipeline — context 构造在一次 turn 中的位置。
  • tape.systems — 更深入的模型与参考资料。