前言
为什么写这本书
AI Agent 正在从演示走向生产。但多数 Agent 框架的源码读起来像一组脚本:字符串拼接的 prompt、宽松的错误处理、随进程重启即消失的状态。做原型时这不碍事;一旦你要求 Agent 长驻运行、执行 Shell 与文件操作、同时服务多个租户,缺的就不是某个功能,而是操作系统级别的基础设施:权限边界、并发调度、可恢复的执行账本、可审计的协议。
octos 是一个用 Rust 构建的 AI Agent 操作系统。本书写作基线为主分支约 70 万行 Rust 源码、26 个 crate 的工作区,workspace 级 deny(unsafe_code)。它不是某种语言重写的调用链封装,而是从第一行代码起就为多租户隔离、生产可靠性、可扩展并发设计的系统:Provider 容错链自带凭据轮换与断路器(详见第 3 章),沙箱与权限走 fail-closed(详见第 7 章),长程任务有事件账本与续跑调度(详见第 12 章)。
第二版把全书从 14 章扩展到 21 章,并新增第四部分「双环」:外环(一个监督用的强模型与运维面)如何驱动内环(干活的一群模型)。octoscode 终端客户端、OctoLoop 外环协议 OLP v2、herdr 运维工具三章合起来回答一个问题——当便宜模型在内环干活时,谁来验收、谁在出错时接管。
这本书不是用户手册。它是一本工程决策解析:每章深入一个子系统,展示为什么这样设计、考虑过哪些替代方案、付出了什么代价。所有论断都给出源码引用,读者可以逐条到仓库里核对。
阅读准备
前置知识
- 必备:熟练使用一门主流编程语言(Python、Go、Java、C++ 皆可),能读懂代码,了解 HTTP 与 JSON。
- 有帮助但不强制:Rust 基础语法。第 2 章会用到 trait 与枚举,第 12 章会用到 async/await。不熟 Rust 的读者不必却步:第 1-2 章特意放慢节奏,语言相关的细节在侧栏与注释里就地解释,跳过不影响主线。
- 有帮助但不强制:LLM API 的基本概念(token、上下文窗口、工具调用)。没调用过 LLM API 也能读:第 3 章从 Provider 抽象讲起,概念随用随定义。
- 不需要:机器学习数学、模型训练经验、编译器原理、操作系统内核开发经验。
推荐阅读路径
路径 A:Rust 初学者(借一个真实的大型项目入门 Rust 与 Agent 工程)
Ch1 → Ch2 → Ch5 → Ch6 → 选读 先在 Ch1 理解为什么选 Rust,Ch2 看类型系统如何定义领域语言,Ch5 跟完一次对话的完整生命周期,Ch6 读工具派发。之后按兴趣选读:Ch8(上下文压缩)、Ch12(并发模型)都是好去处。给自己留出适应借用检查器的前两周。
路径 B:资深 Rust 开发者(关心大型 Rust 系统的架构与并发实现)
Ch1 → Ch3 → Ch12 → 深入 Ch1 的 workspace 拓扑与选型论证是全书的地图;Ch3 看 trait object 在 Provider 抽象上的取舍;Ch12 的三层并发调度(Tokio 层、supervisor 层、peer/lease 层)是本书并发叙事的核心。之后按方向深入:安全看 Ch7 与 Ch10,平台看 Ch14 与 Ch15,多 agent 内核看 Ch16-18。
路径 C:AI 应用开发者(从会调 API 到理解运行时内部)
Ch1 → Ch5 → Ch6 → Ch14 → Ch19 → Ch20 → Ch21 Ch5-6 回答「我发的消息到底经历了什么」,Ch14 讲清运行模式与配置后,直接进入双环部分:octoscode 客户端(Ch19)、外环协议 OLP v2(Ch20)、herdr 运维实务(Ch21)。Ch18 的 goal/peer 是 Ch20 的服务端前提,时间允许先读。跳过实现细节侧栏不影响理解。
路径 D:octos 贡献者(要给仓库提第一个 PR)
全书按序 + 附录 A 与附录 E 附录 A 的 26 crate 依赖图(63 条边)帮你定位改动影响面,附录 E 给出从源码构建与贡献流程。第 10 章的契约测试与事件 ABI 是多数 PR 绕不开的接口。
全书知识地图
graph LR
subgraph P1["第一部分 地基"]
C1["Ch1 为什么是 Rust"]
C2["Ch2 octos-core 类型"]
C3["Ch3 octos-llm"]
C4["Ch4 octos-memory"]
C1 --> C2
C2 --> C3
C2 --> C4
end
subgraph P2["第二部分 引擎"]
C5["Ch5 Agent Loop"]
C6["Ch6 工具系统"]
C7["Ch7 安全纵深"]
C8["Ch8 上下文管理"]
C9["Ch9 扩展机制"]
C10["Ch10 Harness"]
C5 --> C6 --> C7
C5 --> C8
C6 --> C9
C5 --> C10
end
subgraph P3["第三部分 平台"]
C11["Ch11 octos-bus"]
C12["Ch12 并发模型"]
C13["Ch13 pipeline"]
C14["Ch14 运行模式"]
C15["Ch15 生产化"]
C16["Ch16 Fleet"]
C17["Ch17 Swarm"]
C18["Ch18 Goal 与 Peer"]
C5 --> C11 --> C14 --> C15
C10 --> C12
C12 --> C16 --> C17
C12 --> C18
end
subgraph P4["第四部分 双环"]
C19["Ch19 octoscode"]
C20["Ch20 OctoLoop"]
C21["Ch21 herdr"]
C18 --> C20
C19 --> C20 --> C21
end
subgraph APX["附录"]
A1["A Crate 依赖图"]
A2["B 工具速查"]
A3["C 配置参考"]
A4["D Feature Flags"]
A5["E 构建与贡献"]
A6["F OLP 速查与 E2E 追踪"]
end
C3 --> C5
C4 --> C5
C15 -.-> A1
C19 -.-> A5
C20 -.-> A6
两个阅读提示。其一,Ch5(Agent Loop)是全书枢纽:前四章是它的地基,第 6-10 章是它的展开,第 12、16、18 章的执行内核都以它为基准变体。其二,第三部分内部有两条线:Ch11-15 讲平台基础设施(消息、并发、工作流、配置、生产化),Ch16-18 讲多 agent 内核(Fleet、Swarm、Goal/Peer);第四部分(Ch19-21)站在外环视角回看全书,第 20 章依赖第 18 章的服务端机制。
阅读标记说明
- 源码引用:全路径加行号,如
crates/octos-agent/src/agent/execution.rs:2483,可在源码仓库直接定位。仓库持续演进,行号会漂移,因此正文同时给出符号名(函数、类型、常量);行号对不上时以符号名为锚。 - 三层标注体系(第 20 章引入):描述一个机制时标注它由哪一层强制——协议条款(纯文档)、Markdown 约定(黑板、ACK 等无运行时代码的约定)、契约测试(对快照或真实子进程的机械校验)。这一区分最初用于外环协议 OLP v2,回读第三、四部分时同样适用:知道「谁在强制这条规则」才能判断违约时会发生什么。
- 工程决策侧栏:每章一个引用块(
>开头),比较 2-3 种替代方案的利弊,说明最终取舍的代价。 - 版本演化说明:每章末尾交代该子系统的机制如何随版本变化,帮助读者对照手头版本的差异。
- 章首定位:每章开头的一段引用块,写明本章前置依赖与适用读者,可据此快速判断是否跳读。
- Mermaid 图:架构图、状态机、时序图穿插正文,均可在支持 Mermaid 的阅读器中渲染。
- 思考题:每章末 2-5 道开放式问题,适合团队讨论或自测。
本书基准
版本基线:octos 主仓 main @ 9c157101(2026-09-02 统计)、octoscode @ 1129fa33、herdr feat/octoscode-agent 分支 @ fefe5c4f。全书规模与行号数字(26 crate、约 70 万行 Rust、63 条依赖边、各模块行数)取自各章事实表(assets/chNN-facts.md),逐条附复现命令,可在对应基线上核对。第 19-21 章涉及的三个仓库迭代很快,行号漂移属正常现象,引用以符号名为准。
愿这本书帮你建立的不只是对 octos 的理解,还有一套评估 Agent 基础设施的尺子。读任何 Agent 系统时,带着四问:
- 安全边界在哪:哪个进程、哪条系统调用、哪行代码在强制?
- 谁在强制协议:编译器、运行时、契约测试,还是仅有一份文档?
- 状态能否恢复:崩溃之后,重启到哪个断点,丢掉哪段工作?
- 错误去了哪里:被吞掉、被记录、被上报,还是被静默重试?
第 1 章:为什么是 Rust?为什么是 Agent OS?
定位:本章是全书开篇,回答两个根本问题:多租户 AI Agent 平台究竟难在哪里?为什么 octos 用 Rust 写、又为什么长成今天这个 26 个 crate 的形状?前置依赖:无。适合全部四类读者:A(Rust 初学者)能借此建立「Rust 在真实大型系统里解决什么问题」的直观图景;B(资深 Rust 开发者)可以直接跳到 1.3 节看 workspace 分层与依赖拓扑;C(AI/LLM 应用开发者,不要求 Rust 背景)重点读 1.1 的问题空间,即使最终选择 Python,这三大挑战也绕不过去;D(贡献者)请特别留意 1.3.3 的三处事实澄清,它们是读懂仓库结构的前提。
当你第一次打开 octos 的源码仓库,ls crates | wc -l 会给出 26;find crates -name '*.rs' | xargs wc -l | tail -1 会给出 700,915 行 Rust;根 Cargo.toml 的 members 列表有 38 个成员。这里躺着一个消息总线(17 个频道源文件)、一个 Agent 运行时(crates/octos-agent/src/tools/ 下 59 个工具源文件)、一个 DOT 驱动的流水线引擎、一个 Fleet 计划执行内核,以及供 Python/Swift/Kotlin/浏览器/Node 嵌入的整条绑定链。看到这些,从 LangChain 或 AutoGen 过来的开发者第一反应是质疑语言选型;看到满屏的 tokio::spawn 与 Arc<Mutex<_>>,Go 开发者则会怀疑 goroutine 是不是更省事。
这不是语言品味之争。当你把「AI Agent」从单用户玩具推向多租户生产平台,你面对的是一组相互纠缠的工程约束——安全隔离、并发、性能。任何一条单独看都不难,三条同时成立时,语言与工程组织的选型就从偏好问题变成了架构决策。本章先讲清楚这三大挑战(1.1),再论证为什么 Rust 是当前对这组约束最合适的答案(1.2),然后展开 octos 的 workspace 拓扑,为后续 20 章建立全局地图(1.3)。本章所有规模数字都标注了统计口径,均可按第 1.3.1 节给出的命令在源码仓库逐条复现。
1.1 问题空间:多租户 AI Agent 平台的三大挑战
octos 不是 chatbot 框架,而是一个多租户 AI Agent 操作系统:同一份进程要为多个用户、多个 Agent 实例服务,而每个 Agent 都能调用带副作用的工具:执行 Shell、读写文件、发起网络请求、再派生子 Agent。三大挑战正是从这里生长出来的。
1.1.1 挑战一:安全隔离,59 个工具源文件就是 59 个攻击面入口
设想一个真实场景:租户 A 的 Agent 在总结一篇网页时被 prompt 注入,恶意指令试图读取租户 B 的会话历史,或者执行 rm -rf /。在多租户环境里这不是理论风险。octos 的攻击面可以从事实表直接量化:crates/octos-agent/src/tools/ 下有 59 个工具源文件(口径:ls crates/octos-agent/src/tools/*.rs | wc -l)。注意这是源文件数,不是工具数,其中包含 crates/octos-agent/src/tools/mod.rs、crates/octos-agent/src/tools/registry.rs、crates/octos-agent/src/prompt_guard.rs 这类框架文件。
每个真正暴露给 LLM 的工具(shell、browser、web_fetch、write_file、spawn 等)都是一条独立的攻击路径。再加上 crates/octos-bus/src/ 下的 17 个 *_channel.rs 频道源文件(Telegram、Discord、Slack、WhatsApp、飞书、邮件、Matrix、企业微信、钉钉、QQ Bot、Twilio、Line 等;其中 crates/octos-bus/src/api_channel.rs 与 crates/octos-bus/src/cli_channel.rs 是两个内部通道,并非全部对外),平台在物理上就是「一张接满了外部消息网络的、能执行代码的图」。每接入一个频道,就多一个入站消息的信任边界;每注册一个工具,就多一个出站副作用的通道。
Agent 场景的安全隔离比传统 Web 服务难,原因有三:
- 工具调用是核心能力而非旁路。Web 服务的危险操作通常收敛到少数几个 handler;Agent 的每一次循环迭代都可能触发任意工具组合,且调用序列由 LLM 生成、不可预先枚举。防御必须下沉到「每次调用」的粒度,octos 因此在工具层实现了 deny-wins 的策略引擎与审批流(详见第 6、7 章)。
- Prompt 注入是新型攻击向量。它发生在自然语言层面,WAF 式的正则规则基本失效。攻击载荷可以藏在一篇被
web_fetch抓回来的文档里,再借工具调用落地为文件写入或命令执行。 - 隔离边界必须是进程级的。语言层的权限检查只能挡「合法 API 的滥用」,挡不住 Shell 命令本身。octos 把真正的沙箱子系统放在
octos-agent内部的crates/octos-agent/src/sandbox/(六个文件:crates/octos-agent/src/sandbox/bwrap.rs、crates/octos-agent/src/sandbox/docker.rs、crates/octos-agent/src/sandbox/landlock.rs、crates/octos-agent/src/sandbox/macos.rs、crates/octos-agent/src/sandbox/windows.rs、crates/octos-agent/src/sandbox/mod.rs),分别对接 Linux 的 bubblewrap/Landlock、Docker、macOS 的 sandbox-exec 与 Windows 的 AppContainer(crates/octos-agent/src/sandbox/mod.rs:1-23)。第 7 章会逐个拆解。
这三层防御(语言内存安全 → 工具策略 → 进程沙箱)能成立的前提,是承载它们的运行时本身不引入新的内存安全漏洞。这正是 1.2 节语言选型的第一个约束。
1.1.2 挑战二:并发,同一进程里的三重并发面
第二个挑战同样可以量化。Gateway 模式下,17 个频道的入站消息同时到达;octos 用一个 tokio::sync::Semaphore 把并发会话数压在配置上限内(默认 10,crates/octos-cli/src/config.rs:1633-1635),信号量在 Gateway 启动时创建(crates/octos-cli/src/commands/gateway/gateway_runtime.rs:1731-1732):
#![allow(unused)] fn main() { // Semaphore to bound concurrent session processing let concurrency_semaphore = Arc::new(Semaphore::new(gw_config.max_concurrent_sessions)); }
但「限流」只是并发的第一重。第二重在 Agent 循环内部:单次迭代里 LLM 可能一次下发多个工具调用,octos 把每个调用 tokio::spawn 成独立任务再 join_all 聚合、保持调用顺序(crates/octos-agent/src/agent/execution.rs:598 的 spawn_tool_task 与 :2483 的 execute_tools)。第三重在进程外:后台 Cron/Heartbeat 定时唤醒会话、子 Agent 同步阻塞或后台异步双模式、fleet worker 独立进程消费任务,它们共享同一套会话状态与消息总线。
并发本身不是问题,并发中的正确性才是:
- 会话级串行化:同一会话的两条消息不能并发改写状态,需要 per-session 锁序;
- 工具级并行:同一批次内互不依赖的工具必须并行,否则延迟线性叠加;
- 优雅关停:收到 SIGTERM 时不能腰斩进行中的对话,octos 用
AtomicBool标志位贯穿信号处理与循环预算检查; - 无共享不误会:派生出的任务(spawned task)不允许借用栈上状态,一切跨
.await与跨任务的数据都必须显式所有权转移或Arc。
这四条里任何一条写错,症状都不是「崩溃」而是「偶发的、无法复现的数据错乱」。在生产多租户平台上,这是最贵的一类故障。
1.1.3 挑战三:性能,LLM 慢不代表框架可以慢
「LLM 调用都要几秒钟,框架性能无所谓」是 Agent 工程里最流行的误解。它错在三个地方:
第一,延迟是乘性的。 一次 Agent 执行可能跑几十次迭代,每次迭代都有消息构建、上下文压缩、工具调度。框架层每迭代多 50ms,几十次迭代就是肉眼可见的卡顿。在流式场景里,这直接决定「打字机手感」的成败。
第二,内存是多租户的硬约束。 每个会话都持有对话历史、工具状态与上下文窗口。运行时本身每会话的开销乘上并发会话数,就是必须提前规划的容量:解释型运行时的每会话基线开销通常显著高于编译型,具体差多少要按目标并发数实测容量,100 个并发会话时这笔差值直接决定还能不能再挤进一个容器。
第三,热路径在框架里,不在 LLM 里。 SSE 流式解析、消息频道的分片切割、上下文压缩的截断、工具输出的尺寸估算,这些都在每次迭代里执行。octos-core 里有一个典型的小函数 truncate_utf8(crates/octos-core/src/utils.rs:6-16):
#![allow(unused)] fn main() { pub fn truncate_utf8(s: &mut String, max_len: usize, suffix: &str) { if s.len() <= max_len { return; } let mut limit = max_len; while limit > 0 && !s.is_char_boundary(limit) { limit -= 1; } s.truncate(limit); s.push_str(suffix); } }
它做的事很小:在 UTF-8 字符边界安全截断。但它出现在消息裁剪、频道分片、工具输出限幅等所有热路径上。注意它接收 &mut String 原地截断而不是返回新分配的 String:在每条消息都可能被截断的路径上,省掉的是每次迭代里成千上万次的堆分配。这类「不惊人但无处不在」的开销,恰恰是框架性能的主体。
三大挑战讲完了。它们的共性是:都不能靠「写代码时小心」解决,必须靠语言与组织的结构性保证。这就引出语言选型。
1.2 语言选型:Python、Go 与 Rust 的四维对比
octos 的候选集其实只有三个:Python(LangChain/AutoGen 生态最厚)、Go(云原生并发最顺手)、Rust(安全与性能的上限最高)。下面按安全、并发、性能、生态四个维度逐一对照,结论先行:Rust 赢在前三维,输在第四维,而第四维的缺口可以用工程手段弥补。
1.2.1 安全维度:内存安全是防御纵深的第零层
1.1.1 节的三层防御中,最底层是「运行时自身无内存安全漏洞」。一个用 C/C++ 写的 Agent 运行时,沙箱做得再好,也可能在解析恶意构造的 SSE 分片或 WebSocket 帧时自己先倒下,因为解析不可信输入正是内存安全漏洞的高发区。octos 的做法很直接:workspace 根 Cargo.toml 统一 lint(Cargo.toml:50-51):
[workspace.lints.rust]
unsafe_code = "deny"
一行配置,26 个目录、38 个成员全部继承,unsafe 在整个代码库被编译器拒绝(除非逐 crate 显式豁免)。Python 与 Go 在这一层各有姿态:Python 解释器由 C 实现,历史上多次出现解析恶意输入导致的内存安全 CVE;Go 自带内存安全,但 unsafe 包与 cgo 交界处同样存在失守面。Rust 的差异在于把「无 unsafe」变成可机器验证的全库不变量,而不是编码规约。
类型系统的另一重价值是「让非法状态不可表示」。Agent 领域最容易出 bug 的地方(消息角色的枚举、任务状态的迁移、工具结果的形状)在 octos-core 里都是穷尽匹配的 enum,漏一个分支编译不过。这比「运行时校验 + 测试覆盖」的防线提前了整整一个阶段。
1.2.2 并发维度:把数据竞争消灭在编译期
先看三段语言语义的对照(示意代码,非 octos 源码):
// Go:两个 goroutine 并发自增。编译通过,数据竞争潜伏
go func() { counter++ }()
go func() { counter++ }()
# Python:CPython GIL 使线程无法并行执行 CPU 工作
threads = [threading.Thread(target=process_chunk) for c in chunks]
#![allow(unused)] fn main() { // Rust:同样的写法无法通过编译,借用在跨线程时必须显式 s.spawn(|| counter += 1); // ❌ error: closure may outlive borrowed value }
Go 的 goroutine 模型写起来最省事,但正确性依赖纪律与 -race 检测。race detector 基于 happens-before 算法,本质上是测试期工具,覆盖不到的路径不受保护。Python 的 GIL 则直接封死了「单进程多线程并行」这条路,asyncio 能处理 I/O 并发但一个同步调用就会卡住整个事件循环,多进程方案的序列化开销又与 1.1.2 要求的细粒度共享状态模型相抵触。
Rust 的答案是把并发正确性编码进类型系统:Send(可跨线程转移)与 Sync(可跨线程共享)两个 marker trait 由编译器自动推导,跨 .await 持有非 Send 的借用会直接编译失败。1.1.2 节那四条正确性要求(会话串行化、工具并行、优雅关停、任务不借用栈状态)在 Rust 里每一条都有对应的编译期保障形态。代价是学习曲线,收益是整类故障从生产事故清单上消失。
1.2.3 性能维度:无 GC 的确定性延迟
Agent 框架的性能画像很特殊:大量短生命周期分配(消息、分片、工具结果)、长驻任务(会话、频道连接)、以及必须平滑的流式路径(SSE 解析逐 token 转发)。GC 语言在这种画像下的典型症状是:停顿本身不频繁,但停顿发生在哪次迭代里不可预测:今天压测 p99 很好,明天某条会话的长对话触发大回收,流式输出肉眼可见地卡一下。
Rust 无 GC:分配与释放跟随着所有权在编译期确定,Drop 析构让文件描述符、子进程句柄、锁的释放都确定性地发生。1.1.3 节的 truncate_utf8 已经展示了「避免分配」的写法;再往上一层,octos-agent 的工具 trait 签名(crates/octos-agent/src/tools/mod.rs:609-642,节选):
#![allow(unused)] fn main() { #[async_trait] pub trait Tool: Send + Sync { fn name(&self) -> &str; fn description(&self) -> &str; fn input_schema(&self) -> serde_json::Value; async fn execute(&self, args: &serde_json::Value) -> Result<ToolResult>; } }
注意 Tool: Send + Sync 这个约束,它是 1.2.2 节的编译期并发保证在工具系统上的落点:任何工具实现想被注册进注册表并在多会话间共享,编译器就强制它证明自己线程安全。性能与并发两个维度在这里合流:同一个类型系统约束,同时买到了并行安全与零开销抽象。
1.2.4 生态维度与选型代价
诚实地说,生态是 Rust 的相对短板:LangChain/AutoGen 的教程与现成集成更丰富,Python 是 LLM 事实上的「胶水语言」;Go 在云原生基础设施(K8s 生态、指标、部署工具链)上更成熟。octos 的应对是把生态问题转化为架构问题。这解释了 1.3 节拓扑里的两条链。
第一条链向外接语言生态:octos-ffi(C-ABI)之下挂 octos-pyo3 / octos-uniffi / octos-wasm,让 Python、Swift/Kotlin、浏览器以原生绑定嵌入 octos,Python 生态的用户不必放弃 Python。
第二条链接能力生态:octos-plugin 让外部能力以 manifest + 二进制协议接入,app-skills 14 个能力二进制干脆零 octos-* 依赖、完全独立演进。Rust 只负责必须由 Rust 负责的核心,边缘留给各个生态自己。
选型的代价同样要写明:学习曲线陡(借用检查器对新人不友好,读者 A 请预期前两周的挫败感);编译时间长(全量构建 70 万行代码需要耐心,增量构建与 26 crate 的拆分正是缓解手段);部分领域库(如某些新兴协议的 SDK)需要自己写而不是 pip install。这些代价换来的是上面三个维度的结构性保证。对「多租户 + 可执行代码 + 长驻进程」这三类工作负载,Rust 的编译期保证收益大于生态成本。
1.3 Workspace 拓扑:26 个 crate 的八层地图
语言解决「怎么写得对」,workspace 解决「怎么长得对」。octos 用一个 Cargo workspace 组织 26 个顶层目录,本节先立统计口径,再按依赖方向推导分层,然后澄清三处最容易搞错的事实,最后给出完整的依赖拓扑图。
1.3.1 规模基准与统计口径
本章所有规模数字以源码仓库 main 分支 commit 9c157101(2026-09-02 统计)为准,逐条可复现:
| 指标 | 值 | 口径与命令 |
|---|---|---|
| crate 目录总数 | 26 | ls crates | wc -l;= 23 个 octos-* crate + app-skills + platform-skills + octos-web 三个目录 |
| Rust 总行数 | 700,915 | find crates -name '*.rs' | xargs wc -l | tail -1;仅统计 .rs 文件,octos-web 计 0 |
| workspace 成员数 | 38 | 根 Cargo.toml members 列表长度;26 目录中 app-skills/platform-skills 是多 crate 目录(各含 14/1 个成员),23+14+1=38,octos-web 不是成员 |
| 消息频道源文件 | 17 | ls crates/octos-bus/src/*_channel.rs | wc -l |
| 工具源文件 | 59 | ls crates/octos-agent/src/tools/*.rs | wc -l;是文件数不是工具数(含 crates/octos-agent/src/tools/mod.rs、crates/octos-agent/src/tools/registry.rs、测试等框架文件) |
26 与 38 的关系值得单独强调:目录数 ≠ 成员数 ≠ 核心库数。crates/app-skills/ 没有顶层 Cargo.toml,它是一个装着 14 个独立二进制 crate 的目录;platform-skills/ 装 1 个(voice);octos-web 连 Rust 都没有。把这四个数字混为一谈,是外部读者对 octos 结构最常见的第一重误解。
1.3.2 八层分层:L0–L7 加一层「能力层」
分层由依赖方向推导得出:每个 crate 的层 = 1 + max(其 [dependencies] 中所有 octos-* 依赖的层),零内部依赖者为 L0。按此规则,26 个目录落入 8 层(L0–L7)加一个不计层数的能力层:
- L0 基础层:
octos-core(核心类型、任务模型与协议,22,313 行,全库的领域语言)、octos-plugin(插件 SDK:manifest 解析、发现、门控,5,165 行)、octos-sandbox(1,468 行;注意它是平台助手二进制,见 1.3.3)。 - L1 原语层(只依赖 core):
octos-bus(消息总线、17 个频道、会话管理,42,767 行)、octos-llm(Provider 抽象,35,087 行)、octos-memory(情景记忆,6,428 行)、octos-diagnostics(诊断与更新规划,2,243 行)、octos-store(自包含持久化存储,2,664 行)、octos-fleet(Fleet 内核存储:事务记录 + 单写事务 CAS + 恢复对账,16,888 行)、octos-wasm(浏览器端协议绑定,883 行)。 - L2 运行时层:
octos-agent(Agent 运行时、工具执行与协调,191,985 行,全库心脏,依赖 core/bus/memory/llm/plugin)、octos-services(从 CLI 抽出的支撑服务,3,223 行)、octos-embed-llama(进程内 GGUF embedding,911 行)。 - L3 编排层(直接依赖 agent):
octos-pipeline(DOT 流水线引擎,32,799 行)、octos-swarm(fan-out/sequence/pipeline 编排原语,4,980 行)、octos-dora-mcp(dora 桥兼容 re-export,11 行)、octos-fleet-worker(封闭非交互任务 worker,6,842 行)。 - L4 工作流层:
octos-workflows(依赖 pipeline,1,059 行)。 - L5 集成层:
octos-server(HTTP/WebSocket API + 会话运行时,门面薄层仅 21 行)、octos-cli(CLI 入口,307,299 行,依赖 15 个 crate,是全库汇聚点)。 - L6 嵌入核心层:
octos-ffi(C-ABI 绑定,1,372 行,依赖 cli/agent/llm/memory/embed-llama 等)。 - L7 绑定层:
octos-uniffi(Python/Swift/Kotlin,465 行)、octos-pyo3(推荐 Python 绑定,756 行)。 - 能力层(不计层数):
app-skills(14 个能力二进制,合计 12,098 行)、platform-skills/voice(1,188 行)、octos-web(前端,0 行 Rust)。它们的共同特征:零octos-*依赖、不被任何 crate 依赖,因此不参与核心库分层。
两个行数悬殊值得停下来看一眼:octos-server 只有 21 行却站在 L5,因为它是纯粹的组装门面,真正逻辑全在下层 crate;octos-cli 占全库 44% 站在 L5,因为它是「前门」,把 15 个下层 crate 的能力装配成 chat/gateway/serve 等运行模式。层反映的是依赖深度,不是重要性,更不是代码量。
1.3.3 三处事实澄清:读仓库前先纠正三个印象
外部材料(包括本书 v1 版稿)对 octos 结构有三处系统性误传,读源码前必须纠正:
澄清一:octos-sandbox 不是沙箱子系统。 它的 Cargo.toml description 是 "Platform sandbox helper for octos",1,468 行,[dependencies] 只有 clap 和 eyre,零 octos-* 依赖。它是随平台分发的辅助二进制。真正的沙箱在 octos-agent 内部:crates/octos-agent/src/sandbox/ 下六个文件(crates/octos-agent/src/sandbox/bwrap.rs、crates/octos-agent/src/sandbox/docker.rs、crates/octos-agent/src/sandbox/landlock.rs、crates/octos-agent/src/sandbox/macos.rs、crates/octos-agent/src/sandbox/windows.rs、crates/octos-agent/src/sandbox/mod.rs),覆盖 Linux bubblewrap/Landlock、Docker、macOS sandbox-exec、Windows AppContainer 与无沙箱回退(crates/octos-agent/src/sandbox/mod.rs:1-23)。谈「octos 沙箱」,指的永远是 octos-agent::sandbox。
澄清二:octos-web 不含 Rust、不是 workspace 成员。 它是 TypeScript 项目(package.json/tsconfig.json/vitest.config.ts),0 个 .rs 文件;根 Cargo.toml 的 members 里 grep 不到 octos-web。它是前端静态资源,不在 Cargo 依赖图内,拓扑图里它是孤立节点。
澄清三:不存在 harness crate。 ls crates | grep harness 零命中。harness 是 octos-agent 内部的模块:crates/octos-agent/src/lib.rs:30-31 声明 pub mod harness_errors; 与 pub mod harness_events; 两个平级模块文件。别把 app-skills/harness-starter-* 四个脚手架当成 harness 本体,它们是面向用户的起步模板,不是 harness 实现。第 10 章会专门讲 Harness。
1.3.4 依赖拓扑图:63 条边一张图
下图是整个 workspace 的依赖拓扑,63 条依赖边一条不多、一条不少,每条边都来自对应 crate Cargo.toml 的 [dependencies] 段(机器核对命令:awk '/^\[dependencies\]/{f=1;next} /^\[/{f=0} f && /^octos-/' crates/*/Cargo.toml | wc -l → 63)。方向为 A --> B 即 A 依赖 B,graph BT 自底向上读:越靠上的 crate 越靠近用户。
graph BT
subgraph L0["L0 基础层"]
core[octos-core]
plugin[octos-plugin]
sandbox[octos-sandbox<br/>平台助手二进制]
end
subgraph L1["L1 原语层"]
bus[octos-bus]
llm[octos-llm]
memory[octos-memory]
diag[octos-diagnostics]
store[octos-store]
fleet[octos-fleet]
wasm[octos-wasm]
end
subgraph L2["L2 运行时层"]
agent[octos-agent]
services[octos-services]
embed[octos-embed-llama]
end
subgraph L3["L3 编排层"]
swarm[octos-swarm]
dora[octos-dora-mcp]
fworker[octos-fleet-worker]
pipeline[octos-pipeline]
end
subgraph L4["L4 工作流层"]
workflows[octos-workflows]
end
subgraph L5["L5 集成层"]
server[octos-server]
cli[octos-cli]
end
subgraph L67["L6/L7 嵌入与绑定层"]
ffi[octos-ffi]
uniffi[octos-uniffi]
pyo3[octos-pyo3]
end
subgraph CAP["能力层 / 前端(孤立,不计层数)"]
appskills[app-skills<br/>14 个能力二进制]
platskills[platform-skills/voice<br/>能力二进制]
web[octos-web<br/>前端静态资源<br/>不在依赖图]
end
bus --> core
llm --> core
memory --> core
diag --> core
store --> core
fleet --> core
wasm --> core
agent --> core
agent --> bus
agent --> memory
agent --> llm
agent --> plugin
services --> core
services --> llm
services --> bus
embed --> llm
swarm --> agent
dora --> agent
fworker --> agent
fworker --> core
fworker --> fleet
fworker --> llm
fworker --> memory
pipeline --> core
pipeline --> agent
pipeline --> plugin
pipeline --> llm
pipeline --> memory
workflows --> core
workflows --> agent
workflows --> pipeline
server --> core
server --> agent
server --> llm
server --> bus
server --> store
server --> services
server --> workflows
server --> pipeline
server --> plugin
cli --> core
cli --> bus
cli --> llm
cli --> memory
cli --> agent
cli --> diag
cli --> store
cli --> services
cli --> workflows
cli --> pipeline
cli --> swarm
cli --> plugin
cli --> fleet
cli --> fworker
cli --> embed
ffi --> core
ffi --> agent
ffi --> llm
ffi --> memory
ffi --> cli
ffi --> embed
uniffi --> ffi
pyo3 --> ffi
读图要点:(1) octos-core 是唯一被 15 个 crate 依赖的根,它必须保持零内部依赖与极高稳定性(第 2 章的主题)。(2) octos-agent 被 8 个 crate 依赖(pipeline、swarm、dora-mcp、fleet-worker、workflows、server、ffi、cli),是事实上的第二枢纽。(3) 四个孤立节点(app-skills、platform-skills/voice、octos-web、octos-sandbox)不在 63 条边内:能力二进制与前端通过进程边界而非 Cargo 依赖接入,这正是它们「不属于核心库分层」的形式化表述。(4) 依赖全部指向下方,无环;新增任何一条向上的边都会在 cargo 那里直接报错,分层是被工具链强制的不变量。
工程决策侧栏:mono-repo workspace vs multi-repo
26 个 crate、63 条内部依赖边,摆在面前的组织方案至少有三种:
方案 A:multi-repo,每 crate 一仓。 优点:权限边界清晰、每仓可独立发版与裁剪 CI、外部贡献者 clone 面小。缺点对 octos 是致命的:63 条边意味着跨仓改动是常态而非例外:改一处
octos-core的类型定义会波及 15 个下游 crate,multi-repo 下你要开 16 个 PR、维护 16 个版本号与 16 份 lockfile,跨仓原子重构不可能,CI 还要在每仓重复编译公共依赖。语义化版本挡不住「接口变了但版本没升对」的人祸。方案 B:single mega-crate。 把全部 70 万行塞进一个 crate。优点:无跨 crate 版本问题、重构随便改。缺点同样致命:编译时间失控(任何一行改动触发全量重编);
octos-web这类非 Rust 资产无处安放;app-skills的 14 个二进制会被迫拖上全部核心依赖(它们本来零octos-*依赖);feature flag 组合爆炸,任何一个模块开错 feature 都可能污染全局编译。方案 C(octos 的选择):Cargo workspace mono-repo。 既保留了单仓的原子性(跨 crate 重构是一个 commit、一次 CI),又用 crate 边界保住了编译隔离与依赖方向:根
Cargo.toml统一members(38 项)、统一workspace.package(version/edition 2024/rust-version 1.85.0)、统一workspace.lints(unsafe_code = "deny"一处生效全库)、统一workspace.dependencies管内部 path 依赖版本。分层靠[dependencies]的方向自然成立,cargo本身就是架构守护者。附带的工程红利:能力二进制(app-skills/platform-skills)作为 workspace 成员享受统一工具链,却因零
octos-*依赖而不进入核心编译图;将来若某个 crate 需要独立演进,从 workspace 拆出也远比从 mega-crate 拆出便宜。取舍:mono-repo 的代价是仓库体积增长、CI 必须做分层缓存、外部贡献者面对的是一棵 26 目录的大树,octos 用目录分层(1.3.2)与本书的导航(1.4)来对冲。
1.4 全书导览
本书 v2 版按「地基 → 引擎 → 平台 → 双环」四部分展开,共 21 章 + 附录。编号以 v2 重写计划为准(相对 v1 有平移:原第 10 章 bus 移至第 11 章,第 10 章成为新增的 Harness 章):
| 部分 | 章 | 主题 |
|---|---|---|
| 第一部分 地基 | Ch1 | 为什么是 Rust?为什么是 Agent OS?(本章) |
| Ch2 | octos-core:用类型系统定义领域语言 | |
| Ch3 | octos-llm:驯服 LLM Provider 的混乱 | |
| Ch4 | octos-memory:混合搜索的工程实现 | |
| 第二部分 引擎 | Ch5 | Agent Loop:一次对话的完整生命周期 |
| Ch6 | 工具系统:59 个源文件背后的设计模式 | |
| Ch7 | 安全纵深:从沙箱到 Prompt 注入防御 | |
| Ch8 | 上下文管理:让 Agent 在有限窗口中高效工作 | |
| Ch9 | 扩展机制:Skills 与 Plugins 双轨制 | |
| Ch10 | Harness(新增):校验器、事件 ABI、schema 版本化 | |
| 第三部分 平台 | Ch11 | octos-bus:17 频道的统一消息抽象 |
| Ch12 | 并发模型:Tokio、supervisor、peer、lease 三层调度 | |
| Ch13 | octos-pipeline:DOT 图驱动的工作流引擎(12 种 IR 节点) | |
| Ch14 | 三种运行模式与配置体系(含 stdio/solo) | |
| Ch15 | 生产化:octos-store / octos-services、认证与监控 | |
| Ch16 | Fleet(新增):可恢复的计划执行内核 | |
| Ch17 | Swarm(新增):契约扇出与聚合门禁 | |
| Ch18 | Goal 与 Peer(新增):把目标从上下文里搬出来 | |
| 第四部分 双环 | Ch19 | octoscode(新增):终端客户端与 UI Protocol |
| Ch20 | OctoLoop(新增):外环协议 OLP v2 | |
| Ch21 | herdr 与外环运维实务(新增) | |
| 附录 | A–D、F | 26 crate 依赖全图、工具速查、配置参考(mcp_servers/sub_providers/validators)、Feature Flags、OLP v2 协议速查(新增) |
阅读路线建议:A 类读者按顺序读,Ch1–Ch5 建立 Rust + Agent 的双基础;B 类读者可跳过语言论证直达 Ch5、Ch12、Ch16;C 类读者优先 Ch1、Ch7、Ch11、Ch13,跳过实现细节侧栏;D 类贡献者请先读完本章与附录 A 再动第一个 PR。
1.5 本章回顾
- 问题空间:多租户 AI Agent 平台的安全隔离(59 个工具源文件 + 17 个频道的攻击面)、并发(会话/工具/进程三重并发面的正确性)、性能(乘性延迟、多租户内存、框架热路径)三大挑战相互纠缠,决定了选型是架构决策。
- 语言选型:Rust 在安全(全库
deny(unsafe_code)+ 类型系统)、并发(Send/Sync编译期保证)、性能(无 GC、确定性析构、零分配热路径)三维胜出;生态短板用 FFI/绑定链(pyo3/uniffi/wasm)与 plugin/skill 扩展机制对冲。 - Workspace 拓扑:26 目录 = 23 个
octos-*crate + 2 个技能目录 + 1 个前端目录;38 个 workspace 成员按依赖方向落入 L0–L7 八层加不计层数的能力层;63 条依赖边无一环、全部向下。octos-sandbox是助手二进制(真沙箱在octos-agent::sandbox六文件)、octos-web非 Rust 非成员、harness 是octos-agent内模块而非 crate,三处澄清是读仓库的正确起点。
从下一章开始自底向上:先看 L0 的 octos-core 如何用类型系统为整个平台定义领域语言。
延伸阅读
- Cargo Workspaces:官方文档 "Workspaces" 章,https://doc.rust-lang.org/cargo/reference/workspaces.html ,对照 1.3 的 members/lints/dependencies 继承机制。
- Rust 所有权与并发:The Rust Programming Language Ch4 "Understanding Ownership" 与 Ch16 "Fearless Concurrency",https://doc.rust-lang.org/book/ ,
Send/Sync的推导规则。 - Tokio 教程:https://tokio.rs/tokio/tutorial ,Ch12 并发模型的前置。
- OWASP Top 10 for LLM Applications:https://owasp.org/www-project-top-10-for-large-language-model-applications/ ,prompt 注入与 Agent 安全的威胁分类。
- 本书附录 A:26 crate 完整依赖图(本章拓扑图的全量版)。
思考题
- 攻击面估算:以 1.1.1 的口径为方法,为一个「接入 3 个频道、注册 20 个工具」的自建 Agent 服务估算攻击面:哪些边界需要独立信任模型?哪一层最薄弱?
- 并发的编译期 vs 运行期:Go 的
-race检测与 Rust 的Send/Sync各自兜住哪类错误、漏掉哪类?如果团队从 Go 迁到 Rust,哪些测试可以删,哪些必须保留? - 层数权衡:octos 的 L6/L7(ffi → uniffi/pyo3)拆成两层,而不是让 pyo3 直接依赖全部核心 crate。这样拆让什么变贵、让什么变便宜?如果明天要加一个 Kotlin 绑定,改哪一层?
- 能力层的隔离价值:
app-skills的 14 个二进制零octos-*依赖。这带来什么工程性质?如果让它们直接依赖octos-agent,编译图与发布节奏会发生什么变化? - 组织镜像:对照 1.3.4 的拓扑图,为一个你熟悉的系统画出同样的依赖图。它的分层是像 octos 一样被工具链强制,还是只存在于文档里?差异会带来什么长期后果?
版本演化说明
本章分析基于 octos main @
9c157101(2026-09-02 统计)的 26 crate 工作区;所有规模数字(26 目录、38 成员、700,915 行 Rust、17 个频道源文件、59 个工具源文件、63 条依赖边)的统计口径与复现命令见assets/ch01-facts.md,逐条可在源码仓库核对。相对本书 v1 旧稿,本章作了三类更新。其一,三处事实纠正:
octos-sandbox是平台助手二进制而非沙箱子系统(真沙箱是octos-agent::sandbox的六个文件);octos-web不含 Rust、不是 workspace 成员;不存在 harness crate,harness 是octos-agent内部的harness_errors/harness_events模块。其二,拓扑层数更新:旧稿的四层架构与核心 crate 数口径作废,v2 按[dependencies]依赖方向推导为 L0–L7 共 8 层加不计层数的能力层。其三,全书结构调整:Harness 独立成第 10 章,原第 10 章 bus 平移至第 11 章,第 16 章起为新增章。
第 2 章:octos-core:用类型系统定义领域语言
定位:本章深入 octos 最底层的 crate octos-core(当前 22,313 行 Rust 源文件,剔除 crates/octos-cli/src/api/ui_protocol_tests.rs 测试后约 15,005 行,主要增长来自 UI Protocol wire 类型),展示如何用 Rust 类型系统构建 AI Agent 平台的领域语言。前置依赖:第 1 章。适用场景:想理解 octos 类型基础的所有读者,尤其是希望通过实战项目学习 Rust 枚举和错误处理设计的读者(读者 A),以及想了解"零依赖 core crate"设计哲学的资深开发者(读者 B)。
如果把 octos 比作一座城市,octos-core 就是它的语言——不是建筑、不是道路,而是居民用来交流的词汇和语法。Task、Message、MessageRole、Error、UI Protocol wire 类型,这些类型定义了系统中所有组件如何描述自己的状态和意图。octos 的上层 crate 都依赖这些类型,但 octos-core 本身不依赖 workspace 中的任何其他 crate。
这个零依赖约束不是偶然的。它是一个刻意的架构决策,确保领域语言演进时仍保持清晰边界:当 octos-llm 重构 Provider 实现或 octos-bus 调整频道时,core 只承接真正跨 crate 的 wire identity 与 domain model,而不吸收上层实现逻辑。本章将从 Task 状态机开始,逐个解析这些基础类型的设计,最后讨论这个零依赖策略的工程意义。
2.1 Task 状态机:用枚举编码合法状态
每个 AI Agent 执行的工作在 octos 中被建模为一个 Task。Task 是整个系统的工作单元:从"帮我写一段代码"到"审查这个 diff",所有用户请求最终都被转化为 Task。
2.1.1 Task 结构体
Task 的定义位于 crates/octos-core/src/task.rs:11-29:
#![allow(unused)] fn main() { pub struct Task { pub id: TaskId, // UUID v7,自带时间排序 pub parent_id: Option<TaskId>, // 子任务层级 pub status: TaskStatus, // 当前状态 pub kind: TaskKind, // 任务类型 pub context: TaskContext, // 执行上下文 pub result: Option<TaskResult>, // 完成后的结果 pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>, } }
几个值得注意的设计选择:
TaskId 使用 UUID v7 而非 v4(crates/octos-core/src/types.rs:180-189)。UUID v4 是纯随机的,而 v7 的前 48 位编码了毫秒级时间戳。这意味着 TaskId 天然按创建时间排序:在调试和日志分析中,你可以直接通过 ID 判断任务的先后顺序,无需额外查询 created_at 字段。
parent_id: Option<TaskId> 支持任务层级。当一个复杂任务需要分解为多个子任务时(例如,Pipeline 中的多步骤执行),每个子任务通过 parent_id 指向父任务,形成树状结构。
result: Option<TaskResult> 只在 Task 完成后存在。这利用了 Rust 的 Option 类型在编译期强制调用者处理"结果可能不存在"的情况:你无法在未检查的情况下访问一个 Pending 状态 Task 的结果。
2.1.2 TaskStatus:编译期防止非法转换
TaskStatus 是一个带数据的枚举(crates/octos-core/src/task.rs:63-77):
#![allow(unused)] fn main() { pub enum TaskStatus { Pending, InProgress { agent_id: AgentId }, Blocked { reason: String }, Completed, Failed { error: String }, } }
注意 InProgress 变体携带 agent_id,这不只是状态标记,还记录了谁在执行这个任务。同样,Blocked 携带阻塞原因,Failed 携带错误信息。这种"状态 + 上下文数据"的组合是 Rust 枚举相比其他语言的 enum(如 Go 的 iota 常量或 Java 的传统 enum)的核心优势。
在 Python 中,你可能会用一个字符串字段 status: str 加上几个可选字段 agent_id: Optional[str]、error: Optional[str] 来表达同样的语义。但这种设计允许非法状态:比如一个 status="pending" 但 agent_id="agent-1" 的 Task,或者 status="completed" 但 error="something failed" 的 Task。Rust 的枚举让这些状态在类型层面就不可能存在。
2.1.3 状态转换图
Task 的合法状态转换如下:
stateDiagram-v2
[*] --> Pending: 创建
Pending --> InProgress: 分配给 Agent
InProgress --> Blocked: 等待外部资源
InProgress --> Completed: 执行成功
InProgress --> Failed: 执行失败
Blocked --> InProgress: 阻塞解除
Blocked --> Failed: 超时或取消
图 2-1:Task 状态机。 每个状态转换对应一个明确的业务事件。注意 Pending 只能转向 InProgress(不能直接跳到 Completed),InProgress 是唯一可以到达 Completed 的路径。这确保了每个完成的任务都经历过执行阶段。
2.1.4 TaskKind:五种任务类型
TaskKind 定义了五种工作类型(crates/octos-core/src/task.rs:79-99):
#![allow(unused)] fn main() { pub enum TaskKind { Plan { goal: String }, Code { instruction: String, files: Vec<PathBuf> }, Review { diff: String }, Test { command: String }, Custom { name: String, params: serde_json::Value }, } }
前四种是预定义的常见场景:规划(Plan)、编码(Code)、审查(Review)、测试(Test)。第五种 Custom 是扩展点:通过 name 标识任务类型,params 携带任意 JSON 数据。这种"有限预定义 + 开放扩展"的模式在 octos 中反复出现(详见第 6 章工具系统和第 9 章扩展机制)。
2.1.5 TaskContext 与 TaskResult
TaskContext(crates/octos-core/src/task.rs:102-115)是任务执行时的环境快照:
#![allow(unused)] fn main() { pub struct TaskContext { pub working_dir: PathBuf, pub git_state: Option<GitState>, // 分支、未提交变更、HEAD commit pub working_memory: Vec<Message>, // 近期对话轮次 pub episodic_refs: Vec<EpisodeRef>, // 过往 episode 引用 pub files_in_scope: Vec<PathBuf>, } }
working_memory 和 episodic_refs 的区别值得关注:working_memory 是当前会话的短期记忆(最近几轮对话),而 episodic_refs 是从长期记忆中检索出的相关片段(详见第 4 章)。这种双记忆架构模仿了人类的工作记忆(working memory)和情景记忆(episodic memory)的区分。
TaskResult(crates/octos-core/src/task.rs:125-157)记录任务的产出,共 7 个字段:schema_version(durable ABI 版本,见 2.3 节)、success、output、files_modified、files_to_send、subtasks、token_usage。
TokenUsage(crates/octos-core/src/task.rs:159-173)值得特别关注。它不只追踪 input/output tokens,还包含 reasoning_tokens(思维链 token,用于 o1、kimi-k2.5 等推理模型)和 cache_read_tokens/cache_write_tokens(Provider 缓存命中/写入)。这五个维度让上层可以精确计算成本和优化缓存策略。序列化时,为零的字段会被跳过,避免 JSON 膨胀。
2.2 Message 与 MessageRole:跨 Provider 的统一抽象
AI Agent 平台需要对接多个 LLM Provider:Anthropic 的 Claude、OpenAI 的 GPT-4、Google 的 Gemini、本地的 Ollama。每个 Provider 的 API 对消息角色的命名和语义略有不同。octos-core 定义了统一的 Message 和 MessageRole 类型,作为所有 Provider 的公约数。
2.2.1 Message 结构体
Message 的定义位于 crates/octos-core/src/types.rs:227-258:
#![allow(unused)] fn main() { pub struct Message { pub role: MessageRole, pub content: String, pub media: Vec<String>, // 图片/音频文件路径 pub tool_calls: Option<Vec<ToolCall>>, // LLM 请求的工具调用 pub tool_call_id: Option<String>, // 工具响应对应的调用 ID pub reasoning_content: Option<String>, // 思维链内容 pub client_message_id: Option<String>, // 客户端乐观 UI / 幂等 token pub thread_id: Option<String>, // AppUI thread 渲染分组 key pub timestamp: DateTime<Utc>, } }
media 字段(crates/octos-core/src/types.rs:232-234)支持多模态:当用户发送图片或语音时,文件路径存储在这里,由 octos-llm 在构建 API 请求时转换为对应 Provider 的格式(base64 编码或 URL 引用)。序列化时,空的 media 向量会被跳过。
reasoning_content(crates/octos-core/src/types.rs:239-241)是为推理模型(如 OpenAI o1、Kimi k2.5)设计的:这些模型会先输出一段内部推理过程,然后才给出最终回答。将推理内容与正式回答分离存储,让上层可以选择是否展示思维链。
client_message_id 和 thread_id 是当前主分支里很重要的 UI / 持久化 identity 字段(crates/octos-core/src/types.rs:229-256)。它们不是 Provider 消息格式的一部分,而是 AppUI、Gateway、Session 持久化共同依赖的跨 crate 协议字段:前者用于把客户端乐观 UI 气泡和服务端持久化 seq 对齐,后者用于把 user / assistant / tool 消息归入同一个渲染 thread。
2.2.2 Message identity:ClientMessageId、ThreadId、TurnId 不能混用
当前源码把三种看似相近的 identity 拆成三个不同类型:
| 类型 | 定义位置 | 职责 | 典型来源 |
|---|---|---|---|
ClientMessageId | crates/octos-core/src/types.rs:12-64 | 客户端提交 user message 时生成的乐观 UI / 幂等 token | Web/AppUI client |
ThreadId | crates/octos-core/src/types.rs:79-177 | 渲染分组 key,让 assistant/tool 回复落回 originating user bubble | 通常 root at ClientMessageId |
TurnId | crates/octos-core/src/ui_protocol.rs:607-623 | UI Protocol 的服务器端 turn identity | backend 创建 |
flowchart LR
C[ClientMessageId<br/>client optimistic identity] --> T[ThreadId<br/>render grouping]
U[User Message] --> C
U --> T
A[Assistant / Tool Message] --> T
R[TurnId<br/>server protocol identity] -. distinct .- T
R -. distinct .- C
这个分离是一次真实 bug 链的类型化修复。ClientMessageId 和 ThreadId 都包的是 String,但语义完全不同;TurnId 则是 UUID 形态的协议 identity。把它们做成 newtype,可以让编译器阻止“把 client message id 当 turn id 用”这类错误(crates/octos-core/src/types.rs:12-177,crates/octos-core/src/ui_protocol.rs:607-623)。
Message 也提供了显式绑定构造函数:
#![allow(unused)] fn main() { pub fn user_with_cmid(content: impl Into<String>, cmid: ClientMessageId) -> Self pub fn user_rooting_thread(content: impl Into<String>, cmid: ClientMessageId) -> Self pub fn assistant_with_thread(content: impl Into<String>, thread_id: ThreadId) -> Self pub fn tool_with_thread(content: impl Into<String>, tool_call_id: impl Into<String>, thread_id: ThreadId) -> Self }
其中 assistant_with_thread() 和 tool_with_thread() 要求调用方显式传入 ThreadId(crates/octos-core/src/types.rs:310-350)。这和 Ch10 的 Session 写入规则是一致的:Assistant/Tool 新写入不能再靠“回看最近一个 user”来猜 thread,因为并发 turn 下这个猜测会把 late delta 或工具结果归到错误气泡。core crate 在类型层先表达这个约束,上层持久化路径再 fail closed。
2.2.3 MessageRole:as_str() 与 Display 的双重实现
MessageRole 只有四个变体(crates/octos-core/src/types.rs:444-451):
#![allow(unused)] fn main() { pub enum MessageRole { System, User, Assistant, Tool, } }
关键在于它的两个方法实现。as_str()(crates/octos-core/src/types.rs:453-463)返回 &'static str,四个变体分别映射为 "system"、"user"、"assistant"、"tool",一个穷尽的 match,没有通配分支,新增变体时编译器会强制补齐这里的映射。
Display trait 实现(crates/octos-core/src/types.rs:465-469)的函数体只有一行 f.write_str(self.as_str())。为什么要同时实现这两个?因为它们服务于不同场景:
as_str()接受self(按值传递),返回&'static str。这是可行的因为MessageRole实现了Copytrait:枚举只有四个无数据变体,拷贝成本等同于拷贝一个字节。按值传递用于需要零分配的场景,比如构建 API 请求时设置 JSON 字段值。Display用于格式化字符串(format!()、println!()等),是 Rust 生态的标准接口。
这种模式确保了跨 Provider 的一致性:无论是发送给 Anthropic 还是 OpenAI,消息角色始终序列化为 "system"、"user"、"assistant"、"tool" 这四个小写字符串。各 Provider 的差异(比如 Anthropic 的 system message 是独立字段而非消息数组的一部分)在 octos-llm 中处理,不会泄漏到核心类型层。
2.2.4 ToolCall 与元数据扩展
ToolCall(crates/octos-core/src/types.rs:471-479)是 LLM 请求调用工具时的数据结构:
#![allow(unused)] fn main() { pub struct ToolCall { pub id: String, pub name: String, pub arguments: serde_json::Value, pub metadata: Option<serde_json::Value>, } }
metadata 字段(crates/octos-core/src/types.rs:476-478)是为 Provider 特定数据预留的扩展点。例如,Google Gemini 的工具调用会携带 thought_signature 字段,用于验证工具调用是否来自模型的推理过程。通过 Option<Value> 存储这些异构数据,核心类型无需为每个 Provider 添加特定字段。
2.2.5 便捷构造函数
Message 仍保留了三个 legacy 便捷构造函数(crates/octos-core/src/types.rs:355-417),用于测试、反序列化 round-trip 和旧调用点:Message::user(content)、Message::assistant(content)、Message::system(content),签名均为 (content: impl Into<String>) -> Self。
注意参数类型是 impl Into<String> 而非 String 或 &str。这让调用者可以传入 String、&str、甚至 Cow<str>,编译器会自动选择最高效的转换路径。这是 Rust 中常见的 API 设计模式:通过泛型减少调用者的类型转换负担。三个构造器的函数体都只是把 content 包进对应 role 的 Message,不再赘述。
2.3 Durable ABI:TaskResult 与 SessionSummary 的 schema_version
octos-core 现在还承担一类更隐蔽的职责:给跨 crate 持久化 payload 定义 stable ABI。两个例子最典型。
TaskResult 带有 schema_version(crates/octos-core/src/task.rs:138-157):
#![allow(unused)] fn main() { pub struct TaskResult { #[serde(default = "default_task_result_schema_version")] pub schema_version: u32, pub success: bool, pub output: String, pub files_modified: Vec<PathBuf>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub files_to_send: Vec<PathBuf>, pub subtasks: Vec<TaskId>, pub token_usage: TokenUsage, } }
这个字段不是展示用版本号,而是 Harness / workspace contract / task result 在磁盘和进程边界上传递时的 durable ABI。旧 payload 没有该字段时,会通过 serde default 回到 TASK_RESULT_SCHEMA_VERSION = 1,保证历史数据可读。
第二个例子是 SessionSummary(crates/octos-core/src/task.rs:233-305)。它是 Ch8 里 typed compaction 的核心载体:
#![allow(unused)] fn main() { pub struct SessionSummary { #[serde(default = "default_session_summary_schema_version")] pub schema_version: u32, pub goal: String, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub constraints: Vec<String>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub progress_done: Vec<String>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub progress_in_progress: Vec<String>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub decisions: Vec<DecisionRecord>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub files: Vec<FileRecord>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub next_steps: Vec<String>, } }
SessionSummary 的设计重点不是“把聊天记录压成一段摘要”,而是把 goal、constraints、progress、decisions、files、next_steps 分开保存,让后续 compaction 可以迭代更新结构化字段。缺失 schema_version 时默认到当前版本;如果读到未来版本,validate_schema_version() 返回 UnsupportedSessionSummaryVersion typed error,而不是 panic 或静默丢字段(crates/octos-core/src/task.rs:188-305)。
flowchart TD
History[Conversation history] --> Summary[SessionSummary schema_version=1]
Summary --> Goal[goal]
Summary --> Constraints[constraints]
Summary --> Decisions[decisions + stale markers]
Summary --> Files[files]
Summary --> Next[next_steps]
Summary --> Check{future schema?}
Check -->|no| Continue[compaction continues]
Check -->|yes| Error[UnsupportedSessionSummaryVersion]
这也是为什么 SessionSummary 放在 octos-core 而不是 octos-agent:Ch8 的 compaction、Ch9 的 Harness ABI、Ch14 的 UI/runtime 观测都会引用它。core crate 在这里提供的是 wire-stable domain language,而不是具体 compaction 算法。
2.4 Error 设计:为什么选 eyre 而不是 anyhow
Rust 生态中有两个主流的错误处理库:anyhow 和 eyre。它们都提供类型擦除的错误报告(anyhow::Error / eyre::Report),但 octos 选择了 eyre/color-eyre。这个选择值得深入分析。
2.4.1 octos 的错误类型
octos-core 的 Error 定义在 crates/octos-core/src/error.rs:10-17:
#![allow(unused)] fn main() { pub struct Error { pub kind: ErrorKind, pub context: Option<String>, pub suggestion: Option<String>, } }
这里的关键设计是三层结构:kind 分类错误类型,context 添加执行上下文,suggestion 提供可操作的修复建议。
ErrorKind 是一个 15 变体的枚举(crates/octos-core/src/error.rs:20-56),覆盖了系统中所有错误类别:
#![allow(unused)] fn main() { pub enum ErrorKind { TaskNotFound(String), AgentNotFound(String), InvalidStateTransition { from: String, to: String }, LlmError { provider: String, message: String }, ApiError { provider: String, status: u16, body: String }, ToolError { tool: String, message: String }, ConfigError(String), ApiKeyNotSet { provider: String, env_var: String }, UnknownProvider(String), Timeout { operation: String, seconds: u64 }, ChannelError { channel: String, message: String }, SessionError(String), IoError(std::io::Error), SerializationError(String), Other(eyre::Report), } }
注意最后一个变体 Other(eyre::Report):这里使用了 eyre::Report 而非 anyhow::Error。
2.4.2 eyre vs anyhow:选型理由
anyhow 和 eyre 的核心 API 几乎相同,但有两个关键差异:
差异一:自定义错误报告器。 eyre 支持通过 eyre::set_hook() 安装自定义的错误报告器。color-eyre 就是这样一个报告器,它在错误发生时自动捕获 std::backtrace::Backtrace 和 tracing_error::SpanTrace,并以彩色格式输出。对于一个 CLI 工具来说,当 Agent 执行失败时,开发者能立即看到彩色高亮的错误链和调用栈,这比 anyhow 的纯文本输出提供了更好的调试体验。
差异二:生态对齐。 octos 的 workspace 依赖声明中同时使用了 eyre 和 color-eyre(Cargo.toml:97-98)。这是因为 color-eyre 在 CLI 入口初始化(main() 中调用 color_eyre::install()),而 eyre::Report 作为通用错误类型在整个代码库中使用。如果混用 anyhow::Error 和 eyre::Report,需要在边界处做转换,增加不必要的复杂度。
2.4.3 可操作的错误消息
octos 的错误设计最值得学习的不是库的选择,而是"让错误消息可操作"的理念。看几个便捷构造函数的实现(crates/octos-core/src/error.rs:80-173):
#![allow(unused)] fn main() { pub fn api_key_not_set(provider: impl Into<String>, env_var: impl Into<String>) -> Self { let provider = provider.into(); let env_var = env_var.into(); Self::new(ErrorKind::ApiKeyNotSet { provider: provider.clone(), env_var: env_var.clone(), }) .with_suggestion(format!( "Set the API key:\n export {env_var}=your-api-key\n Or add to .octos/config.json:\n {{\"api_key_env\": \"{env_var}\"}}" )) } }
当用户忘记设置 API Key 时,错误消息不只告诉你"key 没设",还告诉你"设置 ANTHROPIC_API_KEY 环境变量,或在 config.json 中配置"。同样,api_error() 会根据 HTTP 状态码给出不同的建议:401 提示检查 key,429 提示被限流,504 提示超时。
Display 实现(crates/octos-core/src/error.rs:174-224)将这三层信息格式化为用户友好的输出,并使用 truncated_utf8() 安全截断过长的 API 响应体,避免错误日志中出现巨大的 JSON dump。
2.5 truncate_utf8:一个小函数背后的 UTF-8 安全哲学
truncate_utf8 是 octos-core 中最小但最精巧的函数之一。它只有 10 行代码(crates/octos-core/src/utils.rs:6-16),却解决了一个在多语言 AI 应用中极其常见的问题:如何安全地截断可能包含中文、日文、emoji 等多字节字符的字符串。
2.5.1 问题:UTF-8 的多字节陷阱
UTF-8 是一种变长编码:ASCII 字符占 1 字节,中文字符占 3 字节,emoji 占 4 字节。当你需要将字符串截断到 N 个字节时,截断点可能正好落在一个多字节字符的中间。以"你好世界"为例:每个汉字 3 字节,四个字共 12 字节;若截断到 7 字节,保留的是 [E4 BD A0] [E5 A5 BD] 加上孤立的 [E4],最后这个字节是"世"的第一个字节,不再构成合法的 UTF-8 序列。
在 C/C++ 中,这种截断会产生无效的 UTF-8 字符串,可能导致下游解析崩溃。Python 的 str[:7] 按字符而非字节截断,避免了这个问题但无法精确控制字节预算。
2.5.2 两个变体:in-place 与 copying
octos 提供了两个截断函数:
truncate_utf8(crates/octos-core/src/utils.rs:6-16)原地截断,修改原字符串:
#![allow(unused)] fn main() { pub fn truncate_utf8(s: &mut String, max_len: usize, suffix: &str) { if s.len() <= max_len { return; } let mut limit = max_len; while limit > 0 && !s.is_char_boundary(limit) { limit -= 1; } s.truncate(limit); s.push_str(suffix); } }
truncated_utf8(crates/octos-core/src/utils.rs:21-30)返回新字符串,不修改原始数据:
#![allow(unused)] fn main() { pub fn truncated_utf8(s: &str, max_len: usize, suffix: &str) -> String { if s.len() <= max_len { return s.to_string(); } let mut limit = max_len; while limit > 0 && !s.is_char_boundary(limit) { limit -= 1; } format!("{}{}", &s[..limit], suffix) } }
核心算法相同:从 max_len 位置向前回退,直到找到一个合法的 UTF-8 字符边界(is_char_boundary())。截断后追加 suffix。注意这两个函数的 max_len 不包含 suffix 的长度,追加 suffix 后最终字符串可能超过 max_len。这是有意的设计:max_len 控制的是保留内容的上限,suffix 是额外的标记。调用者需要在设置 max_len 时预留 suffix 的空间。
两个变体的区别在于所有权语义:
truncate_utf8接受&mut String,原地修改,零额外分配(除了 suffix 追加)truncated_utf8接受&str(不可变引用),返回新String,需要一次堆分配
调用者根据场景选择:如果拥有字符串所有权且不再需要原始内容,用 in-place 版本;如果字符串是借用的(比如来自 API 响应的 &str),用 copying 版本。
2.5.3 truncate_head_tail 与 TruncationReport:结构化的首尾截断
对于工具输出和错误消息,仅保留开头往往不够:尾部的错误信息或结论同样重要。2026-08-31 的提交 d8125d18 之后,octos 用一对函数解决这个问题:truncate_head_tail(crates/octos-core/src/utils.rs:91-96)与它背后的 truncate_head_tail_report(crates/octos-core/src/utils.rs:105)。
truncate_head_tail_report 是真正的实现,返回结构化的 TruncationReport:
#![allow(unused)] fn main() { pub fn truncate_head_tail_report(s: &str, max_len: usize, head_ratio: f32) -> TruncationReport }
报告除了截断后的 content,还携带这次截断的全部事实:truncated 标记是否发生了截断;truncated_by 记录触发的是哪条上限(当前只有 TruncatedBy::Bytes 可达,Lines 变体为未来的行数截断预留);total_bytes / output_bytes / omitted_bytes 分别是输入长度、输出长度和丢弃的字节数;max_len 与 head_ratio 回记实际生效的参数(head_ratio 已钳位到 [0.1, 0.9])。截断输出中间以 \n\n... [N bytes omitted] ...\n\n 标记连接首尾,omitted_bytes 就是与标记中 N 一致的准确数字,两端的切点都经 is_char_boundary() 保证 UTF-8 安全。
为什么要把报告带回来?源码注释写明了动机:调用方若用 total - content.len() 反推丢弃量,会漏算省略标记自身的长度。上层需要向模型如实告知"你看到的是裁剪过的输出、裁掉了多少"时(第 8 章的上下文管理正是这样用),应从报告里读,而不是自己做减法。
truncate_head_tail 则是保持旧接口的薄包装,整个函数体只有一行:
#![allow(unused)] fn main() { pub fn truncate_head_tail(s: &str, max_len: usize, head_ratio: f32) -> String { truncate_head_tail_report(s, max_len, head_ratio).content } }
一份实现,两个出口:只要 String 的旧调用点继续用旧名字,需要完整报告的调用方换新函数,同样的输入产出字节一致的 content。
这个函数家族在 octos 的多个场景中使用:
- 工具输出截断(Shell 命令的 stdout/stderr)
- 错误消息中的 API 响应体截断(
crates/octos-core/src/error.rs) - 上下文压缩时的消息摘要(详见第 8 章)
2.5.4 tool_output_limit:按工具类型定制的截断策略
tool_output_limit(crates/octos-core/src/utils.rs:180-199)为不同工具设置了不同的字节上限:
| 工具 | 上限 | 理由 |
|---|---|---|
read_file | 50,000 | 源码文件可能很大 |
shell, grep | 30,000 | 命令输出通常更精简 |
web_fetch | 40,000 | 网页内容适中 |
web_search | 20,000 | 搜索结果是摘要 |
search, deep_search, news_fetch | 200,000 | 深度搜索要聚合多源原文 |
deep_research, spawn | 50,000 | 深度任务需要更多上下文 |
| 默认 | 50,000 | 安全上限 |
search 是内建 deep-search 技能的运行时工具名,deep_search 是为未来变体保留的契约槽位,源码注释写明了这层防御性别名关系。这些限制不是任意的:普通搜索结果的信息密度高(每条结果都是有用的摘要),20,000 字节足够;源码文件的信息分布不均(可能需要看到完整的函数体),给 50,000 字节;深度搜索的输出是聚合了多个来源的原文,截短会砍断证据链,因此给到 200,000 字节。这些限制与 LLM 的上下文窗口预算配合使用(详见第 8 章上下文管理)。
2.6 SessionKey:多租户会话标识的设计
SessionKey(crates/octos-core/src/types.rs:489-567)是多租户系统中会话路由的关键。它的设计演进反映了从单频道到多频道、从单 Profile 到多 Profile,再到 topic 分支会话的需求扩展。
2.6.1 格式演变
SessionKey 支持四种格式,向后兼容:
| 格式 | 示例 | 场景 |
|---|---|---|
channel:chat_id | telegram:12345 | 基础:单 Profile |
profile:channel:chat_id | work:telegram:12345 | 多 Profile 隔离 |
channel:chat_id#topic | telegram:12345#research | 同一会话的多主题 |
profile:channel:chat_id#topic | work:telegram:12345#research | 完整形式 |
base_key() 方法(crates/octos-core/src/types.rs:525-528)返回去掉 #topic 后缀的部分,用于会话持久化(同一个 base_key 的不同 topic 共享持久化文件)。topic() 方法(crates/octos-core/src/types.rs:530-533)提取主题后缀。
2.6.2 Channel 推断,而不是构造期验证
当前源码没有在 SessionKey::new() 构造时拒绝未知 channel;构造函数只是格式化字符串。公开的 is_reserved_channel_name()(crates/octos-core/src/types.rs:603-630,内部委托私有 is_channel_name())的职责是帮助 split_base_key() 判断三段式 key 里的第一段究竟是 profile_id 还是 channel。白名单包含 19 个已知 channel:
api, cli, dingtalk, discord, email, feishu, line, local, matrix, qq-bot,
slack, system, telegram, test, twilio, wechat, wecom, wecom-bot, whatsapp
这个细节很重要:SessionKey 解决的是 session routing key 的结构化解析,不是 channel 配置合法性的全局验证。真正的 channel / profile 配置校验发生在更上层的 CLI/API 配置路径中;core 这里只提供低依赖、可序列化的 key 形态。
2.7 AgentMessage:Agent 间协调协议
AgentMessage(crates/octos-core/src/message.rs:10-29)定义了 Agent 之间的协调协议:
#![allow(unused)] fn main() { pub enum AgentMessage { TaskAssign { task: Box<Task> }, TaskUpdate { task_id: TaskId, status: TaskStatus }, TaskComplete { task_id: TaskId, result: TaskResult }, ContextRequest { task_id: TaskId, query: String }, ContextResponse { task_id: TaskId, context: Vec<Message> }, } }
五种消息类型涵盖了 Agent 协调的核心场景:分配任务、更新状态、完成通知、请求上下文、返回上下文。注意 TaskAssign 中的 Box<Task>:Task 结构体较大,使用 Box 堆分配避免了枚举变体之间的大小不均导致的内存浪费。
task_id() 方法(crates/octos-core/src/message.rs:31-42)返回 Option<&TaskId>,为所有变体提供统一的任务 ID 访问接口。调用者无需对每个变体做模式匹配就能获取关联的任务 ID,只需处理 Option 即可。
2.8 abort:多语言中断检测
一个有趣的小模块:crates/octos-core/src/abort.rs 实现了多语言的 Agent 中断检测。当用户在 Agent 执行过程中发送"停"、"stop"、"やめて"、"стоп"等中断信号时,系统需要立即识别并终止当前操作。
ABORT_TRIGGERS 数组(crates/octos-core/src/abort.rs:32-71)包含 9 种语言、28 个触发词(源文件顶部注释写 30+,以数组实际条目数为准)。is_abort_trigger()(crates/octos-core/src/abort.rs:6-13)对输入进行 trim + lowercase 后精确匹配。abort_response()(crates/octos-core/src/abort.rs:15-30)返回与触发语言匹配的本地化取消确认。
代码注释还记录了故意排除的词:"wait"、"exit"、"para" 在正常对话中出现频率太高,会导致误判。这是一个务实的设计选择:宁可漏掉一些中断信号(用户可以再说一次),也不要在正常对话中误触发中断。
2.9 core 的边界:2026 年新增的七个文件
octos-core 在 2026 年下半年又收进了七个模块。表面上看 core 变胖了,归类判据却是一致的:它们都是"被多个上层 crate 共同依赖、自身又不依赖任何上层"的单一事实源。逐个给出一句话定位:
| 文件 | 行数 | 定位 |
|---|---|---|
crates/octos-core/src/abort.rs | 120 | 多语言中断触发词检测:9 种语言 28 个取消词,识别聊天消息里的"停",用于取消在途 Agent 操作 |
crates/octos-core/src/app_ui.rs | 445 | 面向 App 客户端的稳定 UI API 层,刻意位于 draft JSON-RPC wire 协议之上,让 TUI / App 依赖 app 概念而非 wire 细节 |
crates/octos-core/src/app_ui_codec.rs | 345 | AppUI 共享的 JSON-RPC 文本帧编解码:传输中立的帧规则,供 WebSocket 文本帧与 stdio NDJSON 复用 |
crates/octos-core/src/env_hygiene.rs | 230 | 子进程环境卫生:进程注入变量 denylist、秘密名启发式与 Command 消毒器,agent spawner 与 core 自身的 git 操作共用一份 |
crates/octos-core/src/gateway.rs | 182 | 基于频道的网关消息类型:入站 / 出站消息与来源标记 MessageOrigin |
crates/octos-core/src/git_worktree.rs | 1,579 | 共享 git worktree 管道:让 fleet-worker 池为每个任务分配真实 worktree,而无需依赖 octos-agent |
crates/octos-core/src/session_scope.rs | 1,880 | SessionScope:octos 组件文件系统访问的单一契约,所有组件必须由它派生工作目录并校验路径,禁止各自从 raw 输入推算 |
crates/octos-core/src/session_scope.rs 和 crates/octos-core/src/git_worktree.rs 值得多看一眼。前者把"组件如何确定工作目录"从各自为政的 raw 输入推算,收拢成一份显式契约:solo / multi_tenant 两种 ScopeMode、路径分类结果、安全 session id 校验都在类型里。后者的顶部注释明说这是从 octos-agent 的 spawn 工具"借魂"上来的纯增量模块:agent 侧经过重重审查的旧实现保持原样,fleet-worker 走新路径,两者暂不合并。这两个例子给出了 core 边界的实际判据:进 core 的不是"最小的类型集",而是"任何上层各留一份副本都会漂移的契约"。七个模块各自的实现细节属于后续章节,本章只做定位。
工程决策侧栏:为什么 core crate 零内部依赖
octos-core 是 workspace 中唯一没有依赖其他 octos crate 的基础 crate(octos-plugin 和 octos-sandbox 也无内部依赖,但它们不被其他 crate 依赖)。这个"零内部依赖"约束是刻意的设计选择。
方案一:胖 core(把更多逻辑下沉到 core)
优势:
- 所有公共逻辑集中在一处,减少 crate 间的重复
- 下游 crate 只需依赖 core 就能获得大部分能力
劣势:
- core 的编译时间随功能膨胀而增加,影响所有依赖它的 crate 的增量编译速度
- core 的变更频率增加,每次修改都触发全 workspace 重编译
- 不相关的功能被耦合,修改 LLM 相关逻辑可能影响 Task 类型
方案二:瘦 core(只放类型定义和最基础的工具函数)
优势:
- 极少变更,提供稳定的类型基础
- 编译边界清晰(octos-core 当前 22,313 行 Rust 源文件,剔除 crates/octos-cli/src/api/ui_protocol_tests.rs 后约 15,005 行,其中大头是 UI Protocol wire 类型与配套测试)
- 依赖图清晰:所有 crate 依赖 core 的类型,但 core 不依赖任何人
劣势:
- 跨 crate 共享的逻辑需要放在其他地方(比如 octos-agent 中的工具函数)
- 可能出现"本应在 core 中"的类型被定义在上层 crate 的情况
octos 的选择:瘦 core,理由如下。
octos-core 的外部依赖仅限于
serde、serde_json、chrono、uuid、eyre、tracing、sha2这几个基础库,分别覆盖序列化、时间、错误处理、结构化日志与哈希,均为行业标准。这意味着 octos-core 的编译时间极短,而所有依赖它的 crate(octos-llm、octos-memory、octos-agent、octos-bus、octos-pipeline、octos-cli)都能从这个快速编译中获益。更重要的是稳定性保证。在 octos 的开发历程中,octos-llm 经历了多次 Provider 重构,octos-agent 的工具系统不断扩展,octos-bus 新增了多个频道——但 octos-core 的核心类型(Task、Message、Error)保持了高度稳定。瘦 core 策略使得这种稳定性成为可能。
2.10 本章回顾
octos-core 用 22,313 行 Rust 源文件(剔除 crates/octos-cli/src/api/ui_protocol_tests.rs 后约 15,005 行)定义了整个系统的领域语言:
-
Task 状态机:用 Rust 枚举编码合法状态和转换,在类型层面消除非法状态组合。UUID v7 提供时间排序,五维 TokenUsage 支持精细的成本追踪。
-
Message 抽象:四角色统一模型(System/User/Assistant/Tool)+
as_str()/Display双重实现,确保跨 Provider 的序列化一致性。ClientMessageId、ThreadId、TurnId则把 UI 乐观消息、渲染分组和协议 turn identity 分开,避免并发 turn 下的错误归属。 -
Durable ABI:
TaskResult.schema_version和SessionSummary.schema_version让 task result 与 typed compaction summary 成为可升级的 wire-stable payload;未来版本通过 typed error fail closed。 -
Error 设计:选择 eyre/color-eyre 获取彩色错误报告和 SpanTrace 支持。三层结构(kind + context + suggestion)让错误消息可操作。
-
UTF-8 安全工具:
truncate_utf8的两个变体(in-place 和 copying)通过is_char_boundary()保证截断安全。truncate_head_tail_report返回结构化TruncationReport,truncate_head_tail是其字节一致的薄包装。 -
零依赖设计:瘦 core 策略确保类型基础稳定、编译快速,支撑上层 crate 的独立演进。
-
core 的边界:2026 年新增的七个模块(abort、app_ui、app_ui_codec、env_hygiene、gateway、git_worktree、session_scope)按"跨上层的单一事实源"判据归入 core。
下一章,我们将进入 octos-llm,看看这些核心类型如何被用来驯服多个 LLM Provider 的混乱接口。
延伸阅读
- Rust 枚举与模式匹配:The Rust Programming Language 第 6 章 "Enums and Pattern Matching",https://doc.rust-lang.org/book/ch06-00-enums.html
- eyre 错误处理:eyre crate 文档,https://docs.rs/eyre/latest/eyre/
- color-eyre:color-eyre crate 文档,https://docs.rs/color-eyre/latest/color_eyre/
- UUID v7 规范:RFC 9562 "Universally Unique IDentifiers (UUIDs)",Section 5.7
- UTF-8 编码:The Unicode Standard Chapter 3 "Conformance":理解 UTF-8 变长编码对安全截断至关重要
思考题
-
状态机扩展:如果要为 Task 添加一个
Cancelled状态(用户主动取消),它应该从哪些状态可达?添加这个状态会对现有的match表达式产生什么影响? -
胖 core vs 瘦 core:假设 octos-core 把
LlmProvidertrait 也放进来(因为所有上层 crate 都需要它),会带来什么问题?提示:考虑async-trait、reqwest等依赖的传递效应。 -
错误设计权衡:octos 的
ErrorKind有 15 个变体。如果系统继续增长到 50 个变体,这种设计会遇到什么问题?你会如何重构? -
截断策略的替代方案:
truncate_utf8按字节截断并回退到字符边界。另一种方案是按 Unicode 字素簇(grapheme cluster)截断。两种方案在处理 emoji 组合序列(如 👨👩👧👦)时有什么区别?哪种更适合 LLM 上下文管理场景?
版本演化说明
本章分析基于 octos main @
9c157101(2026-09-02 勘误核对):crates/octos-core/src/*.rs全量 22,313 行(15 个源文件,其中crates/octos-cli/src/api/ui_protocol_tests.rs7,308 行),外部依赖为 serde / serde_json / chrono / uuid / eyre / tracing / sha2,全部引用行号经assets/ch02-refcheck.md逐条复核。本次勘误要点:
TurnId区间更新为crates/octos-core/src/ui_protocol.rs:607-623;api_key_not_set摘录按crates/octos-core/src/error.rs:89-99源码改写为多行 export / config.json 提示;Error 的 Display 区间修正为crates/octos-core/src/error.rs:174-224;2.5.3 按 d8125d18(2026-08-31)改写为truncate_head_tail_report/TruncationReport结构化截断;tool_output_limit限额表更新(search、deep_search、news_fetch 均为 200,000);channel 白名单 15 改 19(补 dingtalk、line、local、wechat);eyre / color-eyre 依赖声明位于根Cargo.toml:97-98;ABORT_TRIGGERS 实为 28 个触发词;依赖侧栏补 tracing、sha2;新增「core 的边界」小节归类 2026 年新增的七个源文件。阅读后续版本时,除了 Task / Message / Error,还应核对 identity newtypes、
SessionSummaryABI、UI Protocol capabilities 这些跨 crate wire 类型。
第 3 章:octos-llm:驯服 LLM Provider 的混乱
定位:本章深入 octos-llm crate,展示如何用 Rust trait 抽象统一多种 LLM Provider 的混乱接口,以及如何用三级容错能力与两级装配实现生产级可靠性。前置依赖:第 2 章。适用场景:想理解多 Provider 架构设计的 AI 应用开发者(读者 C),以及对 trait object、异步容错、凭据轮换和模型分层路由感兴趣的 Rust 开发者(读者 B)。
每个 LLM Provider 都有自己的 API 风格:Anthropic 把 system message 作为独立字段,OpenAI 把它放在消息数组里;Gemini 的工具调用格式与其他两家完全不同;Ollama 虽然是本地部署,但在 octos 里复用了 OpenAI 兼容接入层。当你需要同时支持少数专用协议实现,再加上一批复用 OpenAI / Anthropic 兼容层的 Provider 时,混乱是不可避免的——除非你在正确的层次建立正确的抽象。
octos-llm 的解决方案自底向上分三块:底层的 LlmProvider trait 统一调用接口,中层的 Provider 注册表实现模型名自动检测和工厂创建,顶层的容错装配提供生产级可靠性:每个 Provider 恒包一层 RetryProvider,外层再按配置二选一套上 ProviderChain(静态有序故障转移,缺省)或 AdaptiveRouter(评分路由,需显式 opt-in,替换而非叠加)。当前主分支还把 provider metadata、HTTP timeout knobs、credential pool、content classifier 和 routing.decision 事件纳入这条链路。本章将自底向上逐层展开。
3.1 LlmProvider trait:最小化的统一接口
3.1.1 trait 签名
LlmProvider 的定义位于 crates/octos-llm/src/provider.rs:16-121:
#![allow(unused)] fn main() { #[async_trait] pub trait LlmProvider: Send + Sync { // 核心方法:非流式对话 async fn chat( &self, messages: &[Message], tools: &[ToolSpec], config: &ChatConfig, ) -> Result<ChatResponse>; // 流式对话(默认实现:调 chat() 后合成事件流) async fn chat_stream( &self, messages: &[Message], tools: &[ToolSpec], config: &ChatConfig, ) -> Result<ChatStream> { /* Text→ToolCall→Usage→Done 依次产出 */ } // 元数据查询 fn context_window(&self) -> u32 { context::context_window_tokens(self.model_id()) } // 异步初始化钩子:见下文 async fn ensure_ready(&self) {} fn max_output_tokens(&self) -> u32 { context::max_output_tokens(self.model_id()) } // #2143 part 3:估算本 provider 实际发出的请求 token 数(含序列化开销) fn estimate_request_tokens( &self, messages: &[Message], tools: &[crate::types::ToolSpec], ) -> u32 { /* 默认走基线估算,具体 provider 可收紧 */ } fn model_id(&self) -> &str; fn provider_name(&self) -> &str; fn provider_metadata(&self) -> ProviderMetadata; fn provider_metadata_for_index(&self, _provider_index: Option<usize>) -> ProviderMetadata; // 可选:指标上报 fn export_metrics(&self) -> Option<serde_json::Value> { None } fn report_late_failure(&self) {} fn report_stream_metrics(&self, _output_tokens: u32, _stream_duration_us: u64) {} } }
这个 trait 的设计遵循了"最小必要接口"原则(crates/octos-llm/src/provider.rs:13 的注释明确说明了这一点):只定义所有 Provider 共同的能力,差异在各实现中处理。
几个值得关注的设计选择:
Send + Sync 约束。 trait 要求实现者是线程安全的,因为 Provider 实例会被多个异步任务通过 Arc 共享。这个约束在编译期保证了不会出现单线程 Provider 实现被意外用在多线程场景的错误。
chat_stream() 的默认实现。 不是所有 Provider 都原生支持流式响应。默认实现(crates/octos-llm/src/provider.rs:27-56)调用非流式的 chat() 方法,然后将完整响应包装为一个合成流:按内容、工具调用、usage、done 事件依次输出。这让新 Provider 只需实现 chat() 就能基本工作,流式支持可以后续优化。
Provider metadata。 provider_metadata() 与 provider_metadata_for_index() 是当前主分支新增的重要接口(crates/octos-llm/src/provider.rs:97-109)。它们把实际命中的 provider slot、模型 ID、provider name 等信息交给上层,用于观测、成本归因和多 provider chain 的精确指标记录。provider_metadata_for_index() 解决的是组合 Provider 的问题:当一个 ProviderChain 内部选中了第 N 个 slot,上层不能只看到 chain 本身的名字,而必须知道真正被调用的是哪一个后端。签名现在是 _provider_index: Option<usize>(103 行):调用方知道具体 slot 时传 Some(idx),只知道 provider 本体时传 None,简单调用方不必伪造下标。
指标上报方法。 export_metrics()、report_late_failure()、report_stream_metrics() 三个方法都有空的默认实现。它们为 AdaptiveRouter 的 EMA 评分系统提供数据源(见 3.4 节),但不强制所有 Provider 实现。这种"可选钩子"模式避免了 trait 膨胀。
ensure_ready() 与 estimate_request_tokens()。 这是基线上两个较新的成员。ensure_ready()(crates/octos-llm/src/provider.rs:65)是异步初始化钩子:agent loop 与 prompt-context 桥在每 turn 第一次依赖窗口数的决策前 await 它,本地上下文探测(见 3.7 节)借此保证真实窗口在首次 compaction 决策前 resolve,而不是在第一次 chat 之后;包装器逐层委托。estimate_request_tokens()(crates/octos-llm/src/provider.rs:82-89,#2143 part 3)估算该 provider 实际构建的请求 token 数,含 provider 特有序列化开销(独立 system 块、逐消息 content-block 框架、cache-control 元数据),路由适配守卫据此不再需要用 12.5% 的安全余量顶替这部分开销;默认是 provider 无关的基线估算,具体 provider 覆写收紧,包装器同样委托内层。
3.1.2 核心数据类型
ChatConfig(crates/octos-llm/src/config.rs:8-70)封装单次请求的全部可调参数:
max_tokens: 最大输出 token 数temperature: 采样温度(Anthropic 协议路径上Some(0.0)视为未设置,不上 wire)tool_choice: 工具选择策略(auto/required/none/指定工具)stop_sequences: 停止序列reasoning_effort: 思考模型的推理力度(none/low/medium/high/max,映射到 OpenAI reasoning.effort、Anthropic thinking budget、Gemini thinkingConfig)response_format: 响应格式约束(文本/JSON/结构化输出)context_management: Anthropic 专有的不透明 payload(如clear_tool_uses_20250919),让服务端做 tier-2 工具调用清理,其他 provider 忽略sampling_params: 运营者透传的额外采样参数表(机制见下文)cache_retention: 单次请求的 prompt-cache 保留偏好(None表示这一次不写缓存,机制见 3.6 节)
这份清单刻意没有 model 和 system_prompt 两个看似必然的字段:model 在 provider 构造期由注册表工厂决定(见 3.2.3),system prompt 走 messages 数组(正是本章开头强调的各家协议差异点)。sampling_params 与 cache_retention 的完整机制分别展开于本小节下文与 3.6 节。
ChatResponse 包含 LLM 返回的完整信息:内容、stop reason、工具调用请求、token 使用量。ChatStream 是一个异步流(Pin<Box<dyn Stream<Item = Result<StreamEvent>>>>),逐事件产出流式响应。
sampling_params(crates/octos-llm/src/config.rs:44-57)承载 octos 不建模的采样参数,例如 {"repeat_penalty": 1.1, "top_p": 0.95}。OpenAI 兼容层把它们展平注入请求体(crates/octos-llm/src/openai.rs:635-640,字段文档 908-909,测试 1614-1616),并防御性剔除 octos 已有专用字段的同名键(#2172),避免流式与非流式两条序列化路径出现重复或分叉的字段。默认为空,展平后什么都不加,云端请求不变。这套 cloud-safe sampler passthrough 由提交 b0072e70(#2172/#2176)引入,服务 llama.cpp / vLLM / SGLang 这类接受非标采样参数的本地推理服务。
上下文窗口也不再只有静态目录一个来源。提交 3e479ce3(#2142)引入 per-profile context_window 覆盖,落点跨两个 crate:配置结构 LlmModelSelectionConfig(crates/octos-cli/src/profiles.rs:824-875,字段 875 行)、config.llm 侧字段(crates/octos-cli/src/config.rs:39-46、489-492)与装配函数 apply_context_window_override(crates/octos-cli/src/qos_catalog.rs:24-36)都在 octos-cli;octos-llm 只提供 crates/octos-llm/src/context_override.rs(135 行)的 ContextWindowOverride 薄包装。它被套在包装栈最外层,值同时压过静态目录与运行时探测(#2135)。profile 里 config.llm.primary.context_window / fallbacks[i].context_window 的接线主体在 octos-cli,不是 octos-llm 内部,引用时须如实标注这个跨 crate 落点。
Provider 工厂还接收 LLM HTTP timeout knobs。CreateParams::http_timeout()(crates/octos-llm/src/registry/mod.rs:110-118)把 llm_timeout_secs、llm_connect_timeout_secs 两个可选字段合成 Option<(u64, u64)>:任一字段缺省时用默认常量补齐,两者都缺省时返回 None。默认常量在 crates/octos-llm/src/provider.rs:158-172 定义:LLM 总超时 300 秒、连接超时 10 秒(从 30 秒下调,连不上就尽快 failover),流式读空闲超时 300 秒(DEFAULT_LLM_STREAM_IDLE_TIMEOUT_SECS,165 行,经 reqwest 的 .read_timeout() 生效,每次读到数据即重置,持续产 token 的长流不会被总时长截断),embedding 总超时 60 秒、连接超时 15 秒。这把"模型推理慢"和"网络连接失败"分开处理,避免所有超时都挤在一个不可解释的配置项里。
3.2 Provider 注册表:模型名自动检测
当用户配置 model: "claude-sonnet-4" 时,octos 需要自动确定使用 Anthropic Provider。这个映射由 Provider 注册表实现(crates/octos-llm/src/registry/mod.rs)。
3.2.1 检测机制
每个 Provider 注册时声明自己的名称、别名、API key 环境变量(含别名)、默认 base URL、各项要求、检测模式、模型发现能力和工厂函数(crates/octos-llm/src/registry/mod.rs:121-167,ProviderEntry 结构体):
#![allow(unused)] fn main() { struct ProviderEntry { name: &'static str, aliases: &'static [&'static str], api_key_env: Option<&'static str>, key_env_aliases: &'static [&'static str], default_base_url: Option<&'static str>, requires_api_key: bool, requires_base_url: bool, requires_model: bool, detect_patterns: &'static [&'static str], model_discovery: ModelDiscovery, create: CreateFn, } }
detect_provider() 方法(crates/octos-llm/src/registry/mod.rs:244-260)按优先级顺序遍历所有 Provider,检查模型名是否包含检测模式。两处结构值得注意:default_model 不再是每个条目上的静态字符串,而是从 model_catalog.json 中标 "default": true 的行构建 family -> default model 映射(crates/octos-llm/src/registry/mod.rs:24-65);model_discovery 字段声明该 family 的模型列举能力(协议或"手动输入"),由 crates/octos-llm/src/discovery.rs:134 的 resolve_model_discovery(family, api_type) 在运行时解析:api_type: "anthropic" 直接命中 Anthropic Messages 策略,已知 family 查注册表声明,未知或 custom family 回落 OpenAI 兼容策略,协议从不由单个 family 字面量推断。
| Provider | 检测模式 | 匹配示例 |
|---|---|---|
| Anthropic | "claude" | claude-sonnet-4, claude-haiku-4-5 |
| OpenAI | "gpt",以及 o1/o3/o4 前缀 | gpt-4o, o4-mini |
| Gemini | "gemini" | gemini-2.5-flash, gemini-2.5-pro |
| DeepSeek | "deepseek" | deepseek-chat, deepseek-coder |
| Groq | "llama", "mixtral" | llama-3.3-70b-versatile, mixtral-8x7b |
| Moonshot | "kimi", "moonshot" | kimi-k2.5 |
| Dashscope | "qwen" | qwen-max |
| Minimax | "minimax" | MiniMax-Text-01 |
| Zhipu | "glm" | glm-4-plus |
特殊处理:O 系列模型。 OpenAI 的 o1、o3、o4 系列需要前缀匹配而非子串匹配(crates/octos-llm/src/registry/mod.rs:248-250),因为 "o1" 作为子串可能匹配到其他 Provider 的模型名中(如 ro1and 假设模型名)。
另一个容易误读的点是:有些 Provider 的 detect_patterns 为空,这不是遗漏,而是明确要求用户通过显式 provider 名或 alias 命中。典型例子包括 Vertex、R9s、OpenRouter、Z.AI、两个 coding-plan family、NVIDIA、Ollama、vLLM 和 local。它们的模型名往往是跨平台转发名、OpenAI 兼容名或用户本地自定义名,盲目做子串检测会产生错误路由。coding-plan family(moonshot-coding、zai-coding)注册在基础 family 之前,显式点名时 coding 端点先于普通端点解析,避免 coding-plan 密钥被静默送到会拒绝它的普通端点。
3.2.2 完整 Provider 注册表
octos 当前注册 19 个 provider family(口径:crates/octos-llm/src/registry/mod.rs 中 static ALL 的条目数,186-210 行;registry/ 目录 20 个文件 = 19 个 family 模块 + crates/octos-llm/src/registry/mod.rs 本身),按数组顺序即检测优先级:
| 序 | family | 协议 | 别名(示例) | 检测模式 | 默认模型(取自目录) |
|---|---|---|---|---|---|
| 1 | anthropic | 原生 | - | claude | claude-sonnet-4-20250514 |
| 2 | openai | 原生 | - | gpt, o1/o3/o4 前缀 | gpt-4o |
| 3 | gemini | 原生 | google | gemini | gemini-2.5-flash |
| 4 | vertex | 原生(Gemini via Vertex SA) | vertex-ai | - | gemini-2.5-flash |
| 5 | r9s | OpenAI/Anthropic 兼容 | r9s.ai | - | claude-sonnet-4-6 |
| 6 | openrouter | OpenAI 兼容元路由 | - | - | anthropic/claude-sonnet-4-6 |
| 7 | deepseek | OpenAI 兼容 | - | deepseek | deepseek-v4-flash |
| 8 | groq | OpenAI 兼容 | - | llama, mixtral | llama-3.3-70b-versatile |
| 9 | moonshot-coding | OpenAI 兼容(coding 端点) | kimi-coding | - | k3 |
| 10 | moonshot | OpenAI 兼容 | kimi | kimi, moonshot | kimi-k2.5 |
| 11 | dashscope | OpenAI 兼容 | qwen | qwen | qwen-max |
| 12 | minimax | OpenAI 兼容 | - | minimax | MiniMax-M3 |
| 13 | zai-coding | Anthropic 兼容(coding 端点) | glm-coding | - | glm-5.3 |
| 14 | zhipu | OpenAI 兼容 | glm | glm | glm-4-plus |
| 15 | zai | Anthropic 兼容 | z.ai | - | glm-5-turbo |
| 16 | nvidia | OpenAI 兼容 | nim | - | meta/llama-3.3-70b-instruct |
| 17 | ollama | OpenAI 兼容 | - | - | llama3.2 |
| 18 | vllm | OpenAI 兼容 | - | - | 用户必须显式提供 model |
| 19 | local | OpenAI 兼容 | llamacpp, lmstudio, openai-compatible 等 | - | local-default(占位符) |
相比旧稿的 15 个,新增 4 个 family:vertex(Vertex AI 上的 Gemini,凭据走 service-account JSON 环境变量 VERTEX_SA_JSON,无 base URL 默认值,模型列举标记 Unsupported)、moonshot-coding / zai-coding(两个 coding-plan 端点,显式点名命中,见上文)、local(llama.cpp / LM Studio 等本地服务器,别名最多,keyless 默认,缺模型时用占位符 local-default 而不是报错,真实窗口靠运行时探测,见 3.7 节)。
Anthropic、OpenAI、Gemini 使用专用实现;其余多数条目通过兼容层接入,其中 Ollama、vLLM、OpenRouter、DeepSeek、local 等复用 OpenAIProvider,Z.AI 复用 Anthropic Messages API,R9s 则按模型族在两种协议间自动切换。旧稿历史版本曾有 enum Provider 枚举,现已不存在。注册方式是"加一个文件 + 在 ALL 里加一行"(crates/octos-llm/src/registry/mod.rs:2-4 的模块注释),每个子模块导出 pub const ENTRY: ProviderEntry 和 create() 工厂。这种"少数专用实现 + 多数兼容适配"架构让新 Provider 的接入成本极低。
3.2.3 Provider 工厂
检测到 Provider 后,注册表通过工厂函数创建具体实例。每个工厂函数读取对应的环境变量(ANTHROPIC_API_KEY、OPENAI_API_KEY 等)或配置文件中的凭据,构造带有正确 base URL 和认证头的 HTTP 客户端。
工厂返回的类型是 Arc<dyn LlmProvider>——这是动态分发的关键点。注册表不知道(也不需要知道)具体的 Provider 类型,只知道它实现了 LlmProvider trait。这让上层代码可以用统一的方式处理所有 Provider,包括将它们放入容错链中。
3.3 容错链:三级能力与两级装配
生产环境中,LLM API 调用可能因为多种原因失败:速率限制(429)、服务器过载(503/529)、认证失效(401)、网络超时。octos-llm 把容错拆成三级能力、两级装配:RetryProvider 处理单个 Provider 的瞬时故障,是每个 slot 恒备的基座;其外的装配层二选一:ProviderChain 做静态有序故障转移(缺省装配),或 AdaptiveRouter 做评分路由(adaptive_routing.enabled 显式 opt-in,替换而非叠加 chain)。生产装配点在 crates/octos-cli/src/qos_catalog.rs:305-352。
flowchart TD
Request["用户请求"] --> Sel{"adaptive_routing.enabled?<br/>(缺省 false)"}
Sel -->|"false / 未配置"| PC["ProviderChain<br/>静态有序故障转移"]
Sel -->|"true(显式 opt-in)"| AR["AdaptiveRouter<br/>EMA 评分路由 / 对冲竞赛"]
PC --> RP1["RetryProvider<br/>(Provider A)"]
PC -->|"failover"| RP2["RetryProvider<br/>(Provider B)"]
AR -->|"评分选中"| RP1
AR -.->|"hedge racing / 探针"| RP2
RP1 --> LLM_A["Anthropic API"]
RP2 --> LLM_B["OpenAI API"]
style Sel fill:#f9f,stroke:#333
style PC fill:#bbf,stroke:#333
style AR fill:#bbf,stroke:#333
style RP1 fill:#bfb,stroke:#333
style RP2 fill:#bfb,stroke:#333
图 3-1:容错链的装配结构。 providers 向量的每个元素已是 RetryProvider 包装的 slot;外层按 adaptive_routing.enabled 二选一:缺省回退到静态 ProviderChain,显式 opt-in 才换成 AdaptiveRouter(替换而非叠加)。断路器也是两套独立的:ProviderChain 用 ProviderSlot.failures(crates/octos-llm/src/failover.rs),AdaptiveRouter 用自己的 per-slot circuit breaker(crates/octos-llm/src/adaptive.rs:1036 一带)。
装配点的源码事实:crates/octos-cli/src/qos_catalog.rs:305-352 是唯一的分叉处,crates/octos-cli/src/commands/gateway/profile_factory.rs:363,413、crates/octos-cli/src/commands/chat.rs:1210、crates/octos-cli/src/tools/switch_model.rs:289 全部是 ProviderChain::new(vec![RetryProvider...]),不存在 AdaptiveRouter 内嵌多个 ProviderChain 的路径。
3.3.1 基座:RetryProvider — 每个 Provider 恒包的指数退避
RetryProvider(crates/octos-llm/src/retry.rs:41-312)处理单个 Provider 的瞬时故障。
退避算法(crates/octos-llm/src/retry.rs:259-265):
#![allow(unused)] fn main() { fn calculate_delay(&self, attempt: u32) -> Duration { let delay = self.config.initial_delay.as_secs_f64() * self.config.backoff_multiplier.powi(attempt as i32); let delay = Duration::from_secs_f64(delay); std::cmp::min(delay, self.config.max_delay) } }
默认配置(crates/octos-llm/src/retry.rs:28-37):最多重试 3 次,初始延迟 1 秒,退避乘数 2.0,最大延迟 60 秒。重试循环是 for attempt in 0..=max_retries(crates/octos-llm/src/retry.rs:322,流式版同构),且仅当还有重试额度(attempt < max_retries)且错误可重试时才计算延迟并 sleep。因此默认退避序列实际是 1s → 2s → 4s,共三次等待,第 4 次调用失败后直接向上抛错;2^3 = 8s 那一档在循环里走不到,60 秒钳位对默认指数序列同样永不触达,它只在运营者调大退避参数、或 429 解析出超过 60 秒的 retry-after 时才生效。
哪些错误可重试?(crates/octos-llm/src/retry.rs:107-147)
| HTTP 状态码 | 含义 | 是否重试 | 是否触发 failover |
|---|---|---|---|
| 429 | 速率限制 | 是(解析 retry-after) | 是 |
| 500, 502, 503 | 服务器错误 | 是 | 是 |
| 529 | 过载 | 是 | 是 |
| 401, 403 | 认证错误 | 否 | 是(立即 failover) |
| 504 | Gateway 超时 | 是(服务器可能恢复) | 是 |
| 408 | 请求超时 | 看具体情况 | 是 |
| reqwest timeout | 网络超时 | 否(本地不重试) | 是(立即 failover) |
| 400 | 请求错误 | 看具体消息 | 部分情况 |
注意 reqwest 级别的网络超时(连接超时、读超时)的特殊处理:不在本地重试(因为同一个 Provider 大概率还是超时),而是立即向上层触发 failover,让 ProviderChain 切换到另一个 Provider。HTTP 504(Gateway Timeout)则被视为可重试,因为服务器可能在短暂过载后恢复。
速率限制解析(crates/octos-llm/src/retry.rs:267-311):当收到 429 响应时,RetryProvider 会尝试从错误消息中解析推荐的等待时间(如 "Please try again in 29.159s"),加上 1 秒缓冲后等待。如果无法解析,回退到 30 秒固定等待。
3.3.2 装配选项 A:ProviderChain — 静态有序故障转移(缺省)
ProviderChain(crates/octos-llm/src/failover.rs:36-249)管理一组 Provider 的故障转移顺序。
Circuit Breaker 设计(crates/octos-llm/src/failover.rs:27-30):
#![allow(unused)] fn main() { struct ProviderSlot { provider: Arc<dyn LlmProvider>, failures: AtomicU32, // 连续失败计数器 } }
每个 Provider 维护一个原子计数器记录连续失败次数。当失败次数达到阈值(默认 3),该 Provider 被标记为"降级"(degraded)。成功调用后计数器重置为 0(record_success @ crates/octos-llm/src/failover.rs:111-114,swap(0) 在 114 行)。
故障转移逻辑(crates/octos-llm/src/failover.rs:85-99):
- 首先尝试第一个未降级的 Provider
- 如果所有 Provider 都降级了,选择失败次数最少的那个
- 跳过已降级的 Provider,除非它是最后的选择
延迟故障上报(crates/octos-llm/src/failover.rs:378):report_late_failure() 处理一种微妙的场景:Provider 返回了 200 响应,但流式解析后发现内容为空或格式错误。这时需要回溯性地惩罚该 Provider,增加其失败计数,让后续请求优先选择其他 Provider。
3.3.3 装配选项 B:AdaptiveRouter — EMA 评分与对冲竞赛(显式 opt-in)
AdaptiveRouter(crates/octos-llm/src/adaptive.rs:803 起的 struct,inherent impl 延伸至文件尾 2795 行)是与 ProviderChain 同位的另一个装配选项:opt-in 时它接收的 providers 向量与 chain 本会是同一个(每个元素已包 RetryProvider),在其上做评分路由,而不是在 chain 外再叠一层。
三种模式(crates/octos-llm/src/adaptive.rs:619-631):
- Off (0):静态优先级排序 + circuit breaker,最简单可靠
- Hedge (1):基于评分选择 + 对冲竞赛(hedge racing)
- Lane (2):基于评分的车道切换,比 hedge 更节省成本
EMA 评分系统
AdaptiveRouter 为每个 Provider 维护一个实时评分。默认配置下,评分由四个因子的加权组合决定(score() @ crates/octos-llm/src/adaptive.rs:1766 起,权重配置字段 @ 42-64);其中配置字段仍保留 weight_latency 这个名字,但第二项实际是"质量 + 吞吐"的复合因子,源码并不直接使用原始 latency:
| 因子 | 权重 | 含义 | 数据来源 |
|---|---|---|---|
| 稳定性 (error_rate) | 30% | 错误率 | 实时统计 + 目录基线混合 |
质量/吞吐 (weight_latency) | 30% | 输出质量 + 运行时吞吐 | 60% 深度搜索 token 数 + 40% 吞吐量 |
| 优先级 (priority) | 20% | 配置顺序 | 用户配置 |
| 成本 (cost) | 20% | 价格 | 模型目录 |
混合权重设计(crates/octos-llm/src/adaptive.rs:1582 与 1772,score 内的 EMA 混合):稳定性因子使用"目录基线 + 实时数据"的混合计算。混合权重按调用次数递增:min(total_calls / 20.0, 0.5),这意味着目录基线始终至少占 50% 的影响力。这个设计防止了"冷启动"问题:新 Provider 只有少量调用时,不会因为一两次偶然失败就被判为不可靠。
这里要特别注意:score() 明确用 throughput 而不是原始 latency 做运行时速度信号,因为单次请求延迟更容易受任务复杂度影响,不适合作为跨 Provider 的直接质量指标。
对冲竞赛(Hedge Racing)
在 Hedge 模式下,AdaptiveRouter 会同时向两个 Provider 发起请求,取先返回的结果(hedged_chat() @ crates/octos-llm/src/adaptive.rs:2150 起,tokio::select! 竞赛在 ~2220):
#![allow(unused)] fn main() { // 简化后的逻辑 tokio::select! { result = primary_future => { // 主 Provider 先返回 // 备选 Provider 的 future 被 drop(取消) result } result = alternate_future => { // 备选 Provider 先返回 // 主 Provider 的 future 被 drop(取消) result } } }
备选 Provider 的选择优先选最便宜的(减少冗余成本),且必须与主 Provider 不同名(避免向同一 API 发重复请求)。当前实现只记录完成请求的运行时指标;输掉竞赛而被 drop 的 future 不会可靠地产生完整指标,因此 hedge 能改善尾延迟,但会降低观测数据的完整性。
对冲竞赛的代价是双倍的 API 调用成本(输掉竞赛的请求仍然消耗 token,即使被取消,Provider 通常已经开始处理)。因此 Hedge 模式适用于延迟敏感、成本不敏感的场景。Lane 模式则通过评分排序实现类似的路由优化,但不发送冗余请求。
探针策略(Probe)
为了保持备用 Provider 的评分数据新鲜,AdaptiveRouter 以一定概率(默认 10%)向非最优 Provider 发送"探针"请求(should_probe() @ crates/octos-llm/src/adaptive.rs:2097,陈旧判定 is_stale @ 337,默认值 0.1/60s 在配置结构 42-44 与 62-63 行),刷新其性能指标。探针间隔默认 60 秒,避免频繁探测带来的成本。
3.3.4 Credential Pool 与 Content Classifier:从 Provider failover 到 key / tier 路由
当前主分支的容错链不只是在 Provider 之间切换,还会在同一 Provider 的多组凭据之间轮换,并根据内容复杂度选择模型 tier。
Credential pool。 crates/octos-llm/src/credential_pool.rs 的模块注释明确把目标定义为"持久化 cooldown / rotation state"(crates/octos-llm/src/credential_pool.rs:1-29)。每个凭据都有 cooldown、rate-limit 计数、reset 时间、last used、usage count、reservation 等状态;轮换策略包括 FillFirst、RoundRobin、Random、LeastUsed(crates/octos-llm/src/credential_pool.rs:166-189)。当 AdaptiveRouter 发现 401/403 或认证文本时,会把失败分类为 auth failure;发现 429 或 rate limit 文本时,会分类为 rate-limit failure,并通知 credential pool 进入 cooldown 或刷新流程(notify_credential_failure() @ crates/octos-llm/src/adaptive.rs:1081 起,错误侧入口 notify_credential_failure_from_error @ 2352)。轮换结果还会发出稳定的 harness event,schema 为 octos.harness.event.v1,kind 为 credential_rotation(crates/octos-llm/src/credential_pool.rs:213-245)。
Content classifier。 crates/octos-llm/src/content_classifier.rs 是一个无 I/O 的启发式分类器,默认关闭;关闭时返回 Strong,避免因为未启用策略而误把复杂任务路由到便宜模型(crates/octos-llm/src/content_classifier.rs:1-18, crates/octos-llm/src/content_classifier.rs:61-80)。启用后,它根据代码块、消息长度、强模型关键词和 URL 信号产生 Cheap 或 Strong tier:代码块、长度超过阈值、命中 debug/refactor/architecture/prove/proof/analyze/design 等关键词会升到 Strong;URL 只作为 reason 记录,不单独触发 Strong(crates/octos-llm/src/content_classifier.rs:158-209)。AdaptiveRouter 可挂载 classifier,并在选择 lane 之前通过 callback 发出 routing.decision harness event(RoutingDecisionCallback 类型 @ crates/octos-llm/src/adaptive.rs:801,安装入口 set_routing_decision_callback @ 1414)。这让上层 harness 可以解释"为什么这个 turn 走强模型",而不是只能看到最终 provider。
3.4 SSE 流式解析:字节安全的有状态解析器
LLM 的流式响应以 Server-Sent Events(SSE)协议传输。SSE 看似简单(每个事件以 \n\n 分隔,每行以 data: 前缀标记数据),但在生产环境中,有几个工程挑战需要解决。
3.4.1 为什么需要有状态解析
HTTP 响应的 body 以任意大小的字节块(chunk)到达。一个 SSE 事件可能跨越多个 chunk,一个 chunk 也可能包含多个事件。更微妙的是,chunk 的边界可能正好切开一个 UTF-8 多字节字符。
考虑以下场景:
Chunk 1: data: {"text": "任务完
Chunk 2: 成后请检查结果"}\n\n
"完" 和 "成" 之间不会有问题(它们各自是完整的 UTF-8 字符),但如果 chunk 边界恰好落在"完"字的三个字节中间:
Chunk 1: data: {"text": "任务\xe5\xae
Chunk 2: \x8c成后请检查结果"}\n\n
此时 Chunk 1 末尾的 \xe5\xae 是"完"字的前两个字节,不是合法的 UTF-8。如果逐 chunk 做 String::from_utf8(),就会得到一个解析错误或替换字符(U+FFFD)。
3.4.2 octos 的字节安全解析器
octos-llm 的 SSE 解析器(crates/octos-llm/src/sse.rs:16-72)采用字节级缓冲策略:
- 原始字节累积:将每个 chunk 的原始字节追加到
Vec<u8>缓冲区,不做 UTF-8 转换 - 事件边界检测:在原始字节中搜索
\n\n或\r\n\r\n分隔符 - 按事件转换:找到完整事件后,才将该事件的字节块转换为 UTF-8 字符串
- 剩余字节保留:未形成完整事件的尾部字节保留在缓冲区中
这种设计保证了 UTF-8 转换只发生在完整事件上:SSE 协议保证事件边界不会落在 UTF-8 字符中间(因为 \n 是 ASCII 单字节字符)。
解析器使用 stream::unfold() 构建为一个异步流,保持状态(字节流 + 缓冲区)在 yield 事件之间传递。
3.4.3 1MB 缓冲上限
安全考量:如果恶意或异常的 LLM Provider 发送一个永远不包含 \n\n 的超长响应,缓冲区会无限增长。MAX_BUFFER_SIZE(crates/octos-llm/src/sse.rs:6-7)设为 1MB,超过后解析器发出错误事件并清空缓冲区。
#![allow(unused)] fn main() { const MAX_BUFFER_SIZE: usize = 1024 * 1024; // 1MB }
1MB 对于单个 SSE 事件来说绰绰有余。正常的 LLM 流式响应中,每个事件通常只有几十到几百字节(一个 token 的 JSON 表示)。
3.4.4 UTF-8 分割测试:为什么字节级缓冲不可省略
crates/octos-llm/src/sse.rs 的测试(crates/octos-llm/src/sse.rs:261-281)构造了一个精确的多字节分割场景:
"完成后" 的 UTF-8 编码:
完 = [E5 AE 8C] (3 bytes)
成 = [E6 88 90] (3 bytes)
后 = [E5 90 8E] (3 bytes)
故意在"成"字中间切开:
Chunk 1: data: {"text": "完[E6 88 ← "成"的前 2 字节
Chunk 2: 90]后"}\n\n ← "成"的第 3 字节 + "后"
如果逐 chunk 做 String::from_utf8(),Chunk 1 末尾的 [E6 88] 不是合法 UTF-8,会被替换为 U+FFFD(替换字符),"成"字永久丢失。
字节级缓冲策略避免了这个问题:两个 chunk 的原始字节被拼接后,在 \n\n 边界整体转换,"完成后" 被正确重组。
这不是一个理论风险:当 LLM 流式输出中文回复时,每个 SSE 事件通常只包含 1-3 个 token。HTTP 的 chunked transfer encoding 可能在任何字节位置切割,与 token 边界无关。对于一个服务中文、日文、韩文用户的 Agent 平台,字节级缓冲是必需的而非优化。
3.5 模型目录与成本追踪
ModelCatalog(crates/octos-llm/src/catalog.rs:48-274)为每个已知模型维护元数据:
#![allow(unused)] fn main() { pub struct ModelInfo { pub id: String, pub name: String, pub provider: String, pub context_window: u32, pub capabilities: ModelCapabilities, // vision, tool_use, streaming, reasoning pub cost: ModelCost, // input/output/cache 每百万 token 价格 pub aliases: Vec<String>, } }
别名系统:除了完整的模型 ID(如 claude-sonnet-4-20250514),目录还支持别名查找(如 sonnet → claude-sonnet-4-20250514)。查找顺序(crates/octos-llm/src/catalog.rs:72-74):精确 ID 匹配 → 别名匹配 → None。
成本追踪:ModelCost 记录输入、输出、缓存读取三种 token 类型的百万 token 价格。AdaptiveRouter 的评分系统使用这些数据计算成本因子(见 3.3.3 节),在延迟和成本之间做权衡。
3.6 成本层:cache 经济学
容错链解决"能不能调通",成本层解决"这次调用花多少钱"。提交 f3aa07f0(#2194)把 prompt cache 的写入与计价纳入这条链路,出发点是一个不对称的事实:cache 写按 1.25 倍输入价计费,cache 读有折扣,而一次不再重放前缀的调用(compaction 摘要、子 agent 汇总)写了缓存也不会有人读,写入的溢价是纯损失。
一次性调用的 cache-write 退出。 ChatConfig::cache_retention(crates/octos-llm/src/config.rs:58-75,CacheRetention 枚举 74-92 行)为单次请求声明缓存保留偏好:None 表示这次不写缓存。Anthropic 协议上的具体动作是不发任何 cache_control 断点,因为每个断点既读又写,一次没人读的写仍按 1.25 倍计费。Default 维持 provider 配置的缓存行为(Anthropic 的三个 ephemeral 断点)。默认值保持 Default 且 skip_serializing_if 不上 wire,持久化的 ChatConfig JSON 形状不变。装配点在 crates/octos-llm/src/anthropic.rs 的 build_request(cache_control 装配 179-207 行;一次性请求不写 cache 的分支见 148 行附近)。
cache-write 感知定价。 定价层(crates/octos-llm/src/pricing.rs:149-195)按应答 slot 的协议区别建模:只有 Anthropic Messages 协议上报 cache_creation_input_tokens(写,1.25 倍)与 cache_read_input_tokens(读,折扣);其他协议(openai、openrouter、deepseek、local、未知)的缓存读折扣不可知:目录中唯一的 OpenAI 行 cache_read_per_mtok 为 None,折扣随模型代次变化,套一个 provider 级折扣等于替部分模型编数。因此读按全输入价(1.0 倍,宁可高估不低估)计,写按 1.25 倍而非 0 计(cache_write_tokens 只有 Anthropic 解析器会填)。
flowchart TD
Req["请求到达"] --> CR{"ChatConfig::cache_retention?"}
CR -->|Default| CC["Anthropic: 装配三个 cache_control 断点<br/>读 + 写"]
CR -->|"None(一次性调用)"| NC["不发任何断点<br/>跳过 1.25x 写入溢价"]
CC --> P["crates/octos-llm/src/pricing.rs 计价"]
NC --> P
P --> AP{"slot 说 Anthropic Messages?"}
AP -->|是| W["写 1.25x / 读折扣价"]
AP -->|否| F["读按全输入价 1.0x(不低估)<br/>写 1.25x,非 0"]
图 3-2:cache 经济学决策流。 配置层决定写不写,计价层决定怎么算。
3.7 车道与路由:容错链之外的调度件
容错链之外,基线上还有一组围绕"谁来接这次调用"的模块。它们不是同一条链的层,而是按场景挂在 provider 之上的调度件。
crates/octos-llm/src/lane.rs(per-topic 模型车道,RFC-3 / #1292)。 按会话主题(如 slides:*、code:*、research:*)把请求路由到不同模型档位,让"做幻灯片"和"写核心代码"不必共享同一个 primary/fallback 组合。
crates/octos-cli/src/api/router.rs(多模型子 agent 的 provider 路由)。 按前缀方案把 LLM 调用分派给不同子 provider:子 agent 用不同前缀声明自己的模型需求,路由器据此选中对应后端,母 agent 与子 agent 可以在同一会话内并行使用不同模型。
crates/octos-llm/src/call_policy.rs(每 turn 调用策略)。 语音 turn 把 agent run 包在 with_llm_call_policy(FailFast, ..) 里:每个 provider 包装器与叶子 provider 都短路 retry、failover 与 hedge,低延迟优先。策略是 per-turn 的任务级输入,不是全局开关。
crates/octos-llm/src/throttle.rs(信号量节流)。 用信号量限制 provider 的并发调用数,保护有严格并发配额的端点(多凭据时按池分摊)。它管的是"同时几条",与 registry 管的"用哪个"正交。
crates/octos-llm/src/credential_pool.rs(凭据池,M6.5)。 见 3.3.3 节:多凭据轮换 + 持久化 cooldown,429/auth 失败驱动。
crates/octos-llm/src/local_context_probe.rs(本地窗口探测,#2135)。 提交 10022387 引入。目录里 local / ollama / vllm 家族的窗口数是注册时的保守猜测(约 32K),因为注册时没人知道运营者启动了什么引擎。探测器(1300 行模块,new @ 201,new_ollama @ 226)在后台问服务器拿真实窗口:OpenAI 兼容端点走 props/models 两个 URL,Ollama 原生走 GET /api/ps(取运行中模型的分配窗口,无需 key)。探测结果经 trait 的 ensure_ready() 钩子(crates/octos-llm/src/provider.rs:65)在首次 compaction 决策前 resolve,context_window() 不再是纯静态查表。要压过探测值,用 3.1.2 节的 per-profile context_window 覆盖(操作员覆盖 > 探测 > 目录)。
工程决策侧栏:Arc<dyn Trait> vs 泛型 vs 枚举分发
octos-llm 在 Provider 抽象层大量使用
Arc<dyn LlmProvider>。这个选择值得与两种替代方案对比。方案一:泛型(
impl LlmProvider/T: LlmProvider)优势:
- 零运行时开销:编译器在每个调用点生成特化代码(单态化)
- 方法调用可被内联优化
劣势:
- RetryProvider、ProviderChain 等包装器需要泛型参数传染:
RetryProvider<T: LlmProvider>- 容错链的组合会产生类型爆炸:
AdaptiveRouter<ProviderChain<RetryProvider<AnthropicProvider>>, ProviderChain<RetryProvider<OpenAIProvider>>>- 无法在运行时基于用户配置动态选择 Provider(泛型在编译期就确定了具体类型)
方案二:枚举分发(
enum Provider { Anthropic(...), OpenAI(...), ... })优势:
- 编译期确定所有变体,分支预测更友好
- 无 vtable 间接调用开销
劣势:
- 每增加一个 Provider 就需要修改枚举定义和所有 match 表达式
- 对于 19 个 provider family,match 块会非常庞大
- 无法支持用户自定义 Provider(除非用
Custom变体退化回 trait object)octos 的选择:
Arc<dyn LlmProvider>,原因如下。在 AI Agent 场景中,LLM 调用的网络延迟(100ms-10s)远大于 vtable 间接调用的开销(<1ns)。动态分发的性能代价在这里完全可以忽略。
更重要的是组合性。octos 的容错链本质上是装饰器模式的嵌套组合:RetryProvider 包装任意 Provider,ProviderChain 管理一组 Provider,AdaptiveRouter 在多个 Chain 之间路由。
Arc<dyn LlmProvider>让这些包装器可以自由组合,不受泛型参数的限制。最后,Provider 的种类在运行时才确定:用户通过配置文件指定使用哪些 Provider,注册表工厂根据配置动态创建实例。这种"运行时多态"正是 trait object 的核心使用场景。
3.8 本章回顾
octos-llm 解决了 LLM Provider 集成的核心挑战:
-
LlmProvider trait:最小化的统一接口,
chat()+chat_stream()双方法设计,Send + Sync约束保证线程安全。Provider metadata 让上层能看到实际命中的 provider slot,而不是只看到组合包装器。 -
Provider 注册表:模型名子串匹配自动检测 Provider,工厂模式动态创建
Arc<dyn LlmProvider>实例。特殊处理 O 系列模型的前缀匹配,并明确把 R9s、OpenRouter、Z.AI、NVIDIA、Ollama、vLLM 这类detect_patterns为空的 Provider 留给显式 provider / alias 选择。 -
容错链(三级能力、两级装配):
- RetryProvider:指数退避(1s→2s→4s),智能解析 429 响应的 retry-after 头
- ProviderChain:有序故障转移 + circuit breaker(3 次连续失败触发降级),缺省装配
- AdaptiveRouter:四因子 EMA 评分(稳定性 30% + 质量/吞吐 30% + 优先级 20% + 成本 20%)+ 对冲竞赛 + 探针策略,需显式 opt-in,替换而非叠加 chain
-
Credential pool 与 content classifier:429/auth failure 会进入凭据 cooldown / refresh 路径;content classifier 发出
routing.decision事件,把 Cheap/Strong tier 的选择暴露给 harness。 -
成本层与调度件:cache 经济学(
cache_retention退出 + cache-write 感知定价)把"写不写缓存"变成显式决策;lane / router / call_policy / throttle / credential_pool / local_context_probe 六个调度件按场景挂在容错链之外。 -
SSE 流式解析:字节级缓冲避免 UTF-8 分割问题,1MB 上限防止内存耗尽,
stream::unfold()构建有状态异步流。 -
Arc<dyn Trait>选择:网络延迟远大于 vtable 开销,动态分发换来的组合性和运行时灵活性物超所值。
下一章将进入 octos-memory,看看混合搜索(BM25 + HNSW 向量索引)如何为 Agent 提供长期记忆能力。
延伸阅读
- async-trait crate:https://docs.rs/async-trait/latest/async_trait/ — 了解
#[async_trait]宏如何将 async 方法编译为 trait object 兼容的形式 - SSE 协议规范:HTML Living Standard "Server-Sent Events" 章节,https://html.spec.whatwg.org/multipage/server-sent-events.html
- 指数退避算法:Google Cloud 的 "Truncated exponential backoff" 文档,https://cloud.google.com/storage/docs/exponential-backoff
- Circuit Breaker 模式:Martin Fowler, "CircuitBreaker",https://martinfowler.com/bliki/CircuitBreaker.html
- Rust 动态分发:The Rust Programming Language 第 17 章 "Using Trait Objects That Allow for Values of Different Types"
思考题
-
容错层次设计:octos 的容错装配中,如果把 RetryProvider 的重试职责合并进 ProviderChain(chain 直接管理裸 Provider 并自己做退避)会怎样?分离的好处是什么?
-
对冲竞赛的成本模型:假设你有两个 Provider:Provider A 价格 $10/M tokens、平均延迟 500ms;Provider B 价格 $3/M tokens、平均延迟 1500ms。在什么条件下开启 hedge racing 是划算的?
-
SSE 解析器的替代方案:如果不用字节级缓冲,而是用
String::from_utf8_lossy()处理每个 chunk,会产生什么问题?在什么场景下这些问题会变得可观测? -
泛型 vs trait object 的边界:如果 octos 只需要支持 3 个 Provider(Anthropic、OpenAI、Gemini),枚举分发是否是更好的选择?支持多少个 Provider 时动态分发才开始胜出?
版本演化说明 本章分析基于
../octosmain @ 9c157101(2026-09-02)。本次修订要点:① 注册表从 15 个 Provider 重列为 19 个 provider family(新增 vertex、moonshot-coding、zai-coding、local),注册叙事改为 registry/ 目录 +crates/octos-llm/src/discovery.rs:134的模型发现协议,不再出现enum Provider;② trait 补入ensure_ready()与estimate_request_tokens(),provider_metadata_for_index签名改为Option<usize>;③ timeout 叙事改讲CreateParams::http_timeout()与DEFAULT_LLM_STREAM_IDLE_TIMEOUT_SECS;④ 新增 3.6「成本层」(cache 经济学,提交 f3aa07f0)与 3.7「车道与路由」(六个调度件模块与 10022387 本地窗口探测);⑤ sampler passthrough(b0072e70)与 per-profile context_window 覆盖(3e479ce3,接线主体在 octos-cli)写入 3.1.2;⑥ 全部引用行号按 9c157101 逐条重标(crates/octos-llm/src/adaptive.rs 已扩至 2795 行,crates/octos-llm/src/catalog.rs 勘误为 274 行,见 assets/ch03-refcheck.md)。Provider 注册表与评分权重可能继续调整。本轮修复(C2 报告 assets/ch03-techreview.md 裁定项):⑦ 容错链叙事改为三级能力与两级装配(mermaid 图改为二选一分支,AdaptiveRouter 与 ProviderChain 同位替换而非嵌套);⑧ ChatConfig 字段清单按 crates/octos-llm/src/config.rs:8-70 重列(删去不存在的 model/system_prompt,补入 stop_sequences、reasoning_effort、context_management);⑨ 退避序列更正为 1s → 2s → 4s(8s 档位与 60s 钳位在默认序列下不可达);⑩ 本章回顾由重号的 3.6 改为 3.8。容错能力的三级递进仍是理解 octos-llm 的主线。
第 4 章:octos-memory:混合搜索的工程实现
定位:本章深入 octos-memory crate(
wc -l口径 6,428 行,六个源文件,含测试),展示如何用纯 Rust 构建嵌入式 BM25 + HNSW 混合搜索引擎,为 Agent 提供长期记忆能力。前置依赖:第 2 章。适用场景:想理解 RAG(检索增强生成)底层实现的 AI 应用开发者(读者 C),以及对嵌入式数据库和搜索算法感兴趣的 Rust 开发者(读者 B)。
AI Agent 和 chatbot 的根本区别之一在于记忆。chatbot 的每次对话都是独立的——上一次帮你写的代码、做过的决策、踩过的坑,下一次全部忘记。Agent 需要记忆来积累经验:上次用户偏好什么代码风格?这个仓库最近修改了哪些文件?三天前解决一个类似 bug 时采取了什么策略?
octos-memory 用 6,428 行代码(wc -l 口径,含测试与注释)实现了这个记忆系统。它不依赖任何外部服务——没有 Qdrant,没有 Milvus,没有 PostgreSQL。一个 redb 嵌入式数据库文件加一个内存中的混合搜索索引,就是全部。本章将从存储层开始,逐步深入 BM25 全文搜索、HNSW 向量索引和混合排名融合的工程实现。
4.1 存储选型:redb 嵌入式数据库
4.1.1 为什么是 redb
octos-memory 的持久化层使用 redb(crates/octos-memory/src/store.rs),一个纯 Rust 实现的嵌入式键值数据库。在选型时,最直接的替代方案是 SQLite:Rust 生态中有成熟的 rusqlite 绑定。
redb 相比 SQLite 的优势在 octos 的场景中非常明确:
零 C 依赖。 SQLite 是 C 实现的,rusqlite 需要编译 C 代码或链接系统库。这与 octos workspace 级别的 deny(unsafe_code) 策略冲突:虽然 SQLite 的 C 代码质量极高,但它不受 Rust 编译器的安全检查。redb 是纯 Rust 实现,完全在 deny(unsafe_code) 的保护范围内。
ACID 事务。 redb 提供完整的 ACID 事务支持(读事务和写事务分离),足以满足 episode 存储的持久化需求。octos 不需要 SQL 查询,所有搜索都在内存中的混合索引上进行。
单文件部署。 redb 数据库是一个文件(episodes.redb),不需要额外的 WAL 文件或 SHM 文件。这简化了部署和备份。
4.1.2 三张表结构
Episode Store 在 redb 中定义了三张表(crates/octos-memory/src/store.rs:14-20):
| 表名 | Key 类型 | Value 类型 | 用途 |
|---|---|---|---|
EPISODES_TABLE | &str (episode_id) | &str (JSON) | Episode 元数据 |
CWD_INDEX_TABLE | &str (工作目录路径) | &str (JSON 数组) | 目录→Episode 索引 |
EMBEDDINGS_TABLE | &str (episode_id) | &[u8] (bincode) | 向量嵌入 |
这个设计把 Episode 数据和向量嵌入分开存储。好处是当不需要向量搜索时(比如 embedding provider 不可用),Episode 的存取不受影响。向量嵌入使用 bincode 序列化(比 JSON 紧凑得多),减少存储和 I/O 开销。
CWD_INDEX_TABLE 是一个辅助索引,按工作目录聚合 Episode ID。当 Agent 在某个项目目录下工作时,优先检索该目录下的历史 episode,提高相关性。
4.2 Episode:Agent 的经验记录
4.2.1 Episode 结构体
Episode 是 Agent 完成一个任务后的经验摘要(crates/octos-memory/src/episode.rs:32-59;结构体前新增了 EpisodeSource 枚举,crates/octos-memory/src/episode.rs:26-31,区分任务摘要与会话压缩摘要,#[serde(default)] 让旧数据照常反序列化):
#![allow(unused)] fn main() { pub struct Episode { pub schema_version: u32, // 数据格式版本 pub id: String, // UUID v7 pub task_id: TaskId, pub agent_id: AgentId, pub working_dir: PathBuf, // 任务执行目录 pub summary: String, // 任务摘要 pub outcome: EpisodeOutcome, // 结果 pub source: EpisodeSource, // 产生者:任务或会话压缩 pub key_decisions: Vec<String>, // 关键决策记录 pub files_modified: Vec<PathBuf>, pub created_at: DateTime<Utc>, } }
EpisodeOutcome(crates/octos-memory/src/episode.rs:86-98)定义了内存层可表达的四种结果:Success、Failure、Blocked、Cancelled。它和 Task 的几类终态语义相近,但不能反推"系统一定会把所有终态都写成 Episode";是否落库取决于上层调用点。当前主 Agent loop 实际只会写入 Success episode。
schema_version 字段(crates/octos-memory/src/episode.rs:36,#[serde(default)] 在 35)是前向兼容的关键。当 Episode 的格式需要升级时(比如新增字段),旧版本的数据仍然可以通过版本号正确解析。默认值为 1(crates/octos-memory/src/episode.rs:16-18,常量在 14),反序列化时如果 JSON 中没有这个字段,自动填充默认值。
4.2.2 写入时机
在当前主 Agent 路径里,Episode 只会在 LLM 响应以 StopReason::EndTurn 或 StopSequence 正常结束,且 save_episodes 打开时创建并存储(crates/octos-agent/src/agent/loop_runner.rs:2457-2504,EndTurn 臂从 2,457 起,写入块在 2,474-2,504;落库逻辑位于 crates/octos-memory/src/store.rs:371-423)。写入过程:
- 将 Episode 序列化为 JSON,存入
EPISODES_TABLE - 更新
CWD_INDEX_TABLE,将 Episode ID 追加到对应工作目录的列表中 - 更新内存中的混合搜索索引(文本部分)
向量并不是在 store() 中同步写入的。当前实现把 episode 文本和 embedding 分成两个阶段:store() 先写 JSON 和 BM25 索引;store_embedding() 再把 Vec<f32> 用 bincode 写入 EMBEDDINGS_TABLE,并调用 HybridIndex::add_embedding() 给已有文档补上 HNSW 向量。这让 episode 落库不依赖 embedding provider 的可用性。
启动时,EpisodeStore::open() 会扫描 EPISODES_TABLE 和 EMBEDDINGS_TABLE,重建内存中的 HybridIndex。这意味着 redb 是唯一持久源,HNSW 和倒排索引都是可重建缓存。
腐败恢复(crates/octos-memory/src/store.rs:84-111):CWD_INDEX_TABLE 中的值是 JSON 数组(["id1", "id2", ...])。如果之前的写入因为崩溃被中断,JSON 可能是损坏的。代码会尝试从损坏的 JSON 中按引号分割抢救 Episode ID,而不是丢弃整个索引。这种防御性编程确保了即使在非正常关闭后也不会丢失索引数据。
删除路径(crates/octos-memory/src/store.rs:757-825):delete_by_id() 会从 EPISODES_TABLE、CWD_INDEX_TABLE 和 EMBEDDINGS_TABLE 中删除对应数据;内存索引侧调用 HybridIndex::remove()(crates/octos-memory/src/hybrid_search.rs:358-365),但它不会重排 HNSW 的内部 doc index,而是把 ids[pos] 清空作为 tombstone,搜索时过滤空 ID。这是一个典型的 ANN 索引工程取舍:删除快、索引稳定,代价是需要在未来重建索引来清理 tombstone。
4.3 BM25 全文搜索
BM25(Best Matching 25)是信息检索领域最经典的排名算法之一。octos-memory 在内存中维护一个倒排索引来实现 BM25 搜索(crates/octos-memory/src/hybrid_search.rs)。
4.3.1 倒排索引结构
#![allow(unused)] fn main() { // crates/octos-memory/src/hybrid_search.rs:8-39(简化) struct HybridIndex { inverted: HashMap<String, Vec<(usize, u32)>>, // 词项 → [(文档ID, 词频)] doc_lengths: Vec<usize>, // 每个文档的长度 total_len: usize, // 所有文档长度之和 avg_dl: f64, // 平均文档长度 ids: Vec<String>, // Episode ID 列表 hnsw: Option<Hnsw<'static, f32, DistCosine>>, // 向量索引(无 embedding 时为 None) dimension: usize, // 索引接受的向量宽度 // ... 权重与向量覆盖统计字段 } }
inverted 是倒排索引的核心:给定一个词项(如"重构"),可以快速找到包含该词项的所有文档及其出现频率。
分词策略(crates/octos-memory/src/hybrid_search.rs:731-737):
#![allow(unused)] fn main() { fn tokenize(text: &str) -> Vec<String> { text.to_lowercase() .split(|c: char| !c.is_alphanumeric()) .filter(|s| s.len() >= 2) .map(String::from) .collect() } }
采用最简单的分词方式:转小写后按非字母数字字符分割,过滤掉长度小于 2 的词项。对于中文,这并不会自动按单字切开:连续中文(如 中文测试)会保留成一个 token,只有遇到空格、标点等非字母数字字符才会断开,所以 中文 测试 会分成 中文 和 测试 两个 token;中英混写串(如 重构parser模块)也会连成一个 token。效果不如专业分词器(如 jieba),但胜在零依赖,适合摘要式经验检索。它也不过滤停用词,这一点会在 4.3.4 节的热路径优化里再出现。
4.3.2 BM25 评分公式
BM25 的核心公式(crates/octos-memory/src/hybrid_search.rs:636-722,bm25_score() 内;K1/B 项在 655-661):
score(q, d) = Σ IDF(qi) × (tf(qi, d) × (K1 + 1)) / (tf(qi, d) + K1 × (1 - B + B × |d| / avgdl))
octos 使用的参数(crates/octos-memory/src/hybrid_search.rs:83-84):
| 参数 | 值 | 含义 |
|---|---|---|
| K1 | 1.2 | 词频饱和度控制。值越大,高频词的权重越高 |
| B | 0.75 | 文档长度归一化。B=0 时忽略长度差异,B=1 时完全按长度归一化 |
这两个参数值是信息检索领域经过数十年实践验证的经典默认值(源自 TREC 评测实验),octos 直接采用而非自行调优。
IDF 计算(crates/octos-memory/src/hybrid_search.rs:677):
#![allow(unused)] fn main() { let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln(); }
IDF(逆文档频率)衡量一个词项的区分度:出现在越多文档中的词(如"代码"、"修改")IDF 越低,出现在越少文档中的词(如"deadlock"、"HNSW")IDF 越高。
4.3.3 epsilon 防 NaN
BM25 的评分归一化步骤(crates/octos-memory/src/hybrid_search.rs:703-706 与 718-721)中有一个微妙的工程细节:
#![allow(unused)] fn main() { let max_score = result.iter().map(|&(_, s)| s).fold(0.0f64, f64::max); if max_score < 1e-10 { return HashMap::new(); // 所有分数接近零,直接返回空结果 } // 归一化到 [0, 1] let normalized = score / max_score; }
1e-10 阈值检查的作用是防止除以接近零的数。当所有文档的 BM25 分数都极小时(比如查询词没有出现在任何文档中),直接除以 max_score 会放大浮点噪声。通过提前返回空结果,避免了这个问题。
这看起来是一个微不足道的细节,但 NaN 的传播性极强:一旦出现 NaN,后续的排序和融合都会产生错误结果,且不会报错(浮点运算中 NaN 与任何值比较都返回 false),这类 bug 极难定位。
4.3.4 检索热路径:dense 累加器与 top-k 分区
bm25_score() 的两遍开销在高文档频率(df)查询下会成为瓶颈。octos 的分词器不过滤停用词,查询 the fix 里的 the 会命中几乎全部文档(df ≈ n)。2026-08 的 cc6744ba 提交(#1855)改写了这条热路径,结果不变,高 df 查询实测 10 倍加速。
累加器按触达量切换(crates/octos-memory/src/hybrid_search.rs:645-699)。打分前先预估查询会遍历多少 postings(matched_postings,L645-649):
- dense 分支(L671-686):触达 ≥
n_docs / 8时(DENSE_ACCUM_DIVISOR = 8,L89),分配vec![0.0f64; n_docs]定长数组,用touched向量去重记录碰过的文档,最后回收。彻底消除哈希开销,代价是一次 O(n) 清零。 - sparse 分支(L688-699):选择性查询留在
HashMap累加器上,避免为少数几个文档付出整表清零的成本。
分支选择的注释写得很直白:哈希每个 posting 的开销在查询触达大半语料时占主导,而 selective 查询做 dense 清零是纯开销。BM25 每项贡献恒为正(IDF 对数参数对一切 df ≤ n 都大于 1,tf ≥ 1),dense 路径靠"分数从 0 变非 0"检测首次触碰,touched 因此不会漏。
top-k 分区替代全排序(crates/octos-memory/src/hybrid_search.rs:713-716)。旧的实现把所有命中文档全量排序再截断;单个高 df 词就能命中整个语料,全排序成了词法查询的最大成本。现在用 select_nth_unstable_by(limit - 1, by_score_desc) 做 O(matches) 分区后 truncate(limit)。返回类型是 HashMap,保留集内的顺序本来就不在契约里,契约只约束哪些候选存活,所以不需要排序。by_score_desc(L726-728)用 partial_cmp 并把 NaN 视为相等,与 4.3.3 的防 NaN 措施呼应。
4.4 HNSW 向量索引
4.4.1 HNSW 算法简介
HNSW(Hierarchical Navigable Small World)是目前最流行的近似最近邻(ANN)搜索算法之一。它构建一个多层图结构:
- 底层(Layer 0):包含所有数据点,每个点连接到最多 M 个最近邻
- 上层(Layer 1, 2, ...):只包含部分数据点,形成"高速公路":搜索从最高层开始,快速定位到目标区域,然后逐层下降进行精细搜索
这种分层结构让搜索复杂度从线性 O(N) 降低到对数 O(log N)。
4.4.2 octos 中的 HNSW 配置
octos 使用 hnsw_rs crate 构建向量索引(crates/octos-memory/src/hybrid_search.rs:130-138,四个 HNSW_* 常量):
| 参数 | 值 | 含义 |
|---|---|---|
max_nb_connection | 16 | 每个节点的最大边数(M 参数) |
capacity | 10,000 | 预分配的槽位数 |
ef_construction | 200 | 构建时的搜索宽度(越大越准确但越慢) |
max_layer | 16 | 最大层数 |
10,000 的容量对于 Agent 的经验存储来说绰绰有余:即使每天执行 10 个任务,也需要近 3 年才会达到上限。索引在容量达到 80% 和 100% 时会打印警告(crates/octos-memory/src/hybrid_search.rs:277-290,insert() 内的 tracing::warn!;计数的是 HNSW 实际点数而非文档总数,BM25-only 文档不占向量槽位)。
4.4.3 L2 归一化与 cosine similarity
向量搜索的距离度量使用 cosine similarity(余弦相似度),但 HNSW 内部使用的是 DistCosine 距离(泛型参数见 crates/octos-memory/src/hybrid_search.rs:20)。两者的关系是:
similarity = 1.0 - distance
为了确保 cosine similarity 的正确性,所有嵌入向量在插入索引前都经过 L2 归一化(crates/octos-memory/src/hybrid_search.rs:741-747):
#![allow(unused)] fn main() { fn l2_normalize(v: &[f32]) -> Option<Vec<f32>> { let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt(); if norm < f32::EPSILON { return None; // 零向量无法归一化 } Some(v.iter().map(|x| x / norm).collect()) } }
零向量保护:norm < f32::EPSILON 检查(crates/octos-memory/src/hybrid_search.rs:743)防止除以零。当 embedding provider 返回全零向量时(可能因为模型错误或空输入),归一化函数返回 None,该文档不会被加入向量索引(但仍然可以通过 BM25 搜索到)。
4.5 混合排名融合
混合搜索的核心价值在于结合 BM25 的精确关键词匹配和向量搜索的语义理解。octos 采用简单的加权融合策略。
4.5.1 融合流程
flowchart LR
Query["查询文本 + 查询向量"] --> BM25["BM25 搜索<br/>倒排索引<br/>关键词匹配"]
Query --> HNSW["HNSW 搜索<br/>向量索引<br/>语义匹配"]
BM25 --> Fusion["加权融合<br/>0.3 × BM25 + 0.7 × 向量"]
HNSW --> Fusion
Fusion --> TopK["Top-K 结果<br/>按分数降序"]
图 4-1:混合搜索流程。 查询同时走 BM25 和 HNSW 两路,结果通过加权求和融合。
4.5.2 权重配置
默认权重(crates/octos-memory/src/hybrid_search.rs:92、94):
#![allow(unused)] fn main() { const DEFAULT_VECTOR_WEIGHT: f32 = 0.7; const DEFAULT_BM25_WEIGHT: f32 = 0.3; }
向量搜索权重(0.7)高于 BM25(0.3),因为语义相似性在 Agent 经验检索中通常比精确关键词匹配更有价值。例如,查询"如何解决并发死锁"应该能找到之前记录的"用 Mutex 排序避免循环等待"的 episode,即使两者没有共同的关键词。
权重可通过 with_weights() 方法配置(crates/octos-memory/src/hybrid_search.rs:250-254),适应不同场景需求。
4.5.3 融合算法
融合逻辑(crates/octos-memory/src/hybrid_search.rs:465-481,has_vectors 分支):
#![allow(unused)] fn main() { // 对每个候选文档,计算最终分数 for doc_id in all_candidates { let vec_score = vector_scores.get(doc_id).unwrap_or(&0.0); let bm25_score = bm25_scores.get(doc_id).unwrap_or(&0.0); let score = self.vector_weight * vec_score + self.bm25_weight * bm25_score; results.push((doc_id, score)); } }
候选集是两路搜索结果的并集:即使一个文档只出现在 BM25 结果中(向量分数为 0),它仍然可以通过 BM25 分数进入最终排名。这确保了精确关键词匹配不会被语义搜索完全淹没。
当前实现还有一个细节:只有当 vector_scores 非空时才使用 vector_weight / bm25_weight 做加权融合;如果没有可用向量结果,则直接采用归一化后的 BM25 分数,而不是把 BM25 再乘以 0.3。这避免了 BM25-only 降级时所有分数被人为压低。
4.5.4 无 embedding 时的降级策略
当 embedding provider 不可用时(未配置 API key,或 provider 暂时不可达),系统自动降级为纯 BM25 搜索:
- 插入时:
insert()接受embedding: Option<&[f32]>,为 None 时只更新倒排索引 - 搜索时:
query_embedding为 None 时,向量分数全部为 0,最终分数完全由 BM25 决定 - 索引为空时:如果混合索引中没有任何文档,退回到直接扫描 redb 数据库(判定分支
crates/octos-memory/src/store.rs:461-467,扫描实现find_relevant_db_scan在crates/octos-memory/src/store.rs:554起),通过 CWD 索引和词项匹配提供基础检索
这种三级降级(混合搜索 → BM25 only → DB 扫描)确保了记忆系统在任何条件下都能提供结果,只是精度逐级降低。
4.5.5 降级的可见性:VectorCoverage
三级降级有一个共同的问题:降级发生时,搜索结果是"悄悄变差"的。一条宽度与索引不符的向量被丢弃,该 episode 静默回落到 BM25-only 召回,查询结果里没有任何迹象。4ccdbe7e(#1851,承接修 1536/768 错宽 bug 的 #1816)把这种状态从"靠翻日志推断"变成"可查询":
VectorCoverage(crates/octos-memory/src/hybrid_search.rs:48-58)是一个四字段快照:total(索引内文档数)、vectorized(真正进了 HNSW 的文档数)、dimension_mismatches(因宽度不符被拒绝的向量数)、dimension(索引接受的宽度)。方法bm25_only()/ratio()/has_dimension_mismatch()(L60-81)给出健康检查可直接上报的指标。- 计数而非刷屏(
crates/octos-memory/src/hybrid_search.rs:36-38、318-337):store 启动重建索引时会重新插入每条持久化 episode,换过 embedding 模型后逐条告警意味着启动日志一条 episode 一行。现在第一条警告后只计数(mismatch_logged标志),由vector_coverage()(crates/octos-memory/src/hybrid_search.rs:231-243;store 侧入口crates/octos-memory/src/store.rs:640)汇出总数。 - 启动汇总(
crates/octos-memory/src/store.rs:289-300):索引重建完成后打一条汇总警告:错宽 embedding 已丢弃、这些 episode 在重新嵌入前是 BM25-only、存储的向量无法转换只能再生。
配套的修复入口是 octos memory reindex(9ad56caa,#1853,见 4.7.2)。
4.6 MemoryStore:Markdown 持久化记忆
除了 Episode Store 的结构化记忆,octos-memory 还提供了 MemoryStore(`crates/octos-memory/src/memory_store.rs``),一个基于 Markdown 文件的简单记忆系统。
4.6.1 三种记忆形式
| 形式 | 文件 | 特点 |
|---|---|---|
| 长期记忆 | MEMORY.md | 单文件,全量替换 |
| 每日笔记 | YYYY-MM-DD.md | 按天分文件,追加写入 |
| 实体库 | bank/entities/<slug>.md | 按主题分文件,支持摘要注入与按需全文召回 |
4.6.2 7 天窗口记忆
get_memory_context()(crates/octos-memory/src/memory_store.rs:571-573,只是把 load_sections() 的三段渲染成字符串;带 token 预算的版本 get_injectable_context 在 583 起)构建 Agent 的记忆上下文时,load_sections() 读取最近 7 天的笔记(crates/octos-memory/src/memory_store.rs:520):
#![allow(unused)] fn main() { let recent = self.read_recent(7).await?; }
7 天窗口是一个务实的选择:太短(如 1 天)会丢失近期上下文;太长(如 30 天)会引入过多噪音并占用 LLM 的上下文窗口。7 天大致对应一个工作周,覆盖了大部分"上次我做过类似的事"的记忆需求。
超过 7 天的经验不会消失:它们仍然存在于 Episode Store 中,可以通过混合搜索检索到。7 天窗口只影响自动注入到系统提示中的上下文量。
4.6.3 Memory Bank:二级检索
当前 MemoryStore 还有一层 entity bank(crates/octos-memory/src/memory_store.rs:673-841)。它把稳定事实按实体拆成 Markdown 文件,路径为 memory/bank/entities/<slug>.md。这套机制不是全文搜索,而是两级提示注入:
- Level 1:摘要索引。
get_bank_summary()(crates/octos-memory/src/memory_store.rs:785-841)遍历所有 entity 文件(目录定位bank_dir673、枚举list_entities685),跳过 YAML frontmatter(extract_abstract,1222-1241),提取第一个非空、非标题行作为最多 100 字符的摘要,然后把- **name**: abstract注入系统提示。chat和gateway都会在长期记忆/每日笔记之后追加这段 Memory Bank 摘要。 - Level 2:按需全文。 当摘要不够时,Agent 调用
recall_memory工具读取完整 entity 页面。工具会把用户传入的名字 trim、转小写、空格替换成-,再从read_entity()读取对应 Markdown。
写入由 save_memory 工具完成(落盘在 write_entity,crates/octos-memory/src/memory_store.rs:734)。它要求内容以标题和一行摘要开头;更新已有 entity 时会先读出旧内容(read_entity,718),在工具结果中返回旧版本,提醒调用方不要覆盖掉已有事实。save_memory 的并发等级是 Exclusive,避免同一批工具调用中读写 Memory Bank 产生半写状态。
4.6.4 内容防线:crates/octos-memory/src/guard.rs
Memory Bank 的内容会进入每一次未来会话的系统提示,一条被投毒的条目会跨重启、跨渠道持续存在。多渠道入口(Telegram、邮件、web)意味着第三方文本可以借一句"记住这个"到达这些存储。octos-memory 的第六个源文件 crates/octos-memory/src/guard.rs(325 行)就是为此设的写入与渲染双侧闸门:
- 写入闸(
crates/octos-memory/src/memory_store.rs:741调crates/octos-memory/src/guard.rs:117的first_threat):write_entity对内容做威胁模式扫描,命中即bail!拒绝落盘。 - 渲染闸(
crates/octos-memory/src/memory_store.rs:815):get_bank_summary逐行扫描将要渲染进 bank 索引的行,含注入威胁的行被跳过而不是渲染。名称与摘要分开发检查,因为渲染时拼成- **name**: abstract一行,拼接缝隙可能重组出注入。
first_threat 的归一化处理两类廉价绕过:零宽字符被过滤、空白折叠;_/-/. 等分隔符映射为空格再扫一遍,让 ignore_all_previous_instructions 这类去分隔形式仍能命中。策略明确偏向严格:记忆内容是用户策展的,误报的代价(用户看到理由后改写)远小于静默接受一次注入。
工程决策侧栏:为什么不用 Qdrant/Milvus 等外部向量数据库
在 AI 应用领域,Qdrant、Milvus、Pinecone 等向量数据库是主流选择。octos 放弃它们而选择嵌入式方案,理由如下。
方案一:外部向量数据库(Qdrant/Milvus/Pinecone)
优势:
- 支持百万甚至亿级向量规模
- 丰富的索引类型和查询能力(过滤、多向量、稀疏向量)
- 分布式扩展能力
- 成熟的运维工具和监控
劣势:
- 增加部署复杂度:用户需要额外运行一个服务
- 网络延迟(即使本地部署也有 IPC 开销)
- 运维成本(备份、升级、监控)
- 启动依赖:向量库不可用时整个 Agent 无法工作
方案二:嵌入式方案(redb + hnsw_rs)
优势:
- 零部署依赖:
cargo install或下载二进制即可使用- 零网络延迟:搜索在进程内完成
- 零运维:数据库是一个文件,随 Agent 一起备份和迁移
- 优雅降级:即使没有 embedding provider,BM25 搜索仍然可用
劣势:
- 规模上限(10,000 个向量,单机内存限制)
- 搜索功能有限(无过滤、无多向量支持)
- 非分布式(单实例)
octos 的选择:嵌入式方案。
关键洞察是规模需求的差异。RAG 应用需要在百万文档中搜索,这是外部向量库的主战场。但 Agent 的经验记忆是增量积累的:每天几个到几十个 episode,一年下来可能只有几千个。10,000 的容量上限对于绝大多数使用场景绰绰有余。
更重要的是部署体验。octos 的目标用户包括个人开发者和小团队,他们可能只想用一个命令启动 Agent,而不是先部署一套向量数据库基础设施。嵌入式方案让 octos 保持了"下载即用"的简洁性。
4.7 本章回顾
octos-memory 用六个源文件、wc -l 口径 6,428 行 Rust 构建了一个自包含的 Agent 记忆系统:
-
redb 嵌入式存储:三张表(Episodes/CWD 索引/向量嵌入),纯 Rust 实现,ACID 事务保证,单文件部署。
-
BM25 全文搜索:经典 K1=1.2/B=0.75 参数,倒排索引 + IDF 加权,epsilon 防 NaN 归一化。
-
HNSW 向量索引:
hnsw_rscrate 提供分层图搜索,L2 归一化保证 cosine similarity 正确性,零向量保护防止索引污染。 -
混合排名融合:0.7 向量 + 0.3 BM25 加权求和,候选集取并集,三级降级(混合→BM25→DB 扫描)确保任何条件下都能返回结果。
-
Markdown + Memory Bank:
MEMORY.md、每日笔记和 entity bank 共同组成提示侧记忆;entity 摘要自动注入,全文通过recall_memory按需加载;crates/octos-memory/src/guard.rs在写入与渲染两侧拦截注入内容。
4.7.1 行数口径
本章行数统一按 find crates/octos-memory -name '*.rs' | xargs wc -l | tail -1 实测(2026-09-02,基线 9c157101):6,428 行,六个源文件(crates/octos-memory/src/memory_store.rs 2,417、crates/octos-memory/src/store.rs 1,940、crates/octos-memory/src/hybrid_search.rs 1,527、crates/octos-memory/src/guard.rs 325、crates/octos-memory/src/episode.rs 195、crates/octos-memory/src/lib.rs 24),含测试与注释。此前"约 1,750 行"与"1,635 行"两个数字分别对应旧基线与 tokei 代码行口径,本次勘误统一为 wc -l 原始口径。
4.7.2 运维:octos memory reindex
换 embedding 模型等于换向量宽度,旧向量无法转换只能再生。在此之前唯一的"修复"手段是删掉整个 episodes.redb。9ad56caa(#1853)补上了正路:octos memory reindex [--dry-run](入口 crates/octos-cli/src/commands/memory.rs:44-51,分发 L95,run_reindex L148 起)用已持久化的 episode.summary 重新嵌入。工作清单由 episodes_needing_vectors()(crates/octos-memory/src/store.rs:670)从数据库而非内存索引计算:缺失 embedding 或宽度不符(跳过空摘要)的 episode 全部列出,回填走 store_embedding()(crates/octos-memory/src/store.rs:712)。未配置 embedder 时直接报错退出(L156);reindex 需要 store 写锁,先停 octos serve 再跑。
下一章将进入 octos-agent,看看 Agent 主循环如何利用这些类型和记忆能力编排一次完整的对话。
延伸阅读
- BM25 算法:Robertson & Zaragoza, "The Probabilistic Relevance Framework: BM25 and Beyond"(Foundation and Trends in IR, 2009)
- HNSW 算法:Malkov & Yashunin, "Efficient and Robust Approximate Nearest Neighbor using Hierarchical Navigable Small World Graphs"(IEEE TPAMI, 2020)
- redb:https://docs.rs/redb/latest/redb/ — 纯 Rust 嵌入式数据库
- hnsw_rs:https://docs.rs/hnsw_rs/latest/hnsw_rs/ — Rust HNSW 实现
- 混合搜索:Anthropic 的 "Contextual Retrieval" 博文讨论了 BM25 + 向量搜索的互补价值
思考题
-
BM25 参数调优:如果 Agent 主要处理中文任务,K1 和 B 参数需要调整吗?中文的分词粒度(单字 vs 词组)如何影响 BM25 的检索效果?
-
向量维度选择:octos 默认使用 1536 维向量(OpenAI text-embedding-3-small)。如果切换到 384 维的轻量模型,对检索质量和内存占用的影响分别是什么?
-
混合权重的动态调整:固定的 0.7/0.3 权重是否最优?设想一种根据查询类型动态调整权重的策略:关键词精确查询偏向 BM25,开放式语义查询偏向向量搜索。这需要在架构上做什么修改?
-
规模瓶颈:如果 octos 需要支持企业级部署(10 万个 episode),当前的嵌入式方案需要做哪些改造?有没有介于"嵌入式"和"外部数据库"之间的中间方案?
版本演化说明 本章分析基于 octos main @
9c157101(2026-09-02),octos-memory crate 位于crates/octos-memory/src/,六个源文件按wc -l口径共 6,428 行。本次勘误(v2):① 24 处源码行号引用按9c157101重标(cc6744ba / 4ccdbe7e / 9ad56caa 三个提交后行号整体下移);② 补写三处新面:cc6744ba 的 dense 累加器与 top-k 分区(§4.3.4)、4ccdbe7e 的VectorCoverage降级可见性(§4.5.5)、9ad56caa 的octos memory reindex(§4.7.2),以及 spec 事实边界要求的crates/octos-memory/src/guard.rs定位段(§4.6.4);③ L145 epsilon 摘录与现实现同步(result.iter().map(|&(_, s)| s)/ 初值0.0f64);④ 行数口径统一为wc -l6,428。相比早期版本,MemoryStore 的 entity bank 已经接入save_memory/recall_memory工具和系统提示摘要注入;EpisodeStore 支持 embedding 后写入、tombstone 删除与降级可见性上报。
第 5 章:Agent Loop:一次对话的完整生命周期
定位:本章按
crates/octos-agent/src/agent/的 20 个模块重组 Agent Loop 的完整叙事,讲清一次process_message/run_task从入口到 stop_reason 的六阶段生命周期、预算五道闸与 #27e 落盘检查点、typed retry-bucket 状态机、错误恢复三层类型,以及 goal 层续跑队列与循环的边界。前置依赖:第 2 章(octos-core 类型)、第 3 章(LlmProvider 抽象)。适用场景:想知道「一条用户消息进来之后运行时到底做了什么」的 AI 应用开发者(读者 C),以及要给 loop 提 PR 的贡献者(读者 D);第 16 章 fleet worker 与第 18 章 goal 续跑都以本章循环为基准变体。
Agent loop 的教科书定义只有一行:收到消息,调用 LLM,解析意图,要工具就执行工具,把结果喂回去,重复直到任务完成。生产级实现的全部难度藏在这行字的边界条件里:迭代上限、token 预算、空闲超时、上下文溢出、消息格式破损、重复输出、provider 故障、优雅关停。octos 把这些边界条件拆进了 crates/octos-agent/src/agent/ 的 20 个模块:排除 *crates/octos-agent/src/agent/loop_runner_tests.rs 后共 21,369 行(口径:cat $(ls *.rs | grep -v '_tests') | wc -l),最大模块不是主循环文件 crates/octos-agent/src/agent/loop_runner.rs(3,979 行),而是工具派发 crates/octos-agent/src/agent/execution.rs(4,730 行)。这个反直觉的事实本身就是本章的切入点:主循环的价值不在「循环」这个动词,而在它编排的决策面。本章所有行号与数字来自 assets/ch05-facts.md(基准 octos main @ 9c157101,2026-09-02),正文中每个源码引用(路径加行号)都可在事实表与源码仓库核对。
5.1 模块地图:20 个模块、21,369 行
先把目录读成一张地图。每个模块的首行 //! 文档注释就是它的自我声明:
| # | 模块 | 行数 | 一句话职责(首行文档注释) |
|---|---|---|---|
| 1 | crates/octos-agent/src/agent/activity.rs | 150 | Loop activity tracking for idle-timeout enforcement |
| 2 | crates/octos-agent/src/agent/append_only_audit.rs | 364 | Append-only context audit: does a turn's request history only ever GROW? |
| 3 | crates/octos-agent/src/agent/budget.rs | 821 | Budget tracking and enforcement for the agent loop |
| 4 | crates/octos-agent/src/agent/compaction.rs | 358 | Context window trimming and fallback truncation |
| 5 | crates/octos-agent/src/agent/detection.rs | 858 | Detection of repetitive output and retriable responses |
| 6 | crates/octos-agent/src/agent/execution.rs | 4,730 | Tool execution: dispatching tool calls with hooks and timeout handling |
| 7 | crates/octos-agent/src/agent/llm_call.rs | 988 | LLM call orchestration with lifecycle hooks and retry logic |
| 8 | crates/octos-agent/src/agent/loop_compaction.rs | 414 | Message preparation pipeline for long-turn loop calls |
| 9 | crates/octos-agent/src/agent/loop_runner.rs | 3,979 | Main agent loop: process_message and run_task orchestration |
| 10 | crates/octos-agent/src/agent/loop_state.rs | 768 | Typed retry-bucket state machine for the agent loop (M6.2, issue #489) |
| 11 | crates/octos-agent/src/agent/memory.rs | 1,062 | Initial message building and episodic memory context for the agent |
| 12 | crates/octos-agent/src/agent/message_repair.rs | 1,260 | Message normalization, ordering repair, and tool pair validation |
| 13 | crates/octos-agent/src/tools/mod.rs | 1,644 | Agent implementation |
| 14 | crates/octos-agent/src/agent/prompt_segments.rs | 233 | Ordered system-prompt segments |
| 15 | crates/octos-agent/src/agent/realtime.rs | 602 | Real-time agent loop extensions for robotic operation |
| 16 | crates/octos-agent/src/agent/rich_output.rs | 309 | Rich output: produce a self-contained HTML document from a short brief |
| 17 | crates/octos-agent/src/agent/streaming.rs | 1,491 | Stream consumption, shutdown handling, and cost reporting |
| 18 | crates/octos-agent/src/agent/turn_failure.rs | 69 | Voice-turn failure projection |
| 19 | crates/octos-agent/src/agent/turn_state.rs | 388 | Typed turn state for loop execution |
| 20 | crates/octos-agent/src/agent/verifier.rs | 881 | Optional inference-time verifier and compact structured turn ledger |
按职责可以分三层。主线九模块承担一次 turn 的全部决策:crates/octos-agent/src/agent/loop_runner.rs(编排)、crates/octos-agent/src/agent/turn_state.rs / crates/octos-agent/src/agent/loop_state.rs(turn 内与跨 turn 的 typed 状态)、crates/octos-agent/src/agent/budget.rs(预算与检查点)、crates/octos-agent/src/agent/llm_call.rs / crates/octos-agent/src/agent/streaming.rs(调用与流消费)、crates/octos-agent/src/agent/execution.rs(工具派发)、crates/octos-agent/src/agent/message_repair.rs(消息修复)、crates/octos-agent/src/agent/detection.rs(退化检测)。支线模块各管一个侧面:crates/octos-agent/src/agent/activity.rs(空闲超时)、crates/octos-agent/src/agent/append_only_audit.rs(请求历史只增审计)、crates/octos-agent/src/agent/memory.rs(首轮消息与情节记忆)、crates/octos-agent/src/agent/prompt_segments.rs(系统提示分段)、crates/octos-agent/src/agent/verifier.rs(推理期校验)、crates/octos-agent/src/agent/realtime.rs / crates/octos-agent/src/agent/rich_output.rs / crates/octos-agent/src/agent/turn_failure.rs(实时、富输出、语音失败投影)。压缩双模块(crates/octos-agent/src/agent/compaction.rs 与 crates/octos-agent/src/agent/loop_compaction.rs)本章只交代入口,算法细节详见第 8 章。
crates/octos-agent/src/tools/mod.rs 是装配层:pub struct Agent 的构造与 builder 家族(with_reporter、with_hooks、with_snapshot_manager、with_realtime、with_compaction_runner、with_persistent_retry_state 等,crates/octos-agent/src/agent/mod.rs:691 起)把上述模块的运行时状态接进 Agent 结构体;超时与限额常量集中在 AgentConfig(first-token grace 180s、stream idle 90s、LLM call max 1200s,crates/octos-agent/src/agent/mod.rs:148)。
一个容易被忽略的分层事实:这个目录对外的公开面很窄。crates/octos-agent/src/agent/execution.rs 的工具派发主入口 execute_tools 是 pub(super)(crates/octos-agent/src/agent/execution.rs:2483),crates/octos-agent/src/agent/streaming.rs 的流消费主入口 consume_stream_with_input_estimate 同样是 pub(super)(crates/octos-agent/src/agent/streaming.rs:73),crates/octos-agent/src/agent/message_repair.rs 只有 normalize_tool_call_id 一个全 crate 公开函数(crates/octos-agent/src/agent/message_repair.rs:95)。真正的全公开入口只有 crates/octos-agent/src/agent/loop_runner.rs 的 process_message / run_task 家族。换句话说,crate 边界上只暴露「循环」,不暴露循环的器官。这个可见性设计有一层工程动机:器官级函数的签名还在演化(execute_tools 的返回元组已经长到七个元素,每加一种派发产物就要动签名),若把它们开成 pub,外部调用方会立刻把签名焊死,此后每次演化都是破坏性变更。只公开两个入口家族,等于把「循环怎么编排」的稳定性承诺收缩到最小面,内部模块得以自由重构——事实上 crates/octos-agent/src/agent/execution.rs 比 crates/octos-agent/src/agent/loop_runner.rs 更大,却没有一行代码泄漏到 crate 外。
主线九模块的拆分本身也值得追问:为什么是这九个,而不是按「调用 LLM 的代码」聚成一个文件?答案藏在变更轴上。crates/octos-agent/src/agent/message_repair.rs 修的是消息格式破损,变化频率跟随 Provider 协议差异(Anthropic 要求 tool result 紧跟 assistant、OpenAI 允许间隔);crates/octos-agent/src/agent/detection.rs 检测的是模型行为退化,变化频率跟随模型生态(小模型的空响应、重复输出形态一直在出新);crates/octos-agent/src/agent/budget.rs 是纯决策逻辑,几乎不变;crates/octos-agent/src/agent/execution.rs 跟随工具系统演化(详见第 6 章);crates/octos-agent/src/agent/streaming.rs 跟随 SSE 解析与成本上报需求。五类变更轴互不相同,塞进一个文件意味着每次 Provider 协议调整都要在两千行混合代码里找上下文。按变更轴拆模块,让每个文件的 diff 都有单一主题。
5.2 一次 turn 的生命周期
5.2.1 入口家族
两个入口家族对应两种驱动方式。对话式入口给交互场景:process_message(crates/octos-agent/src/agent/loop_runner.rs:784)、process_message_with_attachments(L804)、process_message_tracked(L817,实时刷新 TokenTracker 供网关状态条)、process_message_tracked_with_attachments(L834),四者全部收敛到私有核心 process_message_inner(L852,循环体绵延至 L2242)。任务式入口给编排场景:run_task(L2243)与 run_task_with_tracker(L2254,供 fleet worker 在外部超时丢弃 future 后仍能读到真实 token 消耗)收敛到 run_task_inner(L2262)。
四个 process_message 变体不是过度设计,每个都对应一个真实调用方形态:基础版给最简单的聊天路径,attachments 版携带本轮附件上下文,tracked 版接 TokenTracker,tracked_with_attachments 版两者都要。若只留一个全参数入口,简单调用方就得传 TurnAttachmentContext::default() 与 None 之类的占位参数,编译器无法帮忙区分「调用方有意禁用」与「调用方忘了传」。收敛到单一 process_message_inner 则保证四个变体共享同一个循环体,不会出现交互路径与追踪路径行为漂移。run_task_with_tracker 的存在动机更具体:fleet worker 在外部墙钟超时会直接丢弃 future,返回的 TaskResult随之丢失,tracker 是唯一能存活下来的记账通道(源码注释明确要求超时路径的结算「never 0」)。
5.2.2 六个阶段
sequenceDiagram
participant Caller as 调用方(gateway/fleet/orchestrator)
participant LR as loop_runner<br/>(process_message_inner / run_task_inner)
participant MR as message_repair + loop_compaction
participant LC as llm_call
participant ST as streaming
participant EX as execution
participant BU as budget + loop_state
Caller->>LR: process_message / run_task
LR->>MR: ① 消息准备(修复 + 压缩入口)
MR-->>LR: 规范化后的 messages
loop 每次迭代
LR->>BU: ② 预算检查 check_budget
BU-->>LR: BudgetStop 或放行
LR->>LC: ③ call_llm_with_hooks
LC->>ST: ④ consume_stream_with_input_estimate
ST-->>LC: ChatResponse(含 stop_reason)
LC-->>LR: response + attributed_cost
alt stop_reason = ToolUse
LR->>EX: ⑤ execute_tools(派发/超时/hooks)
EX-->>LR: 工具结果消息 + files_modified
else stop_reason = EndTurn / MaxTokens
LR-->>Caller: ⑥ 返回 ConversationResponse / TaskResult
end
end
①消息准备:prepare_conversation_messages(crates/octos-agent/src/agent/loop_compaction.rs:27)与任务侧镜像 prepare_task_messages(L71)把历史送进修复管线。crates/octos-agent/src/agent/message_repair.rs 的七个 pub(crate) 函数各修一类破损:normalize_tool_call_ids(L27,统一跨 Provider 的 tool_call_id 前缀与字符集)、normalize_system_messages(L108)、repair_message_order(L189)、repair_tool_pairs(L325)、synthesize_missing_tool_results(L414)、truncate_old_tool_results(L513)。每类修复记入 LoopRepairReason 的七个变体(crates/octos-agent/src/agent/turn_state.rs:48,从 ContextTrimmed 到 ToolCallIdsNormalized),让「这次 turn 动过什么」可观测。
②预算检查:每次迭代开头跑 check_budget(见 5.3 节)。
③LLM 调用:整个调用编排收敛在 crates/octos-agent/src/agent/llm_call.rs 的单一主函数 call_llm_with_hooks(crates/octos-agent/src/agent/llm_call.rs:22),模块其余部分几乎全是测试 mock。它返回的三元组里,usage 把被丢弃的重试尝试合并进最终尝试,attributed_cost_usd 则按实际消耗 token 的 provider 分别计价,调用方必须直接记账而不是用胜出者费率重算合并总量(这是 #1632 P2 钉住的契约)。把调用编排压成单一函数是个自觉的选择:调用前后的 hook、跨 provider 的重试梯、成本归因三者必须严格咬合,任何一处插入新逻辑都可能破坏「重试尝试的 token 不丢账」这条不变量。单一入口让所有调用路径共享同一份编排逻辑,测试 mock 也只需模拟一个函数的形态。若每个调用点自行编排 hook 与重试,第一处遗漏就会造成成本统计黑洞。
④流消费:consume_stream_with_input_estimate(crates/octos-agent/src/agent/streaming.rs:73)消费 SSE 流,处理 shutdown 信号(wait_for_shutdown,L64),结束后由 response_usage_cost(L555)与 emit_cost_update(L581)上报成本,response_to_message(L669)把原始响应转成消息。流消费独立成模块的动机是它管着两类别的模块都不该管的资源:SSE 连接的生命周期(首 token 超时、token 间隔超时、中断检测)与输入 token 的估计。流式响应在结束前拿不到完整的 usage 块,而成本上报又不能等流结束,于是需要一个「边消费边估」的组件。把这段逻辑留在调用方意味着每个 provider 的重试路径都要重复实现一次中断检测;集中在 crates/octos-agent/src/agent/streaming.rs,中断语义全 crate 只有一份。
⑤工具派发:execute_tools(crates/octos-agent/src/agent/execution.rs:2483)决定串行还是并行、计算批超时(compute_batch_timeout_secs,L209)、执行前后 hook、识别长时工具(is_long_running_tool,L174)、处理 spawn_only 产物文件。工具语义本身详见第 6 章。这个 4,730 行的最大模块之所以最大,是因为它独自承担了「把 LLM 的意图变成副作用」的全部工程问题:批量调度(串行、并行、混合三路,run_serial_calls L2738 与 execute_mixed_batch L2846)、超时预算(批内最长工具决定整批时限)、取消与 panic 的结果投影(cancelled_result L3003、timed_out_result L3035、panic_result L3062,三种异常都要变成 LLM 能读的工具结果消息而不是让循环崩掉)、快照(snapshot_before_mutating_tools L2457,变更类工具执行前留底)。若这些职责留在 loop_runner,主循环文件会突破八千行且每条路径都混着工具细节;拆出来后 loop_runner 只看到「一批工具调用进、一批结果消息出」的干净接口。
⑥状态更新与 stop 判定:LoopTurnState(crates/octos-agent/src/agent/turn_state.rs:59)持有 iteration、total_usage、turn_spend_usd、retry/repair 原因列表与 terminal_reason,record_usage(L138)累积消耗,record_budget_stop(L187)记录终止。响应的 stop_reason 决定下一跳:EndTurn 返回、ToolUse 回到 ①、MaxTokens 走续跑与自愈(见 5.4 与 5.6 节)。turn 状态独立成类型(而非散落在循环体的局部变量)的动机在两个伴生枚举:LoopRetryReason(L41,三个变体)与 LoopRepairReason(L48,七个变体)把「这次 turn 重试过什么、修复过什么」变成可枚举的数据。当用户抱怨「这个任务为什么慢」,答案不再是翻日志猜,而是直接读 turn 结束时携带的原因列表。局部变量做不到这件事:它们没有类型,也没有序列化出口。
5.3 预算五道闸与 #27e 检查点
5.3.1 check_budget:五道闸的顺序
每次迭代开头,check_budget(crates/octos-agent/src/agent/budget.rs:100)按固定顺序过五道闸,任何一道命中即返回 BudgetStop:
#![allow(unused)] fn main() { pub(super) fn check_budget( &self, iteration: u32, start: Instant, total_usage: &TokenUsage, activity: &LoopActivityState, ) -> Option<BudgetStop> { use std::sync::atomic::Ordering; if self.shutdown.load(Ordering::Acquire) { return Some(BudgetStop::Shutdown); } if iteration >= self.config.max_iterations { return Some(BudgetStop::MaxIterations { limit: self.config.max_iterations, }); } }
(摘自 crates/octos-agent/src/agent/budget.rs:100,续后依次为 idle progress timeout、activity timeout、token 预算三道闸。)顺序本身就是设计:shutdown 是一次原子加载,用户中断必须最先响应;迭代数是最常见的停止原因,一次整数比较次之;两种超时依赖 crates/octos-agent/src/agent/activity.rs 的活动跟踪(LoopActivityState,crates/octos-agent/src/agent/activity.rs:16);token 预算最后,因为 budget_tokens_used(crates/octos-agent/src/agent/budget.rs:90)要做四次饱和加法,且很多部署根本不设上限。token 统计把 cache_read 与 cache_write 一并计入:只加 input+output 会让开了 prompt caching 的 Anthropic 循环把预算跑穿约十倍。
BudgetStop(crates/octos-agent/src/agent/budget.rs:13)五个变体各携带对用户有意义的上下文:MaxIterations { limit } 带上限值,MaxTokens { used, limit } 带双方数字,两种 timeout 带 limit。BudgetStop::message()(L41)负责把这些变体渲染成可操作的终止消息;report_budget_stop(L141)再把它变成 ProgressEvent 上报。
预算侧四个公开函数各守一段,分工不是随手切的。BudgetStop 枚举(L13)把「为什么停」定义成数据,五个变体就是五种用户可区分的结局;message()(L41)是唯一的渲染点,终止文案集中在这里,用户在任何界面看到的预算终止话术都出自同一处,不会出现 CLI 说「达到上限」而网关说「budget exceeded」的分裂;budget_tokens_used(L90)是唯一的算术点,cache 计入规则改一处即全局生效;check_budget(L100)是唯一的判定点,五道闸顺序只在这一个函数里存在;report_budget_stop(L141)是唯一的上报点。判定、算术、渲染、上报四件事各一个函数,意味着预算行为的任何变更都能定位到单一函数,而调用方(两个循环体)只需面对 Option<BudgetStop> 这一种中间表示。若把这四件事摊进循环体,五道闸的顺序约束就会散落两处,两个循环迟早演化出不同的停止顺序。
5.3.2 Grace:硬停前的一次宽限
预算命中不总是立刻停。try_budget_grace_call(crates/octos-agent/src/agent/loop_runner.rs:602)先问 LoopRetryState:如果这是首次硬预算耗尽、且自上次宽限以来至少有过一次生产性工具调用,状态机回 Grace(observe_budget_exhaustion,crates/octos-agent/src/agent/loop_state.rs:303),循环多跑一轮,让 Agent 有机会把话说完、把文件写完。全局只宽限一次,第二次直接 Escalate。
5.3.3 #27e:落盘检查点
预算真正耗尽时的最大风险是丢工作树。#27e 在 crates/octos-agent/src/agent/budget.rs 的非测试段落(L484 起)实现落盘检查点,主函数 checkpoint_budget_exhaustion(crates/octos-agent/src/agent/budget.rs:546):
#![allow(unused)] fn main() { pub(super) fn checkpoint_budget_exhaustion( workdir: Option<&std::path::Path>, stop: &BudgetStop, iteration: u32, ) -> Option<String> { // Only the ITERATION-cap stop checkpoints: MaxTokens/Shutdown/timeouts // have different re-dispatch semantics and stay on the legacy path. let BudgetStop::MaxIterations { limit } = stop else { return None; }; let dir = workdir?; if !dir.join(".git").exists() { return None; // not a git worktree — nothing to checkpoint. } }
(摘自 crates/octos-agent/src/agent/budget.rs:546。)触发条件是三个合取:stop 必须是 MaxIterations(L553 的 else 分支让其余四种走 legacy 路径)、workdir 必须是 git 仓库、git status --porcelain 必须非空。命中后做三件事:先以 tmp+rename 原子写 staged result(write_result_md_named,L499;tmp 文件名含 .tmp-27e 标记,L501),再 git add -A 并提交 wip: budget exhausted (#27e) — checkpointed mid-task(本地提交,永不 push),最后返回 budget_exhausted:{limit} 标记拼进 TaskResult.output。
不触发同样重要:clean 工作树不会产生空提交,非 git 目录直接返回,MaxTokens/Shutdown/两种 timeout 不走这条路径,默认 50 次的迭代预算不因此提高。#27h 的写权判定也在这里:result_md_owned_by_peer(L532)读 sidecar .result-owner,内容 trim 后恰为 peer 时,检查点改写 result.checkpoint.md(L579-581)而不碰 peer 的 result.md;sidecar 缺失或不可读按无 owner 处理,fail-open。该判定的唯一公开入口是 result_md_owner_content_is_peer(L542),cli 侧 #1824 安全读路径复用同一函数,保证两个消费者永不漂移。
调用点在任务循环的预算终止分支(crates/octos-agent/src/agent/loop_runner.rs:2328):Grace 未获准时,先 record_budget_stop 与 report_budget_stop,再取 workspace_root 交给检查点函数,marker 存在时与 stop.message() 一起拼进 TaskResult。
落盘侧的调用链同样一条线:git_in(L484)是最底层的命令执行原语,dirty 判断与 commit 都走它;write_result_md_named(L499)是唯一的落盘写法(tmp 加 rename,tmp 名含 .tmp-27e 后缀,L501),并发读者永远看不到半个文件;result_md_owned_by_peer(L532)与 result_md_owner_content_is_peer(L542)构成写权判定的两层,前者负责读 sidecar、后者负责判断内容,公开的只有后者,cli 侧 #1824 的 fd 锚定安全读路径把读到的内容喂给同一个判断函数,两个消费者共享一份语义;checkpoint_budget_exhaustion(L546)在最上层编排:先做 MaxIterations 合取短路,再判 git 仓库、判 dirty,然后依写权选文件名、原子写 staged result、add -A、本地提交、返回 marker。五个函数一条依赖链,每个环节独立可测(原子写在 L626 起的 budget_checkpoint_tests 有独立测试模块),这正是不把整段逻辑内联进 loop_runner 的原因:检查点逻辑的测试需要真实的 git 仓库环境,放在 crates/octos-agent/src/agent/budget.rs 里可以独立构造临时仓库跑,而不用模拟整个 Agent。
5.4 stop_reason 决策树
把一次迭代的出口画成一棵树:
flowchart TD
A[迭代开始] --> B{check_budget 五道闸}
B -- Shutdown/MaxIterations/MaxTokens/ActivityTimeout/IdleProgressTimeout --> C{try_budget_grace_call}
C -- Grace --> A
C -- 未获准 --> D[#27e 检查点<br/>MaxIterations + git + dirty 才落盘] --> E[返回 TaskResult]
B -- 放行 --> F[call_llm_with_hooks]
F --> G{stop_reason}
G -- EndTurn --> H[返回响应]
G -- ToolUse --> I[execute_tools 派发] --> A
G -- MaxTokens --> J{有内容?}
J -- 是:任务循环 --> K[push_max_tokens_continuation<br/>续跑 ≤2 次] --> A
J -- 空内容+无工具调用:对话循环 --> L[nudge-and-retry ≤2 次<br/>耗尽则终态错误]
F -- 错误 --> M{HarnessError -> RecoveryHint<br/>-> LoopDecision}
M -- Continue/RotateAndRetry/CompactAndRetry --> A
M -- Escalate/Exhausted --> N[上抛错误]
任务循环与对话循环对 MaxTokens 的处理不对称。任务循环(run_task_inner)早在 #2174 之前就有续跑:push_max_tokens_continuation(crates/octos-agent/src/agent/loop_runner.rs:3912)在内容被截断时追加「从停下的地方继续」提示,上限 MAX_TOKENS_CONTINUATION_LIMIT = 2(L36),边界检查在 crates/octos-agent/src/agent/loop_runner.rs:2661。对话循环(process_message_inner)原来直接返回 content.unwrap_or_default(),空响应会让进程无错无声退出;#2174 补齐了这条路,见 5.6 节。
5.5 LoopDecision:typed retry-bucket 状态机
错误恢复的心脏在 crates/octos-agent/src/agent/loop_state.rs:LoopRetryState(crates/octos-agent/src/agent/loop_state.rs:217)持有 16 个错误桶各自的计数器(LoopRetryCounters,L186)与上限(LoopRetryLimits,L81)。上限刻意压低:结构性故障的桶几乎不给重试机会(authentication 1、quota 1、content_filtered 1、internal 1),瞬态故障给几次(rate_limited 5、network 4、provider_unavailable 4、timeout 3),shell_spiral 只有 1。
对循环的裁决是 LoopDecision(crates/octos-agent/src/agent/loop_state.rs:131)六个变体,每个变体的裁决语义都值得单独说清。Continue(L134)的含义是「失败预期会自愈」:限流突发、网络抖动、慢工具,重试时上下文原样保留,因为问题不在请求内容;RotateAndRetry(L138)是「当前 lane 病了但任务没病」:5xx、流中断、配额烧穿都属于 provider 级故障,换一条 credential lane 大概率就好,改写上下文反而浪费;CompactAndRetry(L141)是唯一的「改写上下文」裁决,且只服务 ContextOverflow:请求超窗时裸重试必然再失败,压缩是唯一出路,这个变体的存在把「哪类错误需要动上下文」从调用方判断收进类型系统;Escalate(L145)是不可重试类的归宿:auth、invalid request、content filter、工具或插件故障、内部 bug,继续循环只会烧钱,上抛给操作者才是正确动作;Exhausted(L149)不对应任何错误类别,它对应的是「同一桶的计数超过了上限」,这是 #489 不变量 2 的类型化表达:无限循环在这里被强制终结;Grace(L153)是最特殊的一个,它不是错误裁决而是预算裁决的延伸,硬预算耗尽后若循环至少做过一次生产性工具调用,多给一轮收尾。核心判定只有八行:
#![allow(unused)] fn main() { pub fn observe(&mut self, error: &HarnessError) -> LoopDecision { let (count, limit) = self.bump_counter(error); let decision = if count > limit { LoopDecision::Exhausted } else { decide_for_variant(error) }; Self::record_metric(error.variant_name(), decision); decision } }
(摘自 crates/octos-agent/src/agent/loop_state.rs:266。)桶未满时交给 decide_for_variant(L431)按恢复提示映射:BackoffRetry 到 Continue、SwitchProvider 到 RotateAndRetry、CompactContext 到 CompactAndRetry、FailFast 与 Bug 都到 Escalate。
八个方法各在生命周期的固定位置被调用,拼起来才是一个完整的 turn。new(L234)在循环构造时建立初始态,全桶清零、装默认上限;with_limits(L240)给测试与运维留显式覆盖口;进入循环后,每次工具成功返回都调用 record_productive_tool_call(L253),把 Grace 资格计数器加一,这个资格的意义是:宽限只给「在干活却被预算掐断」的循环,不给空转的循环;LLM 或工具报错时 observe(L266)是主裁决入口,入桶、比限、出裁决、记指标,一次调用完成;shell 重试的退化走专用入口 observe_shell_spiral(L284),第一次螺旋就 Escalate(停住 shell 重试、上抛最近输出),超限则 Exhausted,计数由状态机持有而非 loop_runner 的局部变量,运维在 ledger 里看到的是同一本账;预算命中时 observe_budget_exhaustion(L303)决定 Grace 还是 Escalate,判据是「首次且生产性调用至少一次」;turn 结束或调试时 counters(L319)导出快照;emit_event(L327)把每次裁决写成结构化事件进 harness event sink。时序上一次典型 turn 的调用序是:new(或从持久句柄恢复)、若干次 record_productive_tool_call、零或数次 observe、可能一次 observe_budget_exhaustion、随时的 emit_event。整个状态经 serde 序列化,可随 session ledger 往返;PersistentRetryStateGuard(crates/octos-agent/src/agent/loop_runner.rs:255 附近)在构造时加载、drop 时写回,让桶计数跨 turn 存活,同一 session 里昨天烧掉的三次 network 重试今天仍然计入桶。
stateDiagram-v2
[*] --> Looping
Looping --> Continue : observe() 瞬态错误且桶未满
Looping --> RotateAndRetry : observe() SwitchProvider(quota/provider 不可用)
Looping --> CompactAndRetry : observe() ContextOverflow
Looping --> Escalate : observe() FailFast/Bug 或 shell 螺旋首触发
Looping --> Exhausted : observe() 计数 > 桶上限
Continue --> Looping : 下一迭代
RotateAndRetry --> Looping : 换 lane 后重试
CompactAndRetry --> Looping : 压缩后重试
Looping --> Grace : observe_budget_exhaustion() 首次且 productive ≥ 1
Grace --> Looping : 宽限迭代
Looping --> Escalate_2 : observe_budget_exhaustion() 二次耗尽
Escalate --> [*]
Exhausted --> [*]
Escalate_2 --> [*]
note right of Grace : record_productive_tool_call() 恢复资格, 全局仅一次
图里每条转移对应状态机里一个可指认的判定。五个从 Looping 出发的裁决转移全部由 observe()(或 observe_shell_spiral())的一次返回值决定:桶计数与上限的比较结果决定走 Exhausted 还是按 decide_for_variant 的映射走其余四个;Continue / RotateAndRetry / CompactAndRetry 三个回到 Looping 的转移差别在「下一迭代开始前循环体做了什么」:Continue 什么都不做,RotateAndRetry 推进 provider chain 的 lane 指针,CompactAndRetry 触发一次对话压缩(由 dispatch_loop_error 在带内完成,调用方无感)。Grace 的进入条件是两个谓词的合取(grace_calls_fired == 0 且 productive_tool_calls_since_last_grace >= 1),进入即清零生产计数、自增 fired 计数,两个字段一读一写保证「全局仅一次」由数据结构自身而不是调用纪律来保证。Escalate_2 与 Escalate 终点相同但入口不同:前者来自预算耗尽的重访,后者来自错误裁决,分开画是因为两者的运维含义不同,错误上抛要看 variant,预算上抛要看 iteration。
工程决策侧栏:为什么把 retry 从 bool 提升为 typed bucket 状态机
朴素实现是一个「已重试几次」的计数器加一个退避计时器,全部错误共享额度。这在生产里有两个失败模式。其一,额度被错误的错误消耗:一次 429 风暴吃掉全部重试次数,后面真正的结构性 401 反而没有重试预算可谈;反过来 auth 错误被当作可重试错误空转,直到迭代上限。其二,决策不可解释:监控里只能看到「重试了」,看不到为什么重试、还能重试几次、下一跳是什么。M6.2(issue #489)的答案是给每类错误一个独立桶、每桶独立上限、每桶映射到带名字的裁决。
LoopDecision::as_str()(crates/octos-agent/src/agent/loop_state.rs:159)输出稳定 snake_case 供指标与事件用,OCTOS_LOOP_RETRY_TOTAL(L42)是总计数指标名。类型化的代价是 16 个字段与 serde 兼容负担(quota 桶后加,还要default_quota_limit兜住旧 JSON),换来的是不变量可陈述:同一故障超过桶上限必然 Exhausted,无限循环在类型层面被挡住。
5.6 harness_errors:错误恢复的三层类型
crates/octos-agent/src/harness_errors.rs 用三个类型搭起恢复链:RecoveryHint(L47,五变体:BackoffRetry L50、SwitchProvider L53、CompactContext L56、FailFast L60、Bug L63)描述「这类错误该怎么救」;HarnessError(L93,十五变体,从 RateLimited L96 到 Internal L160)是规范化错误,变体名冻结,其他里程碑按名引用;HarnessErrorEvent(L166)把错误写成结构化事件进 sink。链路是 HarnessError 经 recovery_hint()(L240)得到 RecoveryHint,再由 loop 侧状态机裁决成 LoopDecision。
两处映射值得细看。ProviderUnavailable 映射到 SwitchProvider(crates/octos-agent/src/harness_errors.rs:249):5xx、流中断、模型不可用都归此变体,agent 内部没有额外的 provider lane hook,实际换 lane 由 loop 侧 RotateAndRetry 裁决驱动 ProviderChain 前进。Quota 也映射到 SwitchProvider(L267,#27b):配额烧穿是 provider 级降级而非致命错误,链上的 fallback lane 可能还健康;而真正的 401 Authentication 保持 FailFast,因为错误密钥在每条 lane 上都会失败。这条红线有测试钉住。
进入这条链的入口在 crates/octos-agent/src/agent/loop_runner.rs:classify_loop_error(crates/octos-agent/src/agent/loop_runner.rs:313)把 eyre::Report 经 HarnessError::classify_report 归类、记指标、写事件;dispatch_loop_error(L437)把归类结果喂给 LoopRetryState::observe 并再写一条 retry 事件;调用侧拿到的只是二选一的粗粒度动作 LoopErrorAction(L249,Retry 或 Bail),CompactAndRetry 在带内完成,调用方无须穿透压缩状态。
5.7 循环自愈:两个退化实例
小模型会让循环以两种方式退化,两个提交各修一种。
空 MaxTokens 的静默死路(#2174,提交 9fe39f1b,2026-08-28)。Qwen 3.8 在一次压缩后把 16,384 token 的输出预算全部花在 reasoning 上,没有内容也没有工具调用,octos chat 干净地退出,无输出无错误。修复只针对这个退化分支:MaxTokens 响应空内容且无工具调用时,做有界 nudge-and-retry:
#![allow(unused)] fn main() { if content_empty && response.tool_calls.is_empty() && max_token_empty_recoveries < MAX_TOKENS_CONTINUATION_LIMIT { max_token_empty_recoveries += 1; let mut assistant = Message::assistant(String::new()); assistant.reasoning_content = response.reasoning_content.clone(); messages.push(assistant); messages.push(Message::user(MAX_TOKENS_EMPTY_RECOVERY_PROMPT)); }
(摘自 crates/octos-agent/src/agent/loop_runner.rs:2171 附近的对话循环 MaxTokens 分支;nudge 提示常量在 L42,上限仍是 2,耗尽后改为返回 L45 的终态错误消息而非空成功。带内容的 MaxTokens 响应原样返回,云端正常截断不受影响。)
强制贪心采样的重复坍缩(#2172,提交 3c7ff8bf,2026-08-28)。octos 曾对每个 chat 请求硬编码 temperature=0.0,本地小模型上贪心 argmax 会确定性重发同一工具调用,实测一条响应里重复了 763 次相同的 update_plan 调用。修复加了 gateway.llm_temperature 配置项,穿透到 AgentConfig.chat_temperature(crates/octos-agent/src/agent/mod.rs:97),在纯函数 build_chat_config(crates/octos-agent/src/agent/loop_runner.rs:3957)里应用。云端安全是构造保证:配置缺省时函数保持 ChatConfig::default() 的 0.0,云端请求逐字节不变;本地模型运维方设置如 0.7 即可避开坍缩。把覆盖语义提为自由函数,正是为了让「缺省不动默认值」这条不变量可以脱离完整 Agent 做单元测试。
这两个实例共同说明循环自愈的形状:检测退化条件(空内容、无进展),有界恢复(≤2 次),恢复不了就给可读的终态而不是空成功或静默退出。
5.8 退化检测
crates/octos-agent/src/agent/detection.rs 是循环的免疫层,五个 pub(super) 函数:is_retriable_response(crates/octos-agent/src/agent/detection.rs:60,空响应等可在调用层重试的形态)、normalize_inline_invokes(L84)、is_repetitive_output(L111,重复文本检测)、is_retryable_stream_error(L146)、is_truncated_tool_call_error(L168)。加上工具序列层面的 LoopDetector(窗口 12,任务循环注释明确对齐对话循环窗口)与 crates/octos-agent/src/agent/loop_state.rs 的 SHELL_SPIRAL_VARIANT(L180,shell 螺旋计数由状态机持有),构成三层:响应层、序列层、桶层。检测命中后的动作都已类型化:响应层回到调用重试梯,序列层停住本轮工具执行并上抛最近输出,桶层走 5.5 节的裁决。
5.9 支线模块一览
四个支线模块各用一段交代。crates/octos-agent/src/agent/memory.rs 负责首轮消息构建与情节记忆召回:build_initial_messages(crates/octos-agent/src/agent/memory.rs:249)、recall_relevant_episodes(L86)、save_conversation_episode(L174),相似度阈值 MIN_EPISODE_SIMILARITY = 0.55(L44);检索机制详见第 4 章。crates/octos-agent/src/agent/prompt_segments.rs 用 PromptSegmentProvider trait(crates/octos-agent/src/agent/prompt_segments.rs:25)维护有序系统提示段,支持具名替换与热刷新。crates/octos-agent/src/agent/verifier.rs 是可选的推理期校验器加紧凑 turn ledger:TurnLedger(L168)逐工具批记录条目,should_verify_after_tool_batch(L212)决定是否触发校验,ready_gate_active(L243)实现「校验通过才许终止」的门。crates/octos-agent/src/agent/append_only_audit.rs 回答一个问题:一次 turn 的请求历史是否只增不减(Rewrite 枚举,L162),默认关闭,OCTOS_APPEND_ONLY_AUDIT=1 才开启且绝不改写请求。crates/octos-agent/src/agent/realtime.rs(心跳与传感器注入,Heartbeat L128、SensorContextInjector L238)、crates/octos-agent/src/agent/rich_output.rs(从短 brief 生成自包含 HTML,author_html L85)、crates/octos-agent/src/agent/turn_failure.rs(语音 turn 失败投影,TurnFailure L9)服务特定运行模式,详见第 14 章。
5.10 续跑边界:goal 层的 master_continuation_scheduler
循环返回 stop_reason 之后,谁决定再开一个 turn?答案不在 agent crate。crates/octos-cli/src/autonomy/master_continuation_scheduler.rs(1,416 行)是一个纯数据结构:延迟优先队列,无 tokio 依赖、无 LLM 调用。入队原因 MasterContinuationReason(L137)六变体:ChildCompleted、ScatterJoinComplete、LoopFire、GoalContinue、GoalWrapUp(#1131,预算耗尽的收尾 turn)、External(String);优先级映射在 priority()(L152):LoopFire 最高,ChildCompleted 与 ScatterJoinComplete 次之,GoalContinue 与 GoalWrapUp 同级,External 最低。队列本体 MasterContinuationScheduler(L477)提供 enqueue(L525)、drain_ready(L687)、drain_ready_for_session(L706)等接口。
它被 AgentOrchestrator 持有为状态字段 continuations(crates/octos-cli/src/autonomy/agent_orchestrator.rs:11783),出队走 drain_ready_continuations_for_session(L3027)到内部 drain_ready_continuations_locked(L3112)。loop 侧唯一可见的效果是:一次 run_task 返回后,orchestrator 依据 ready 队列与优先级再次派发新 turn。「turn 结束后再创造 turn」的完整语义属于 goal 层,详见第 18 章。
5.11 本章回顾
- 模块地图:
agent/20 模块 21,369 行,三层分工(主线九模块、支线模块、压缩双模块),公开面只有crates/octos-agent/src/agent/loop_runner.rs的process_message/run_task家族,器官级入口全部pub(crate)/pub(super)。 - 生命周期:消息准备(修复七函数)、预算检查(五道闸)、LLM 调用(单一主函数)、流消费、工具派发、状态更新六阶段,主循环文件编排而不实现。
- 预算与检查点:
check_budget顺序即设计;Grace 给硬停前一次宽限;#27e 在 MaxIterations 且 git 且 dirty 三条件合取时本地提交 WIP、原子写 staged result(peer 持有写权时写result.checkpoint.md)、永不 push、fail-open。 - 错误恢复:
HarnessError(十五变体)→RecoveryHint(五变体)→LoopDecision(六变体)三层链路,16 桶独立上限,ProviderUnavailable与Quota换 lane、Authentication保持 FailFast。 - 循环自愈:空 MaxTokens 的有界 nudge(
9fe39f1b)与云端安全的 temperature 覆盖(3c7ff8bf),检测退化、有界恢复、终态可读。 - 续跑边界:scheduler 是 goal 层纯队列,loop 只负责把 stop_reason 交回去。
延伸阅读
assets/ch05-facts.md:本章全部行号与数字的事实表,附生成命令,可逐条复现。- 第 3 章的三层容错链(RetryProvider / ProviderChain / AdaptiveRouter):
RotateAndRetry裁决落地后实际换 lane 的机制。 - 第 8 章:
crates/octos-agent/src/agent/compaction.rs/crates/octos-agent/src/agent/loop_compaction.rs背后的分层压缩算法。 - 第 18 章:
MasterContinuationScheduler的消费侧与 goal 续跑全貌。 - issue #489(M6.2 typed retry bucket)与 #2172 / #2174 的提交说明:本章状态机与两个自愈实例的第一手动机。
思考题
check_budget的五道闸顺序如果重排(比如把 token 预算放最前),会在什么部署形态下引入可观开销或行为回退?给出你的排序理由与边界条件。- #27e 只在
MaxIterations触发。若要让MaxTokens也支持「耗尽即检查点」,re-dispatch 语义需要哪些额外信息?改触发条件本身够吗? - 16 个桶的上限里,
provider_unavailable给 4 次、authentication给 1 次。假如引入一种新的「provider 计费窗口冷却」错误,你会给它独立桶还是并入 quota?判断标准是什么? - 对话循环的空 MaxTokens nudge 上限是 2。把这个上限做成可配置,收益与风险各是什么?谁应该是这个配置的所有者:模型运维、平台运维,还是用户?
execute_tools是pub(super)而process_message是全公开。如果外部调用方需要绕过循环直接执行一批工具,应该在 crate 边界上开新口,还是复用execute_approved_tool(crates/octos-agent/src/agent/execution.rs:469)这样的既有公开函数?两种选择的审计面差异在哪里?
版本演化说明
本章分析基于 octos main @
9c157101(9c1571016e5ea86955b4b3486c04f0359dfff339,2026-09-02 19:37 +0800 统计)。所有行号与规模数字(20 模块、21,369 行、crates/octos-agent/src/agent/execution.rs4,730 行、crates/octos-agent/src/agent/loop_runner.rs3,979 行等)的口径与复现命令见assets/ch05-facts.md。相对 v1 旧稿,本章整章重写:按 20 模块地图重组叙事,取代旧稿「单文件逐段走读」骨架;预算检查点(#27e)、Grace 宽限、typed retry-bucket 状态机(M6.2 #489)、循环自愈两实例(#2172 / #2174)与 goal 层续跑边界为新增内容;旧稿引用的loop_detect等行号如与新版源码不符,以事实表为准。
第 6 章 工具系统:按能力域组织的 58 个源文件
定位:本章深入 Agent Loop 中"行动"阶段的核心,即工具系统。与旧稿按固定数量罗列内置工具的线性清单写法不同,本章按能力域重组叙事:先纵览
crates/octos-agent/src/tools/的 58 个条目如何划分成 10 个能力域,再下沉到三个骨架文件(crates/octos-agent/src/tools/registry.rs3,581 行、crates/octos-agent/src/tools/policy.rs741 行、crates/octos-agent/src/tools/args.rs479 行)讲清Tooltrait 契约、注册与查找、曝光控制、策略判定与参数安全。前置依赖:第 5 章(agent loop 如何调用工具)。适用场景:想理解 Agent 工具架构的 AI 应用开发者(读者 C),以及想为 octos 贡献新工具的开发者(读者 D)。
Agent 的"智能"来自 LLM,但 Agent 的"能力"来自工具。没有工具,Agent 只能生成文本;有了工具,Agent 可以读写文件、执行命令、搜索网页、管理 Git 仓库、派出子代理。
先统一口径。以 ls crates/octos-agent/src/tools/ | grep -v test 实测,tools/ 下共 58 个条目:57 个 .rs 文件加一个 admin/ 子目录(内含 7 个文件,合计 3,424 行)。黑板与早期规划里出现的"59 个源文件"是把测试类文件与 crates/octos-agent/src/tools/mod.rs 计满后的口径(ls crates/octos-agent/src/tools/*.rs | wc -l)。本章统一采用"58 条目 + admin 子目录 7 文件"的结构口径,不再沿用旧稿那个早已失效的固定工具数:当前源码里本来就不存在一个稳定的"总工具数":同一批源文件在不同构造路径下注册出不同的工具面,理解这个分层注册模型,比记住一个固定数字更重要。
工具带来能力的同时也带来风险:每个工具调用都是一个潜在的攻击面。octos 的答案是一道骨架三防线:ToolPolicy 控制哪些工具可用(deny-wins),参数验证控制输入安全(1 MB 上限与结构化报错),symlink-safe I/O 与 SSRF 校验控制文件系统与网络边界。第 7 章会展开沙箱与 write_grant 的运行时语义,本章只讲工具侧的接口与注册。
6.1 能力域全景:58 个条目怎么分组
按事实表(assets/ch06-facts.md,commit 552be31)的归属,tools/ 下的源文件可以划分成 10 个能力域。下表是本章的导航图,每个能力域随后各有一小节:
| 能力域 | 文件数 | 源文件 |
|---|---|---|
| 文件系统 | 12 | read_file, read_paging_probe, read_window, write_file, edit_file, apply_patch, diff_edit, list_dir, glob_tool, grep_tool, replacer, workspace_history |
| Shell/执行 | 3 | shell, check, write_grant |
| Web/研究 | 9 | web_fetch, web_search, deep_search, synthesize_research, research_utils, site_crawl, http, ssrf, browser |
| 记忆 | 5 | memory_note, record_memory_use, recall, recall_memory, save_memory |
| 消息/交互 | 4 | message, send_file, send_app_card, ask_user_question |
| Peer/Fleet | 10 | peer_handoff, peer_send_input, peer_gather, peer_list, peer_respond, peer_close, spawn, delegate, check_background_tasks, read_task_output |
| 代码/结构 | 2 | code_structure, coding_tools |
| Git | 1 | git |
| 技能/插件 | 3 | manage_skills, dora_bridge, mcp_agent |
| 平台杂项 | 6 | check_workspace_contract, tool_config, mofa_make, robot_groups, mod, admin/(7 文件) |
表的合计口径:58 个条目 = 上表 10 域的 55 个文件级条目(平台杂项按实际清单 6 条,admin/ 整体算 1 条)+ 不归属任何域的骨架三件(registry、policy、args)。
图 6-1 能力域工具家族(mermaid,正文渲染):
graph LR
subgraph 文件系统
A1[read_file] --> A2[read_window]
A3[write_file] --> A4[edit_file/diff_edit]
A5[apply_patch] --> A6[replacer]
A7[list_dir/glob/grep] --> A8[workspace_history]
end
subgraph 执行
B1[shell] --> B2[check]
B3[write_grant]
end
subgraph Web/研究
C1[web_fetch/web_search] --> C2[site_crawl/deep_search]
C3[synthesize_research] --> C4[http/ssrf]
C5[browser]
end
subgraph 记忆
D1[save_memory] --> D2[recall_memory/recall]
D3[memory_note] --> D4[record_memory_use]
end
subgraph Peer/Fleet
E1[spawn/delegate] --> E2[peer_* 六件]
E3[check_background_tasks] --> E4[read_task_output]
end
有几个名字与直觉不符,值得先说清。crates/octos-agent/src/tools/glob_tool.rs 注册的工具名是 glob,crates/octos-agent/src/tools/grep_tool.rs 是 grep,crates/octos-agent/src/tools/workspace_history.rs 里是 workspace_log,crates/octos-agent/src/tools/site_crawl.rs 是 deep_crawl,crates/octos-agent/src/tools/deep_search.rs 是 search,crates/octos-agent/src/tools/delegate.rs 是 delegate_task。文件名与工具名分离,是这批文件早期为避让 Codex 兼容别名留下的痕迹。crates/octos-agent/src/tools/coding_tools.rs 更特殊:一个文件里实现了 exec_command、write_stdin、spawn_agent、bash、delegate、view_image、tool_search、tool_suggest、image_generation 等一整组 Codex 兼容 shim(crates/octos-agent/src/tools/coding_tools.rs:381、:738、:1283、:1945、:2428、:2800、:3149、:3250、:3388),所以"文件数不等于工具数"在两个方向都成立。crates/octos-agent/src/tools/replacer.rs、crates/octos-agent/src/tools/read_paging_probe.rs、crates/octos-agent/src/tools/ssrf.rs、crates/octos-agent/src/tools/dora_bridge.rs、crates/octos-agent/src/tools/http.rs、crates/octos-agent/src/tools/read_window.rs、crates/octos-agent/src/tools/write_grant.rs 甚至不是独立工具,而是被其他工具复用的支撑模块或特性开关,这也是能力域分组的第二个维度:按"谁依赖谁"而不只按"叫什么名字"。
能力域之外还有第二套坐标系要认清。上面的域是文件组织的事实划分,crates/octos-agent/src/tools/policy.rs 里另有一张按 LLM 可见入口划分的策略分组表 TOOL_GROUPS(crates/octos-agent/src/tools/policy.rs:187 起,ToolGroupInfo 结构在 :179-183,共 10 组),两套并不重合。group:fs 只收 read_file、write_file、edit_file、diff_edit、apply_patch 五个主入口,文件系统域的 12 个文件里过半不在组内;bash 别名的实现在 crates/octos-agent/src/tools/coding_tools.rs(代码/结构域),却因 #1172 被收进 group:runtime;group:sessions 的七个成员横跨 Peer/Fleet 域与 Codex 兼容 shim。分组按工具名而非文件名,理由直接:策略要约束的是模型喊得到的名字,不是源码存放的位置。
这张静态表有一条硬不变量。group:delegated(M6.7,crates/octos-agent/src/tools/policy.rs:307 起)是委派子代理的标准 deny 表,必须覆盖 group:sessions 的全部成员,否则子代理能再派孙代理,从递归护栏底下穿过去。expand_group 返回扁平静态切片,组机制没有组内组展开,超集只能手工维护,测试 group_delegated_supersets_session_spawn_family 在每次构建时逐项核对,注释里记录了这次漂移确实发生过:spawn_agent、send_input、*_agent 生命周期工具与 delegate 包装器一度全部可达。同一张表还故意不收 shell,注释原文写明这里不是 confinement boundary,命令执行的约束交给沙箱层,工具策略层管不了也不该装作能管。
下面按域速览,每域点出代表性设计与源码位置。
6.1.1 文件系统(12 个)
这是最重的一域:crates/octos-agent/src/tools/read_file.rs 2,366 行、crates/octos-agent/src/tools/write_file.rs 2,220 行,单文件就超过多数域的总和。设计要点有三。
第一,读有多条路径而不是一条。read_file 负责常规读取,crates/octos-agent/src/tools/read_paging_probe.rs 是一个"只观察不改变行为"的分页探针(crates/octos-agent/src/tools/read_paging_probe.rs:1-6),用来回答"强制分页到底省不省 token"这个测量问题;crates/octos-agent/src/tools/read_window.rs 是特性开关 OCTOS_READ_WINDOW=1 才启用的窗口化强制(crates/octos-agent/src/tools/read_window.rs:3、:178),默认关闭,不设环境变量时行为与旧版逐字节一致。三者并存说明:读路径的每一次收紧都必须先拿数据,再拿默认值。
第二,写有四个层次。write_file 全量写,edit_file 精确替换,diff_edit 按上下文匹配,apply_patch 接受标准 patch 格式。它们共用 crates/octos-agent/src/tools/replacer.rs 的级联模糊匹配链(#1771):LLM 给出的 old_string 常有空白、缩进、转义差异,edit_file 不再硬失败,而是按 exact → line_trimmed → … 的顺序逐级放宽,用第一个有结果的匹配器(crates/octos-agent/src/tools/replacer.rs:1-8)。这条链是"容忍模型的不精确"这一原则的落地。
第三,检索三件(list_dir/glob/grep)与 crates/octos-agent/src/tools/workspace_history.rs(注册名 workspace_log,见 crates/octos-agent/src/tools/workspace_history.rs:101)补齐"找文件"与"看历史"。
6.1.2 Shell/执行(3 个)
crates/octos-agent/src/tools/shell.rs 2,745 行,注册名 shell,是执行域的主力;同域的 bash 别名在 crates/octos-agent/src/tools/coding_tools.rs:1945。crates/octos-agent/src/tools/check.rs 是工作区检查器(注册名 check,crates/octos-agent/src/tools/check.rs:526)。crates/octos-agent/src/tools/write_grant.rs 不是工具而是 #1976 引入的按路径写授权结构:WritePathGrant 通过 write_file/edit_file 的 with_write_grant 构造器绑定,让"写越界"从礼貌请求变成类型化拒绝(crates/octos-agent/src/tools/write_grant.rs:53、:72)。它的运行时语义与沙箱翻译在第 7 章,这里只需要知道:授权的匹配语法是单段通配(*/? 限一段,拒绝 **、[...]、{...})。
6.1.3 Web/研究(9 个)
从 web_fetch 单页抓取、web_search 搜索,到 site_crawl(注册名 deep_crawl)与 deep_search(注册名 search)的站点级/多跳研究,再到 synthesize_research 汇总成文。支撑模块三个:crates/octos-agent/src/tools/http.rs 是原始 HTTP 客户端(工具名由构造器传入,crates/octos-agent/src/tools/http.rs:130),crates/octos-agent/src/tools/ssrf.rs 是共享的 SSRF 防护(check_ssrf_with_addrs 在 crates/octos-agent/src/tools/ssrf.rs:24,含 DNS pinning 防重绑定,validate_answer_set 在 :69,重定向上限 SSRF_MAX_REDIRECTS = 10 在 :101),crates/octos-agent/src/tools/browser.rs 是无头浏览器。注意输出限额按工具名区分:octos_core::tool_output_limit 里 read_file 50,000 字节、shell 30,000、web_fetch 40,000、web_search 20,000、search(deep_search)200,000(crates/octos-core/src/utils.rs:180-196)(研究类工具拿到的是最宽的额度)。
6.1.4 记忆(5 个)
save_memory 写入、recall_memory 召回、memory_note 记笔记、record_memory_use 记使用账;crates/octos-agent/src/tools/recall.rs 是 #2131 引入的新成员:当上下文压缩把某次工具输出替换成占位符后,recall 凭 tool_call_id 从内容寻址的输出台账里把完整字节重新物化,而不必重跑那次调用(crates/octos-agent/src/tools/recall.rs:1-4)。它通过 ToolOutputLedger trait 与 octos-cli 的 ContextManager 保持独立(crates/octos-agent/src/tools/recall.rs:15-21),注册名 recall(:106)。这条机制与第 8 章的压缩台账直接呼应。
6.1.5 消息/交互(4 个)
message 发文本、send_file 发文件、send_app_card 发卡片、ask_user_question 向用户提问。这一域的特点是"结果不是给模型的,是给人的":输出走消息总线而非工具结果(MessageTool/SendAppCardTool 共享同一个 tx 通道注册,crates/octos-agent/src/tools/registry.rs:1735-1736;send_app_card 的描述里明确要求 initial_state 必须来自真实工具数据、严禁编造,crates/octos-agent/src/tools/send_app_card.rs:130-140),第 10 章展开。
6.1.6 Peer/Fleet(10 个)
最大的一域。crates/octos-agent/src/tools/spawn.rs 5,309 行,是全书最大的单工具文件(注册名 spawn,crates/octos-agent/src/tools/spawn.rs:2922);crates/octos-agent/src/tools/delegate.rs(注册名 delegate_task)是单次调用版。peer_handoff/peer_send_input/peer_gather/peer_list/peer_respond/peer_close 六件构成 peer 通道原语,check_background_tasks 与 read_task_output 补齐后台查询。这一域与 spawn_only 曝光控制(6.3 节)绑定最深:后台化工具的调用正是被执行循环拦截转成后台任务的。
6.1.7 代码/结构(2 个)与 Git(1 个)
crates/octos-agent/src/tools/code_structure.rs 输出源码符号结构(注册名 code_structure;内部有 1 MB 解析上限,crates/octos-agent/src/tools/code_structure.rs:72),crates/octos-agent/src/tools/coding_tools.rs 如上是 Codex 兼容 shim 集合。crates/octos-agent/src/tools/git.rs 注册名 git,diff 侧同样有 1 MB 单文件上限(crates/octos-agent/src/tools/git.rs:291)。
6.1.8 技能/插件(3 个)与平台杂项
manage_skills 管理技能包,dora_bridge 接机器人框架(工具名来自映射表,crates/octos-agent/src/tools/dora_bridge.rs:178),mcp_agent 让 spawn 能把任务派给任何 MCP 后端(crates/octos-agent/src/tools/mcp_agent.rs:1-6)。平台杂项里 crates/octos-agent/src/tools/tool_config.rs 注册名是 configure_tool(crates/octos-agent/src/tools/tool_config.rs:633),mofa_make 是分发器(其目标工具被 mark_internal_hidden 隐藏,6.3 节),crates/octos-agent/src/tools/robot_groups.rs 维护机器人安全分级(SafetyTier:工具在 RobotToolRegistry 里按最低所需档位注册,档位展开时低档自动包含高档工具,crates/octos-agent/src/tools/robot_groups.rs:26-38、51-59),admin/ 子目录 7 文件提供 20 个 admin_* 运维工具(admin_list_profiles、admin_system_health、admin_update_octos 等)。
为什么按域拆文件,而不是一个大文件
回到 6.1 开头的分组表,值得问一句:12 个文件系统工具为什么不并成单个大文件?域内共享的是不变量与依赖方向,不是代码量。文件系统域共享三条:路径必须先过 resolve_path 族归一;读写必须走 no-follow 系调用;输出必须接受 tool_output_limit 截断。把这三条放进一个共享模块、把工具语义放进各自的文件,新增一个读路径工具时只需要实现语义,不用重抄防线。反过来,域与域之间刻意不共享:Web 域的 SSRF 校验与文件域的路径校验互不引用,因为两者的威胁模型不同(前者是外发请求重定向,后者是本地符号链接),混装会让一个域的修改变成另一个域的回归面。crates/octos-agent/src/tools/spawn.rs 5,309 行已经证明单文件膨胀的代价,crates/octos-agent/src/tools/registry.rs 3,581 行是另一个极端,中间态就是按域分文件的现状。
6.2 Tool trait:契约在 crates/octos-agent/src/tools/mod.rs
所有工具实现同一个 trait(crates/octos-agent/src/tools/mod.rs:609):
#![allow(unused)] fn main() { #[async_trait] pub trait Tool: Send + Sync { fn name(&self) -> &str; fn description(&self) -> &str; fn input_schema(&self) -> serde_json::Value; fn tags(&self) -> &[&str] { &[] } fn contexts(&self) -> &[String] { &[] } async fn execute(&self, args: &serde_json::Value) -> Result<ToolResult>; fn truncation_recovery( &self, args: &serde_json::Value, omitted_bytes: usize, ) -> Option<String> { None } async fn execute_with_context( &self, ctx: &ToolContext, args: &serde_json::Value, ) -> Result<ToolResult> { /* 默认委托 execute */ } } }
契约分三层。声明层(name + description + input_schema)构成发给 LLM 的 ToolSpec。执行层是两个入口:execute 是遗留的仅参数签名,execute_with_context 是 M8.1 引入的类型化入口(crates/octos-agent/src/tools/mod.rs:11-28 的模块文档详细记录了这次迁移):默认实现互相委托,迁移后的工具覆写 execute_with_context 并让 execute 以零值上下文重入,保证外部调用者行为可预测。一个工具至多覆写其一,同时覆写会产生执行器无法调和的两条独立路径。治理层是 tags()(能力标签,供 require_tags 过滤)与 contexts()(声明本工具只在哪些模型上下文曝光,空列表表示处处可见)。
truncation_recovery 值得单说。截断发生在执行循环,而执行循环拿到的是裸字符串,不知道哪个工具产出、是否支持分页、参数叫什么;工具知道怎么续读,却不知道自己被截了。这个钩子是缺失的另一半:循环截断后带着 args 与 omitted_bytes 来问,工具返回一条"下一步该怎么调"的具体建议,默认 None 表示没有续读路径,不编造。配套的输出上限正是 6.1.3 提到的 tool_output_limit(crates/octos-core/src/utils.rs:180)。
类型化上下文 ToolContext(crates/octos-agent/src/tools/mod.rs:261)本身是一份架构自传:reporter 与 harness 事件槽是 UI 通道,agent_definitions、permissions、file_state_cache、notifications、app_state 是 M8.1 预埋、后续里程碑逐一填充的桩(各自标注了归属 issue),subagent_output_router/subagent_summary_generator/task_supervisor/cost_accountant/parent_session_key 让 spawn 子代理与流水线 worker 共享父会话的路由、汇总、监督与成本台账,spawn_depth 是 #607 的 spawn 嵌套深度护栏。读这个 struct 的字段顺序,基本等于读 octos 半年的演进史。
6.3 ToolRegistry:注册、查找与曝光控制
crates/octos-agent/src/tools/registry.rs 3,581 行,ToolRegistry 定义在 crates/octos-agent/src/tools/registry.rs:127。它不是"名字到工具"的简单字典,字段清单(:127-235 附近)暴露了它同时管着七件事:工具表、workspace 根、provider 级策略(过滤 specs() 但不移除工具)、上下文标签过滤器、缓存过的 specs、插件工具标记、MCP 传输句柄所有权。
这些字段不是一次设计出来的,读注释里的 issue 编号能看出沉积顺序。spawn_only 与 spawn_only_messages(:164-166)是 RFC-0 之后的幸存者;live_catalog(:179)是 #1148 加的:tool_search 与 tool_suggest 需要一个随注册表变更实时刷新的目录,而工具本身的执行又拿不到注册表引用,于是注册表在每次变更(register、apply_policy、mark_spawn_only 等,统一经 invalidate_cache,:1086)时把可见工具克隆进这个 Mutex 保护的单元,查询侧持 live_catalog_handle()(:1420)取快照。注释特意说明 Mutex 在这里是合适的:刷新只是克隆一个小 Vec,且只发生在注册表变更时。internal_hidden(:206)与 mcp_services 的所有权语义(#1886,可见性过滤不得决定传输生命周期)分别是 RFC-1 fixup 与 MCP 并入时补上的。
字段之外还有一条容易被忽略的性质:specs() 对输出排序(:746)。tools 是 HashMap,迭代顺序随进程与重建变化,而 provider 会把这个数组原样重放进 LLM 的 prompt 前缀,顺序一变就打穿 provider 侧的 prompt 缓存(工具数组是缓存前缀的首段,如 Anthropic 的 cache_control)。按名字排序后,同一工具集的序列化字节稳定,缓存命中才成立。这是"实现细节变成对外契约"的例子:字段顺序本可随意,缓存成本把它钉死了。
6.3.1 三种构造
#![allow(unused)] fn main() { pub fn with_builtins(cwd: impl AsRef<Path>) -> Self { Self::with_builtins_and_sandbox(cwd, Box::new(NoSandbox)) } pub fn with_builtins_and_sandbox(cwd: impl AsRef<Path>, sandbox: Box<dyn Sandbox>) -> Self { let permissions = EffectivePermissions::workspace_write(); Self::with_builtins_and_permissions(cwd, sandbox, permissions) } }
(摘自 crates/octos-agent/src/tools/registry.rs:1243-1252)
with_builtins 用 no-op 沙箱启动,with_builtins_and_sandbox 接自定义沙箱但权限仍是工作区可写,只有 with_builtins_and_permissions(:1254)把权限也交出来。#1607 的注释写明:no-op 沙箱下 Agent 内部的工作区校验器直接跑 argv,因为没有可逃逸的东西。空构造 new()(:236)则从零开始,留给测试与特殊组装。
6.3.2 注册与查找
register(:536)接所有权注册,register_arc(:558)接 Arc<dyn Tool>:同一个工具实例要同时出现在多个注册表(父会话与 spawn 子代理)时必须走后者。查找是 get/get_tool,分派边界在 execute_with_context,那里叠着四层防线:provider 策略拒绝、参数体积上限(6.5 节)、catch_unwind 的 panic 隔离(一个工具 panic 只降级为失败的 ToolResult,不再连带杀死 session actor 与其子代理)、全局执行超时(默认 1,800 秒,set_tool_timeout_secs 在 :279)。
6.3.3 曝光控制:LRU 已死,spawn_only 当立
旧稿花了大量篇幅讲 LRU 工具延迟激活与 activate_tools,必须先明确:该机制已在 RFC-0(commit 172fb2be,issue #1289)中连同 activate_tools 一并删除,find_evictable 与 ToolLifecycle 已不存在。specs() 的当前注释写得很直白:每轮发出所有启用的工具,剩余的排除项只有 internal-hidden、provider 策略拒绝与上下文过滤器 miss,不再有任何基于近因的延迟(crates/octos-agent/src/tools/registry.rs:706-711)。
存活下来的机制是 spawn_only(mark_spawn_only 在 :299):被标记的工具在 specs() 里始终可见、LLM 始终可调,但调用在执行循环的分派点被拦截,转成后台任务并立即返回任务句柄(crates/octos-agent/src/agent/execution.rs:775-780 附近)。图 6-2 是这条路径的时序:
sequenceDiagram
participant LLM
participant Loop as 执行循环
participant Reg as ToolRegistry
participant BG as 后台任务
LLM->>Loop: tool_call(spawn_only 工具)
Loop->>Reg: is_spawn_only(name)?
Reg-->>Loop: true
Loop->>Loop: provider 策略复查(拦截点)
Loop->>BG: 起后台 tokio task 执行
Loop-->>LLM: 立即返回 task_handle
BG--)Loop: 完成回调(background_result_sender)
Loop--)LLM: 下一轮注入结果消息
配套细节:每注册表持 spawn_only_invoked 原子布尔记录本运行是否真的用过;background_result_sender 让 session actor 在任务完成时收到通知;#896 修复了文件名在 task_handle 信封里的传播,让 LLM 有稳定文件名可引用。配套细节之外还有两个容易漏看的语义。其一,标记可以被整体清除:clear_spawn_only(crates/octos-agent/src/tools/registry.rs:355)会清空两个集合并让 specs 缓存失效,注释写明它的用途是子代理注册表,在那里 spawn_only 工具应当可以直接调用,因为子代理本身就是后台上下文,再后台化一次没有意义。这个方向性(父会话拦截、子会话放行)与 6.3.4 的注册路径分层对上:曝光控制是每注册表一份的状态,不是工具的固有属性。其二,M10 Phase 4 之后 LLM 拿到的不再是那行自由文本加完整 stdout,而是 spawn_only_handle_message(crates/octos-agent/src/tools/registry.rs:388)构造的小型 task_handle 信封:任务 id、期望产物文件、输出目录、以及一个 read_with: read_task_output 的提示和五种读法(head/tail/grep/line_range/file)。完整输出仍经 M8.7 的 SubAgentOutputRouter 落盘并送达 SPA,这条改动只影响模型看到什么。设计动机是让模型能按需检查中间产物而不撑大上下文,与 read_window 的预限制方向互补。
RFC-0 删 LRU 的取舍放在本章末的侧栏。
mark_internal_hidden(:324)是另一个方向的隐藏:internal-hidden 工具仍可被内部转发器调用,但从 specs() 消失,且没有 LLM 可达的"取消隐藏"路径,即单向。mofa_make 的分发目标(mofa_slides、mofa_cards 等)靠它保证只经分发器可达。
6.3.4 注册路径六处
"58 个条目"并不等于"每次会话都有 58 个工具"。同一批源文件经由六条路径进入注册表,每条路径都会追加或裁剪:
- 内置基座:构造器(
crates/octos-agent/src/tools/registry.rs:1243-1254)注册文件/shell/检索/git 等核心工具。 - profile 名册:
crates/octos-agent/src/assets/profiles/coding.json声明 allow_list(lean coding roster,9ecc5845引入),crates/octos-agent/src/profile/mod.rs负责应用;名单只有 12 项但含group:runtime/group:search/group:memory三个组条目,并特意补回check、update_plan、tool_search。早期 lean 名单把后者也拒了,导致默认 coding 会话跑不了工作区检查、建不了计划。tool_search是逃生口:模型可动态发现被 allow_list 排除的工具。 - chat 命令:
crates/octos-cli/src/commands/chat.rs:855、:1337用with_builtins_and_permissions起 registry,再补 peer 工具等运行时注入。 - ACP:
crates/octos-cli/src/commands/acp.rs:224、:501等多处用with_builtins_and_sandbox。 - gateway:
crates/octos-cli/src/commands/gateway/gateway_runtime.rs:823;注意它调apply_policy的时机早于 session actor 复用注册表(crates/octos-cli/src/session_actor.rs:2884的注释记录了这一顺序约定)。 - MCP:
crates/octos-agent/src/mcp.rs:532的register_tools把远端工具并入,名字与内置冲突即拒绝,防止远端服务器静默替换核心功能;传输句柄在工具之前先交给注册表持有(#1886:可见性过滤不得决定传输生命周期,否则 profile 收窄会顺手杀掉 stdio 子进程)。
图 6-3 注册路径叠加(mermaid):基座 → profile 名册 → 各运行模式的追加 → 策略裁剪 → specs() 输出。
graph TD
A[with_builtins 基座] --> B[profile allow_list 裁剪]
B --> C1[crates/octos-cli/src/commands/chat.rs 注入 peer 等]
B --> C2[crates/octos-cli/src/commands/acp.rs / gateway 追加]
B --> C3[session_actor: cron/pipeline]
C1 --> D[apply_policy / provider 策略]
C2 --> D
C3 --> D
D --> E[mcp.register_tools 并入远端]
E --> F[specs 缓存输出]
6.4 ToolPolicy:deny-wins 与分组
crates/octos-agent/src/tools/policy.rs 741 行。核心结构(crates/octos-agent/src/tools/policy.rs:28-40):
#![allow(unused)] fn main() { pub struct ToolPolicy { pub allow: Vec<String>, pub deny: Vec<String>, pub require_tags: Vec<String>, pub bash_file_writes: BashFileWrites, } }
三维语义加一个 shell 专项。allow 空表示"未显式拒绝即允许";deny 永远压过 allow;require_tags 非空时工具必须声明至少一个匹配标签,无标签的工具直接失败(fail closed)。这是 peer 评审后改过的行为,is_allowed_with_tags(crates/octos-agent/src/tools/policy.rs:131-148)的注释保留了这次安全收紧的记录。判定流程见图 6-4:
flowchart TD
S[evaluate(name)] --> D{deny 列表命中?}
D -- 是 --> DR[Deny, reason=policy_deny<br/>或 robot_tier_gate]
D -- 否 --> A{allow 为空?}
A -- 是 --> OK[Allow]
A -- 否 --> M{allow 命中?}
M -- 是 --> OK
M -- 否 --> DR2[Deny, 记 metric]
OK --> T{require_tags 非空?}
T -- 否 --> PASS[可见]
T -- 是 --> TG{工具标签有交集?}
TG -- 是 --> PASS
TG -- 否 --> FAIL[不可见, fail closed]
evaluate(crates/octos-agent/src/tools/policy.rs:77-128)在拒绝时发 octos_tool_policy_denial_total 指标,拒绝原因区分 robot_tier_gate 与 policy_deny,机器人集成的运维可据此告警。
条目支持三种形态:工具名、group:* 分组、通配。分组表 TOOL_GROUPS(crates/octos-agent/src/tools/policy.rs:180 起,ToolGroupInfo)把工具归成 group:fs、group:runtime、group:web、group:search、group:sessions 等。#1172 的教训写在 group:runtime 里:组内必须包含 Codex 兼容别名 bash,否则禁用 runtime 的 profile 仍可经 bash(cmd=…) 绕过。别名等价是策略正确性的硬约束。
bash_file_writes(#28b)是三档开关(crates/octos-agent/src/tools/policy.rs:55-68):allow(默认)零行为变化;warn 在变更回执尾部追加"改用 edit_file/diff_edit"的提示;deny 用启发式预筛拒绝写形命令,带 # octos:allow-write 行尾注释逃生口。
两个应用入口要分清。apply_policy(crates/octos-agent/src/tools/registry.rs:869-875)用 retain 物理裁剪注册表:被裁的工具真的没了;set_provider_policy(crates/octos-agent/src/tools/registry.rs:947-953)只设一个过滤器,工具留在表里,specs() 与分派边界按 deny-wins-then-allow 语义(含别名等价)动态判断,provider_policy_permits 还供工作区校验器确认"provider 策略是否允许模型调这个工具"。provider 级策略的配置键是 tool_policy_by_provider(crates/octos-cli/src/config.rs:132),按 provider 名映射到各自的 ToolPolicy,provider_policy() 取出的策略会随 spawn 传播到子代理注册表。
6.5 参数与路径安全
三道闸门。
第一道,体积。分派边界拒绝超限参数,上限 1 MB(MAX_ARGS_SIZE = 1_048_576),estimate_json_size 递归估算序列化体积(crates/octos-agent/src/tools/registry.rs:1132-1143,估算函数在 :95-119)。注意这个 1 MB 在源码里的位置是 crates/octos-agent/src/tools/registry.rs 而非 crates/octos-agent/src/tools/args.rs。事实表对此的口径提醒是对的,crates/octos-agent/src/tools/args.rs 里没有这个常量。
值得停在 estimate_json_size(crates/octos-agent/src/tools/registry.rs:95-119)的实现上多看一眼,因为它示范了一类常见取舍。最省事的做法是 serde_json::to_string(args).len(),但那要先把整个参数序列化成 String,分配一块与参数等大的内存,只为得到一个数字,然后扔掉。在分派边界这个每轮工具调用都要过的位置,这个分配纯属浪费。这个函数改为递归走查 serde_json::Value 树按节点计字节:字符串按内容长度加转义开销加两个引号,数组按 2 加元素和加逗号,对象对每个键值加键长加 3(冒号与引号)再加子树,拿到的估算值恰好够做"是否超限"这一个判断。零分配、零拷贝、够用即止,是边界检查类代码该有的形状。
第二道,结构。crates/octos-agent/src/tools/args.rs 479 行解决的是"报错质量"而非"报不报错":serde 失败时只说第一个问题且无视未知字段,模型要么原样重试(死循环,#1765)要么瞎猜修法。parse_tool_args 保持 serde 为解析真源,失败后对照工具自己的 input_schema() 走查原始 JSON,一次性报出全部问题:缺哪个必填参数、哪个参数名拼错(附 did-you-mean)、哪个类型不对(crates/octos-agent/src/tools/args.rs:1-30)。错误经 ToolInputError 送达模型,消息上限 4,096 字节(crates/octos-agent/src/tools/mod.rs 的 TOOL_INPUT_ERROR_MAX_BYTES),既保证模型能自我修复,又保证调用方可控内容不会把错误撑爆输出限额(#2193 R4)。同时这类失败被标记为无副作用、不级联(#1690):一次坏调用不连坐同批好调用。
第三道,边界。路径经 resolve_path(crates/octos-agent/src/tools/mod.rs:972)及带 scope 的变体(:1014、:1052、:1061)归一到会话文件系统契约;文件读写走 read_no_follow/write_no_follow 防 symlink 逃逸(crates/octos-agent/src/tools/edit_file.rs:182 附近可见其用法);网络侧由 crates/octos-agent/src/tools/ssrf.rs 把关(6.1.3 节)。这三者的完整威胁模型在第 7 章。
6.5.1 编码工具契约
前端要的可不是"大概这些工具",而是一份机器可校验的契约。crates/octos-cli/src/api/coding_tool_contract.rs 定义 coding.tool_contract.v1(:12),契约 ID codex-compatible-coding-v1,策略 ID coding-v1。P0 必备工具集 CODING_P0_REQUIRED_TOOL_NAMES(:85)列出 10 项:apply_patch、exec_command、write_stdin、update_plan、request_user_input、spawn_agent、send_input、resume_agent、wait_agent、close_agent。工具状态词汇是 available/aliased/disabled_by_policy/missing/unimplemented/deferred(:21-34),MCP 侧另有 connected/connecting/failed/disabled。有趣的是 deferred 的注释仍提到 LRU 自动淘汰与 activate_tools 恢复(那是契约词汇表为前端兼容保留的历史语义),机制本身已随 RFC-0 删除,不要据此以为 LRU 还在。
6.5.2 三个治理工具的样本
write_grant(#1976)、read_window(#1638)、recall(#2131)代表了工具系统近期的演化方向:不再加"能做更多事"的工具,而加"把已做的事管起来"的工具。write_grant 把 fleet 任务的 fs.write 授权下沉为工具内的内核侧栅栏;read_window 默认关闭、需 OCTOS_READ_WINDOW=1 显式启用,参数来源有据可查。2,000 行上限取自 pi harness 的实测默认,字节上限 48 KiB 是为连页脚一起塞进 50,000 字节的输出上限反推的(crates/octos-agent/src/tools/read_window.rs:1-30);recall 让被压缩替换的输出可凭 id 取回,与第 8 章的内容寻址台账一体。三者共同的前缀是:默认不改变现有行为,启用才收紧。
6.6 工程决策侧栏:为什么删掉 LRU 延迟激活
RFC-0(commit 172fb2be,#1289)删 LRU,不是因为它坏了,而是因为它解决的问题换了答案。LRU 的假设是"工具 schema 是显著的每轮 token 开销,冷工具应藏起来,需要时由模型经 activate_tools 取回"。三个事实瓦解了这个假设:其一,取回路径本身要求模型知道自己缺什么工具,而模型恰恰在被藏起来后不知道。tool_search 逃生口(现随 lean coding roster 默认提供)以更低的复杂度解决了发现问题;其二,延迟激活让工具面的可见性变得依赖会话历史,"这个会话里到底有哪些工具"从静态问题变成动态问题,契约校验、策略审计、前端渲染都要为此买单;其三,真正需要"先声明后执行"的长任务工具,需求其实是"别阻塞前台轮次",这正是 spawn_only 自动后台化的语义,与曝光控制无关。删掉后,specs() 每轮全量输出,缓存机制(cached_specs,注册表变更时失效)把重复序列化的开销吃掉。教训:当两个机制看似解决同一个症状时,先问它们各自的因果链是否还成立。
6.7 小结
本章按能力域纵览了 tools/ 的 58 个条目与 10 个域,再下沉到三个骨架文件:Tool trait 的三层契约与类型化上下文迁移,ToolRegistry 的三种构造、六条注册路径与以 spawn_only 为核心的曝光控制(LRU 已删),ToolPolicy 的 deny-wins、分组、别名等价与 fail-closed 标签门,以及参数体积、结构化报错、路径与网络三道安全闸。第 7 章把沙箱与授权的运行时语义补全,第 16 章的封闭注册表与第 18 章的 goal/peer 工具,都建立在本章的注册与策略语义上。
延伸阅读
crates/octos-agent/src/tools/mod.rs顶部模块文档:typedToolContext迁移(M8.1)的完整设计说明。crates/octos-agent/src/tools/registry.rs的ToolRegistry字段注释:MCP 传输所有权(#1886)与 panic 隔离的设计依据。crates/octos-agent/src/tools/policy.rs:is_allowed_with_tags的 fail-closed 修订注释。- 第 5 章(agent loop 的工具调用循环)、第 7 章(沙箱与 write_grant)、第 8 章(recall 与压缩台账)。
思考题
- 同一份
coding.jsonallow_list 在apply_policy(物理裁剪)与set_provider_policy(动态过滤)两种应用方式下,行为差异会在什么场景暴露?提示:考虑 MCP 工具随后并入的情形。 require_tags非空时,无标签工具 fail closed 而不是 pass,这个"不友好"的选择换来了什么安全性质?group:runtime必须包含bash别名。如果新增一个 shell 入口别名而忘了进组,攻击面在哪一层出现:策略层、注册表层,还是执行层?recall依赖内容寻址台账,read_window依赖窗口加页脚。两者都在"截断"上做文章,但方向相反(一个取回,一个预先限制)。哪种更适合迁移到grep这类输出形态不可预测的工具?为什么?- RFC-0 删 LRU 时保留了
spawn_only。假如你要为新工具决定是否标spawn_only,判据应该看工具的什么性质?
版本演化说明
本章基线为 octos 源码 commit 9c157101(2026-09-03 实测,main 分支)。行号与结构以 assets/ch06-facts.md(commit 552be31)为准,关键行号均经亲测复核。历史注记:LRU 工具延迟激活与 activate_tools 已于 RFC-0(172fb2be,#1289)删除;crates/octos-cli/src/api/coding_tool_contract.rs 的 deferred 状态词汇为前端兼容保留,不代表机制存续。
第 7 章:安全纵深:沙箱 fail-closed、注入防御与能力授予
定位:本章从纵深防御的视角完整展示 octos 的安全体系:进程沙箱的 fail-closed 语义、命令与派发策略、注入检测与输出脱敏,以及 WorkerGrant 能力授予模型。前置依赖:第 6 章(ToolPolicy 与 deny-wins 语义)。适用场景:需要理解或扩展沙箱后端、为 fleet worker 设计能力授予的读者(B/D 类),以及关心 Agent 安全实践的 AI 应用开发者(C 类)。
7.0 本章解决什么问题
Agent 与普通软件的区别在于它执行动作:运行 shell 命令、读写文件、发起网络请求。每一项都是攻击面,而其中 shell 命令最危险,它能触达前两者的全部能力。安全设计因此面临一个具体问题:当操作者要求「命令必须在沙箱里跑」而宿主机上根本没有可用沙箱时,系统应该怎么办?
旧版本的答案是尽力而为:探测不到后端就静默降级为无沙箱直通,命令照跑。这个答案在 2026-08-31 被推翻。eb7c7221(PR #2196,14 个文件,+1621/−158)把决策改成了 fail-closed:显式指定的模式无法兑现时拒绝执行,Auto 模式找不到后端时降级照旧但告警响亮。同一时期的 ffcde205 收紧了 cargo 授权,crates/octos-fleet/src/grant.rs 的 WorkerGrant 则把「worker 能做什么」从配置文件变成了带验证的类型。本章按四层展开这套体系:进程沙箱 → 命令与派发策略 → 注入与脱敏 → 能力授予。
先澄清一个容易搞错的 crate 边界。crates/octos-sandbox/src/main.rs:1 的文档写着「octos-sandbox: platform sandbox helper binary」:它是一个平台助手二进制,Windows 上创建/复用 AppContainer profile 并在其中启动命令,Linux 上应用 Landlock 文件系统规则加 seccomp 拒绝清单后再 exec,macOS 与其他平台上是 no-op 直通。真正的沙箱子系统在 crates/octos-agent/src/sandbox/,下文的引用均使用完整 crates/ 路径。
7.1 进程沙箱:七个 impl 与 fail-closed 决策
7.1.1 规模与全貌
crates/octos-agent/src/sandbox/ 目录六个文件合计 5,347 行:
| 文件 | 行数 | 首行文档 |
|---|---|---|
crates/octos-agent/src/sandbox/mod.rs | 2190 | //! Sandboxing for shell command execution. |
crates/octos-agent/src/sandbox/macos.rs | 1767 | //! macOS sandbox using sandbox-exec. |
crates/octos-agent/src/sandbox/bwrap.rs | 498 | //! Linux sandbox using bubblewrap (bwrap). |
crates/octos-agent/src/sandbox/docker.rs | 392 | //! Docker container sandbox. |
crates/octos-agent/src/sandbox/windows.rs | 325 | //! Windows sandbox using AppContainer via helper binary. |
crates/octos-agent/src/sandbox/landlock.rs | 175 | //! Linux container sandbox using the octos-sandbox Landlock/seccomp helper. |
核心抽象是 pub trait Sandbox(crates/octos-agent/src/sandbox/mod.rs:443),只有一个必须实现的方法 wrap_command(&self, shell_command: &str, cwd: &Path) -> Command:把 shell 命令字符串包装成受限的进程启动。围绕这个 trait 有恰好七个 impl Sandbox for:五个真后端加两个哨兵。
| impl 位置 | 类型 | 定位 |
|---|---|---|
crates/octos-agent/src/sandbox/bwrap.rs:29 | BwrapSandbox | Linux bubblewrap,mount namespace 隔离 |
crates/octos-agent/src/sandbox/docker.rs:36 | DockerSandbox | Docker 容器隔离,任何 OS 可用 |
crates/octos-agent/src/sandbox/landlock.rs:27 | LinuxContainerSandbox | 委托 octos-sandbox 助手施加 Landlock + seccomp |
crates/octos-agent/src/sandbox/macos.rs:185 | MacosSandbox | macOS sandbox-exec(SBPL profile) |
crates/octos-agent/src/sandbox/windows.rs:46 | AppContainerSandbox | 委托 octos-sandbox.exe 创建 AppContainer |
crates/octos-agent/src/sandbox/mod.rs:500 | NoSandbox | 哨兵:直通执行(sh -c / cmd /C) |
crates/octos-agent/src/sandbox/mod.rs:914 | RefusingSandbox | 哨兵:拒绝执行,fail-closed 的载体 |
两个哨兵值得注意:NoSandbox 不是「没有沙箱」这个概念的名字,而是一个真的类型(pub struct NoSandbox; 在 crates/octos-agent/src/sandbox/mod.rs:498),is_noop() 返回 true 让调用方可以检查;RefusingSandbox(crates/octos-agent/src/sandbox/mod.rs:909 定义 struct)的 wrap_command 永远不执行请求的命令,而是替换成一个打印拒绝文本到 stderr 并 exit 1 的命令。
7.1.2 SandboxMode 与 MountMode
配置层由两个枚举驱动。MountMode(crates/octos-agent/src/sandbox/mod.rs:408)控制 Docker 后端如何挂载 workspace,serde 小写命名,三个变体:None(:410,不挂载)、ReadOnly(:413,serde 名 ro)、ReadWrite(:417,默认,serde 名 rw)。
SandboxMode(crates/octos-agent/src/sandbox/mod.rs:423)七个变体:
Auto(:426,默认):文档注释写明「bwrap on Linux, sandbox-exec on macOS, AppContainer on Windows」Bwrap(:428)、Landlock(:430)、Macos(:432)、Docker(:434)AppContainer(:437,serde 名appcontainer)None(:439):不沙箱,直通
Auto 是唯一「找不到后端可以降级」的模式,其余显式模式都走 fail-closed 拒绝。这个不对称是 7.1.3 的主题。
Auto 的探测序与回退逻辑全在 decide_sandbox 的 SandboxMode::Auto 分支(crates/octos-agent/src/sandbox/mod.rs:875-901):先按 OS 找原生后端(macOS 查 sandbox_exec,Linux 先查 bwrap 再查 Landlock 助手,Windows 查 AppContainer 助手),原生全无才用 docker().then_some(SandboxBackendChoice::Docker) 兜底,再无则按 fail_closed 分流为拒绝或降级。这个顺序有两层考量。原生优先是因为隔离粒度:bwrap/sandbox-exec/AppContainer 直接复用内核机制,无守护进程依赖,失败模式是探测期一次性的;Docker 兜底任何 OS 都可用但依赖守护进程与镜像。探测本身分诚实度两档:HostBackendProbe(crates/octos-agent/src/sandbox/mod.rs:574)的 bwrap() 文档写明「actually runs bwrap, not a PATH scan」,RealHostProbe 里它调 bwrap_works()(crates/octos-agent/src/sandbox/mod.rs:1140)真实跑一次带最小挂载的 bwrap 加 /bin/true,验证内核允许当前用户建 namespace;而 docker() 只是 which_exists("docker") 的 PATH 扫描。Linux 上这个差别尤其重要:unprivileged user namespace 常被发行版关闭,PATH 里有 bwrap 不等于 bwrap 能跑,PATH 式探测会造出一个每次 spawn 都失败的假后端,而「配置了却每次失败」正是 eb7c7221 要消灭的那类静默失败。Linux 原生内部 bwrap 优先于 Landlock,两者粒度相近,但 bwrap 不依赖助手二进制,Landlock 探测要等助手应答 --probe-linux 才为真。
7.1.3 decide_sandbox:纯函数决策与 fail-closed
eb7c7221 之前,create_sandbox 把 cfg! 平台探测与后端构造交织在一起,结果是两个问题:跨平台矩阵没法在开发机上测试(你在 macOS 上测不了 Linux 路径),以及显式模式悄悄降级。重构把决策抽成纯函数 pub fn decide_sandbox(config, os, probe)(crates/octos-agent/src/sandbox/mod.rs:809):
#![allow(unused)] fn main() { pub fn decide_sandbox( config: &SandboxConfig, os: HostOs, probe: &dyn HostBackendProbe, ) -> SandboxDecision { }
所有宿主机事实(HostOs 是数据枚举,crates/octos-agent/src/sandbox/mod.rs:534;后端可用性走 HostBackendProbe trait)都以参数传入,每个 OS 的完整矩阵可以从任何开发主机测试。返回 SandboxDecision(crates/octos-agent/src/sandbox/mod.rs:744)三值:Confine(SandboxBackendChoice)、Unconfined(UnconfinedReason)、Refuse(SandboxUnavailable)。契约直接写在函数文档里(crates/octos-agent/src/sandbox/mod.rs:800-808):
enabled = false与mode = "none"是显式 opt-out,直通且优先于fail_closed- 显式后端模式无法兑现(OS 不对或后端缺失)时拒绝,绝不静默降级为无隔离
Auto选最佳可用后端(原生优先,Docker 兜底);全无时降级为直通但要告警;fail_closed可把降级变成拒绝
UnconfinedReason(crates/octos-agent/src/sandbox/mod.rs:664)区分三种直通原因:Disabled(--danger-full-access 设置的显式 opt-out)、ExplicitNone(mode="none")、AutoNoBackend(Auto 全无后端,唯一合法的降级)。SandboxUnavailable(crates/octos-agent/src/sandbox/mod.rs:694)是类型化的拒绝理由,携带 requested、reason 和 per-OS 的 remediation 块(remediation_for 在 crates/octos-agent/src/sandbox/mod.rs:754,按 OS 给安装建议)。
拒绝如何到达用户分两个受众,这是 #2196 review 的 MUST-FIX:Display 实现面向模型,写明「Shell/exec commands will keep refusing until then」;操作者修复建议只进日志和 octos doctor,不进模型上下文。stderr_line()(crates/octos-agent/src/sandbox/mod.rs:724)把拒绝文本过滤到只剩 [A-Za-z0-9 ./:_=,-],确保嵌入 sh -c 'echo ... >&2; exit 1' 时不可能被引号逃逸重新变成命令执行。
fail-closed 的执行还有一条更早的短路路径。exec 形态的工具在 spawn 之前先查 refusal():crates/octos-agent/src/tools/shell.rs:1069 与 crates/octos-agent/src/tools/coding_tools.rs:501、:625、:2068 都是 if let Some(refusal) = self.sandbox.refusal() 开头,命中就直接把模型侧拒绝文本作为工具结果返回,连子进程都不起。也就是说 RefusingSandbox 的 wrap_command(crates/octos-agent/src/sandbox/mod.rs:914 起的 impl)只是兜底:真正到达它的命令(例如经第三方调用点直接拿 Box<dyn Sandbox> 包装的)才会走 echo 加 exit 1 的替身命令。两层设计的分工是,工具层短路保证模型看到可读的拒绝而非一条 exit 1 的裸输出,trait 层兜底保证任何调用点都不可能把命令无沙箱跑出去。
这个改动的工程动机值得展开。eb7c7221 之前的行为由 crates/octos-agent/src/sandbox/mod.rs:524-528 的重构注释记录在案:create_sandbox 把 cfg! 门控的探测与后端构造交织在一起,导致「两个显式模式静默降级为无隔离」。具体场景是操作者在 CI 或 fleet 配置里写死 sandbox.mode = "bwrap",宿主机没装 bwrap,命令照跑无沙箱,唯一的线索是启动日志里一行容易淹没的 warn。故障模式有三个叠加属性使它必须修:配置表达了隔离意图而被覆盖(fail-open);覆盖不可见(用户以为有沙箱);行为矩阵不可测(cfg! 编译期分派意味着 macOS 开发机上根本没有 Linux 的代码路径,回归只能靠真实 Linux CI 撞出来)。decide_sandbox 把三类事实全部参数化(配置、HostOs、probe),一次重构同时解决三个:显式模式不可兑现返回 Refuse,拒绝对操作者可见且带修复建议,全平台矩阵可以在任何开发机的单元测试里穷举。
create_sandbox(crates/octos-agent/src/sandbox/mod.rs:1005)是决策到后端的投影:Confine 构造后端,Unconfined 返回 NoSandbox 并按原因记日志(AutoNoBackend 走 warn_auto_unconfined_once,crates/octos-agent/src/sandbox/mod.rs:1039,std::sync::Once 保证每进程只告警一次),Refuse 返回 RefusingSandbox。签名保持不可失败,大量构造点不用改;但结果上的每条命令都会带着类型化的 SandboxUnavailable 拒绝。
flowchart TB
S["decide_sandbox(config, os, probe)<br/>crates/octos-agent/src/sandbox/mod.rs:809"] --> Q1{"enabled = false<br/>或 mode = none?"}
Q1 -->|是| U["Unconfined(Disabled / ExplicitNone)<br/>显式 opt-out,直通"]
Q1 -->|否| Q2{"mode 是显式后端?"}
Q2 -->|是| Q3{"OS 匹配且后端可用?"}
Q3 -->|是| C["Confine(backend)"]
Q3 -->|否| R["Refuse(SandboxUnavailable)<br/>RefusingSandbox:拒绝执行<br/>fail-closed"]
Q2 -->|Auto| Q4{"有可用后端?<br/>原生优先,Docker 兜底"}
Q4 -->|有| C
Q4 -->|无| Q5{"fail_closed?"}
Q5 -->|false| U2["Unconfined(AutoNoBackend)<br/>唯一合法降级,每进程告警一次"]
Q5 -->|true| R
图 7-1:SandboxMode 解析与 fail-closed 决策流。 显式模式不可兑现必然拒绝;只有 Auto 找不到后端才可能降级为直通。
7.1.4 五个后端各自的实现要点
bwrap(Linux):wrap_command 的顺序是:清掉 BLOCKED_ENV_VARS → --ro-bind 只读挂 /usr、/lib、/lib64、/bin、/sbin、/etc → --tmpfs /tmp 和 --tmpfs /var/tmp(先于 workspace bind,防止 workspace 在 /tmp 下时被 tmpfs 语义反噬)→ workspace --bind 或 --ro-bind(取决于 workspace_write)→ 可选的 .git 定向 rw bind(repo_git_write)→ --dev、--proc → !allow_network 时 --unshare-net → --unshare-pid、--die-with-parent。repo_git_write 的注释写得很清楚:这是窄授权,只 bind <repo>/.git,绝不 bind 整个 /,因为后者会把 SSH_AUTH_SOCK、docker.sock 这类宿主 AF_UNIX socket 暴露给沙箱内进程(crates/octos-agent/src/sandbox/bwrap.rs:17-26 的字段文档)。
Landlock(Linux):crates/octos-agent/src/sandbox/landlock.rs:27 的 LinuxContainerSandbox 不自己施加任何限制,全部委托给 octos-sandbox 助手进程,让 Landlock 与 seccomp 的设置发生在 exec shell 之前。找不到助手时不是降级而是拒绝:构造一条 echo 'sandbox error: octos-sandbox helper not found' >&2; exit 1 命令(crates/octos-agent/src/sandbox/landlock.rs:31-39)。
macOS sandbox-exec:最厚的后端(1,767 行),生成 SBPL(sandbox profile 语言)profile。注入防御在 crates/octos-agent/src/sandbox/macos.rs:205-212:cwd 含控制字符或 SBPL 元字符((、)、\、")时直接拒绝执行,因为路径已验证不含引号,后续拼接无需转义。macOS 也是唯一能精确表达 #1976 写围栏的后端:每个 glob 变成一条 (allow file-write* (regex ...)) 规则(build_backend 的注释,crates/octos-agent/src/sandbox/mod.rs:1081-1085)。
Docker:跨平台兜底。按 MountMode 挂 workspace(ReadWrite → -v cwd:/workspace,ReadOnly → 加 :ro,None → 不挂),支持 CPU/内存限制。is_blocked_bind_source(crates/octos-agent/src/sandbox/docker.rs:20-30)拒绝把 docker.sock、/etc、/proc、/sys、/dev 作为 bind source,cwd 命中危险源时整个命令拒绝执行。
AppContainer(Windows):crates/octos-agent/src/sandbox/windows.rs:46 委托 octos-sandbox.exe 创建/复用 AppContainer profile。每个 octos profile 有自己的 AppContainer SID,提供默认拒绝的文件系统访问与可配置的网络隔离。
7.1.5 环境变量清理
所有后端共享 BLOCKED_ENV_VARS(从 octos-core 转发,crates/octos-agent/src/sandbox/mod.rs:33),一个 18 项的清单,按注入面分组:Linux 动态库注入(LD_PRELOAD 等 3 项)、macOS dylib 注入(DYLD_* 5 项)、运行时代码注入(NODE_OPTIONS、PYTHONSTARTUP 等 7 项)、shell 启动注入(BASH_ENV、ENV、ZDOTDIR 3 项)。清单的执行点因后端而异:bwrap、Landlock、macOS(crates/octos-agent/src/sandbox/macos.rs:478)与 Windows(crates/octos-agent/src/sandbox/windows.rs:90、:139)都会逐项 env_remove;Docker 后端反而一处不清理,容器隔离本身承担了环境隔离。
7.1.6 cargo 授权:从尽力写到 lock-only
ffcde205(4 个文件,+140/−82)解决的是编码 agent 的一个实际矛盾:沙箱把写权限收得越紧,cargo build 越容易死在编译之前。修复后的策略是默认 lock-only:可写集仅 ~/.cargo/.package-cache(锁文件所需),registry index/cache/src、git checkout、rustup 安装全部只读;只有配置 allow_network 时才补上下载所需的写权限与网络。配套的 allow_toolchains 字段(crates/octos-agent/src/sandbox/mod.rs:149)默认 true,授予工具链运转必需的少量写路径,但 ~/.cargo/bin(PATH 上的可写 shim 等于持久化后门)与 ~/.rustup/toolchains(可写编译器二进制)明确不授。deny-wins 在这里同样生效:只读 workspace 或 #1976 写围栏会整体压制这些授予。
工程决策侧栏:为什么放弃「尽力而为的沙箱」
旧语义下,操作者写
sandbox.mode = "bwrap",宿主机上没有 bwrap,结果是命令无沙箱照跑。从安全角度这是 fail-open:配置表达了隔离意图,系统用「能跑就行」覆盖了它。问题在于这条路径不可见,用户以为有沙箱。eb7c7221的取舍是:显式模式不可兑现就拒绝执行,把矛盾暴露给操作者(附 per-OS 修复建议与octos doctor入口);Auto 保持降级(「最佳可用」本就是它的契约)但每进程告警一次,并提供sandbox.fail_closed开关把降级也变成拒绝。代价是可用性:装错环境的用户会看到命令全部拒绝。团队接受这个代价,理由是拒绝文本本身可操作,而静默无沙箱不可修复。
7.2 命令与派发策略:每条路径都有门
沙箱管「命令在什么环境里跑」,策略层管「命令/工具能不能跑」。
crates/octos-agent/src/policy.rs(746 行):命令审批策略。Decision(crates/octos-agent/src/policy.rs:16)三值 Allow / Deny / Ask,ApprovalPolicy(:28)决定 Ask 在无人值守时是弹审批还是直接失败(Never),FilesystemScope(:46)的 Workspace / Host 二值在 7.4 节的 grant 模型里会再次出现。ShellTool 的 SafePolicy(危险命令拒绝、whitespace 归一化、词边界检测)在第 6 章已展开,这里不重复;本章强调的是它明确自称「不是安全边界」,真正的边界是本节的沙箱与下一节的脱敏。
crates/octos-agent/src/dispatch_policy.rs(566 行):MCP-agent 派发前策略门。模块文档(crates/octos-agent/src/dispatch_policy.rs:1-10)记录了它的来历:#714 之前 SpawnTool 的 agent_mcp 分支直接派发,完全绕过策略,哪怕 octos serve 在 swarm 侧装了门。把门提到本 crate 让两个调用点(SpawnTool::agent_mcp 分支与 octos_swarm::Swarm 派发器)执行同一形状的检查:工具策略、审批、sandbox-required、env 允许/拒绝清单。每个失败产生带稳定标签的 GateDenial(crates/octos-agent/src/dispatch_policy.rs:253,last_dispatch_outcome 字段),其中 approval_unavailable(需要审批但没有接审批器)与 sandbox_required(策略要求沙箱后端但后端不自报沙箱)都 fail-closed,不落空到派发。
crates/octos-agent/src/permissions.rs(167 行):机器人工具的监督安全分级。SafetyTier(crates/octos-agent/src/permissions.rs:19)四级从低到高 Observe < SafeMotion < FullActuation < EmergencyOverride,通过 ToolPolicy 的 group:robot:<tier> 分组执行而非独立 trait 方法。物理世界的风险分级与本章的数字沙箱共用同一套 deny-wins 机制,此处一段带过。
为什么这扇门长成这样,设计判据有三条,全部写在 crates/octos-agent/src/dispatch_policy.rs 自身的文档里。判据一,门必须共享而不是复制:模块文档(:1-10)记录了 #714 之前的旁路,SpawnTool 的 agent_mcp 分支直接派发,octos serve 在 swarm 侧装了门也拦不住;把门提进 octos-agent crate,两个调用点对同一组共享类型(ToolPolicy、ToolApprovalRequester、McpAgentBackend)执行同一形状检查,将来出现第三个派发点时没有「自己再写一套检查」这个选项。判据二,默认 no-op、生产显式装配:DispatchPolicy(:121)每个门独立可选,全默认值时 is_noop()(:175)为真、派发行为与旧版逐字节一致,这是为了不回归既有调用方;生产构造器 from_agent_gates(:223)则刻意只继承 #701 审计点名的两道门(工作区级工具名策略与 BLOCKED_ENV_VARS 注入环境拒绝清单),审批桥与 sandbox-required 故意不镜像,理由写在构造器 rustdoc(:197 起):原生审批桥 TOOL_APPROVAL_CTX 是 per-turn task-local,服务器启动时没有可克隆的全局请求器;尚无 MCP 后端自报沙箱,require_sandboxed 只是为前向兼容保留(:150-152),今日置真等于每次派发都 fail-closed。判据三,门序固定且失败可观测:enforce_dispatch_gates 的文档(:285 起)规定检查顺序为 sandbox-required(纯配置,最便宜)→ 工具策略 → env 拒绝清单 → env 允许清单 → 审批(唯一可能阻塞等人的,放最后),拒绝清单先于允许清单是防宽松允许清单放过已知坏键(:144-146);每道失败产生带稳定标签的 GateDenial(:253,五个标签枚举在字段文档里),调用方把它折进自己的 outcome 形状,本 crate 对外的 API 面因此只是门语义,不是又一套失败类型。
7.3 注入检测与输出脱敏
7.3.1 prompt_guard:明文注入的检测层
crates/octos-agent/src/prompt_guard.rs(772 行)扫描工具输出与用户消息中的注入模式。ThreatKind(crates/octos-agent/src/prompt_guard.rs:29)五类:SystemOverride(覆盖系统提示)、RoleConfusion(「System: you are now...」式角色混淆)、ToolCallInjection(注入工具调用 JSON/XML)、SecretExtraction(套取系统提示或密钥)、InstructionInjection(「you must / always respond with」式通用指令注入)。入口 pub fn scan(text) 在 :195。
这个模块的文档(crates/octos-agent/src/prompt_guard.rs:5-19)第一句就自我定位:「Not a security boundary.」它用正则匹配朴素明文注入,可被 base64 编码、URL 编码、HTML 实体、Unicode 同形字、零宽字符、RTL 覆盖字符绕过,这些都记录在测试套件里作为已知局限。真正的缓解是架构性的:沙箱隔离保证不管 prompt 状态如何工具层损害有限,工具策略限制可调用的工具集,human-in-the-loop(before_tool_call hook exit 1)拦截高影响动作待人工批准。prompt_guard 提供的是日志与尽力脱敏这层额外防线,不是前三者的替代品。
7.3.2 sanitize:工具输出进上下文前的清洗
crates/octos-agent/src/sanitize.rs(245 行)的 sanitize_tool_output(:90)在工具结果回喂 LLM 前剥掉三类内容。噪声类:base64 data URI(data:...;base64,<64+ 字符>,:13)与 64 位以上连续 hex(SHA-256、原始密钥,:16),目的是省上下文。凭据类七个正则:OpenAI(sk- 前缀,:24)、Anthropic(sk-ant-)、AWS(AKIA + 16 位大写)、GitHub(ghp_/gho_/ghs_/ghr_/github_pat_)、GitLab(glpat-)、Bearer token、以及通用赋值模式(password|secret|api_key|... = "...")。redact_credential(:55)保留前 4 个可见字符加 [credential-redacted] 标记,兼顾可辨识与不泄漏。scrub_credentials(:62)的注释强调顺序:Anthropic 先于 OpenAI 处理,避免 sk-ant-... 被 sk- 规则截断匹配。
7.3.3 SSRF:网络工具的入口检查
crates/octos-agent/src/tools/ssrf.rs(620 行)被 web_fetch 与 browser 两个工具共享。check_ssrf_with_addrs(:24)的流程:解析 URL → is_private_host(:228)拦 localhost 与字面私有 IP → 域名走 DNS 解析,答案集经 validate_answer_set(:69)做 DNS pinning(防 rebinding:校验时解析到的地址绑定到实际抓取,TOCTOU 不复存在)。DNS 失败 fail-closed 当作阻断处理,注释写明原因:攻击者可以让校验时 DNS 失败、抓取时成功。
is_private_ip(:258)的范围清单超出 std 谓词:IPv4 除 is_private/is_link_local/is_loopback/is_unspecified 外,is_special_purpose_v4(:244)显式匹配 std 夜行版才有的段:CGNAT 100.64.0.0/10(运营商级 NAT)、192.0.0.0/24(IETF 协议分配)、198.18.0.0/15(基准测试)、224.0.0.0/4(组播)与 240.0.0.0/4(保留)。IPv6 覆盖 loopback、::、组播、ULA fc00::/7、链路本地 fe80::/10、废弃的站点本地 fec0::/10,以及两条映射规则:IPv4-mapped ::ffff:x.x.x.x 递归回 IPv4 检查,IPv4-compatible ::x.x.x.x 同样处理。漏掉映射规则是经典绕过,测试里有专门用例(:580、:590)。
prompt_guard 与 sanitize 的分工边界值得单独说清:两个模块都在「输出进上下文之前」动手,但管的对象不同。crates/octos-agent/src/prompt_guard.rs 管指令,即文本里是否有人试图操纵模型的下一步行为;crates/octos-agent/src/sanitize.rs 管物质,即文本里是否携带不该回喂的字节(凭据、base64 data URI、长 hex)。分工的接点在 sanitize_tool_output(crates/octos-agent/src/sanitize.rs:90-94):流水线依次替换 data URI、长 hex、七类凭据,最后一步调用 prompt_guard::sanitize_injection,注入 defang 因此是清洗流水线的末段而不是一条独立通道;执行侧的调用点是 crates/octos-agent/src/agent/execution.rs:2414,每条工具结果在截断之后、回喂模型之前统一过这一道,单点漏斗意味着绕过清洗需要绕过整个执行层,而不是挑某个不设防的工具。检测粒度同样分层:scan(crates/octos-agent/src/prompt_guard.rs:195)产出带 Severity 三档(:56,Low/Medium/High)的 Threat 列表,Low 只记日志,Medium 起才动手脱敏(sanitize_injection 内部按 severity >= Medium 过滤,:262),替换按 span 逆序进行以保住字节偏移(:270 起)。这套阈值承认的是正则检测的误报成本:宁可只对中高置信度动手,把「检测不到的情形」明确留给架构性控制(沙箱、工具策略、human-in-the-loop),与模块文档第一句「Not a security boundary」的自我定位一致。两个模块都是纵深里的额外一层,不是边界本身。
7.4 WorkerGrant:能力授予的类型化
前三层防御都在「限制」侧。fleet 架构(第 16 章讲装配)还需要反向的能力:master 派任务给 worker 时,如何精确表达「这个 worker 可以用网络、只能写这三个路径」?答案是 crates/octos-fleet/src/grant.rs(714 行)的 WorkerGrant,一个带验证的类型,而不是散落的配置项。
7.4.1 三个粒度轴
NetworkGrant(crates/octos-fleet/src/grant.rs:76)三值:None(默认,零网络)、Hosts(Vec<String>)(按主机允许表)、Full(原始出网全开)。关键语义在方法注释里:allows_raw_egress 仅对 Full 为真,Hosts 刻意保持原始出网关闭,它的许可完全通过被授予的 web 工具按主机允许表实现,shell 永远 curl 不到允许表之外。文档同时坦承 v1 局限:原始网络是全有或全无,按主机过滤原始流量需要内核级 egress proxy,Hosts 只覆盖 web 工具的 HTTP(S),这是记录在案的未解问题。
FsGrant(crates/octos-fleet/src/grant.rs:127)刻意粗粒度:Workspace(默认,仅 worker 自己的尝试目录可读写)或 Host(全盘读写,须操作者显式授予)。v1 不做按路径的文件系统允许表,所以诚实的授予也是二值的。
WorkerGrant(crates/octos-fleet/src/grant.rs:151)五个字段把这些轴组装成一个可序列化、可验证的整体:
#![allow(unused)] fn main() { pub struct WorkerGrant { #[serde(default)] pub network: NetworkGrant, // :154,默认 None #[serde(default = "base_tools_vec")] pub tools: Vec<String>, // :157,默认 BASE_TOOLS 七项 #[serde(default)] pub fs: FsGrant, // :160,默认 Workspace #[serde(default, skip_serializing_if = "Option::is_none")] pub write_paths: Option<Vec<String>>, // :179,#1976 写围栏 #[serde(default, skip_serializing_if = "core::ops::Not::not")] pub create_only: bool, // :189,仅创建不覆盖 } }
每个字段的 #[serde(default)] 有一层设计:grant 出现之前持久化的旧任务(或什么都没指定的 master)加载为 WorkerGrant::minimal(),最小权限是默认值,旧记录无需 schema 升级就可读。skip_serializing_if 让无围栏的 grant 序列化出与 pre-#1976 逐字节相同的形状,旧读者读新记录看不到新键。
五个字段不是随手凑的,每一条对应一类必须显式回答的授权问题,缺一个就留一个默认放行的洞。没有 network,web 工具与 shell 的出网能力无法区分,「不给网络」无处表达,WEB_TOOLS 只能授予或整体禁用;没有 tools,注册表形状就只能全有或全无,sorted_tools() 这个审计键(「操作者授予了什么,一个不多」)没有存在基础;没有 fs,workspace 与 host 的文件系统边界退回隐式的 cwd 约定,FsGrant::Host 这种操作者的显式信任决定无处落笔;没有 write_paths,#1976 之前「worker 只能写这几个文件」只能靠 workspace_write 二值粗调,要么全 workspace 可写要么全只读;没有 create_only,「创建可以、覆盖不行」这个防篡改语义(重放安全的关注点)在类型上不可表达。反过来,字段也只有这五个:v1 刻意不做按主机的原始网络过滤(需要内核级 egress proxy)、不做文件系统的任意 ACL(工具层没有对应机制,grant 诚实反映它能执行的范围)。字段集合等于「两层执行机制都能兑现的授权问题」的精确并集,多一个是撒谎,少一个是漏洞。
7.4.2 #1976 写围栏:三层语义
write_paths 是 issue 1976 引入的按路径写围栏,三种取值各有语义:
None(默认):无围栏,二值fs独自管辖写权限,行为与 pre-#1976 worker 逐字节一致Some(vec![]):一致的只读围栏,workspace 内什么都不能写Some(列表):列表内可写,workspace 其余部分只读
v1 模式语法刻意收窄:workspace 相对路径、/ 分隔、段内 *(任意字符)与 ?(单字符)通配,没有 **、[...]、{...}。原因写在字段文档里:这个语法是工具层匹配器(globset,literal_separator)与沙箱翻译(SBPL regex)能完全相同表达的交集,两层对「授予了什么」永远不可能产生分歧。validate_write_path_pattern(crates/octos-fleet/src/grant.rs:307)逐条拒绝绝对路径、..、控制字节、SBPL 元字符、:(Docker mount 分隔符/Windows 盘符)与两类通配语法。
create_only: true 表示允许表内的路径只能创建不能覆盖/编辑/删除(文件工具层的 O_CREAT|O_EXCL 语义;edit_file 无论是否在表内都直接拒绝)。沙箱层只能执行路径围栏,没有 OS 后端能区分创建与覆盖,所以 create_only 的不覆盖一半由工具层执行,这是文档写明的降级。
围栏下各后端的 shell 侧表达不一,deny-wins 保证没有后端放宽围栏(crates/octos-agent/src/sandbox/mod.rs:955-1000 三个函数):macOS 精确表达(per-glob SBPL regex);bwrap/Landlock/AppContainer 无法表达按 glob 的 shell 写,workspace 对 shell 整体降为只读,授予路径仅通过受围栏的文件工具保持可写(fence_degraded_workspace_write,:955,构造时告警一次);Docker 同理挂 :ro(fence_degraded_docker,:970);解析结果是无沙箱时告警 shell 围栏完全未执行(warn_fence_unenforced)。
flowchart LR
subgraph G["WorkerGrant(crates/octos-fleet/src/grant.rs:151)"]
N["network: NetworkGrant<br/>None / Hosts / Full"]
T["tools: Vec<String><br/>默认 BASE_TOOLS 七项"]
F["fs: FsGrant<br/>Workspace / Host"]
W["write_paths: Option<Vec<String>><br/>#1976 写围栏"]
CO["create_only: bool<br/>仅创建不覆盖"]
end
V["validate()(crates/octos-fleet/src/grant.rs:247)"] --> G
G --> SB["沙箱投影(crates/octos-agent/src/sandbox/mod.rs:955-1000)<br/>macOS 精确 / 其余降级只读"]
G --> FT["工具层执行<br/>globset 围栏 + O_CREAT|O_EXCL"]
图 7-2:WorkerGrant 结构与两个执行层。
7.4.3 validate:把不一致挡在解析时
WorkerGrant::validate(crates/octos-fleet/src/grant.rs:247)拒绝五类不一致,每类对应 GrantError(crates/octos-fleet/src/grant.rs:359)一个变体:
UnknownTool:授予的工具有不在GRANTABLE_TOOLS目录里的(操作者不能授予宿主构建不出的工具)WebToolWithoutNetwork:NetworkGrant::None下授予web_fetch/web_search(无网络的网络工具)EmptyHostAllowlist:Hosts(vec![])空允许表。注释点名这是 fail-open 陷阱:空表绝不能读作「无限制」,操作者没列主机就是None- #1976 四连:
WritePathsWithHostFs(围栏配fs: Host不一致,Host 让 shell 够得着围栏禁止的一切,deny-wins 直接拒)、CreateOnlyWithoutWritePaths(无表可应用)、EmptyWritePathsWithCreateOnly(空表加 create_only 等于授予创建无)、InvalidWritePath { pattern, reason }(语法外模式)
validate 在两个时点调用:master 的 goal_plan 解析时,以及防御性地在 registry 构建时。与第 6 章 crates/octos-agent/src/tools/write_grant.rs 的关系:第 6 章讲了单工具的参数级写授权,本章的 WorkerGrant 是 worker 级的整体授予模型;fleet 如何把这个 grant 装配成封闭工具注册表与沙箱配置,详见第 16 章。
工程决策侧栏:为什么 grant 是类型而不是配置约定
「worker 能做什么」如果只是若干独立配置项(一个网络布尔、一个工具列表、一个路径列表),不一致组合要到运行时才暴露,甚至永远不暴露(空允许表被读成无限制就是实例)。
WorkerGrant把五轴收进一个 struct,validate把跨字段不一致在解析时类型化拒绝,GrantError七个变体每个都能直接转成给操作者的错误消息。minimal()作为 serde 默认值把最小权限变成无需声明的基线,旧记录自动落入。代价是新增能力要同时扩GRANTABLE_TOOLS目录、validate 规则与错误变体,三处必须同步;换来的是「授予了什么」在序列化形状、注册表审计键(sorted_tools)与错误消息三处始终一致。
7.5 本章回顾
本章沿四层展示了 octos 的安全纵深。进程沙箱层:crates/octos-agent/src/sandbox/ 六文件 5,347 行、七个 impl Sandbox for(五后端加 NoSandbox/RefusingSandbox 两哨兵),decide_sandbox 纯函数(crates/octos-agent/src/sandbox/mod.rs:809)执行 fail-closed 契约,显式模式不可兑现即拒绝,Auto 降级告警一次且可用 fail_closed 升级为拒绝。策略层:crates/octos-agent/src/policy.rs 的命令审批、crates/octos-agent/src/dispatch_policy.rs 补上 MCP 派发旁路(#714)、crates/octos-agent/src/permissions.rs 的机器人安全分级。注入与脱敏层:crates/octos-agent/src/prompt_guard.rs 自认非安全边界的明文检测、crates/octos-agent/src/sanitize.rs 的七凭据正则与 DNS pinning 的 SSRF 检查。能力层:WorkerGrant 五字段(network/tools/fs/write_paths/create_only)配 validate 的五类不一致拒绝,#1976 写围栏经沙箱投影与工具层双层执行。
贯穿全章的一条线是 deny-wins 与 fail-closed 在每一层的重复出现:工具策略 deny 优先于 allow(第 6 章),沙箱显式模式不可兑现拒绝而非降级,空允许表拒绝而非读作无限制,围栏配 Host fs 拒绝而非取宽者。同一原则在四个抽象层级上的相同形状,比任何单点的强度更能说明这套体系的设计一致性。
延伸阅读
- Landlock 文档与 seccomp BPF 手册(
man 2 seccomp):crates/octos-agent/src/sandbox/landlock.rs委托助手所施加的两类内核机制 - Apple Sandbox Guide(SBPL 参考):
crates/octos-agent/src/sandbox/macos.rs生成 profile 的语言 - bubblewrap 项目文档:
crates/octos-agent/src/sandbox/bwrap.rs使用的 mount namespace 与--die-with-parent语义 - OWASP SSRF Prevention Cheat Sheet:
crates/octos-agent/src/tools/ssrf.rs阻断范围清单的对照参考 - issue #1976 与 PR #2196(
eb7c7221)、ffcde205的 commit message:本章三个主线的原始讨论
思考题
decide_sandbox为什么把HostOs与后端探测做成参数而不是在函数内读环境?如果 probe 是真实全局的,哪个质量属性先坏掉?RefusingSandbox的wrap_command仍然构造一条真的 shell 命令(echo 拒绝文本并 exit 1),而不是返回错误。保持Sandboxtrait 签名不可失败带来了什么好处?exec 形态工具经refusal()短路又解决了这个设计的哪个剩余问题?NetworkGrant::Hosts刻意不让 shell 获得原始出网,许可全部经 web 工具的允许表实现。如果 v2 要支持「git 只能 clone 允许表内的主机」,需要什么基础设施?validate把「Hosts空允许表」列为错误而不是等价于None。从操作者心理模型出发论证或反驳这个选择。- bwrap 后端在 #1976 围栏下把 workspace 对 shell 降为只读(授予路径仅文件工具可写)。一个只能用 shell 工具完成任务的 worker 在围栏下会遇到什么?这个降级是设计缺陷还是合理取舍?
版本演化说明
本章分析基线为 octos main @ 9c157101(2026-09-02 提交)。主线变化:eb7c7221(2026-08-31,PR #2196,14 个文件)引入显式模式 fail-closed 与 Auto 降级告警,并把沙箱决策重构为纯函数 decide_sandbox;ffcde205(2026-08-26,4 个文件)将 cargo 授权默认收紧为 lock-only、下载经 allow_network 门控;issue #1976 引入 WorkerGrant 按路径写围栏与 create_only。行号与文件行数均以此基准实测,事实来源为 assets/ch07-facts.md 事实表。
第 8 章:上下文管理:让 Agent 在有限窗口中高效工作
定位:本章展示 octos 如何通过上下文压缩(compaction)、recall 重物化、分层压缩面(tiered compaction)、保真度分级(fidelity)、提示层构建(prompt layer)、steering 与系统提示防篡改(prompt guard),在有限的 LLM 上下文窗口中高效工作。前置依赖:第 5 章(
CompactAndRetry触发点)。适用场景:需要理解或调优 Agent 上下文策略的开发者。
LLM 的上下文窗口是稀缺资源。一个 200K token 的窗口,在十几次工具迭代后就可能耗尽:每次调用的参数和结果都在累积,一次 read_file 的输出可能就占数千 token。窗口接近满时只有两条路,停止任务,或压缩历史。octos 选择压缩,但当前主分支的答案已经从单一摘要函数演进成一套体系:先分层削减(本地占位符、服务端清理、完整摘要三档),再折叠旧消息,压缩掉的输出还能通过 recall 工具凭 id 重新取回。本章沿这条主线展开,并覆盖支撑它的提示层构建、steering 注入与工具输出防篡改。
8.1 Context Compaction:80% 阈值加 recall 的双机制
8.1.1 触发条件与安全边际
legacy 触发路径在 trim_to_context_window()([crates/octos-agent/src/agent/compaction.rs:11-60])。预算公式([crates/octos-agent/src/agent/compaction.rs:25]):
#![allow(unused)] fn main() { let window = self.llm.context_window(); let budget = (window as f64 * 0.8 / crate::compaction::SAFETY_MARGIN) as u32; }
SAFETY_MARGIN = 1.2([crates/octos-agent/src/compaction.rs:45])。实际预算是 window * 0.8 / 1.2 ≈ window * 67%:一边预留 20% 给新一轮对话,一边为 token 估算误差再留缓冲。128K 窗口的模型,触发点约在 85K tokens。总量低于预算就直接返回,不动历史。
8.1.2 保留边界:最近六条与工具组完整性
find_recent_boundary()([crates/octos-agent/src/compaction.rs:68-89])从最后一条消息向前扫描,决定哪些消息原样保留:
#![allow(unused)] fn main() { for i in (1..messages.len()).rev() { let msg_tokens = estimate_message_tokens(&messages[i]); count += 1; if count >= MIN_RECENT_MESSAGES && system_tokens + recent_tokens + msg_tokens > budget / 2 { break; } recent_tokens += msg_tokens; split = i; } while split > 1 && messages[split].role == MessageRole::Tool { split -= 1; } }
MIN_RECENT_MESSAGES = 6([crates/octos-agent/src/compaction.rs:48])。最近消息无条件保留满六条,同时不允许超过预算的一半,给旧消息的摘要留出空间。末尾的 while 回退保证切割点不落在 Tool 消息上:Assistant 与 Tool 的配对组一旦拆开,孤立的 Tool 消息会让 provider 校验失败或让模型困惑。
8.1.3 提取式摘要:参数剥离与首行截取
旧消息交给 compact_messages()([crates/octos-agent/src/compaction.rs:99-153]),逐条经 summarize_message()([crates/octos-agent/src/compaction.rs:155-201])压成一行。压缩比最高的是工具参数剥离:一次 write_file 调用的参数可能包含整份文件内容,上千 token,压缩后只剩 - Called write_file,约五个 token,压缩比接近 200:1。这个设计还有一个安全副作用:工具参数是不受信输入,剥掉它意味着压缩摘要里不会残留恶意 payload 的原文。
各消息角色的处理规则:
| 消息类型 | 摘要形态 |
|---|---|
| User | > User: {首行},带媒体时附 [media omitted] |
| Assistant(有工具调用) | - Called {工具名} 列表 |
| Assistant(纯文本) | > Assistant: {首行} |
| Tool 结果 | -> {工具名}: {ok/error} - {输出前 100 字符} |
| System | > Context: {首行} |
工具结果的 ok/error 判定很朴素:内容以 Error: 开头即视为失败([crates/octos-agent/src/compaction.rs:180-188])。find_tool_name()([crates/octos-agent/src/compaction.rs:219-234])从上下文反查 tool_call_id 对应的工具名,查不到时降级为 unknown_tool。首行截取的理由是信息密度:LLM 的回复通常以结论开头,首行携带最高的语义密度。first_line()([crates/octos-agent/src/compaction.rs:203-216])按 UTF-8 字符边界截断,不会把一个汉字切成两半。摘要目标占预算的 40%(BASE_CHUNK_RATIO,[crates/octos-agent/src/compaction.rs:51]),超出后停止逐条展开,剩余消息记为 ... (N earlier messages omitted)。
一个容易被漏掉的细节:compact_messages 会先把最新的 plan 快照从预算里扣除(#2132),保证保留块不会把摘要顶出预算上界([crates/octos-agent/src/compaction.rs:104-114])。注释里写得很清楚:这个扣除发生在生产者内部,AppUI、session actor、legacy agent channel、summarizer tiers 所有压缩路径自动继承,不需要逐点接线。
如果最近消息本身已超预算,代码退回 fallback_truncate() 尾部截断([crates/octos-agent/src/agent/compaction.rs:319-358]):从尾部向前保留能放下的消息,至少留两条非系统消息,同样避免拆开工具组。压缩的语义是「能摘要就摘要,放不下才截断」,摘要优先但不是无条件。
8.1.4 recall:压缩之后重新物化
以上机制有一个固有代价:被压缩的输出不可逆。#2131 观测到的病态是同一个源文件被反复读取 66 次,每次读取的结果进窗口、被截断、被压缩、再被读取。e312e4c1(2026-08-27 合入)补上了恢复侧:recall 工具。
被逐出的工具结果在窗口里留下一个带类型的 ToolResultPlaceholder([crates/octos-agent/src/compaction.rs:346-361]),其中携带 tool_call_id。RecallTool([crates/octos-agent/src/tools/recall.rs:27-35])凭这个 id 从 ledger 取回原始字节,不需要重新执行工具。抽象层是 ToolOutputLedger trait([crates/octos-agent/src/tools/recall.rs:19-24]),octos-agent 只依赖这个接口,具体实现由 session 侧注入,避免了跨 crate 依赖。大输出按页返回(render_page(),[crates/octos-agent/src/tools/recall.rs:51-99]),页脚标明「下一页调 recall(tool_call_id=…, page=N)」,不静默丢中间段。
ledger 侧的关键实现在 octos-cli 的 ContextManager:recall_index([crates/octos-cli/src/api/context_manager.rs:391-398])在记录时写入 tool_call_id → (artifact_ref, model_visible_content),并且不被 compaction 清理。这正是 825d6a52 修的问题:早期 recall 从 items 解析,而 compact_context 会把 items 清到最近约 16 条,导致真正被逐出的输出(恰恰是 recall 存在的理由)反而取不回,磁盘上的 artifact 字节成了孤儿。修复后的测试 recall_survives_compaction_that_prunes_the_transcript([crates/octos-cli/src/api/context_manager.rs:2408-2440])先构造 20KB 输出(超过 inline 阈值,spill 到 ledger)、压掉其 transcript 信封、再断言完整字节可取回。读取入口是 tool_output_by_call_id()([crates/octos-cli/src/api/context_manager.rs:1192-1206]):spill 到内容寻址 ledger 的输出返回完整原始字节;未 spill 的返回记录时的模型可见内容(截断标记保留,不误导模型);未知 id 返回 None。
recall 句柄选 tool_call_id 而不是内容 sha256,是一个刻意的架构决定(e312e4c1 提交说明):sha256 只存在于 octos-cli 的 ledger 信封,把它加到 octos_core::Message 需要改动约 560 处构造点;而 tool_call_id 已经出现在每个占位符上,零 schema 变更。
8.1.5 recall 与记忆子系统的边界
octos 里有两个名字相近的工具:recall(本章)和 recall_memory(第 4 章)。它们解决不同时间尺度的问题,共用的只有分页器实现。recall 作用于当前 session 的 context ledger:工具输出 spill 到内容寻址存储后,凭 id 取回原始字节,作用域随 session 结束。recall_memory 作用于跨 session 的 memory store:向量检索历史观察与事实,服务于「上次会话学到什么」。
两者的衔接点在压缩摘要的持久化。loop 侧每完成一次压缩,会把摘要作为可检索的 episode 存档(save_conversation_episode,#1587 写侧,见 [crates/octos-agent/src/agent/loop_runner.rs:1215-1245] 的两处调用:入口压缩与轮内压缩各存一次)。判断逻辑写在注释里:一段对话大到需要压缩,就值得被未来的会话回想起。这样 session 内的 recall 取回字节,跨 session 的 recall_memory 取回语义摘要,两层合起来覆盖「压缩之后信息去了哪里」的完整答案:字节在 ledger,语义在 episode,结构化任务状态在 SessionSummary。
recall 把「压缩不可逆」改写成「压缩后有损但可按需恢复」,这是 8.1 双机制的第二条腿。
8.1.6 预算感知读取:预防先于恢复
#2131 的另一半是预防:read_file 在未指定范围读取一个超过工具输出预算的文件时,不返回注定被截断再被逐出的正文,而是返回范围提示([crates/octos-agent/src/tools/read_file.rs:484-506]):文件多大、预算多少、建议传 start_line/end_line 或先 grep。已经显式指定范围的读取不受影响。这一步把「接受再逐出」的死循环在源头掐断。
flowchart TD
Start["每次迭代"] --> Count["估算总 token"]
Count --> Check{"总量 > window×0.67?"}
Check -->|否| Skip["不压缩"]
Check -->|是| T1["Tier 1 本地清理<br/>stale/oversized 工具结果"]
T1 --> Need{"仍超阈值?"}
Need -->|否| Keep["占位符替代,继续"]
Need -->|是| Sum["摘要折叠旧消息"]
Sum --> Later["后续需要细节时"]
Later --> Recall["recall(tool_call_id)<br/>从 ledger 取回原始字节"]
Keep --> Later
图 8-1:压缩与 recall 回路。 压缩不再是一条单行道。
8.2 分层压缩面:三个档位各管一段
crates/octos-agent/src/compaction_tiered.rs(1,271 行,M8.5 #540)把压缩面拆成三档,动机写在模块头:便宜的 tier-1 就能消化大部分压力,昂贵的完整摘要尽量少触发([crates/octos-agent/src/compaction_tiered.rs:1-34])。
| 档位 | 机制 | 触发 | 保真度 |
|---|---|---|---|
| Tier 1 MicroCompaction | 本地把过期/超大工具结果替换为 ToolResultPlaceholder | 每轮迭代(会话与任务模式都跑) | tool_call_id 完整保留,recall 可恢复 |
| Tier 2 API MicroCompaction | 给 Anthropic 请求附加 context_management payload(clear_tool_uses_20250919) | 构造 ChatConfig 时,仅 Anthropic 系 provider | 服务端清理,客户端不重写历史 |
| Tier 3 FullCompactor | 完整摘要 + contract artifact 保留检查 | 超过 policy 阈值时 | typed SessionSummary 或提取式摘要 |
Tier 1 的策略结构是 MicroCompactionPolicy([crates/octos-agent/src/compaction_tiered.rs:72-106]):超过 5 个用户轮(DEFAULT_TIER1_MAX_AGE_TURNS)或单结果超过 8KB(DEFAULT_TIER1_MAX_SIZE_BYTES_PER_RESULT)即替换为占位符;同时带 #2131 的两个补充:最近触碰的 5 个文件的工作集钉住(pin_recent_files,[crates/octos-agent/src/compaction_tiered.rs:78-88]),正在操作的文件的读写结果不逐出,否则模型下一轮还得重读它,正是那 66 次重读的成因;同文件同范围的重复读取只保留最新一条,其余当场变占位符(dedup_duplicate_reads,[crates/octos-agent/src/compaction_tiered.rs:89-104]),消掉重读循环留下的一堆同文件残根。被替换的原因标注为 tier1_stale/tier1_oversized/tier1_superseded([crates/octos-agent/src/compaction_tiered.rs:237-241])。prune() 的调用方必须传 protected_tool_call_ids([crates/octos-agent/src/compaction_tiered.rs:139-142]):retry bucket 或 contract artifact 还引用的结果一个都不动,M6 的契约保证先于压缩。
Tier 1 还照顾了 provider 前缀缓存(KV cache):过期结果位于历史深处,改写它会使整个缓存前缀失效,所以这类清理集中在每轮第一次调用做;超大结果刚落在前缀尾部附近,改写代价小,每轮迭代都可做。这个区分体现在 Tier1Pass::Full / OversizedOnly([crates/octos-agent/src/compaction_tiered.rs:47-57])和 loop 侧的 tier1_pass()([crates/octos-agent/src/agent/loop_runner.rs:3846-3852]):iteration 1 跑 Full,之后只跑 OversizedOnly。会话模式与任务模式(后台 worker)都在 LLM 调用前跑这档清理([crates/octos-agent/src/agent/loop_runner.rs:1227]、[crates/octos-agent/src/agent/loop_runner.rs:2366])。
Tier 2 是请求期装饰而非运行时循环:ApiMicroCompactionConfig([crates/octos-agent/src/compaction_tiered.rs:428-457])默认关闭(enabled: false),开启后由 into_context_management_json()([crates/octos-agent/src/compaction_tiered.rs:478-505])产出 clear_tool_uses_20250919 payload,默认保留最近 10 轮(DEFAULT_TIER2_KEEP_LAST_N_TURNS,[crates/octos-agent/src/compaction_tiered.rs:419-421]);loop 侧在构造请求时注入(with_tier2_context_management,[crates/octos-agent/src/agent/loop_runner.rs:203-213]);非 Anthropic provider 静默忽略,不发任何多余字段。octos 不复刻 Anthropic 的服务端清理逻辑,只做选择加入的开关。
Tier 3 是既有 CompactionRunner 的 trait 包装(FullCompactor,[crates/octos-agent/src/compaction_tiered.rs:524-560]),阈值判断复用 needs_preflight()([crates/octos-agent/src/compaction.rs:572-580]),执行复用 run()([crates/octos-agent/src/compaction.rs:584-706])。run() 内部先做占位符清理(prune_tool_results(),[crates/octos-agent/src/compaction.rs:709-796]),再按清理后的 token 量决定是否摘要:占位符清理本身已把对话压回预算时,就不做摘要,不为压缩而压缩([crates/octos-agent/src/compaction.rs:597-612])。摘要完成后 enforce_preservation() 检查声明的 artifact 与 invariant 是否仍在消息流中被引用,缺失即报错回滚该轮压缩([crates/octos-agent/src/agent/compaction.rs:278-318])。CompactionRunner 按 policy 选择 summarizer(with_provider,[crates/octos-agent/src/compaction.rs:494-514]),并可通过 with_workspace_policy() 把声明的 artifact 名解析为具体 glob 模式([crates/octos-agent/src/compaction.rs:538-556])。每次压缩发出 octos.harness.event.v1 { kind: phase } 事件,运维可以看到 preflight 与 turn_end 的 token 前后对比。
8.2.1 分层的语义:为什么三档而不是一档
三档不是把一个算法拆成三步,而是三个不同成本结构的操作。Tier 1 是纯本地字符串替换,无 LLM 调用、无网络往返,微秒级完成,可以对每轮迭代无脑执行;模块头的估算引用了 Claude Code 的数据:仅便宜的 tier-1 就让 20-40% 的轮次永远不必触达昂贵的摘要器([crates/octos-agent/src/compaction_tiered.rs:5-8])。Tier 2 是零成本附加:不改本地历史,只在请求里多带一个字段,清理由 Anthropic 服务端做。Tier 3 才需要一次 LLM 调用生成摘要,也是唯一可能丢信息的一档。
代价排序决定了触发顺序:每轮先跑 Tier 1,清理后没超阈值就到此为止;超了才在下一轮 LLM 请求时由 Tier 3 判断是否摘要。绝大多数压力在第一档就被消化掉。同时三档的失败域互相独立:Tier 3 的 LLM 调用失败或超时会退回提取式摘要([crates/octos-agent/src/compaction.rs:683-695]),不影响 Tier 1 已完成的清理;Tier 2 被服务端拒绝时请求照常发送,只是没有服务端清理。
三档还有一个共同的保真度承诺:tool_call_id 在 Tier 1 占位符里完整保留([crates/octos-agent/src/compaction.rs:346-361]),Tier 3 的摘要正文虽然丢弃工具输出细节,但被摘要消息里的占位符仍指向 recall 句柄。换句话说,档位越深丢的越多,但每一步丢弃的都是「可通过 recall 找回」的内容,不是永久丢失。这个性质把「压缩」从不可逆操作变成可分层回退的操作。
8.3 Fidelity 四档模式
压缩后的消息保真度分四档:
| 档位 | 保留内容 | 丢弃内容 | 适用场景 |
|---|---|---|---|
| Full | 完整消息 | 无 | 最近六条消息、被钉住的工作集文件 |
| Truncate | 内容截断到 N 字符 | 尾部内容 | 中等重要历史 |
| Compact | 首行 + 工具名 | 参数、详细输出 | 远期历史 |
| Summary | typed SessionSummary 或提取式摘要 | 原始消息细节 | 跨压缩轮次的任务状态 |
实现分两层:默认的 ExtractiveSummarizer 走 Compact 档([crates/octos-agent/src/summarizer.rs:66-85]);LlmIterativeSummarizer(M6.4,[crates/octos-agent/src/summarizer.rs:166-230])调用 LLM 产出 typed SessionSummary,迭代更新已有摘要,连续 3 次失败(DEFAULT_LLM_SUMMARIZER_FAILURE_THRESHOLD,[crates/octos-agent/src/summarizer.rs:93-96])后锁定回提取式并记录 WARN。锁定是单向的:latch 置位后本 session 不再尝试 LLM,成功调用才会重置计数器。Summary 档已是可用选项,不是未来预留;但提取式仍是所有失败路径的兜底。trait 本身刻意保持同步签名([crates/octos-agent/src/summarizer.rs:33-64]):异步实现自己桥接 runtime,调用方不必在消息准备管线里 await。
CompactionRunner 按 policy 选择 summarizer(with_provider,[crates/octos-agent/src/compaction.rs:500-514]),并可通过 with_workspace_policy() 把声明的 artifact 名解析为具体 glob 模式([crates/octos-agent/src/compaction.rs:537-556])。每次压缩发出 octos.harness.event.v1 { kind: phase } 事件,运维可以看到 preflight 与 turn_end 的 token 前后对比。
8.4 Prompt Layer:分层系统提示构建
系统提示不是一个静态字符串,由多层信息组装。PromptLayerBuilder([crates/octos-agent/src/prompt_layer.rs:23-56])的 discover()([crates/octos-agent/src/prompt_layer.rs:58-80])按类别命中第一个可用文件即停:项目指令按 CLAUDE.md → .octos/instructions.md → .claude/instructions.md 顺序,Agent 描述按 AGENTS.md → .octos/agents.md → agents.md 顺序。命中即停意味着项目目录里不会同时装进两份指令文件;且只有显式传入或发现到的层才会拼接。
真正叠加的是 build()([crates/octos-agent/src/prompt_layer.rs:83-102])里的四类内容:base prompt、## Project Instructions 段、## Available Agents 段,以及 with_extra() 注入的运行时层(技能提示、工具说明等由调用方按需追加)。单文件上限 64KB(MAX_PROMPT_FILE_SIZE,[crates/octos-agent/src/prompt_layer.rs:11]),超限文件整体跳过而非截断,防止恶意或意外的大文件把窗口吃光。系统提示本身是每轮请求的固定前缀,也正是 prompt caching 能命中的部分:这层的体积直接影响每轮的 cache 命中率与成本。
8.5 Steering:会话中消息注入
steering 有两套并存的原语。类型层是 SteeringMessage([crates/octos-agent/src/steering.rs:80-90]):FollowUp、SystemReminder、RequestPause、Cancel,配套 channel()(缓冲默认 16)与非阻塞 drain_pending()([crates/octos-agent/src/steering.rs:92-105])。这套 enum 接口定义齐备且有测试,但文件头的 TODO 说明 SteeringReceiver 尚未接入主循环。
已接线的是 SteerBuffer([crates/octos-agent/src/steering.rs:36-63]):宿主(如 octos serve 的 turn/steer RPC)经 SharedSteerBuffer 注入纯 user 消息,agent loop 在每轮迭代顶部、下一次 LLM 调用之前 FIFO 排干(drain_pending_steer_input,[crates/octos-agent/src/agent/loop_runner.rs:744-775],调用点在 [crates/octos-agent/src/agent/loop_runner.rs:1136])。steering 不是中断:模型回答结束后若 buffer 非空,steer_input_pending() 让循环多跑一轮([crates/octos-agent/src/agent/loop_runner.rs:776-781])。
8.6 Prompt Guard:工具输出回流的 defang 层
Prompt Guard 的注入检测在第 7 章已介绍。上下文管理视角下它的接线位置更关键:已验证主路径是 sanitize_tool_output()([crates/octos-agent/src/sanitize.rs:90-95]),先剥 base64 data URI、长 hex、凭据模式,再调用 sanitize_injection()([crates/octos-agent/src/prompt_guard.rs:217])做 defang,最后才把工具结果写回对话历史。它保护的是外部文本回流这一步,不是统一改写所有输入。模块头明确写着 "Not a security boundary"([crates/octos-agent/src/prompt_guard.rs:1-19]):base64、Unicode 同形字、零宽字符都能绕过;真正的控制是 sandbox、tool policy 和 human-in-the-loop hook。
8.7 主干演进:contract-gated compaction 与 prompt_context 桥
8.7.1 CompactAndRetry 的承接
第 5 章看到 ContextOverflow 经分类器进入 LoopDecision::CompactAndRetry。loop 侧的处理([crates/octos-agent/src/agent/loop_runner.rs:494-515])先跑 turn compaction helper,再以 PromptContextPhase::Retry 请求重备提示,然后继续循环。压缩因此是 Agent Loop 的恢复路径,不只是 token 优化。
8.7.2 三层状态与 SessionSummary
压缩后的状态不能只是一段自然语言摘要。当前源码把状态分三层:
| 状态层 | 例子 | 是否进 prompt |
|---|---|---|
| prompt-visible state | messages、typed SessionSummary、workspace contract 摘要 | 是 |
| runtime control state | retry bucket、grace eligibility、task lifecycle | 否 |
| durable evidence state | validator ledger、harness event sink、cost ledger | 不直接写入,但必须可回放 |
SessionSummary([crates/octos-core/src/task.rs:217-263])是 typed compaction summary:goal、constraints、progress、decisions、files、next_steps 结构化保存,迭代更新时决策要么原文保留要么显式标 [STALE],不允许静默丢弃。它不是长期记忆,长期记忆归第 4 章的 memory 系统。
8.7.3 prompt_context:caller-owned 桥
crates/octos-agent/src/prompt_context.rs(64 行)定义了阶段化的桥接口,解决一个分层问题:octos-agent 刻意保持低层,不依赖 octos-cli 的持久 ContextManager;但 session 运行时确实拥有自己的 context ledger,需要在每次 LLM 调用前做最终 prompt 准备。PromptContextPhase 分 TurnStart / Iteration / Retry([crates/octos-agent/src/prompt_context.rs:12-30])三阶段,loop 在每轮 LLM 调用前按迭代号选择阶段调用 prepare_prompt(实现见 [crates/octos-agent/src/agent/compaction.rs:229-276],调用点在 [crates/octos-agent/src/agent/loop_runner.rs:1251-1258])。桥存在时,octos-agent 自带的 legacy 与 tiered 压缩路径全部让位(各入口的早退检查在 [crates/octos-agent/src/agent/compaction.rs:15-17]、[crates/octos-agent/src/agent/compaction.rs:90-93]、[crates/octos-agent/src/agent/compaction.rs:191-196]):session 运行时独家负责 prompt 压缩,两个组件不同时改同一条消息向量。桥返回错误不致命,loop 记 WARN 后沿用现有 prompt 向量([crates/octos-agent/src/agent/compaction.rs:263-270])。
PromptContextReport([crates/octos-agent/src/prompt_context.rs:44-53])回报替换是否发生、消息前后数量、token 估计与 generation 号,让调用方可观测而不必侵入。这个桥是 AppUI 与 serve/gateway 路径走生产级压缩的原因,也是 octos chat 与其不同的根源:后者没有 ContextManager,仍走 legacy 路径。
阶段化的动机是「同一接口、不同压力模型」。TurnStart(iteration 1)面对的是刚从持久 ledger 加载或恢复的对话,可能远超预算,桥此时最有机会做一次彻底的整理;Iteration(iteration 2 起)面对的是刚追加了几条工具结果的窗口,通常只需要增量维护;Retry 是 CompactAndRetry 恢复路径的专用阶段,loop 刚跑完 turn compaction helper,桥需要知道这次准备是溢出恢复的一部分,而非正常推进。三个阶段对桥的实现方意味着不同的压缩激进度与不同的记账需求,接口上一枚枚举就把上下文带全了,不需要调用方额外传参。
这个设计的另一层动机是错误隔离。桥返回 Result 而不是直接 panic 或中止循环:session 运行时的 ledger 可能损坏、锁可能被占,任何一种失败都不应该让 agent loop 崩溃。trait 的文档注释写明「返回错误不会中止 agent loop,agent 记录日志并回退到现有 prompt 向量」([crates/octos-agent/src/prompt_context.rs:55-58])。生产路径的持久层挂掉时,最坏结果是退化到 octos-agent 自己的 legacy 压缩,而不是整轮任务失败。
8.7.4 validator preservation 与 evidence ledger
压缩策略的约束是「变短的同时保住可验证契约」。crates/octos-agent/src/validators.rs 的 outcome 以 schema version JSONL 持久化到 .octos/validator_outcomes.jsonl([crates/octos-agent/src/workspace_git.rs:708-714]);workspace contract 状态读取 ledger,required validator 不满足则 ready = false 阻止 terminal success,optional validator 失败只计入 warning([crates/octos-agent/src/workspace_git.rs:662-690])。validator runner 的安全执行路径在第 9 章,workflow artifact gate 在第 12 章展开。
8.7.5 cache 经济学:一次性调用退出缓存写入
压缩与 prompt caching 有交互。prompt caching 默认开启时每个请求都打缓存断点,缓存写入按 1.25 倍计价;而压缩摘要这类一次性调用的前缀永远不会被重放,付这笔溢价纯属浪费。f3aa07f0(#2194)为此加了 ChatConfig.cache_retention:CacheRetention::None 让 provider 一个 cache_control 块都不发。压缩摘要调用([crates/octos-agent/src/compaction.rs:1192-1198])和迭代 summarizer 的 LLM 调用([crates/octos-agent/src/summarizer.rs:300-310])都已选择退出;agent loop 主调用因为前缀确实会被重放,保持缓存。成本层的完整分析见第 3 章。
flowchart LR
H[History window] --> T1[Tier 1<br/>占位符替换]
T1 --> T2[Tier 2<br/>Anthropic payload]
T2 --> T3[Tier 3<br/>full compactor]
T3 --> R[最近消息原样保留]
T3 --> S[typed SessionSummary<br/>cache_retention: None]
T3 --> A[保留 artifact 检查]
A --> P[workspace policy]
P --> V[validator JSONL ledger]
V --> G{required 通过?}
G -->|是| L[允许 terminal success]
G -->|否| F[阻止 terminal success]
图 8-2:contract-gated tiered compaction。 三档压缩面在 contract 检查之前完成。
工程决策侧栏:为什么 80% 固定阈值
备选方案一是动态阈值:按任务复杂度调整触发点。它需要预测剩余迭代数,这几乎不可准确预测,复杂度评估本身还要消耗上下文。方案二是预测式:按历史 token 增长率外推。工具输出大小高度可变,预测错误换来的是提前压缩丢信息或延迟压缩冒溢出风险。
固定 80% 的优势是可预测:开发者知道什么时候会发生压缩,运维知道怎么定位。20% 预留足以容纳一次典型迭代(系统提示加用户消息加模型响应加一次工具结果)。在充满不确定性的 Agent 系统里,基础设施层的确定性本身就是价值。安全边际 1.2 再叠一层,承认 token 估算永远不精确。
8.8 本章回顾
-
80% 阈值:
window × 0.8 / 1.2 ≈ 67%触发,最近六条无条件保留且不超预算一半,切割点避开工具组。 -
recall 双机制:预算感知读取在源头拒绝注定被逐出的整读;recall 凭
tool_call_id从recall_index(压缩不清理)取回原始字节,压缩从不可逆变为可按需恢复。 -
分层压缩面:Tier 1 本地占位符(含工作集钉住与读去重)、Tier 2 Anthropic 服务端 payload、Tier 3 完整摘要;Tier 1 按 KV cache 友好性区分清理时机。
-
Fidelity 四档:Full / Truncate / Compact / Summary;LLM iterative typed summary 可用,3 次失败锁定提取式兜底。
-
Prompt Layer 与 Steering:按类别命中即停的发现逻辑加 64KB 上限;
SteerBuffer已接线到每轮迭代顶部,SteeringMessageenum 仍是未接线的扩展点。 -
contract-gated compaction:
CompactAndRetry承接、三层状态分离、prompt_context 阶段化桥、validator JSONL ledger、一次性调用的 cache 写退出,共同保证压缩后任务约束仍可恢复可验证。 -
成本视角:压缩面与 prompt caching 互相作用。Tier 1 把过期清理集中在轮首以保住 KV 前缀缓存;一次性摘要调用以
cache_retention: None退出缓存写入;系统提示层命中缓存前缀。上下文管理的每个决定同时是成本决定。
延伸阅读
- Anthropic "Long context window tips":长上下文使用的实践建议
- Luhn 的自动文摘方法:提取式首行摘要的理论源头
- Anthropic prompt caching 文档:cache-write 定价与断点机制
思考题
- recall 依赖
tool_call_id作恢复句柄。如果 session 重启后 ledger 冷加载,spill 的 artifact 还在磁盘上吗?此时 recall 返回什么? - Tier 1 的
pin_recent_files钉住最近 5 个文件。如果一个任务真正的工作集有 15 个文件,这个参数会带来什么行为?调大它的代价是什么? - prompt_context 桥存在时 tiered 压缩全部让位。为什么不让两层同时工作,各压缩一部分?
版本演化说明 本章分析基线为 octos main @
9c157101(2026-09-03 实测核对)。三个近期变更塑造了本章结构:e312e4c1(#2131,recall 工具 + 预算感知读取)、825d6a52(#2131,recall_index 压缩存活)、f3aa07f0(#2194,一次性调用的 cache-write 退出)。分层压缩面来自 M8.5 #540。后续阅读优先核对crates/octos-agent/src/agent/compaction.rs(1,932 行)、crates/octos-agent/src/compaction_tiered.rs(1,271 行)、crates/octos-agent/src/agent/compaction.rs、crates/octos-agent/src/tools/recall.rs与crates/octos-cli/src/api/context_manager.rs(4,003 行)的 recall 相关段落。
第 9 章:扩展机制:Skills、Plugins 与 MCP
定位:本章展示 octos 当前源码中的三种扩展机制:Skills(Markdown 声明式)、Plugins(本地可执行工具 / skill package extras)、MCP(标准化协议集成)。前置依赖:第 6 章。适用场景:想为 octos 编写自定义扩展的贡献者,以及想理解 Agent 扩展架构设计的开发者。
Agent 的价值来自适配不同场景的能力。法律文书审查需要法律提示,研究 Agent 需要长时后台任务,远程服务集成又需要标准协议。把所有扩展都塞进同一种机制,会让简单需求过度工程化,也会让复杂需求被迫挤进不合适的抽象。
octos 当前的答案不是"一种万能插件",而是三条互补轨道:
- Skills:改变 Agent 的提示与上下文
- Plugins:把本地可执行程序包装成 Tool,并承载 skill package extras
- MCP:通过标准协议连接外部工具服务器
9.1 Skills 轨道:Markdown 声明式扩展
Skills 是最轻量的扩展机制。一个 skill 的核心就是一个 SKILL.md,外加可选的 manifest.json。
先看两条轨道在代码量上的悬殊分工:crates/octos-agent/src/skills.rs 全文 942 行,而 crates/octos-agent/src/plugins/ 八个文件合计 14,675 行。这个数量差不是偶然,而是职责边界的直接体现。crates/octos-agent/src/skills.rs 只回答一个问题:模型能看见什么。它负责发现技能目录、解析极简 frontmatter、判断可用性、把结果压成一个 XML 摘要注入系统提示,全程不碰进程、不碰协议、不碰安全策略。plugins/ 回答的则是另一个问题:系统会执行什么。manifest 解析、可执行文件发现与校验、子进程协议、环境清理、审批与并发控制全部堆在这里;其中光是执行协议与安全约束(crates/octos-agent/src/plugins/tool.rs 3,219 行)加上对应测试(crates/octos-agent/src/plugins/tool_tests.rs 4,406 行)就占了一半以上(51.96%),可见"安全地跑一个外部程序"远比"描述一个提示片段"昂贵。一个 skill package 目录里同时放着 SKILL.md 与 manifest.json 时,正是这两条轨道的汇合点:SkillsLoader 读前者注入提示,PluginLoader 读后者注册工具,XML 索引里的 tools="true" 属性标记的就是这条接缝。
9.1.1 SKILL.md 格式
---
name: code-review
description: Review code changes for bugs, security issues, and style
version: 1.0.0
requires_bins: rg,git
requires_env: GITHUB_TOKEN
---
When reviewing code, focus on:
1. Security vulnerabilities
2. Error handling completeness
3. Behavior regressions
SkillsLoader 并没有实现完整 YAML 解析器。它做的是两步简化处理:
- 用
split_frontmatter()找到首尾---之间的 frontmatter 块(crates/octos-agent/src/skills.rs:301-320) - 用
fm_value()从简单的key: value行里读取name、description、requires_bins、requires_env、always、version、author等字段(crates/octos-agent/src/skills.rs:322-343)
fm_value() 甚至会剥掉行内注释、把 [] / "" / ~ 这类 YAML 空值当缺失处理。这意味着 skill 元数据的设计目标不是"表达力最大",而是"足够稳定、足够便宜":引入完整 YAML 解析器会带来新依赖与解析歧义,而 skill 作者实际需要的只是几个扁平的键值对。测试里专门覆盖了重复键取首个、冒号出现在值里、空 frontmatter 这些边界(crates/octos-agent/src/skills.rs:590-645 的测试组),说明这条简化路径是被当作契约来维护的,不是权宜之计。
available 的判断也来自这里:parse_skill() 逐项检查 requires_bins 里的命令是否在 PATH(which_exists()),requires_env 里的变量是否已设置,两者全过才置 available: true(crates/octos-agent/src/skills.rs:244-294,351-369)。注意这是可用性检查而非安全边界:一个缺依赖的 skill 只是从摘要里标成不可用,并不会被隔离执行。
9.1.2 分层目录与加载顺序
SkillsLoader 只维护一个"技能目录列表",先加的目录优先级高(crates/octos-agent/src/skills.rs:62-116)。list_skills() 的实际组装顺序是:先装入编译内置的 BUILTIN_SKILLS,再按"低优先级目录先扫描、高优先级目录后覆盖"遍历,用 retain 删同名旧条目(crates/octos-agent/src/skills.rs:118-173)。这个"倒序遍历加去重"的实现换来一个性质:无论目录有多少层,同名技能最终只保留最高优先级来源的一份,且加载逻辑不需要知道每层目录的语义。
目录列表本身来自部署面的拼装:Config::plugin_dirs_from_project() 依次收集 <octos_home>/plugins、<octos_home>/skills、<octos_home>/bundled-app-skills/,再追加冒号分隔的 OCTOS_SKILLS_PATH 环境变量目录;旧的 ~/.octos/skills HOME 全局目录已不再扫描,只发一次性迁移警告(crates/octos-cli/src/config.rs:1330-1375)。gateway 启动时还会把二进制里携带的 app-skills / platform-skills 引导到分层目录(crates/octos-cli/src/commands/gateway/gateway_runtime.rs:566-573、crates/octos-agent/src/bootstrap.rs:103-115)。
所以这不是"工作区 / 全局 / 内置"三层固定表,而是一个 layered view:部署目录提供共享基线,账号目录提供私有安装,环境变量提供额外注入。
9.1.3 skill layering v1:per-profile 选择继承(9b1fc38f)
分层目录解决"技能从哪来",skill layering v1(提交 9b1fc38f)解决"这个 profile 允许加载哪些"。
配置面是 per-profile 的 skills 选择块,lowering 成 crate 无关的 SkillFilter(crates/octos-agent/src/skills.rs:12-36):
AllExcept(ids):全发现,减去禁用名单Only(ids):白名单模式,只加载列出的技能
这个枚举被刻意设计成不依赖任何 CLI 类型,为的是让提示侧的 SkillsLoader 与工具侧的 plugin loader 消费同一个选择决定。否则会出现一种危险的不一致:技能的提示卡被禁了,它的可执行工具却还注册着,模型看不到说明但工具仍然可调。
执行面有三处一致收敛(crates/octos-agent/src/skills.rs:156-176,176-201):list_skills() 用 retain 把被禁技能从摘要里删掉;load_skill() 对被禁名字直接返回 None,正文永远进不了 prompt;get_always_skills() 因走 list_skills() 同样被过滤。plugin 侧同一个 filter 也生效:load_into_with_options_and_filter() 对 manifest id 命中禁用名单的 skill package 整体跳过,工具、hooks、prompt fragments 一并不加载(crates/octos-agent/src/plugins/loader.rs:296-316)。
接线发生在 gateway 组装处:runtime 从已解析 profile 的 config.skills 生成 filter,传给账号级 loader(crates/octos-cli/src/commands/gateway/gateway_runtime.rs:646-648);子 bot 在 crates/octos-cli/src/commands/gateway/profile_factory.rs 里继承同一选择(crates/octos-cli/src/commands/gateway/profile_factory.rs:698-702)。None 表示没有 skills 层,一切照旧,完全向后兼容。
这里需要特别区分配置继承和本地 skill 继承:子账号可以继承父 profile 的 skill 选择 filter,但 customer-installed skills 不从父账号继承。account skills 目录严格解析为当前账号自己的 data_dir/skills,plugin dirs 也只返回当前账号的 skills 目录(crates/octos-cli/src/skills_scope.rs:96-112)。父账号安装的本地可执行扩展不会在子账号中静默启用。
9.1.4 XML 技能索引
build_skills_summary() 把过滤后的可见技能转成 XML 注入系统提示(crates/octos-agent/src/skills.rs:203-219):
<skills>
<skill available="true" tools="true">
<name>deep-search</name>
<description>Deep web research...</description>
<location>/.../SKILL.md</location>
</skill>
</skills>
三个容易写错的点:
- 当前 XML 里没有
name="..."属性,而是<name>子节点 tools="true"的含义是"该 skill 目录包含manifest.json",不是"这个 skill 正在执行工具"location把 skill 的真实来源路径暴露给模型,帮助它区分 builtin 与外部 skill
因此 XML 摘要不是单纯的"可用技能列表",它是模型可见的技能目录索引,且已经被 skill layering 过滤过:被 profile 禁用的技能在这里完全不可见,模型连"它存在"这个事实都拿不到。
9.1.5 spawn_only:自动后台化,而不是隐藏工具
spawn_only 标记定义在 skill package manifest 的工具项上(crates/octos-agent/src/plugins/manifest.rs:452-455),但它的运行时语义不在 manifest 里,而在 registry 和 agent 执行循环里:
PluginLoader装载时为这些工具名调用registry.mark_spawn_only(name, msg)(crates/octos-agent/src/plugins/loader.rs:332-339)ToolRegistry为它们维护自定义提示文案和任务跟踪状态(crates/octos-agent/src/tools/registry.rs:156-172)- 主 agent 发现某次 tool call 命中
spawn_only时,不同步执行,而是tokio::spawn一个后台任务,立刻向模型返回spawn_only_message(crates/octos-agent/src/agent/execution.rs:579起)
这意味着 spawn_only 不是"从 ToolSpec 里隐藏掉"。按当前实现它们仍然注册在工具系统里并对模型可见;差别只是调用时被自动后台化。模型不需要学习一套新的"任务提交工具",它照常发起 tool call,运行时替它决定这次调用是同步等待结果,还是立刻返回句柄转后台继续跑。
更进一步,resolve_extras() 在 skill package 含有 spawn_only 工具时注入技能卡 prompt fragment(crates/octos-agent/src/plugins/extras.rs:39-45,449-456)。这样模型既能看到工具,也能同时拿到"什么时候该用这个后台工具"的提示上下文。
而到了 subagent 场景,registry 会清空这些标记,因为"subagent 本身就是后台上下文",此时工具会像普通工具一样直接执行:在已经后台化的上下文里再套一层后台化只会丢失结果句柄,白增加一层调度开销,还让子 agent 无法拿到工具返回值。
9.2 Plugins 轨道:本地可执行工具与 skill package extras
如果说 Skills 负责改变 Agent 的"思维方式",Plugins 负责的就是让 Agent 真正调用外部程序完成工作。
9.2.1 runtime manifest:不只是工具声明
当前 runtime 热路径使用的是 crates/octos-agent/src/plugins/manifest.rs 中的 manifest 结构:
{
"name": "weather",
"version": "1.0.0",
"tools": [
{
"name": "get_weather",
"description": "Get current weather for a location",
"input_schema": { "type": "object", "properties": { "city": { "type": "string" } } },
"env": ["WEATHER_API_KEY"],
"risk": "medium",
"concurrency_class": "safe"
}
],
"sha256": "a1b2c3...",
"timeout_secs": 600,
"requires_network": true
}
但把它理解为"纯工具 manifest"已经不够了。当前这个结构还支持:id、mcp_servers(SkillMcpServer)、hooks(SkillHookDef)、prompts.include、actions、binaries(按 {os}-{arch} 键的预编译下载)、spawn_only / spawn_only_message、env / env_allowlist、risk、concurrency_class、make_type 等(crates/octos-agent/src/plugins/manifest.rs:8-110,387-477)。因此它更接近一个 skill package runtime manifest:工具只是其中一种载荷,MCP server 声明、生命周期 hooks、prompt 注入同样可以独立成包。如果 manifest.tools 为空但声明了 extras,PluginLoader 会跳过可执行文件搜索,照样把 extras 装进系统(has_extras(),crates/octos-agent/src/plugins/manifest.rs:180-198;装载分支 crates/octos-agent/src/plugins/loader.rs:607-626)。换句话说,"没有二进制的 plugin"是合法形态,它退化为纯 extras 包。
9.2.2 Plugin 二进制协议
sequenceDiagram
participant Agent
participant Plugin as Verified Executable
Agent->>Plugin: exec(".weather_verified", argv[1]="get_weather")
Agent->>Plugin: stdin: {"city":"Beijing"}
Plugin->>Agent: stderr: line-oriented progress
Plugin->>Agent: stdout: {"output":"Beijing: 25°C, sunny","success":true}
Agent->>Plugin: process exits
图 9-1:Plugin 二进制协议时序图。
这里的实现细节比"stdin JSON / stdout JSON"稍复杂:
- runtime 实际执行的是经过 hash 校验后落盘的 verified 副本(
crates/octos-agent/src/plugins/tool.rs:109-131) - argv 第一个参数是 tool name
- stdin 发送 JSON 参数
- stderr 逐行读出并转成进度事件
- stdout 优先按结构化 JSON 解析;不是合法 JSON 时退回"原样拼接 stdout + stderr 文本"(
crates/octos-agent/src/plugins/tool.rs:3117-3130附近的结构化字段解析)
把 stderr 单独留作进度流是个务实的设计:很多现成命令行工具本来就会往 stderr 打日志,plugin 作者不需要重写输出逻辑,只要保证最终结果以 JSON 落在 stdout 即可;而调用侧拿到的是流式进度事件,长任务不再是一个黑盒等待。
结构化 stdout 还支持比 output/success 更丰富的语义:file_modified 标记文件系统被改动,files_to_send 请求把产出文件自动回传会话(crates/octos-agent/src/plugins/tool.rs:3117-3130、crates/octos-agent/src/plugins/tool.rs:1229-1307)。runtime 还会尝试从 out 参数或输出文本里自动探测生成文件。
所以 Plugin 协议的真实价值是:把"外部进程"包装成"可流式报告进度、可自动回传文件的 Tool"。
9.2.3 安全与运行时约束
Plugin 这一层的安全措施有几道必须写清楚。
第一道:可执行发现是保守的。
PluginLoader 只把"子目录 + manifest.json"当成候选项。找二进制时依次尝试 manifest 名、目录名、main,最后才是目录扫描(crates/octos-agent/src/plugins/loader.rs:640-646,1189-1193)。
第二道:SHA-256 校验封 TOCTOU 窗口。
Loader 把原始字节一次读进内存,对内存字节算 hash 与 manifest 比对,落盘 verified 副本并记录 load_time_hash;执行前的 re-hash gate 再对盘上文件复验同一 hash,封住 load→exec 的替换窗口(crates/octos-agent/src/plugins/loader.rs:649-712、crates/octos-agent/src/plugins/tool.rs:109-131)。
第三道:资源与环境约束。
- 100MB 可执行文件上限,超限在读入内存前就拒绝(
MAX_EXECUTABLE_SIZE,crates/octos-agent/src/plugins/loader.rs:24,654-664) - 子进程环境经
sanitize_command_env清理注入向量;tool 级env/env_allowlist采用严格语义:secret-like 变量必须被 manifest 显式列出才转发,非 secret 走 legacy 兼容路径(crates/octos-agent/src/plugins/tool.rs:85-91) - 运行时注入
OCTOS_WORK_DIR给 plugin 放输出文件 - 默认超时是 600 秒,不是 30 秒(
DEFAULT_TIMEOUT,crates/octos-agent/src/plugins/tool.rs:151-163);manifest 的timeout_secs只是覆盖默认值
第四道:风险与并发类别不是装饰字段。
risk 会进入审批路径:high / critical 强制交互式 approval,缺少 approval bridge 时安全拒绝;low 默认不触发审批,medium / unknown 主要用于显式呈现(RiskLevel::classify / requires_approval,crates/octos-agent/src/plugins/manifest.rs:479-520)。
concurrency_class 识别 safe 和 exclusive。未知值不被乐观当作 safe:classify_concurrency_class() 返回 FailClosed,执行侧落到 Exclusive,声明笔误不会把互斥工具放成并行(crates/octos-agent/src/plugins/manifest.rs:569-609)。
第五道:Unix 上的符号链接拒绝。
is_executable() 用 symlink_metadata() 检查文件类型,只接受普通文件(crates/octos-agent/src/plugins/loader.rs:1354-1365)。这不是全部安全边界,但能缩小 link-swap 攻击面。
9.2.4 runtime PluginLoader 与 octos-plugin SDK 的边界
这一章最容易写错的地方,是把仓库里的两层代码混成一层。
当前 runtime 热路径是 crates/octos-agent/src/plugins/(8 文件合计 14,675 行,其中 crates/octos-agent/src/plugins/tool.rs 3,219 行、crates/octos-agent/src/plugins/tool_tests.rs 4,406 行):扫描目录、加载 manifest、解析 extras、校验并注册可执行工具,单个 plugin 失败只 warn! 跳过。
crates/octos-plugin 则是 SDK / tooling crate,提供另一层抽象:
discover_plugins():按来源优先级扫描并按 manifestid去重,首个出现者优先(crates/octos-plugin/src/discovery.rs:47-59)check_requirements():做bins/env/os三类 gating(crates/octos-plugin/src/gating.rs:42)- richer manifest:
id/type/requires/install/...(crates/octos-plugin/src/manifest.rs:112)
两层有关联但不能混为一谈:runtime 加载有自己的 manifest 类型与路径,octos-plugin 面向校验器、市场、安装器这类工具链。还有一个很小但真实的细节:gating 把 darwin 和 macos 当等价别名,避免 manifest 与 Rust 平台字符串不一致时误伤(crates/octos-plugin/src/gating.rs:73-78)。
9.3 MCP 集成:rmcp SDK 上的标准协议客户端
MCP(Model Context Protocol)是标准化的工具集成协议。octos 的 MCP client 位于 crates/octos-agent/src/mcp.rs,整体构建在官方 rmcp SDK 之上(提交 65486dad 迁移;crates/octos-agent/Cargo.toml:41-44:rmcp = { version = "1.8", ... },注释明确写着 "stdio + streamable-HTTP + OAuth 2.1")。
9.3.1 为什么迁 rmcp:自研客户端的三个 spec 缺口
crates/octos-agent/src/mcp.rs 模块文档写得很直白:旧的手写客户端存在三个协议级缺陷——缺 notifications/initialized、硬编码协议版本、每请求一行的读取方式在并发下会错位(crates/octos-agent/src/mcp.rs:1-22)。
三个缺口各有真实后果。缺 initialized 通知时,按规范等待该信号才进入工作状态的 server 会永远停在半初始化;硬编码协议版本则在 server 协商出不同版本时要么被拒、要么静默降级;而"一次调用读一行响应"在并发调用下必然错位:JSON-RPC 响应靠 id 匹配请求,不是靠到达顺序,两个并行 tool call 的响应一旦交错,逐行读取的解析就会张冠李戴。
rmcp 把这三件事全部接管:完整生命周期握手(initialize + notifications/initialized)、协议版本协商,以及单连接上的并发请求多路复用与 id 路由。对上层意味着并发 tool call 不再需要排队等前一个响应读完,一个慢工具不会阻塞同 server 上的其他工具。
9.3.2 三种接入方式
McpServerConfig(crates/octos-agent/src/mcp.rs:53-88)按字段分派到三条连接路径:
| 路径 | 触发条件 | 走哪里 |
|---|---|---|
| stdio | 只配 command/args/env | connect_stdio()(crates/octos-agent/src/mcp.rs:452-485) |
| streamable-HTTP(静态头) | 配 url,oauth 未开 | connect_http()(crates/octos-agent/src/mcp.rs:499-528) |
| streamable-HTTP + OAuth 2.1 | url + oauth: true | crates/octos-agent/src/mcp_auth.rs::connect_oauth()(crates/octos-agent/src/mcp.rs:501-504、crates/octos-agent/src/mcp_auth.rs:105-186) |
stdio 路径 spawn 子进程并设 kill_on_drop,子进程的存活与 rmcp 会话的 Arc 引用绑定:最后一个引用释放时传输关闭、子进程被回收。环境沿用与所有 octos 子进程相同的清理规则:剥掉注入向量变量,只转发 operator 显式列出的 env 名,连显式列出的名字也要再过一遍 denylist,防止配置里写 LD_PRELOAD 重新打开进程劫持口子(crates/octos-agent/src/mcp.rs:452-485)。rmcp 的子进程传输用无界 read_until 读 JSON-RPC 帧,源码注释承认这丢掉了旧客户端的单行上限,接受的理由是 stdio server 是 operator 自己点名信任的本地二进制(crates/octos-agent/src/mcp.rs:465-472)。
HTTP 路径不是"纯 SSE 通道":rmcp 的 StreamableHttpClientTransport 走 streamable-HTTP 语义(POST 请求、可流式响应),静态头(包括一条 Authorization bearer token)原样携带,octos 在外面套了 SSRF 防护(见 9.3.4)。
OAuth 路径服务那些需要真实用户授权的远端 server。token 的取得与使用分成两个命令面:octos mcp login <url> 交互式跑一遍 OAuth 2.1 授权码流程(本地回环 redirect 接收回调),把 access + refresh token 以 JSON 序列化写进 OS keyring,keyring 键是"规范化 URL + sha256 前缀",同一 server 换写法也不会存出两份(crates/octos-agent/src/mcp_auth.rs:30-95,186 起)。运行时 connect_oauth() 从 keyring 取回 token 交给 rmcp 的 AuthClient,过期自动刷新,全程无需重新登录;连接前强制 HTTPS 并拒绝字面私网主机(crates/octos-agent/src/mcp_auth.rs:105-125)。这里有一个容易漏掉的细节:OAuth 下 HTTP 客户端有两份,传输客户端带配置头走 SSRF 过滤,而授权服务器端点(discovery / registration / token / refresh)走单独的 SsrfOAuthHttpClient,因为那些请求本来就可能指向另一台主机(crates/octos-agent/src/mcp_auth.rs:126-135)。
9.3.3 发现、超时与工具注册
启动流程是 fail-soft 的(McpClient::start(),crates/octos-agent/src/mcp.rs:374-437):逐个 connect,连不上的 server 记 warning 跳过,不让一个坏 server 拖死整个 agent。工具发现也加了超时:一个完成了 initialize 却迟迟不答 tools/list 的 server 会在 HANDSHAKE_TIMEOUT(30 秒)后被跳过,不阻塞后面的 server。这层保护针对的是"握手成功但列表永不返回"的病态 server,它在协议上合法,在工程上却能把启动卡死。
对每个发现的 tool,validate_schema() 检查 input_schema:嵌套深度 $\le 10$(MAX_SCHEMA_DEPTH)、序列化大小 $\le 64\text{KB}$(MAX_SCHEMA_SIZE,crates/octos-agent/src/mcp.rs:47-49,296-322)。执行侧 McpTool::execute() 把 tools/call 包在 60 秒超时里,非文本 content(图片、资源)会被丢弃,因为 agent 工具面当前是纯文本的(crates/octos-agent/src/mcp.rs:586-612)。
注册前有两道保护(register_tools(),crates/octos-agent/src/mcp.rs:532-558):
- 名称保护:
PROTECTED_NAMES列出 20 个内置工具名(shell、read_file、git、browser 等),同名 MCP tool 直接跳过,防止远端 server 静默劫持核心能力(crates/octos-agent/src/mcp.rs:349-372) - 传输存活(提交
3934aeb6):transports 先于 tools 移交 registry 持有(keep_mcp_service_alive)。否则工具是连接的唯一 owner,profile 收窄把最后一个 MCP tool 滤掉时会顺带杀掉子进程(issue #1886)。一个可见性过滤器不应该有终结连接的副作用
9.3.4 HTTP 路径的 SSRF 防线
远程 MCP 是外呼面,octos 在这一层放了两级防线(crates/octos-agent/src/mcp.rs:122-183):
- 配置级:
reject_private_url_host()拒绝字面私网/回环 IP 或localhost的 URL。reqwest 对字面 IP 主机会跳过自定义 resolver,这层专门补上(crates/octos-agent/src/mcp.rs:165-183) - 解析级:自定义
SsrfDnsResolver挂进 HTTP client,每次解析都重新检查,命中私网/回环/link-local/元数据地址即拒,同时挫败 DNS rebinding;重定向策略设为none,3xx 不能把请求走私到内网主机(crates/octos-agent/src/mcp.rs:122-163)
OAuth 路径的防线更宽:授权服务器的 discovery / registration / token / refresh 端点也全部经过 SSRF 校验的 HTTP client(SsrfOAuthHttpClient,crates/octos-agent/src/mcp.rs:211-293、crates/octos-agent/src/mcp_auth.rs:126-135)。一个自称公网的 MCP server 不能把 token 流量指向 127.0.0.1 或云元数据地址 169.254.169.254,否则攻击者可以用一个恶意 server 探测内网甚至收割凭证。
工程决策侧栏:为什么需要三种扩展机制
维度 Skills Plugins MCP 核心作用 改提示与上下文 跑本地可执行工具 接外部协议化工具服务器 主要载体 SKILL.mdmanifest.json+ executablermcp 会话(stdio / HTTP / OAuth) 运行边界 无独立执行边界 外部进程 + verified copy 本地子进程或远程连接 典型增值点 低成本行为定制 进度流、文件回传、后台任务 跨 Agent 平台复用 安全面 可用性检查 + profile 过滤 hash 校验 + env 清理 + work dir SSRF + schema 验证 + 名称保护 为什么不能统一成一种?
因为它们解决的不是同一类问题。Skills 让模型学会"怎么想",Plugin 让系统学会"怎么做",MCP 让系统学会"怎么接别人的能力"。
如果把 Skills 也做成 Plugin,会让纯提示定制被迫带上二进制、协议和运行时安全成本。反过来,如果把 Plugin 做成纯 Skill,又无法提供真实执行、进度流和文件产出。MCP 看起来和 Plugin 都像"工具扩展",但它追求的是协议互操作,而不是本地 runtime 集成的最低摩擦。
9.4 Harness 工程契约:详见第 10 章
Skills、Plugins 和 MCP 解决的是"能力如何进入 octos";Harness 解决的是能力运行之后如何变成可验证、可观测、可升级的工程契约——ABI schema versioning、OCTOS_EVENT_SINK JSONL side-channel、validator runner 和 starter app skills。自 v2 起这部分独立成章,见 第 10 章。
9.5 配置车道:mcp_servers 与 sub_providers
扩展机制的两条配置车道都在 CLI 侧的顶层 Config 上。它们一个决定"接哪些外部工具服务器",一个决定"子 agent 用哪些模型",名字像但语义完全不同。
9.5.1 mcp_servers
顶层键 mcp_servers: Vec<octos_agent::McpServerConfig>(crates/octos-cli/src/config.rs:108-110)直接复用 agent crate 的配置类型。这是个刻意的接线选择:配置解析面与运行时消费面共享同一个 struct,McpClient::start() 收到的就是配置文件里写的东西,中间不存在第二次转换,也就不存在"配置写了一套、运行时理解另一套"的漂移空间。每个条目的字段就是 9.3.2 表里的三分派条件(command/args/env 或 url/headers 或再加 oauth/scopes),外加可选的 concurrency_class 覆盖(缺省 Safe,未知值同样 fail-safe 落 Exclusive)。最小示例:
{
"mcp_servers": [
{ "command": "uvx", "args": ["mcp-server-fetch"] },
{ "url": "https://mcp.example.com/mcp", "oauth": true, "scopes": ["read"] }
]
}
9.5.2 sub_providers
顶层键 sub_providers: Vec<SubProviderConfig>(crates/octos-cli/src/config.rs:182-184;结构体定义 crates/octos-cli/src/config.rs:616-654)声明可供 spawn 工具选择的子 provider。字段包括 key(引用短名,如 cheap、strong)、provider、model、api_key_env、base_url、description、default_context_window、max_output_tokens、api_type。其中 description 会连同成本与能力元数据一起进 spawn 工具的 schema,模型据此挑模型;default_context_window 决定子 agent 裁剪历史的激进程度。最小示例:
{
"sub_providers": [
{ "key": "cheap", "provider": "openai", "model": "gpt-4o-mini", "api_key_env": "OPENAI_API_KEY" }
]
}
保留键 goal_verifier:一个 sub_providers 条目若以 goal_verifier 为 key,goal 完成判定会路由到这个 provider 上验证(GOAL_VERIFIER_LANE_KEY 与 build_goal_verifier_provider(),crates/octos-cli/src/runtime/profile.rs:128-190)。这条车道的意义在于把"干活的模型"和"验收的模型"分开:未配置时回落到评分会话自身的 provider(#1935 之前的旧行为,作为兼容默认保留),此时目标完成与否由同一个模型自证,而配置了独立 verifier 后,判定由另一条 provider 车道给出。
9.6 本章回顾
-
Skills:
SKILL.md+ 简化 frontmatter 解析,分层目录去重合成一个视图,再由 per-profile 的SkillFilter(layering v1,9b1fc38f)过滤出 XML 摘要注入系统提示。 -
Plugins:runtime manifest 已是 skill package manifest;verified copy 封 TOCTOU,env allowlist、risk 审批、concurrency fail-closed、600 秒默认超时共同构成执行边界。
-
spawn_only:不是隐藏工具,而是自动后台化。主 agent 立即返回spawn_only_message,subagent 上下文里恢复直接执行。 -
MCP:rmcp SDK(1.8)上的 stdio + streamable-HTTP + OAuth 2.1 三接入(
65486dad);fail-soft 启动、schema 验证、PROTECTED_NAMES、SSRF 双级防线,registry 持有传输(3934aeb6)。 -
架构边界:
octos-agent/src/plugins/*(14,675 行)是 runtime 热路径;crates/octos-agent/src/skills.rs(942 行)是提示轨道;octos-plugin是 discovery/gating SDK。 -
配置车道:
mcp_servers决定接哪些 MCP server,sub_providers决定子 agent 用哪些模型,goal_verifier是后者的保留键。
Part 2 到此结束。下一章开始 Part 3,从单机会话推进到消息总线与多会话编排。
延伸阅读
- Model Context Protocol:https://modelcontextprotocol.io/
- rmcp(Rust MCP SDK):https://crates.io/crates/rmcp
- JSON-RPC 2.0:https://www.jsonrpc.org/specification
思考题
-
Skill 过滤的收敛点:为什么
load_skill()对被禁技能必须返回None,而不只是从 XML 摘要里删掉?如果模型凭"记忆"直接点名加载一个被禁技能,会发生什么? -
Plugin 信任链:verified copy 解决了 load→exec 的 TOCTOU,但如果 manifest 与原始二进制一起被替换,hash 仍然会"自洽"。你会如何把信任链再往前推进一层?
-
rmcp 的取舍:迁到 rmcp 修复了三个 spec 缺口,但也丢掉了 stdio 单行 1MB 上限(无界
read_until)。这个取舍在什么前提下是安全的?什么部署形态下需要重新审视?
版本演化说明 本章基于
octos主分支9c157101(2026-09-03 实测)撰写,覆盖三处近期演化:65486dadMCP 客户端整体迁到 rmcp SDK(stdio + streamable-HTTP + OAuth 2.1,crates/octos-agent/src/mcp.rs重写、新增crates/octos-agent/src/mcp_auth.rs);9b1fc38fskill layering v1(per-profile 选择继承,crates/octos-agent/src/skills.rs、crates/octos-agent/src/plugins/loader.rs);3934aeb6registry 持有 MCP 传输。后续阅读时优先核对crates/octos-agent/src/skills.rs、crates/octos-agent/src/plugins/、crates/octos-agent/src/mcp.rs、crates/octos-agent/src/mcp_auth.rs、crates/octos-plugin/src/与crates/octos-cli/src/config.rs。
第 10 章:Harness:把「模型说做完了」变成可验证契约
定位:本章解剖 octos 的 harness 体系:声明式校验器、结构化事件 ABI、schema 版本化、工作区策略、结构化错误分类与生命周期 hooks。先澄清一个常见误解:不存在独立的 harness crate;harness 是
octos-agent内的六个模块加上四个 starter 脚手架。前置依赖:第 5 章(错误恢复链)、第 6 章(Tool trait)、第 7 章(沙箱)。适用场景:需要让长跑 Agent 的产出可审计、可门禁、可回放的读者。
第 5 章讲 Agent Loop 时留了一个口子:模型回答「做完了」,运行时凭什么信?第 6 章的工具让 Agent 能改变世界,第 7 章的沙箱约束它能改到什么程度,但「改完之后是否达标」仍是空白。harness 补上这块:它不提升模型能力,而是把「完成」从一句自然语言变成机器可判定的契约。
先看这六个模块在源码中的体量(octos main @ 9c157101 实测):
| 模块 | 行数 | 首行 //! 文档 |
|---|---|---|
crates/octos-agent/src/validators.rs | 2,772 | Declarative validator runner (harness M4.3). |
crates/octos-agent/src/harness_events.rs | 2,789 | Structured harness event ABI and local sink transport. |
crates/octos-agent/src/abi_schema.rs | 349 | Harness ABI schema versioning. |
crates/octos-agent/src/workspace_policy.rs | 3,165 | (无 //!,首行为 use std::collections::BTreeMap;) |
crates/octos-agent/src/harness_errors.rs | 745 | Structured harness error taxonomy (M6.1, issue #488). |
crates/octos-agent/src/hooks.rs | 2,856 | Hook/lifecycle system for running shell commands at agent lifecycle points. |
合计 12,676 行。没有 harness crate,这六份文件都住在 crates/octos-agent/src/ 下,加上 crates/app-skills/ 里的四个 starter 目录(audio/coding/generic/report),才是 harness 的全部。这个澄清本身有信息量:harness 不是又一层抽象盒子,而是把校验、事件、版本化直接织进 Agent 运行时。
flowchart LR
subgraph octos-agent 内六模块
WP[workspace_policy<br/>声明层 3165 行]
V[validators<br/>执行层 2772 行]
HE[harness_events<br/>事件 ABI 2789 行]
AS[abi_schema<br/>版本化 349 行]
H[hooks<br/>生命周期 2856 行]
HErr[harness_errors<br/>错误分类 745 行]
end
ST[四个 starter<br/>app-skills/harness-starter-*] -->|workspace-policy.toml| WP
WP -->|validators 列表| V
V -->|ValidatorResult 事件| HE
H -->|Feedback 注入模型| HE
HErr -->|Error 事件| HE
AS -.版本护栏.-> WP
AS -.版本护栏.-> HE
三支柱的分工可以这样记:workspace_policy 声明什么算完成,validators 执行判定,abi_schema + harness_events 保证判定的过程与结果能被不同版本的组件听懂。hooks 与 harness_errors 是两翼:前者把生命周期变成可编程点,后者给一切失败配上类型。下面逐一展开。
10.1 支柱一:声明式校验器
10.1.1 为什么校验是数据,不是代码
harness 的第一支柱回答「什么算完成」。先看它反对什么:让 Agent 的产出可验证,最直觉的做法是写一个 fn validate() -> bool。但校验逻辑一旦写成代码,它就跟宿主版本锁死:旧 workspace 遇上新 runtime,函数签名对不上;反过来,runtime 升级也无法解释旧策略文件里的校验意图。octos 的选择是把校验器做成数据:WorkspacePolicy 里的一张表,由统一的 runner 解释执行。
三个工程理由支撑这个决策。第一,可序列化:策略文件能随 workspace 存档、diff、迁移,schema_version 字段让旧文件在新 runtime 里继续可读(见 10.3)。第二,可沙箱:runner 是唯一执行入口,命令型校验器统一过 shell 安全层(SafePolicy)与 BLOCKED_ENV_VARS 环境变量清洗,策略作者无法绕过(详见第 7 章的沙箱与 b64bd532/bf6be8cc 将 project-root 校验器收进沙箱、mcp-serve 路径 fail-closed 的决策)。第三,可回放:每条执行结果落成 JSONL 台账,重启后原样重读,不依赖进程内存里的瞬时状态。代价是表达力受限,复杂判定要拆成现有 ValidatorSpec 变体的组合,或者退到 Command 变体调用外部程序;这个取舍在思考题 1 里继续追问。
10.1.2 声明层:Validator 与三层语义
声明写进 ValidationPolicy(crates/octos-agent/src/workspace_policy.rs:115),它同时容纳旧式字符串校验与类型化校验器两套写法:
#![allow(unused)] fn main() { /// Tiered validation checks run at different points in the turn lifecycle. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct ValidationPolicy { /// Tier 1: cheap checks run every turn (< 100ms). e.g. file_exists, build exit code. #[serde(default)] pub on_turn_end: Vec<String>, /// Tier 2: medium checks run when source files change (1-5s). e.g. preview render. #[serde(default)] pub on_source_change: Vec<String>, /// Tier 3: expensive checks run on completion/publish only (10-30s). e.g. Playwright. #[serde(default)] pub on_completion: Vec<String>, /// Typed declarative validators (M4.3). Runs via `ValidatorRunner`. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub validators: Vec<Validator>, } }
前三行是按成本分层的字符串校验(每轮跑、源码变了跑、完成时才跑),第四行的 Vec<Validator> 才是 harness 主推的类型化形态。每个 Validator(crates/octos-agent/src/workspace_policy.rs:143)有稳定 id、超时与运行相位,正文 spec 是一个按 kind 标注的枚举 ValidatorSpec(crates/octos-agent/src/workspace_policy.rs:301),从跑子进程、调工具、查文件存在,到 HTTP 探测、SHA-256 比对、音频非静音检测逐级扩展。
最有设计感的是失败语义。历史上只有布尔 required:true 失败即拦,false 仅告警。Wave-3a 引入 soft_fail 后,两个字段坍缩成三档 Required(crates/octos-agent/src/workspace_policy.rs:198):
#![allow(unused)] fn main() { pub fn tier(&self) -> Required { if self.soft_fail { Required::Soft } else if self.required { Required::Hard } else { Required::None } } }
Hard 失败阻止 spawn 任务到达终态成功;Soft 失败只留台账与告警,用于「主产物必须交付、副产物尽力而为」的部分交付契约;None 的门禁行为与 Soft 相同,但台账里明确标注「仅供参考」。旧布尔字段原样保留以兼容旧策略文件,新旧两套字段由这一个函数收拢。声明式设计让这种平滑迁移成为可能。
三档的判据写在 Required 枚举(crates/octos-agent/src/workspace_policy.rs:198)的文档注释里,两个辅助方法把语义钉死:is_hard() 决定非 Pass 结果是否把 spawn 任务降级为 Failed,只有 Hard 为真;is_warning_only() 决定是否仅告警,Soft 与 None 都是。运维上这意味着 dashboard 可以按台账里的 tier 标签把失败切成三类:hard 失败要立刻处理,soft 失败要看副产物是否影响下游,none 失败纯属信息。选档的实用判据是问一句「这个校验不过,交付物还能用吗」:主产物文件存在性选 Hard,预览文件、附加报告选 Soft,风格类检查选 None。as_str() 输出的稳定标签(hard/soft/none)直接进指标与台账记录,运维面板不需要理解 Rust 枚举就能过滤。
10.1.3 执行层:ValidatorRunner 的五步时序
执行层的核心是 ValidatorRunner(crates/octos-agent/src/validators.rs:441)。它的构造函数本身就是一份安全清单:证据默认落在 <workspace_root>/.octos/validator-evidence/(常量 EVIDENCE_SUBDIR 定义在 crates/octos-agent/src/validators.rs:46),命令策略默认 SafePolicy,台账与沙箱可选挂载。工具调度被抽成 ValidatorToolDispatcher trait(crates/octos-agent/src/validators.rs:367):生产路径绑定完整的 ToolRegistry,测试与短生命周期调用点用 MapToolDispatcher 只快照需要的几个工具,不必为构造 runner 克隆整个注册表。一批校验器共享的上下文是 ValidatorInvocation(crates/octos-agent/src/validators.rs:167),携带工作区根、仓库标签、运行相位(ValidatorPhase,crates/octos-agent/src/validators.rs:105,分 turn_end 与 completion 两档)以及可选的 spawn 任务输入与工具输出。run_all 一次跑完整批并按声明顺序返回结果,单条命令校验器的处理流程是:
sequenceDiagram
participant P as WorkspacePolicy
participant R as ValidatorRunner
participant S as SafePolicy
participant C as 子进程
participant L as ValidatorLedger
P->>R: run_all(invocation, validators)
loop 每个 Validator
R->>R: interpolate_template 解析 ${args.X}/${output.X}
R->>S: policy.check(command_string, workspace_root)
alt Deny / Ask
R->>R: error_outcome(fail closed)
else Allow
R->>C: 启动(超时 SIGTERM→SIGKILL)
C-->>R: exit code / stdout / stderr
end
R->>R: 组装 ValidatorOutcome(含 evidence 路径与时长)
R->>L: ledger.append(outcome)
end
R-->>P: Vec<ValidatorOutcome>
这张时序图的每一步都能在 ValidatorRunner 的方法里找到落点。五个关键步骤如下。模板插值在 crates/octos-agent/src/validators.rs:677-696:${args.<key>} 取自 spawn 任务输入 JSON,${output.<key>} 取自工具产出的 named_outputs(如 mofa_publish 的 deploy_url),这让「校验刚发布的 URL 可达」成为一等公民;缺 key 直接产出 Error。安全闸门在 crates/octos-agent/src/validators.rs:719-734:SafePolicy 判 Deny 或 Ask 都按 Error 处理,宁可误拦不可放行。执行与超时杀进程(Unix 走 SIGTERM 转 SIGKILL,Windows 走 taskkill /F /T)对应 run_command(crates/octos-agent/src/validators.rs:661)。结果类型 ValidatorOutcome(crates/octos-agent/src/validators.rs:236)带 schema_version、时长、证据路径与 stderr 尾部,信息量刚好够事后审计。落账由 ValidatorLedger.append(crates/octos-agent/src/validators.rs:321)完成,一行一条 JSON。
台账是 append-only 的:ValidatorLedger(crates/octos-agent/src/validators.rs:298)只提供 append 与 read_all 两个动作,后者逐行反序列化并顺手把 Wave-3a 之前的旧记录归一化成新 tier 语义。契约测试 should_persist_outcomes_and_replay_them_byte_for_byte(crates/octos-agent/tests/validator_runner.rs:490)验证的就是「写进去什么样、重读回来就什么样」。回放不靠日志考古,靠结构化记录。
17 个用例的 crates/octos-agent/tests/validator_runner.rs 把这张时序图的每条边都钉住了:required 命令校验失败要拦(crates/octos-agent/tests/validator_runner.rs:123)、optional 失败只告警(:218)、超时杀子进程(:329)、stderr 进结果供操作员查看(:293)、BLOCKED_ENV_VARS 即便显式设置也被剥掉(:531)。
工程决策:为什么超时与拒答都算 Error 而不是 Fail
ValidatorStatus(crates/octos-agent/src/validators.rs:131)把终态分为Pass/Fail/Timeout/Error四档:Fail 表示「校验跑完了、结论是不合格」,Timeout 与 Error 表示「校验根本没跑成」。这个区分对门禁语义至关重要:产物不合格可以打回让 Agent 修,而策略拒绝或环境损坏时「打回」只会让 Agent 对着坏环境空转。消费方(如 Ch16 的门禁)只需要看required && status != Pass这一个条件,四种终态就能各归其位。
10.1.4 工作区策略:一张 TOML 驱动全部
WorkspacePolicy(crates/octos-agent/src/workspace_policy.rs:22)是声明层的总纲:工作区类型、版本控制、追踪、校验、产物、spawn 任务、压缩策略七个段。其中 CompactionPolicy(crates/octos-agent/src/workspace_policy.rs:51)声明压缩的形状,包括目标 token 预算、必须存活的产物与不变量、摘要器风味(CompactionSummarizerKind,crates/octos-agent/src/workspace_policy.rs:104,可选 Extractive 或 LlmIterative)、preflight 阈值、旧工具结果的修剪轮数。缺席时运行时退回旧抽取式路径,行为与 M6.3 之前逐字节一致。「缺省即旧行为」是 harness 各模块共享的兼容哲学(压缩的运行时消费详见第 8 章)。
WorkspacePolicy 的读取端同样受版本护栏保护:文件里的 schema_version 缺席时按 v1 反序列化,超出编译进 runtime 的上限则整体拒绝,细节在 10.3 节。四个 starter 的 workspace-policy.toml 是这套声明的最小可运行样本,10.5 节走读。
10.2 支柱二:事件 ABI:运行中说了什么
10.2.1 octos.harness.event.v1 信封
第一支柱回答终点判定,第二支柱回答过程叙事:运行中都发生了什么、外部组件如何听到。这份需求落在 crates/octos-agent/src/harness_events.rs(2,789 行,六模块中最大)。所有事件共用一个信封:
#![allow(unused)] fn main() { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct HarnessEvent { pub schema: String, #[serde(flatten)] pub payload: HarnessEventPayload, } }
schema 字段恒为常量 HARNESS_EVENT_SCHEMA_V1(crates/octos-agent/src/harness_events.rs:29),值是字符串 octos.harness.event.v1,这是消费者应当分支判断的稳定标识。载荷 HarnessEventPayload(crates/octos-agent/src/harness_events.rs:368)以 kind 标注、按 snake_case 命名,是十几种事件的tagged union:Progress、Phase、Artifact、ValidatorResult、Retry、Failure、McpServerCall、SubAgentDispatch、SwarmDispatch、SwarmReviewDecision、CostAttribution、RoutingDecision、CredentialRotation、SessionSanitized、SubagentProgress、Error。第 13 章的 pipeline 进度事件与第 17 章的 swarm 门禁都会以这套词汇表为共享语言,本章先立词汇。
以最常用的 HarnessProgressEvent(crates/octos-agent/src/harness_events.rs:461)为例,它自带 schema_version、session_id、task_id、phase 与可选 message,还有一个 progress_fraction 的别名兼容字段:老生产者写 progress_fraction、新消费者读 fraction,serde alias 一行解决,不需要 bump 大版本。
10.2.2 本地 sink 传输
事件的传递通道是本地 JSONL 文件:外部 app skill 进程把单行事件写进 OCTOS_EVENT_SINK 指向的文件,runtime 校验 schema 后 tail 并折叠进任务快照,再向 /api/events/harness 广播类型化帧。这条 side-channel 与 stdout 工具结果协议分离:stdout 回答「工具返回了什么」,sink 回答「运行中发生了什么」。所以同一个 app skill 进程有两类输出通道,职责不混。这条通道上还有两道防线:事件写入前先做 schema 校验,单行超过大小上限的记录整条拒绝,脏数据进不了流;关联上下文由环境变量注入,OCTOS_SESSION_ID 与 OCTOS_TASK_ID(以及等价的 OCTOS_HARNESS_SESSION_ID / OCTOS_HARNESS_TASK_ID)让每条事件都能归到具体会话与任务,事后审计不用猜来源。对第 16 章的 fleet 门禁与第 17 章的 swarm 聚合而言,这套按任务归集事件的能力就是它们消费数据的地基,本章只立契约,不展开消费侧。
错误也有专属事件形态:HarnessErrorEvent(crates/octos-agent/src/harness_errors.rs:166)作为 kind: "error" 挂在同一个信封下,携带 snake_case 的变体标识与恢复提示,schema 版本常量 HARNESS_ERROR_SCHEMA_VERSION 注册在 abi_schema。契约测试 should_round_trip_harness_error_through_sink(crates/octos-agent/tests/harness_errors.rs:154)验证错误事件经 sink 写入再读回不变形;should_emit_harness_event_on_error_classification(:126)验证每次错误分类都产生事件。错误分类学本身(RecoveryHint crates/octos-agent/src/harness_errors.rs:47 与 HarnessError crates/octos-agent/src/harness_errors.rs:93 的五提示十数变体)第 5 章已随恢复链讲透,本章只强调两点。其一,错误分类与事件 ABI 的关系是「分类决定事件载荷」:variant_name() 是冻结的命名,事件消费者据此做确定性分支,不碰自然语言 message。其二,分类的粒度是为可观测性设计的:RecoveryHint 只有五个值(backoff_retry、switch_provider、compact_context、fail_fast、bug),可以直接当 Prometheus 标签用而不会基数爆炸;HarnessError 的变体则细到能区分「401 认证失败」与「403 配额耗尽」这两种 4xx 的情况,让操作员看到的提示分别是「检查 API key」与「充值或换供应商」。18 个用例的 crates/octos-agent/src/harness_errors.rs 把这套映射钉得很实:429 归 RateLimited 配 BackoffRetry(crates/octos-agent/tests/harness_errors.rs:26),5xx 归 ProviderUnavailable 配 SwitchProvider(:58),网络错误归 BackoffRetry(:77),同样的输入永远分到同样的变体(:110),403 带 quota 标记归 Quota、不带归 Authentication(:248、:271)。同一分类经 eyre 中转也不失真(:280),这是把分类放进类型而不放进出错现场的回报。
10.3 支柱三:schema 版本化:不同版本还能互相听懂
10.3.1 五个耐久类型与 check_supported
声明会被存档,事件会被回放,于是第三个问题出现了:跨了版本的组件还能不能听懂彼此?这是第三支柱 crates/octos-agent/src/abi_schema.rs 的领地。harness 对外暴露五个耐久序列化类型:WorkspacePolicy、HookPayload、ProgressEvent(发出形状)、TaskResult、SessionSummary。crates/octos-agent/src/abi_schema.rs 为每个类型集中维护版本常量,全部当前值为 1。这 349 行的小模块只干一件事:版本护栏。核心函数只有九行(crates/octos-agent/src/abi_schema.rs:159):
#![allow(unused)] fn main() { pub fn check_supported( kind: &'static str, found: u32, supported: u32, ) -> Result<(), UnsupportedSchemaVersionError> { if found > supported { Err(UnsupportedSchemaVersionError { kind, found, supported, }) } else { Ok(()) } } }
规则是单向的:found <= supported 一律放行(旧版本靠缺省字段保持可读),found > supported 拒绝并返回类型化错误 UnsupportedSchemaVersionError(crates/octos-agent/src/abi_schema.rs:135),其 Display 消息固定含类型名、实际版本与支持上限,并提示「升级 octos 到更新版本」。不是 panic,不是静默截断。调用方可以记日志、向操作员呈现可行动的错误,或退回安全缺省。
10.3.2 版本协商决策流
flowchart TD
A[反序列化载荷] --> B{schema_version 字段存在?}
B -- 缺席 --> C[按 v1 处理<br/>serde default 补值]
B -- 存在 --> D{found 与编译进<br/>runtime 的 supported 比较}
D -- "found ≤ supported" --> E[正常读取<br/>未知新字段忽略]
D -- "found > supported" --> F[UnsupportedSchemaVersionError<br/>fail closed]
F --> G[操作员看到: 类型/实读/上限<br/>+ 升级提示]
为什么这扇门只朝一个方向关?「旧 runtime 读新 payload」与「新 runtime 读旧 payload」的风险天然不对称:旧数据缺的只是新字段,serde 缺省值补零即可安全读出,读到的语义是「老形状」,正确;而旧 runtime 遇上新 payload,新字段可能是旧代码无法理解的语义变更(比如一个字段从单值变成列表),任何「尽力解析」都是在猜,猜错的代价是静默错读。工程权衡因此落在保守侧:宁可让操作员看到一条带类型名、实读版本与支持上限的报错并升级 octos,也不让坏数据无声流过整条管线。这也是 check_supported 的错误消息固定附带「upgrade octos to a newer release」提示的原因(crates/octos-agent/src/abi_schema.rs:144-151 的 Display 实现):fail closed 的报错必须同时给出解法,否则只是把故障换个形态。反向的「新读旧」则交给缺省值:五个耐久类型的 schema_version 字段全部用 #[serde(default = "...")] 标注,2026 年 4 月前的旧策略文件零改动继续加载,升级 octos 不需要迁移仪式。
这套语义与 docs/OCTOS_HARNESS_ABI_VERSIONING.md 的五条兼容规则逐条对应:缺 schema_version 按 v1 反序列化;未知大版本用类型化错误拒绝、绝不 panic;同一大版本内 stable 字段含义不变,新增可选字段必须缺省为空;破坏性变更必须 bump 大版本并留双版本过渡窗口;外部消费者应先分支 schema_version 再读版本特定字段。三条护栏中「缺字段默认 v1」让 2026 年 4 月之前的旧策略文件今天照常加载;「超上限拒绝」保证旧 runtime 不会错读未来 payload。should_reject_future_workspace_policy_schema_version(crates/octos-agent/tests/abi_compat.rs:259)与 should_default_workspace_policy_to_v1_when_schema_version_missing(:197)把两条各钉一钉。13 个用例的 crates/octos-agent/tests/abi_compat.rs 用 fixture 文件驱动,覆盖四个耐久类型的新旧两端。
版本化的对象是 runtime 序列化形状,不是 skill/plugin 自己的发布版本(那是 manifest.json 的 version 字段),第 9 章曾区分过这两个概念,本章是它的执行机制。
10.4 两翼:hooks 生命周期与沙箱交叉
10.4.1 十一个生命周期点
hooks 的会话上下文由 HookContext(crates/octos-agent/src/hooks.rs:24)携带,只有 session_id 与 profile_id 两个可选字段,由 gateway 在 Agent Loop 启动前注入;它让同一份 hook 配置在不同会话里能区分自己服务的是谁,也是 10.4.2 节会话隔离状态键的来源。HookEvent(crates/octos-agent/src/hooks.rs:32)枚举了十一个可挂载点:user_prompt_submit、before/after_tool_call、before/after_llm_call、on_resume、on_turn_end,外加 spawn 验证前后与完成/失败四点。每个 HookConfig(crates/octos-agent/src/hooks.rs:77)声明触发事件、argv 数组(不经 shell 解释)、超时(默认 5000ms)、可选的 tool_filter、path_filter glob(如把 cargo check 限定在 **/*.rs 编辑之后)与 requires_bin(二进制不在 PATH 就静默跳过,不强迫操作员装齐所有 linter)。
载荷 HookPayload(crates/octos-agent/src/hooks.rs:123)自带 schema_version 与事件、工具名、参数、时长、模型、迭代数等上下文;集成方可实现 HookPayloadEnricher(crates/octos-agent/src/hooks.rs:671)注入领域数据。执行结果是六态 HookResult(crates/octos-agent/src/hooks.rs:679):Allow、Deny、Modified、Context、Error、Feedback。
其中 Feedback 最能体现设计取舍:after-hook 以 exit 1 且有输出退出时,那是反馈通道在正常工作:检查器在报告诊断,应注入模型会话;而缺二进制、超时、exit >= 2 属于基础设施故障 Error,绝不能混进模型上下文,否则 Agent 会把「hook 坏了」当成「代码有错」去修。提交 9ebaf468 与 fb0f9eeb 接通的编码反馈回路正是这条:after_tool_call 钩子把 cargo check 的编译错误作为 Feedback 喂回模型,形成「编辑→检查→修正」的循环,且按配置顺序逐条保留,后来的失败钩子不能覆盖编码缺省钩子的编译错误(#2129 评审第 8、9 条)。
10.4.2 断路器与会话隔离
HookExecutor 内建断路器:连续失败达到阈值即停用该钩子,避免一条坏 hook 拖垮每个 turn。值得记录的是 #2153 修复。断路器与去抖状态原先是执行器全局一份,Arc<HookExecutor> 跨所有会话共享时,一个工作区的基础设施故障会向全体会话计数。修复后状态按 session key 隔离(crates/octos-agent/src/hooks.rs:716 起),一个会话的熔断不再波及邻居;after 事件去抖窗口同理,一轮多次 edit_file 只付一次全项目 cargo check。
10.4.3 与第 7 章的安全交叉
harness 的可编程性天然带风险:workspace 里的 .octos-workspace.toml 是不可信输入,它声明的命令校验器不能裸跑在宿主机上。b64bd532 与 bf6be8cc 由此把 project-root 校验器收进沙箱,mcp-serve 路径 fail-closed(Windows 上无安全 argv 包装方案时宁可报错也不绕过沙箱,crates/octos-agent/src/validators.rs:735-749 的注释把这条决策写得很清楚)。沙箱机制本身是第 7 章的内容,此处不展开。
10.5 四个 starter:面向用户的脚手架
crates/app-skills/ 下有四个 harness starter:harness-starter-audio、harness-starter-coding、harness-starter-generic、harness-starter-report,目录结构完全一致,都是 Cargo.toml、manifest.json、SKILL.md、src/、tests/、workspace-policy.toml 六件套。它们不是参考实现意义上的玩具,而是把本章全部概念装配成可复制起点的模板:manifest 声明工具与并发类,workspace-policy 声明产物与校验,tests 里的 smoke test 则核对这两份声明没有说谎。
以 harness-starter-coding 走读。manifest.json 声明一个 spawn_only 的 propose_patch 工具,把补丁渲染成 patches/<slug>.diff 统一差分文件,外加一份 patches/<slug>.files.txt 预览清单,concurrency_class = "exclusive" 防止并发写冲突。workspace-policy.toml 则是 10.1 节声明的直接样本:
[validation]
on_turn_end = []
on_source_change = []
on_completion = ["file_exists:patches/*.diff"]
[artifacts]
primary = "patches/*.diff"
preview = "patches/*.files.txt"
[spawn_tasks.propose_patch]
artifacts = ["primary", "preview"]
on_verify = [
"file_exists:$primary",
"file_size_min:$primary:64",
"file_exists:$preview",
]
注意 $primary 引用:产物声明与校验规则解耦,改名产物只动一处。这个 TOML 还展示了字符串校验的三层结构如何与 WorkspacePolicy 的段一一对应:[validation] 是校验层,[artifacts] 是产物层,[spawn_tasks.*] 把两者装配成任务契约,on_verify / on_deliver / on_failure 分别挂在验证、交付、失败三个时点。file_size_min 64 字节堵住「零字节 diff 也算交付」的漏洞。空补丁文本恰好过 file_exists,但过不了大小下限。字符串校验(on_completion)与类型化 validators 两轨在此并存,新代码建议走后者。
其余三个 starter 各占一段:harness-starter-audio 展示独占资源契约:synthesize_clip 写 audio/<slug>.wav 必须声明 exclusive 并发类,primary 产物配 file_size_min 4096 字节的下限校验;harness-starter-report 是最小报告契约:reports/*.md 主产物、completion file_exists、failure 时 notify_user;harness-starter-generic 则是剥掉领域细节的空白模板,适合作为新 app skill 的起点。写新 app skill 时应把这四个目录当工程清单逐项核对:manifest 声明、并发类、产物契约、校验门禁、失败通知,一项不缺再动手写业务代码。
从用户视角看,starter 的价值在于把本章机制收敛成「复制目录、改两份声明文件」的动手路径:manifest.json 描述工具长什么样(输入 schema、并发类、超时),workspace-policy.toml 描述产出怎么算数(产物模式、校验规则、失败通知),src/ 里的业务代码只管干活,不需要知道 ValidatorRunner 或事件信封的存在。四份 workspace-policy.toml 放在一起读还能看出契约松紧的梯度:coding 的 file_size_min:$primary:64 是字节级下限,audio 提到 4096 因为 WAV 头本身就不小,report 只用 file_exists 因为 Markdown 报告的合理大小无法预判,generic 则留空让用户自己填。同一个机制,四种松紧,全靠声明层的数据调整,一行 runner 代码不用改,这正是 10.1.1 节「校验是数据不是代码」在用户侧的兑现。
10.6 本章回顾
-
事实澄清:不存在 harness crate。harness 是
octos-agent内六模块(validators 2,772 行、harness_events 2,789 行、abi_schema 349 行、workspace_policy 3,165 行、harness_errors 745 行、hooks 2,856 行,合计 12,676 行)加crates/app-skills/harness-starter-*四个 starter。 -
三支柱:workspace_policy 声明什么算完成,validators 执行判定并产出可回放台账,harness_events + abi_schema 让判定过程与结果跨版本可读。hooks 与 harness_errors 是两翼,starter 是面向用户的脚手架。
-
声明式校验:校验器是数据不是代码,换来可序列化、可沙箱、可回放三重工程收益;
Required三档(Hard/Soft/None)收拢新旧字段,Soft 支持部分交付契约。 -
事件 ABI:
octos.harness.event.v1信封 +kind标注的载荷枚举,本地 JSONL sink 传输,validate 后入流;第 13 章 pipeline 进度与第 17 章 swarm 门禁共用这套词汇。 -
版本护栏:五个耐久类型、
check_supported九行核心、缺字段默认 v1、超上限 fail closed;crates/octos-agent/tests/abi_compat.rs13 用例钉住两端。 -
hooks 与安全:十一个生命周期点、六态结果、Feedback 与 Error 严格分流;断路器按会话隔离(#2153);project-root 校验器进沙箱、mcp-serve fail-closed(详见第 7 章)。
第 10 章是第二部分的收官:从第 5 章的一次对话生命周期,到第 6 章的工具、第 7 章的安全、第 8 章的上下文、第 9 章的扩展,再到本章的可验证契约,Agent 引擎的内部机制到此完整。第三部分从单机会话走向消息总线与多会话编排,harness 埋下的事件词汇会在那里被大量复用。
延伸阅读
docs/OCTOS_HARNESS_DEVELOPER_GUIDE.md:harness 开发者指南,外部 skill 作者的入口文档docs/OCTOS_HARNESS_ABI_VERSIONING.md:ABI 版本化契约,stable/experimental 字段清单与废弃规则(M4.6)docs/OCTOS_HARNESS_MASTER_PLAN.md:harness 总体规划,三阶段(后台任务信任 / Web 持久化与重载可靠性 / 自由编码与更丰富运行时)的路线图docs/OCTOS_HARNESS_DEVELOPER_INTERFACE.md:开发者接口约定docs/OCTOS_HARNESS_SKILL_COMPAT.md:skill 兼容性说明
思考题
-
声明式的边界:如果你的校验需要「跑一段领域特定的判定程序」(比如解析内部格式再断言结构),你会扩展
ValidatorSpec加一个新变体,还是退回Command校验器调外部程序?两种做法在沙箱、超时、证据留存上的差异是什么? -
Soft 契约的滑坡:
soft_fail让部分交付成为合法终态。如果团队里每个 validator 都被「顺手」标成 soft,harness 的门禁价值还剩多少?你会用什么机制(评审规则、策略 lint、台账审计)防止这种滑坡? -
版本护栏的单向门:
check_supported只拒未来版本、不拒过去版本。假如某天 v2 需要删除一个 v1 的 stable 字段(而不是新增),缺省值策略还能兜住吗?双版本过渡窗口应该怎么设计,才能让 v1 生产者与 v2 消费者共存一个发布周期?
版本演化说明 本章基于 octos main @
9c157101(2026-09-03 核对)。行号与代码摘录以该 commit 为准;后续阅读时优先复核crates/octos-agent/src/{validators,harness_events,abi_schema,workspace_policy,harness_errors,hooks}.rs与crates/app-skills/harness-starter-*/。里程碑编号(M4.3/M4.6/M6.1/M6.3/M6.5/M6.6/M7.6/M8.6/M8.7)沿用源码注释与docs/OCTOS_HARNESS_MASTER_PLAN.md的阶段划分。
第 11 章:octos-bus:消息总线、频道抽象与会话持久化
定位:本章深入 octos-bus crate(当前
crates/octos-bus/约 40K 行 Rust 源文件(实测 40,937 行,其中 17 个频道文件 18,207 行)),展示如何用Channeltrait 抽象统一多种消息频道,以及会话管理、消息分片、thread-bound streaming、durable commit observer 和 child-session contract 的工程实现。前置依赖:第 5 章。适用场景:想理解多频道消息平台架构的开发者(读者 B),以及需要接入新频道的贡献者(读者 D)。
当 Agent 从单用户 CLI 走向多用户平台时,消息接入层的复杂度急剧上升。Telegram 的消息长度限制是 4,000 字符,Discord 是 1,900;Slack 用 Block Kit 格式化消息,飞书用 Rich Text;邮件是异步的,WhatsApp 需要模板消息。octos-bus 用一个 Channel trait 统一了这些差异。
11.1 Channel trait:统一消息接口
Channel trait(crates/octos-bus/src/channel.rs:17-265)定义了所有频道的统一接口:
当前版本的 Channel trait 一共有 26 个方法,但真正没有默认实现的只有 3 个:name()、start()、send()。其余能力都以默认实现挂在 trait 上,真实频道按需覆盖:
#![allow(unused)] fn main() { #[async_trait] pub trait Channel: Send + Sync { fn name(&self) -> &str; async fn start(&self, inbound_tx: mpsc::Sender<InboundMessage>) -> Result<()>; async fn send(&self, msg: &OutboundMessage) -> Result<()>; fn max_message_length(&self) -> usize; // 默认 4000,可覆盖 fn is_allowed(&self, _sender_id: &str) -> bool { true } async fn send_typing(&self, _chat_id: &str) -> Result<()> { Ok(()) } fn supports_edit(&self) -> bool { false } async fn send_with_id(&self, msg: &OutboundMessage) -> Result<Option<String>> { ... } async fn edit_message(&self, ...) -> Result<()> { ... } async fn finish_stream(&self, ...) -> Result<()> { ... } async fn edit_message_bound(&self, ..., thread_id: Option<&str>) -> Result<()> { ... } async fn finish_stream_bound(&self, ..., thread_id: Option<&str>) -> Result<()> { ... } async fn send_raw_sse_bound(&self, ..., thread_id: Option<&str>) -> Result<()> { ... } async fn health_check(&self) -> Result<ChannelHealth> { ... } // + stop, send_typing_as, stop_typing, send_listening, // delete_message, edit_message_with_metadata, reactions, embed, ... } }
这是一种典型的“大 trait + 多默认实现”设计:简单频道只实现 3 个基础方法就能工作;成熟频道则会继续覆盖 max_message_length()、supports_edit()、send_with_id()、edit_message()、format_outbound()、health_check() 等扩展点。这样做的代价是 trait 面会比较宽,但收益是所有平台能力都能通过一个统一抽象暴露给 Gateway。
关键方法:start() 接收一个 mpsc::Sender<InboundMessage>,频道将收到的用户消息通过这个 sender 发送给 Agent 处理层;send() 负责把 Agent 响应发送回目标频道;max_message_length() 虽然有默认值 4000,但 Discord、Slack、Twilio、WeCom 等真实实现都会覆盖它(例如 Discord=1900,Slack=3900,Twilio=1600)。
send_with_id() 返回消息 ID,支持后续编辑(流式输出场景下先发送占位消息,再逐步更新内容)。默认实现委托给 send() 并返回 None。
11.1.1 流式编辑三步法
对于支持消息编辑的频道(supports_edit() 返回 true),octos 使用三步法实现流式输出:
send_with_id():发送初始消息(可能只有几个 token),返回平台消息 IDedit_message():随着 LLM 流式输出,不断更新同一条消息的内容(crates/octos-bus/src/channel.rs:85-93)finish_stream():流结束后发送最终版本;默认实现仍回退到edit_message()(crates/octos-bus/src/channel.rs:122-133)
这种模式让用户看到 Agent 的回复逐渐生成,而不是等待完整响应后一次性显示。Telegram 和 Discord 都支持这种模式。对于不支持编辑的频道(如邮件),退回到等待完整响应后一次性发送。
11.1.2 thread-bound streaming:显式绑定 turn,而不是依赖 sticky map
当前 Channel trait 的流式编辑接口已经不只是一组“编辑消息”的便捷方法。它新增了三类带绑定参数的默认方法:
edit_message_bound(chat_id, message_id, content, thread_id)finish_stream_bound(chat_id, message_id, content, thread_id)send_raw_sse_bound(chat_id, json, thread_id)
这些方法的核心不是多传一个字段,而是在并发 Web turn 下把每个流式增量、最终消息和历史 raw SSE 事件都显式绑定到 originating thread_id(crates/octos-bus/src/channel.rs:107-201)。旧路径会从 message id 解码或从 per-chat sticky map 里恢复 thread;当同一个 chat 连续快速发起多个 turn 时,sticky map 可能已经被后一个 turn 旋转,导致前一个 turn 的 late delta 被写进后一个气泡。bound API 的语义是:调用方已经知道这个增量属于哪个 turn,就不要再从共享状态里猜。
sequenceDiagram
participant Turn as Web/API turn
participant Reporter as Stream forwarder
participant Channel as Channel bound API
participant UI as Client thread bubble
Turn->>Reporter: bind originating thread_id
Reporter->>Channel: edit_message_bound(..., thread_id)
Reporter->>Channel: send_raw_sse_bound(..., thread_id)
Channel->>UI: update exact thread bubble
默认实现仍会退回旧的 edit_message() / send_raw_sse(),所以 Telegram、Discord 等不关心 thread 的频道保持兼容;历史 API channel 可以覆盖 bound 方法,把事件稳定落到正确的 UI thread。这里需要和第 13、14 章区分:当前 Serve/AppUI 的公开 chat transport 已经迁到 /api/ui-protocol/ws,send_raw_sse_bound() 是 bus trait 中保留的兼容/内部扩展点,不代表新客户端还应该使用 chat SSE。health_check() 也是同一轮演进的一部分:它让 admin dashboard 可以以统一方式展示频道健康状态,而不是把健康探测散落到每个频道自己的管理接口里(crates/octos-bus/src/channel.rs:245-251;ChannelHealth 枚举在同文件 253-265)。
11.1.3 AgentHandle 对称设计
消息总线使用 AgentHandle / BusPublisher(crates/octos-bus/src/bus.rs:8-77)连接频道和 Agent 处理层:
#![allow(unused)] fn main() { // AgentHandle 包含双向通道 struct AgentHandle { in_rx: Receiver<InboundMessage>, // Agent 从这里接收消息 out_tx: Sender<OutboundMessage>, // Agent 从这里发送响应 } struct BusPublisher { in_tx: Sender<InboundMessage>, // 频道从这里发送消息给 Agent out_rx: Receiver<OutboundMessage>, // 频道从这里接收 Agent 响应 } }
这种对称设计的优势是:当所有 Channel 关闭时(所有 inbound_tx 被 drop),inbound_rx.recv() 返回 None,Agent 处理层自动感知到没有更多消息,可以优雅退出。不需要额外的 shutdown 信号。
11.1.4 is_allowed:发送者鉴权
is_allowed()(crates/octos-bus/src/channel.rs:27-30)在消息路由到 Agent 之前检查发送者是否有权使用 Agent。默认实现返回 true(允许所有人),各频道可以覆盖实现自定义鉴权逻辑,例如 Telegram 可以限制只有特定 chat_id 的用户才能访问。
11.2 消息 Coalescing:5 级切割策略
当 Agent 的回复超过频道的字符限制时,需要将长消息分割为多个短消息。octos-bus 的 coalescing 算法(crates/octos-bus/src/coalesce.rs:26-120)按 5 个优先级尝试切割:
flowchart TD
Input["长消息"] --> P1["1. 段落分割 \\n\\n"]
P1 -->|"找到"| Emit1["发送段落"]
P1 -->|"未找到"| P2["2. 换行分割 \\n"]
P2 -->|"找到"| Emit2["发送行"]
P2 -->|"未找到"| P3["3. 句子分割 . + 空格"]
P3 -->|"找到"| Emit3["发送句子"]
P3 -->|"未找到"| P4["4. 空格分割"]
P4 -->|"找到"| Emit4["发送词组"]
P4 -->|"未找到"| P5["5. 硬切<br/>UTF-8 安全边界"]
P5 --> Emit5["发送截断块"]
图 10-1:5 级消息切割策略。 优先在语义边界切割,硬切是最后手段。
MAX_CHUNKS = 50:防止极长消息被分割为数百个小消息导致 DoS。超过上限时,代码不会继续在最后一块后面追加文本,而是单独插入一个 "[message truncated - N chars omitted]" 的截断块(crates/octos-bus/src/coalesce.rs:47(MAX_CHUNKS 常量在同文件 27 行,截断时打 warn! 日志))。
UTF-8 安全:硬切时使用 is_char_boundary() 回退到安全的字符边界(与 octos-core 的 truncate_utf8 使用相同的策略,详见第 2 章)。
平台特定限制(crates/octos-bus/src/coalesce.rs:5-24):
| 频道 | 字符限制 | 配置方法 |
|---|---|---|
| Telegram | 4,000 | ChunkConfig::telegram() |
| Discord | 1,900 | ChunkConfig::discord() |
| Slack | 3,900 | ChunkConfig::slack() |
| 无限制 | 不调用 coalescing | |
| 默认 | 4,000 | ChunkConfig::default_limit() |
11.2.1 find_break_point:核心分割逻辑
find_break_point()(crates/octos-bus/src/coalesce.rs:87-118)是切割的核心,但真正的切割过程分两步:先在 max_chars 以内找一个 UTF-8 安全的搜索窗口,再在这个窗口内寻找最自然的断点。
#![allow(unused)] fn main() { let mut limit = config.max_chars.min(remaining.len()); while limit > 0 && !remaining.is_char_boundary(limit) { limit -= 1; } let search = &remaining[..limit]; let break_at = find_break_point(search); chunks.push(remaining[..break_at].trim_end().to_string()); remaining = remaining[break_at..].trim_start_matches('\n'); if remaining.starts_with(' ') && !remaining.starts_with(" ") { remaining = &remaining[1..]; } }
find_break_point(search) 内部依次对 \n\n、\n、. 、空格做 rfind()(从右向左搜索),只有完全找不到自然边界时才硬切。这保证断点尽量靠近上限,同时避免把多字节字符切坏。trim_end()、trim_start_matches('\n') 和“最多跳过一个前导空格”的小处理,则让最终发出去的块看起来更干净,不会把原始分隔符原样带到下一条消息开头。
11.3 Session 管理
11.3.1 Session 结构体
Session(crates/octos-bus/src/session.rs:587-608)是对话的持久化单元:
#![allow(unused)] fn main() { pub struct Session { pub key: SessionKey, // 会话标识(channel:chat_id) pub parent_key: Option<SessionKey>, // fork 来源 pub topic: Option<String>, // 多主题支持 pub title: Option<String>, // 侧边栏标题 pub title_manual: bool, // 手动标题不被自动覆盖 pub child_contracts: Vec<ChildSessionContract>, pub messages: Vec<Message>, // 对话历史 pub summary: Option<String>, // 会话摘要 pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>, } }
11.3.2 JSONL 持久化与文件命名
当前源码的 Session 持久化比“一个 JSONL 文件”稍复杂一些,核心有两个事实。
第一,JSONL 文件的第一行不是消息,而是 SessionMeta 元数据,后续每一行才是 Message(crates/octos-bus/src/session.rs:560-584、crates/octos-bus/src/session.rs:1365-1381、crates/octos-bus/src/session.rs:2992-3060)。所以它不是“纯消息流”,而是“头一行 schema/meta + 后续消息行”的轻量日志格式。SessionMeta 现在也承载 title、title_manual 与 child_contracts,这使侧边栏标题和后台子任务状态不必塞进普通消息文本。
第二,当前代码同时支持旧布局和新布局:
SessionManager仍支持 legacy flat layout:data/sessions/{encoded-key}[_{hash}]?.jsonl(crates/octos-bus/src/session.rs:1096-1146)SessionActor使用的SessionHandle优先采用 per-user layout:data/users/{encoded_base_key}/sessions/{topic_or_default}.jsonl,并在打开时自动迁移旧文件(crates/octos-bus/src/session.rs:1611-1819)
只有在 legacy flat 布局里,文件名才由下面这两部分构成:
- Percent-encoded SessionKey(
crates/octos-bus/src/session.rs:129-140):将 SessionKey 中的路径不安全字符(/、:、#)编码为%2F、%3A、%23 - FNV-1a 64-bit hash 后缀(
crates/octos-bus/src/session.rs:116-127、crates/octos-bus/src/session.rs:1554-1610):当编码后的名字过长、需要截断时,追加稳定哈希,避免“截断后同名前缀”碰撞
例如,旧布局中的长 key 可能落成 telegram%3A12345_0123ABCD....jsonl;而新布局则更像 users/telegram%3A12345/sessions/default.jsonl。
Schema 版本:CURRENT_SESSION_SCHEMA = 1(crates/octos-bus/src/session.rs:15-16),为未来格式迁移预留。
写入也分两类:
- 日常追加消息走
append_to_disk(),新文件先写 metadata,再逐条 append message 行(crates/octos-bus/src/session.rs:1312-1388、crates/octos-bus/src/session.rs:2992-3060) - 需要重写整个会话时走
rewrite(),使用 write-then-rename 的原子替换模式(crates/octos-bus/src/session.rs:1914-1963、crates/octos-bus/src/session.rs:2862-2987)
当前 rewrite 的临时文件名也做了并发修正:rewrite_tmp_path() 使用进程 PID + 单调 counter 生成 {target}.jsonl.{pid}-{seq}.tmp,避免同一个父 session 被多个后台子任务同时 rewrite 时共享单一 .tmp 文件(crates/octos-bus/src/session.rs:90-117)。这不是性能优化,而是正确性修复:共享 temp path 会让两个 writer 互相截断,最后只有一个 rename 成功,另一个子任务可能被误标成 orphaned。
10MB 文件限制:单个会话文件最大 10MB,防止失控的对话历史耗尽磁盘(crates/octos-bus/src/session.rs:792-793、crates/octos-bus/src/session.rs:1197-1208、crates/octos-bus/src/session.rs:1644-1649)。
11.3.3 /new Fork 机制
用户发送 /new 命令创建新会话时,底层对应的是 fork(parent_key, new_chat_id, copy_messages)(crates/octos-bus/src/session.rs:1972-2010)。它不是“新建一个空白会话”,而是:
- 从父会话复制最近
copy_messages条消息 - 记录
parent_key - 为新 key 重写一个新的 session 文件
这意味着 /new 在当前实现里更接近“带最近上下文的分支”,而不是“只继承配置、不带历史”。
11.3.4 SessionManager 与 LRU 缓存
SessionManager(crates/octos-bus/src/session.rs:903-905)管理 admin/命令侧看到的会话缓存;而真正在线处理消息时,SessionActor 会转而持有自己的 SessionHandle,避免所有活跃会话共用一个大锁(crates/octos-bus/src/session.rs:2426-2530)。
- LRU 内存缓存:活跃会话在内存中保持,减少磁盘 I/O
- 惰性加载:不活跃的会话按需从磁盘加载
- 布局兼容:同时扫描 legacy flat layout 和 per-user layout
- 用户列表性能边界:
list_top_level_sessions*()会跳过child-*与*.tasks等内部 topic,避免用户目录下大量后台子会话拖慢/api/sessions(crates/octos-bus/src/session.rs:965-1005、crates/octos-bus/src/session.rs:934-947) - 同 key 写入串行化:
persist_message_through_canonical_path()通过 per-key Tokio mutex 串行化SessionActor、ApiChannel与/chat路径的同 session 写入,避免多个独立SessionHandle同时看到相同长度并返回重复 sequence(crates/octos-bus/src/session.rs:2332-2420)
11.3.5 thread_id 持久化:新写入 fail closed,旧记录 load-time synthesis
AppUI 的聊天界面不再只是“一个 session 里顺序追加消息”,而是把消息分组成 thread。当前规则写在 derive_thread_id_for_new_write() 与 synthesize_thread_ids()(crates/octos-bus/src/session.rs:288(derive_thread_id_for_new_write)与 crates/octos-bus/src/session.rs:328(synthesize_thread_ids)):
| 路径 | User | Assistant / Tool | System |
|---|---|---|---|
| new write | client_message_id,缺失则 UUIDv7 | 必须由调用方预先 stamp thread_id,否则拒绝写入 | None |
| legacy load | 用旧 client_message_id 或 synth_{seq} 补齐 | 继承当前 thread,缺失时合成稳定 id | None |
这个分裂很重要。旧实现会在写 Assistant/Tool 时“回看最近一个 user”来推导 thread;在 rapid-fire 并发 turn 下,内存历史可能已经被另一个 sibling user 改写,导致 assistant 被归到错误气泡。新写入路径因此 fail closed:Assistant/Tool 没有预先绑定 thread_id 就返回错误,并打 octos_session_persist_total{outcome="rejected_unbound_assistant"} 指标(crates/octos-bus/src/session.rs:1676-1815、crates/octos-bus/src/session.rs:3061-3110)。
legacy JSONL 不能直接套用这个规则,因为历史文件本来没有 thread_id 字段。加载时的 synthesize_thread_ids() 只在内存里补齐旧记录,让旧 transcript 能按 thread 渲染;下一次新写入再进入 fail-closed 规则。也就是说,octos-bus 把“兼容历史数据”和“保护新写入正确性”拆成了两条路径。
11.3.6 durable commit observer:message/persisted 只在真正提交后发出
MessageCommitObserver 是 Session 层给 UI Protocol 的 durable commit hook(crates/octos-bus/src/session.rs:18-89)。add_message_with_seq() 的顺序是:
- 必要时给 User 派生
thread_id,或要求 Assistant/Tool 已经预绑定。 - 先
append_to_disk(),失败则直接返回。 - 再更新内存
Session::messages。 - 最后触发 observer,携带
SessionKey、Message和 committed sequence。
flowchart TD
Write["add_message_with_seq"] --> Stamp["stamp / validate thread_id"]
Stamp --> Disk["append_to_disk"]
Disk -->|fail| Err["return error<br/>no observer"]
Disk -->|ok| Memory["push in-memory mirror"]
Memory --> Observer["MessageCommitObserver"]
Observer --> UI["UI Protocol message/persisted"]
observer 失败或 panic 不会回滚消息,因为 durable commit 已经完成;它只是 best-effort fan-out(crates/octos-bus/src/session.rs:71-89)。这条边界能避免一个常见错误:不要把 message/persisted 理解成“准备写入”,它表示“这一行已经 durable visible”。octos-cli 的 UI Protocol 测试也锁定了这个顺序和去重行为(crates/octos-cli/src/api/ui_protocol_ledger.rs 等 ui_protocol_{transport,ledger,progress,task_output,reasoning_effort,alpha2_bridge,alpha9_bridge}.rs 拆分文件家族(legacy message/persisted 记账在 crates/octos-cli/src/api/ui_protocol_ledger.rs:294-330,恢复期跳过在 1269-1423))。
11.3.7 child-session contract:后台子任务不是只靠消息文本追踪
Session metadata 还持久化 ChildSessionContract,用于把 background spawn / subagent 的生命周期回写到父 session(crates/octos-bus/src/session.rs:519-546、crates/octos-bus/src/session.rs:560-584)。它记录:
task_id、task_labelparent_session_key、child_session_keyworkflow_kind、current_phaseterminal_state:Completed/RetryableFailure/TerminalFailurejoin_state:Joined/Orphanedfailure_action:Retry/Escalateoutput_files
这说明 child session 不是“几条普通消息加一个 parent_key”就结束了。父 session 需要 durable contract 来判断后台任务是否完成、是否 joined、失败后应重试还是升级。这个 contract 也解释了为什么 rewrite temp path 必须唯一:多个子任务可能同时把 terminal outcome 写回同一个父 session metadata。
11.4 Coalescing 源码走读
让我们深入 split_message() 的完整实现(crates/octos-bus/src/coalesce.rs:34-82),理解它如何在安全性和可读性之间取得平衡:
#![allow(unused)] fn main() { pub fn split_message(text: &str, config: &ChunkConfig) -> Vec<String> { if text.len() <= config.max_chars { return if text.is_empty() { vec![] } else { vec![text.to_string()] }; } let mut chunks = Vec::new(); let mut remaining = text; while !remaining.is_empty() { if chunks.len() >= MAX_CHUNKS { chunks.push(format!( "[message truncated - {} chars omitted]", remaining.len() )); break; } if remaining.len() <= config.max_chars { chunks.push(remaining.to_string()); break; } let mut limit = config.max_chars.min(remaining.len()); while limit > 0 && !remaining.is_char_boundary(limit) { limit -= 1; } let search = &remaining[..limit]; let break_at = find_break_point(search); chunks.push(remaining[..break_at].trim_end().to_string()); remaining = remaining[break_at..].trim_start_matches('\n'); if remaining.starts_with(' ') && !remaining.starts_with(" ") { remaining = &remaining[1..]; } } chunks } }
关键设计点:
- 提前返回:空字符串直接返回空
Vec,短消息返回单块 - 先做 UTF-8 安全窗口,再找语义断点:避免把
find_break_point()变成“逻辑断点 + 编码边界”双重职责 - 边界清洗:
trim_end()、去掉前导换行、最多跳过一个空格,让 chunk 之间的视觉边界更自然
窗口语义:先裁剪、再寻点
切割循环里最容易被忽略的是「窗口」这一步。split_message() 不是在整个剩余文本上找断点,而是先构造 search = &remaining[..limit] 这个不超过 max_chars 的搜索窗口,find_break_point() 只在这个窗口内做 rfind()(crates/octos-bus/src/coalesce.rs:72-73)。两层职责由此分开:窗口负责编码安全(is_char_boundary() 回退,crates/octos-bus/src/coalesce.rs:68-71),断点函数负责语义自然(`
→
→. ` → 空格 → 硬切,crates/octos-bus/src/coalesce.rs:87-118)。如果把两者混在一个函数里,每个候选断点都要重复做边界检查,而且很容易在「恰好卡在上限」的路径上漏掉一次。
另一个细节是 find_break_point() 对位置 0 的拒绝:rfind 找到的断点若等于 0 会被跳过,继续尝试下一级边界(crates/octos-bus/src/coalesce.rs:97-109)。这保证了向前推进:如果段落边界恰好是文本开头,切一刀切出空块,循环会永远停在原点。
切割之后的清洗也值得单独看:trim_end() 只作用于块尾,trim_start_matches('\n') 吃掉交界处累积的换行,然后「最多跳过一个前导空格、但两个空格不动」(crates/octos-bus/src/coalesce.rs:76-78)。两个空格在 Markdown 里意味着硬换行,跳过它会改变渲染语义;只跳一个空格是在「块间视觉干净」和「不破坏格式」之间取的折中。
去重与 Coalescing 的关系
Coalescing 解决「一条太长」,去重解决「一条收到两次」。Webhook 类平台(飞书、Twilio、企业微信)在超时重试时可能投递同一事件多次,MessageDedup 用容量 1,000、TTL 60 秒的 LRU 缓存记录已见过的消息 ID(crates/octos-bus/src/dedup.rs:12-25;is_duplicate 在 42-61 行)。Discord 网关重连后重放事件,也在 crates/octos-bus/src/discord_channel.rs:32 挂了同一个实例。两个机制正交:去重发生在 inbound 入口,切割发生在 outbound 出口,各管一段。入口不去重,一条重复消息会被切割成两倍量的块;出口不切割,任何一条超限消息直接被平台 API 拒收。
11.4.1 Unicode 安全的边界检测
find_break_point() 的硬切分支(第 5 级)使用了与 octos-core truncate_utf8 相同的字符边界回退算法:
#![allow(unused)] fn main() { // 硬切:从 max_len 向前回退到安全的 UTF-8 字符边界 let mut limit = max_len; while limit > 0 && !text.is_char_boundary(limit) { limit -= 1; } limit }
这保证了即使在中文、日文、emoji 等多字节字符的任意位置切割,也不会产生无效的 UTF-8 序列。考虑一个包含中文和 emoji 的消息在 Telegram(4000 字符限制)中的切割场景。没有这个保护,切割点可能恰好落在一个 4 字节的 emoji 中间,导致后续的 API 调用因为无效 UTF-8 而失败。
11.5 频道实现概览
octos-bus 通过 feature flags 按需编译各频道实现。每个频道实现 Channel trait 的具体方法:
| 频道 | 连接方式 | 特殊能力 |
|---|---|---|
| Telegram | Long polling (teloxide) | 消息编辑、AtomicBool 优雅关停 |
| Discord | WebSocket gateway (serenity) | 消息去重(MessageDedup) |
| Slack | WebSocket (tokio-tungstenite) | Block Kit 格式支持 |
| Node.js bridge WebSocket | Baileys bridge、独立桥接进程 | |
| IMAP/SMTP (async-imap + lettre) | 异步收发、附件 | |
| 飞书 / Lark | WebSocket / webhook + axum | 加密消息验证、区域化 endpoint |
| Twilio | Webhook/API (axum) | SMS/MMS/RCS/WhatsApp Business |
| 企业微信 | HTTP webhook (axum) | 加密消息 |
| Matrix | HTTP API | AppService 模式、多用户桥接 |
| WeCom Bot | WebSocket long connection | 群机器人通道 |
| QQ Bot | WebSocket gateway | Official QQ Bot API v2 |
| WeChat bridge WebSocket | wechat-bridge 子进程接入 | |
| API | REST / UI Protocol WebSocket / 兼容事件 hook (axum) | 编程式接入与 AppUI 控制面 |
| DingTalk | 自定义机器人 webhook 发送 + outgoing-robot webhook 接收(axum) | 缓存会话级 sessionWebhook 回信 |
| LINE | Webhook 接收 + Messaging API 发送(axum) | HMAC-SHA256 签名校验 |
| Matrix User | Client-Server /sync 长轮询(reqwest) | 用户账号模式,无需 AppService 注册;随 matrix feature 编译 |
| CLI | stdin / stdout | 本地测试通道;无独立 feature,始终编译 |
表中较晚接入的四个频道值得各花一段说清定位。
DingTalk(crates/octos-bus/src/dingtalk_channel.rs,544 行)走「自定义机器人发送 + outgoing-robot webhook 接收」的双通道模式。关键在 sessionWebhook:outgoing 事件里携带一个短期有效的会话级 webhook URL,频道按会话缓存它,回复优先发往这个缓存地址而不是全局配置的 webhook(crates/octos-bus/src/dingtalk_channel.rs:281、发送优先级判定 188-199;无缓存且无配置时报错于 196 行)。这样设计是因为钉钉的 outgoing 模式下,回复必须回到「发起这次对话的那个机器人会话」,全局地址做不到这一点,而短期 URL 又要求缓存失效后能回退。
LINE(crates/octos-bus/src/line_channel.rs,826 行)是标准的 webhook + Messaging API 组合,安全链路在签名校验:对原始请求体做 HMAC-SHA256,Base64 后与 X-Line-Signature 头做常数时间比较(crates/octos-bus/src/line_channel.rs:87-95,ct_eq 来自 subtle crate)。用常数时间比较而不是 ==,是防时序侧信道逐字节猜签名;用原始 body 而不是反序列化后的字符串,是防 JSON 规整化差异导致验签失败。这两个选择都是平台集成里反复踩坑后的标准答案。
Matrix User(crates/octos-bus/src/matrix_user_channel.rs,1,525 行)与第 11.5 节表格里的 Matrix 行是同一平台的两种接入形态。AppService 模式需要 homeserver 侧注册,用户账号模式只需要 access token 或密码登录,然后对 Client-Server /sync API 做长轮询,超时 30 秒(crates/octos-bus/src/matrix_user_channel.rs:44 的 SYNC_TIMEOUT_MS)。长轮询让没有推送权限的自建服务器也能近实时收消息:请求挂着,有事件才返回,返回后立刻发下一个。它没有独立的 Cargo feature,随 matrix 一起编译,因为两者共享 Markdown-to-HTML 渲染和路径编码等基础设施。
CLI(crates/octos-bus/src/cli_channel.rs,137 行)是最小实现样本:stdin 按行读、stdout 写提示符(crates/octos-bus/src/cli_channel.rs:52-57),没有网络栈、没有鉴权、没有 feature 门控,始终编译。它存在的意义是让 Channel trait 的最小契约可验证:一个频道只需实现 name()、start()、send() 三个方法就能跑通全链路,也是新频道开发时最便宜的参照物。
每个频道实现都是独立的——Telegram 频道的 bug 不会影响 Discord,因为它们是不同的代码路径,通过不同的 feature flag 编译。需要注意的是,octos chat 的 CLI readline 不在 octos-bus 的 Cargo feature 频道列表中;bus 侧 feature-gated 频道当前为 api、telegram、discord、slack、whatsapp、email、feishu、dingtalk、line、twilio、wecom、matrix、wecom-bot、qq-bot、wechat 共 15 个,其中 crates/octos-bus/src/dingtalk_channel.rs 与 crates/octos-bus/src/line_channel.rs 是较新的接入;crates/octos-bus/src/matrix_user_channel.rs 没有独立 feature,随 matrix 一起编译;crates/octos-bus/src/cli_channel.rs 始终编译,三类合计 17 个频道源文件。这种隔离设计是 octos-bus 约 40K 行 Rust 源文件中大部分来自各频道独立实现的原因。
工程决策侧栏:为什么 JSONL 而非 SQLite / 单 JSON 文件
方案一:SQLite
优势:结构化查询、索引、事务、跨会话分析方便 劣势:需要显式 schema / migration 层;会话文件不再能直接用文本工具检查;和当前“每个会话按 key 读写”的访问模式相比,抽象更重
方案二:单个 JSON 文件
优势:实现最直观,序列化/反序列化简单 劣势:任何一次写入都要重写整个文件;崩溃恢复最脆弱;多会话下最容易形成热点锁
方案三:JSONL(octos 的选择)
优势:
- 第一行 metadata、后续逐行消息,既能 append,也能整会话 rewrite
- 易于保留 legacy flat layout 与 per-user layout 两套路径
- 每个会话一个文件,更贴合“按 session key 读写”的访问模式
- 备份、迁移、排障都可以直接在文件系统层面完成
劣势:
- 无索引,跨会话查询需要扫描所有文件
- 没有数据库级事务;复杂查询能力弱
选择理由: 从当前源码看,octos 的会话访问模式几乎总是“按 key 读取一个 session、append 新消息、必要时 rewrite 整个 session、按需迁移布局”。JSONL 正好覆盖这些路径,而且能自然配合 SessionActor 的 per-session file ownership。
11.6 Session 持久化的工程细节
11.6.1 FNV-1a 哈希
在 legacy flat layout 里,当编码后的 SessionKey 需要截断时,文件名会追加 FNV-1a 64-bit 哈希后缀(crates/octos-bus/src/session.rs:116-127、crates/octos-bus/src/session.rs:1554-1610)。这是一个非密码学哈希函数,优势在于实现极简且跨 Rust 版本稳定。它不用于安全目的(不防碰撞攻击),只用于“截断后文件名仍然可区分”。
#![allow(unused)] fn main() { fn fnv1a_64(data: &[u8]) -> u64 { let mut hash: u64 = 0xcbf29ce484222325; // FNV offset basis for &byte in data { hash ^= byte as u64; hash = hash.wrapping_mul(0x100000001b3); // FNV prime } hash } }
11.6.2 Percent-encoding
encode_path_component()(crates/octos-bus/src/session.rs:129-140)将 SessionKey 中的特殊字符编码为 URL 安全格式。这防止了 telegram:12345 这样的 key 被文件系统解释为目录路径(因为 : 在某些文件系统中是特殊字符)。
11.6.3 write-then-rename 原子性
整会话 rewrite() 路径的原子性通过两步操作实现:
- 写入唯一临时文件
{session_file}.{pid}-{seq}.tmp rename()临时文件为正式文件
在 Unix/Linux 上,rename() 是原子操作——要么完全成功(新文件替换旧文件),要么完全失败(旧文件保持不变)。即使进程在 rename() 之前崩溃,也只会留下一个孤立的 .tmp 文件,不影响正式会话文件。
11.7 本章回顾
- Channel trait:当前是 26 方法接口,但只有
name()、start()、send()没有默认实现;流式编辑、typing、embed、health check 都是按需覆盖的扩展层。 - Coalescing:5 级语义切割(段落→换行→句子→空格→硬切),MAX_CHUNKS=50 防 DoS,UTF-8 安全,超限时会追加独立的 truncation chunk。
- Session:JSONL 文件第一行是 metadata,不是消息;当前同时兼容 legacy flat layout 和 per-user layout,
/new/fork 会复制最近 N 条消息并记录parent_key。 - Thread binding:新写入路径要求 Assistant/Tool 预先绑定
thread_id,legacy load 则只做内存补齐;bound channel API 防止并发 turn 的流式增量落到错误气泡。 - Durable observer 与 child contract:
message/persisted在磁盘写入和内存 mirror 更新后发出;后台子任务通过ChildSessionContract持久化 terminal/join/failure 状态,而不是只靠普通消息文本。
延伸阅读
- Server-Sent Events:MDN "Using server-sent events" — 理解流式消息推送模式
- Telegram Bot API:https://core.telegram.org/bots/api — Telegram 频道的 API 细节
- JSONL 格式:https://jsonlines.org/ — 行分隔 JSON 格式规范
思考题
- 频道抽象的边界:某些频道支持富文本(Slack Block Kit、飞书 Rich Text),但
Channel::send()只接受纯文本。你会如何扩展 trait 以支持富文本,同时保持向后兼容? - 会话恢复:如果 octos 进程崩溃,JSONL 文件的最后一行可能不完整。你会如何实现崩溃恢复?
版本演化说明 本章分析基于
../octos当前 main 分支源码。octos-bus crate 位于crates/octos-bus/src/;相较早期版本,本章已更新 thread-bound streaming、per-user session layout、durable commit observer、child-session contract、per-key persist lock 与唯一 rewrite temp path 等主分支语义。
版本演化说明
本章基线为 octos main @
9c157101(2026-09-03 核对)。相对旧稿的“约 30K 行、14 频道”口径:crate 现为约 40K 行(40,937 行,频道文件 18,207 行、共 17 个),新增 DingTalk、LINE 频道与 Matrix User 用户账号模式;crates/octos-bus/src/session.rs扩至 3,417 行,SessionHandle家族集中在约 2400-3100 行段;octos-cli侧原crates/octos-cli/src/api/ui_protocol.rs已拆分为 transport、ledger 等 8 个文件(7 个实现文件 + 1 个测试文件crates/octos-cli/src/api/ui_protocol_tests.rs)。本章行号引用以该基线为准。
crates/octos-bus/src/session.rs 的行号漂移不是简单膨胀,而是所有权模型重构的结果。旧稿写作时,读写都汇聚在 SessionManager 的大锁上;现在的代码把在线路径拆给 SessionHandle(crates/octos-bus/src/session.rs:2329 起,集中在约 2400-3100 行段),SessionActor 每会话持有独立 handle,跨会话零锁竞争(struct SessionHandle 上方的 doc 注释写明这一动机)。原 manager 退到两件事上:admin 侧列表查询,以及 persist_message_through_canonical_path()(2388 行)这类需要 per-key Tokio mutex 串行化的跨路径写入口(锁表在 2360 行)。文件变长,主要是迁移状态机(2441-2460 行的三种真实情形)和 child-session contract 带来的持久化分支,不是无序生长。
第 12 章:并发模型:三层调度的生产实现
定位:本章讲 octos 的并发模型如何分三层协同:Tokio 层的 session actor、信号量限流与工具批次并发;supervisor 层把长程编排做成重启幸存的事件账本与续跑调度;peer/lease 层用进程隐喻和租约管理多 agent。前置依赖:第 5 章(Agent Loop)、第 10 章(Harness 与事件 ABI)。适用场景:想看 Rust 异步并发在生产系统里如何落地的开发者,以及需要调优 gateway 并发参数的运维者。fleet 状态机细节详见第 16 章,goal/peer 编排详见第 18 章。
12.1 从一组数字说起
把并发相关源码按职责切开数一遍:17 个文件、74692 行、722 个公开符号,分成三层。
| 层 | 文件数 | 行数 | 关键符号数 | 代表文件 |
|---|---|---|---|---|
| ① Tokio 层(session actor / 信号量 / 工具并发 / 关停) | 5 | 25154 | 170 | crates/octos-cli/src/session_actor.rs 10094 行、crates/octos-agent/src/agent/execution.rs 4730 行 |
② supervisor 层(autonomy/ 十文件) | 10 | 45544 | 454 | crates/octos-cli/src/autonomy/agent_orchestrator.rs 33639 行、crates/octos-cli/src/autonomy/supervisor_store.rs 3277 行 |
| ③ peer/lease 层 | 2 | 3994 | 98 | crates/octos-cli/src/peers/mod.rs 3186 行、crates/octos-fleet/src/records.rs 808 行 |
三个数字说明一件事:并发在 octos 里早已越过「怎么用 Tokio」的阶段。Tokio 层只占三分之一行数,真正的大头是 supervisor 层:4.5 万行代码在解决一个 Tokio 本身不管的问题:进程重启之后,编排状态怎么办。
flowchart TB
subgraph L3["③ peer/lease 层"]
PEERS["crates/octos-cli/src/peers/mod.rs<br/>peer 暂存与寻址"]
LEASE["crates/octos-fleet/src/records.rs<br/>Lease / Attempt 状态机"]
end
subgraph L2["② supervisor 层(autonomy/,45544 行)"]
STORE["SupervisorStore<br/>JSONL 事件账本 + snapshot"]
SCHED["MasterContinuationScheduler<br/>dedupe + 优先级续跑"]
ORCH["InProcessAgentOrchestrator"]
WAKE["crates/octos-cli/src/autonomy/fleet_wake.rs<br/>outbox 消费者"]
MON["crates/octos-cli/src/autonomy/monitor_runtime.rs<br/>零 token 监视器"]
end
subgraph L1["① Tokio 层(25154 行)"]
GW["crates/octos-cli/src/commands/gateway/gateway_runtime.rs<br/>信号量限流"]
REG["ActorRegistry<br/>会话生命周期"]
ACTOR["SessionActor ×N<br/>每会话一个 tokio task"]
TOOLS["crates/octos-agent/src/agent/execution.rs<br/>工具批次并发"]
TS["TaskSupervisor<br/>spawn_only 状态"]
end
GW -->|"Semaphore permit"| REG --> ACTOR
ACTOR --> TOOLS --> TS
TS -->|"lifecycle 事件"| STORE
SCHED -->|"续跑派发"| ACTOR
ORCH --> TS
STORE --> SCHED
WAKE --> SCHED
MON -->|"External continuation"| SCHED
ORCH --> PEERS
PEERS --> LEASE
本章按这个分层走:先讲 Tokio 层的会话状态所有权与并发分界(12.2–12.5),再讲 supervisor 层为什么存在、怎么落地(12.6),最后交代 peer/lease 层作为并发原语的角色(12.7),12.8 收在优雅关停。
12.2 Tokio 层:会话 actor 与分层 spawn
单用户 CLI 模式下 Agent 顺序执行,没有并发问题。Gateway 或 Serve 模式下,多个用户同时发消息,每个会话还夹杂取消、后台子任务结果与状态投递。octos 的答案不是给共享状态加锁,而是 session actor:每个会话由一个长期存活的 tokio 任务独占管理,核心状态只有这一个 owner。
入口在 ActorRegistry(crates/octos-cli/src/session_actor.rs:2524)。它持有 actor 表、工厂和一把信号量;新会话到来时创建 actor 并 tokio::spawn(actor.run())(crates/octos-cli/src/session_actor.rs:4122)。actor 本体 struct SessionActor 在 crates/octos-cli/src/session_actor.rs:4455,持有会话键、工具注册表、工作目录,以及两个关停标志(12.8 展开)。外部世界通过 ActorHandle(crates/octos-cli/src/session_actor.rs:2502)向 actor 的信箱发消息,信箱消息就是 ActorMessage 枚举(crates/octos-cli/src/session_actor.rs:2443):
#![allow(unused)] fn main() { pub enum ActorMessage { /// A user message to process. Inbound { message: InboundMessage, /* … */ }, BackgroundResult { /* … */ }, TaskStatusChanged { /* … */ }, ApprovalExpired { request_id: String }, Cancel, } }
五种消息覆盖了 actor 要响应的全部事件类别:新输入、后台子任务结果、任务状态变化、审批过期、取消。同一会话的两条消息不可能并发修改历史,因为它们都在同一个 actor 的信箱里排队,由 actor 依次处理。
分层 spawn 是这层的主干。tokio::spawn() 在源码里出现在四个层级:
- 会话级:
ActorRegistry为新 session 起 actor,长期存活(crates/octos-cli/src/session_actor.rs:4122)。 - 消息级:actor 把当前消息的主 Agent 调用派生成独立任务,自己继续轮询信箱,随时能接住
Cancel与后台结果。 - 工具级:单轮多个 tool call 时每个工具各起一个任务(
crates/octos-agent/src/agent/execution.rs:701),再按批次策略聚合(12.4)。 - 后台级:
spawn工具的 background 模式起长期子 Agent,可见状态交给TaskSupervisor(12.5)。
gateway 侧还有两个辅助 spawn:Ctrl+C 信号处理(crates/octos-cli/src/commands/gateway/gateway_runtime.rs:1653)与系统提示词的异步刷新(crates/octos-cli/src/commands/gateway/gateway_runtime.rs:1713)。派发参数统一由 DispatchParams(crates/octos-cli/src/session_actor.rs:86)从 gateway 传到 actor。
并发与串行的分界因此非常清楚:跨会话完全并发(每个 actor 一个任务);会话内串行(信箱排队);单轮工具调用按批次准入策略选择并行或串行;后台任务脱离主对话流,靠状态回投汇合。
工程决策:共享 Mutex、spawn-per-message 与 Session Actor
三种方案的取舍:
- 共享
Mutex<SessionState>:实现最快,但锁粒度随状态膨胀,一条消息持锁跨 await 会把其他会话的消息也堵住,取消信号更难插队。旧稿 v1 时代曾按「per-session Mutex 序列化」描述,当前源码已不是这个模型。- spawn-per-message(每条消息一个无状态任务):并发度最高,但会话历史、工具注册表、工作区必须外置到共享存储,状态一致性重新变成锁问题。
- Session Actor:状态所有权唯一,消息天然排队,取消只是信箱里的一条
Cancel。代价是 actor 自身要管理生命周期(空闲回收、死亡重生时保留 profile 覆盖,见ActorHandle字段注释)。octos 选第三种,代价被
ActorRegistry集中承担,业务侧只面对ActorMessage。
12.3 信号量限流:max_concurrent_sessions
会话数量要有上界。gateway 启动时创建信号量,容量直接取配置:
#![allow(unused)] fn main() { // Semaphore to bound concurrent session processing let concurrency_semaphore = Arc::new(Semaphore::new(gw_config.max_concurrent_sessions)); }
位置在 crates/octos-cli/src/commands/gateway/gateway_runtime.rs:1731-1732,紧随其后就把这把信号量交给 ActorRegistry::new。上限来自配置项 max_concurrent_sessions,serde 声明在 crates/octos-cli/src/config.rs:1548-1549,默认值函数 default_max_concurrent_sessions() 在 crates/octos-cli/src/config.rs:1633,函数体返回字面量 10(本会话亲测)。启动日志会打印这个值(crates/octos-cli/src/commands/gateway/gateway_runtime.rs:1666)。
permit 的消费点不在 gateway,而在 actor 侧的消息处理入口:crates/octos-cli/src/session_actor.rs:7485 与 crates/octos-cli/src/session_actor.rs:9293 两处 self.semaphore.acquire().await。这个位置决定了排队行为:超过 10 个会话同时在处理消息时,第 11 个会话的消息任务在 acquire() 上挂起等待,而不是被拒绝;actor 本身还活着、还能收消息,只是新消息的处理被信号量挡住。等某个活跃会话的处理结束、permit 释放,排队中的下一个自动获得执行权。
把限流放在消息处理层而非会话创建层,是一个有意识的选择:空闲会话不占并发额度,10 个名额全部留给真正在跑 LLM 调用的会话。
12.4 工具并发:批次准入三策略
单轮 LLM 响应包含多个 tool call 时,execute_tools(crates/octos-agent/src/agent/execution.rs:2483,可见性为 pub(super),模块内部 API)不盲目并行。每个工具通过 concurrency_class 报告自己是 Safe(只读、无副作用)还是 Exclusive(写操作),执行器按整批构成选三种策略之一(模块文档 crates/octos-agent/src/agent/execution.rs:7-26):
- All-Safe 批:每个调用
tokio::spawn成独立任务(crates/octos-agent/src/agent/execution.rs:701),futures::future::join_all聚合并保持调用顺序(crates/octos-agent/src/agent/execution.rs:2992)。 - All-Exclusive 批:按 LLM 调用顺序串行执行;首个错误(含 hook 拒绝与 panic)之后剩余调用跳过,各自收到合成的「因兄弟任务出错而取消」结果,保证每个
tool_call_id都有应答。 - 混合批(#1766):第一阶段并行跑所有 Safe 调用,第二阶段按原顺序串行跑 Exclusive 调用,最终按原始调用顺序重组结果。
并行路径还带统一超时:join_parallel_handles(crates/octos-cli 无关,位于 crates/octos-agent/src/agent/execution.rs:2959 起)对每个 handle 施加同一 deadline,超时先 abort(),再给一段有界的清理宽限等待,然后返回合成超时结果。LLM 看到的永远是一一对应的工具结果列表,并行细节不外泄。
两道闸(信号量与批次准入)解决的是不同粒度的过载,判据也不同。信号量闸的判据是资源:LLM 调用贵且慢,10 个并发会话大致对应上游配额与内存的安全水位,超了就该排队,排队无害。批次准入的判据是语义:并行本身没有成本问题,问题是 Exclusive 工具之间有顺序依赖(同一文件的写与写、写与读),乱序执行的结果不是慢而是错。所以信号量用「等待」处理超限,批次准入用「降级为串行」处理冲突,一个是背压,一个是正确性防御。把两者混在一处会出现经典误判:有人看到工具串行执行以为是并发不够,实际是批次里有 Exclusive 成员在保护语义。判别方法是看批次的构成:全 Safe 批永远并行,出现串行必有 Exclusive 参与(#1766 之前混合批整体串行,之后只串行必要部分,这是那次优化的全部内容)。
子 Agent 的并发形态由 spawn 工具的 mode 参数决定:"background"(默认,crates/octos-agent/src/tools/spawn.rs:1929 的 default_mode())或 "sync"(crates/octos-agent/src/tools/spawn.rs:1855 的字段定义,JSON schema 在 :3004)。sync 模式阻塞当前轮次等子 Agent 完成,结果直接进本轮上下文;background 模式把子 Agent 剥离主对话流,完成后以 BackgroundResult 消息回到 actor 信箱。前者适合「下一步依赖子任务产出」的场景,后者适合「发了就不用管、晚点收结果」的场景。
12.5 spawn_only 生命周期:TaskSupervisor
后台任务不是 fire-and-forget。TaskSupervisor(首行文档「Background task lifecycle management for spawn_only tools.」,crates/octos-agent/src/task_supervisor.rs:1)是一个状态库:跟踪 spawn_only 任务从派生到终态,并明确声明自己不执行 workspace contract:契约校验先发生,校验通过才更新 supervisor 状态(模块文档前六行)。运行状态用 TaskStatus(crates/octos-agent/src/task_supervisor.rs:140):
#![allow(unused)] fn main() { pub enum TaskStatus { Spawned, Running, Completed, Failed, Cancelled, // 用户取消,终态(:173 is_terminal) Parked, // #27c:serve 重启后的孤儿任务,等待客户端重连收养 } }
六个状态里 Parked 最特殊:它既非活跃(is_active 只含 Spawned/Running,crates/octos-agent/src/task_supervisor.rs:164)也非终态(crates/octos-cli 不涉及;is_terminal 在 crates/octos-agent/src/task_supervisor.rs:173),因为任务的工作成果(staged peer 的 brief 与 worktree)还在磁盘上,客户端重连后 mark_running 能把它救活。这正是「状态要不要持久化」影响并发语义的直接例子。
防失控有硬上限:每父会话最多 200 个注册子任务(MAX_CHILDREN_PER_PARENT,crates/octos-agent/src/task_supervisor.rs:43),可用环境变量 OCTOS_MAX_CHILDREN_PER_PARENT 在进程启动时覆盖(:41 的文档与 :77 的读取)。超限注册返回 RegisterTaskError(crates/octos-agent/src/task_supervisor.rs:90)。
对外的生命周期投射是另一个枚举 TaskLifecycleState(crates/octos-agent/src/task_supervisor.rs:243):Queued、Running、Verifying、Ready、Failed、Cancelled。BackgroundTask::lifecycle_state()(crates/octos-agent/src/task_supervisor.rs:363)把内部 TaskStatus 翻译成这组面向调用方的状态。内部状态机关心「worker 还在不在」,投射状态关心「产出可不可信」,Verifying 这一态就是给 workspace contract 校验留的位置。两套状态的映射关系如下:
内部 TaskStatus | 投射 TaskLifecycleState | 语义分界 |
|---|---|---|
| Spawned | Queued | 已注册,worker 未起 |
| Running | Running | worker 在跑 |
| Completed | Ready(经 Verifying) | 校验通过,产出可信 |
| Failed | Failed | 执行失败 |
| Cancelled | Cancelled | 用户主动取消 |
| Parked | Cancelled(#27c 复用空闲投射槽,crates/octos-agent/src/task_supervisor.rs:373) | 等待重连收养,非终态非活跃 |
这个投射有两个消费面。MCP 侧:octos mcp-serve 只暴露一个工具 run_octos_session(crates/octos-agent/src/mcp_server.rs:66),会话派发通过 SessionLifecycleObserver::mark_state(crates/octos-agent/src/mcp_server.rs:145)上报 Queued → Running → Verifying → Ready/Failed 的迁移(trait 文档在 :142-144);外层 MCP caller 最终收到的是会话级聚合结果(final_state: TaskLifecycleState,:108),不是内部工具事件流。Harness 侧:McpServerCall 事件携带 outcome 字段,取值 ready/failed/queued/running/verifying,与 TaskLifecycleState 一一对应(crates/octos-agent/src/harness_events.rs:651-652,事件变体定义见 :397 附近),后台任务的生命周期由此进入 harness events 与 metrics,成为 operator 可观测面的一部分。
12.6 supervisor 层:重启幸存的长程编排
Tokio task 活在进程里,进程一死全部蒸发。对「跑十分钟的工具调用」这无所谓;对「今天要把这本书二十一章写完」这种跨小时跨天的目标,编排状态必须比进程活得久。supervisor 层的 45544 行代码都在回答这一个问题。
工程决策:为什么把长程编排从 Tokio task 提升为持久化 supervisor
一个长程目标(比如派 5 个 peer 各写一章)天然是多次进程生命周期的叠加:master 会重启,peer 会跑很久,结果会晚到。如果编排状态只存在于内存里的 task 树,重启即失忆,唯一的恢复手段是让人重新下达指令。
提升为持久化 supervisor 后,三件事变了:状态落盘为事件账本,重启后重放恢复;「谁该被唤醒继续跑」从隐式的 task 依赖变成显式的续跑队列;长任务的完成不再要求任何进程持续存活。代价是所有状态迁移都要写成事件、都要处理重放幂等,这正是
crates/octos-cli/src/autonomy/supervisor_store.rs与crates/octos-cli/src/autonomy/master_continuation_scheduler.rs加起来近五千行的原因。
事件账本:SupervisorStore
SupervisorStore(crates/octos-cli/src/autonomy/supervisor_store.rs:697)管理四个路径:事件文件、轮转事件文件、snapshot 与锁。事件文件名固定 supervisor-events.jsonl(常量在 :25),压缩后保留单代 .old(:33),且 .old 只用于取证、永不重放;降级到不认识 snapshot 的旧二进制时,它看到的会是截断视图,注释里把这个兼容性代价写得很直白。
崩溃恢复的设计核心在 load_state(crates/octos-cli/src/autonomy/supervisor_store.rs:780)里,只有十几行:先读 snapshot(若有),取其 last_sequence 作为水位线;再读账本行,只重放 row.sequence > snapshot_last_sequence 的尾部行(:780-793)。重放靠 apply_ledger_row(:381)把每行事件应用到状态机,事件以 serde 的 type/payload 标签自包含(:300-304 的注释解释了为什么变体按值携带完整记录:重放不需要热路径上的装箱优化)。三个设计决策撑起这套重放:
第一,快照节奏是行数触发而非时间触发。SNAPSHOT_EVERY_APPENDS = 512(crates/octos-cli/src/autonomy/supervisor_store.rs:49),常量注释给出了取值依据:512 行让启动重放尾巴保持在几百 KB、毫秒级解析,同时把全量序列化加 fsync 的代价摊到数百次 append 上;计数对象是账本里的行数而非单进程的 append 次数,多个写者共享一个账本时仍会在尾巴越线时统一压缩。
第二,last_sequence 是单调水位线而不是计数器。apply_ledger_row 每应用一行就取 max(:381-382),load_state 装载时也对 snapshot 与状态里的水位线取 max(:784)。这意味着乱序到达或多写者交错写入的行不会让水位线回退,snapshot 只需声明「截至哪个序号」就能安全截断重放。
第三,.old 一代轮转是取证妥协而不是恢复路径。压缩把已应用的行移入 .old 后,旧二进制(不认识 snapshot 的 pre-#1974 构建)重放 live 尾巴会得到截断视图;宁可让降级者看到少,也不让任何版本误读,因为认识 snapshot 的构建会拒绝 schema_version 更新的快照(load_snapshot,:876)而不是猜着解析。
写侧的四个方法在这套读语义上闭合:write_snapshot(:905)、snapshot_now(:920,带锁的强制压缩入口)、append_event(:935,写入同时按水位线判断是否触发压缩)。账本因此是一个「append 快、恢复有界」的结构:正常运行时每次事件只是一次追加写,崩溃后恢复成本被 512 行的尾巴钉死在上界。
续跑调度:MasterContinuationScheduler
账本记录「发生了什么」,调度器决定「接下来谁跑」。MasterContinuationScheduler(crates/octos-cli/src/autonomy/master_continuation_scheduler.rs:477)内部是一个 BinaryHeap 加一个按 dedupe key 索引的 pending_by_key 表(:506 起的 impl)。每个续跑请求带身份与去重键:MasterContinuationId(:99)、MasterContinuationDedupeKey(:112)、MasterContinuationRequest(:221)。
续跑原因是有优先级的:MasterContinuationReason(:137)枚举 ChildCompleted、ScatterJoinComplete、LoopFire、GoalContinue、GoalWrapUp 与 External(String),映射到 MasterContinuationPriority(:186):LoopFire 最高,泛化 External 唤醒最低,注释明说这是「刻意压低,除非未来接线把它映射到类型化原因」。结构体里还有一个 TOCTOU 防护字段 recently_claimed_external:同键的 External 续跑在窗口内重复入队会被拒绝,防止一次终态迁移被两条路径重复投递(:477 结构体字段文档)。去重键加优先级堆,就是「turn 结束后再创造 turn」的并发骨架;第 5 章从 loop 视角看过它的消费侧,完整语义属于第 18 章。
outbox 唤醒与零 token 监视器
唤醒有两条外部通路。crates/octos-cli/src/autonomy/fleet_wake.rs 的后台消费者 spawn_fleet_outbox_consumer(crates/octos-cli/src/autonomy/fleet_wake.rs:343)用 tokio::spawn 起一个每 3 秒的 interval 循环(常量 FLEET_WAKE_INTERVAL_SECS,:59),反复调用 drain_fleet_outbox_once(:235)把 fleet 内核 outbox 里的唤醒提交转成 keeper 的续跑请求。轮询而非推送,换来的是消费端崩溃后下一 tick 自动恢复,不丢唤醒。
crates/octos-cli/src/autonomy/monitor_runtime.rs(引入于 commit c4f03647,#1977/#1988,2026-08-12)提供零 token 的事件监视器:MonitorMode(crates/octos-cli/src/autonomy/monitor_runtime.rs:72)只有两态,Poll 模式按 interval_secs 周期执行探针命令、对 stdout 过滤后以「变化」为事件,Stream 模式跟随进程 stdout 逐行产出。监视器不花模型 token,只有观测变化时才通过 External 续跑唤醒 master,把「等一个外部条件」从对话循环里剥了出去。
AgentOrchestrator:编排器的 RPC 面
多 agent 操作统一在 AgentOrchestrator trait(crates/octos-cli/src/autonomy/agent_orchestrator.rs:606):list_agents、read_agent_status、read_agent_output、list_agent_artifacts 是必选方法,spawn_agent(:662)、send_input(:675)、wait_agent(:688)、resume_agent(:699)四个带默认实现,默认全部返回 method_not_supported_error。也就是说 trait 定义了完整的 RPC 面,但并非每个实现都接线了全部操作,读代码时不能假设四个生命周期方法处处可用。
生产实现是 InProcessAgentOrchestrator(:746,内部 impl 块从 :1451 起,trait 实现块在 :9462)。它把 TaskSupervisor 的后台任务镜像成 agent 状态,核心路径 run_native_specialist(crates/octos-cli/src/autonomy/agent_orchestrator.rs:2471)串起完整一次受监督专家执行:注册 native agent、运行子 Agent、推送 output 与 artifact、最后回写 supervisor。crates/octos-cli/src/autonomy/goal_loop_runtime.rs(1562 行)则提供 goal 与 loop 的调度策略原语:GoalId(crates/octos-cli/src/autonomy/goal_loop_runtime.rs:10)、GoalRuntimePolicy(:239,含 cadence 与 max_continuations)、GoalRuntimeState(:265,Active/Paused/Completed/Failed)。
诚实边界:当前实现是「一个 master 监督一组受管子 agent、按事件续跑」的编排器,不是任意互联、实时对话的 multi-agent society。RPC 面的存在容易让人高估它,四个默认返回错误的方法就是提醒。
sequenceDiagram
participant W as 子任务/fleet/monitor
participant ST as SupervisorStore
participant SC as MasterContinuationScheduler
participant MA as master 会话
W->>ST: append_event(终态/唤醒事件落盘)
ST->>ST: snapshot + 压缩(超阈值时)
ST->>SC: 事件转续跑请求(dedupe key)
SC->>SC: 按优先级入堆,窗口内重复键拒绝
SC->>MA: 就绪续跑出队,派发新 turn
MA-->>ST: 新 turn 产生的新事件
Note over ST,MA: 进程重启后:load_state 重放账本<br/>+ snapshot,续跑队列恢复
12.7 peer/lease 层:进程隐喻与租约
第三层解决「多个 quasi-agent 并存」的隔离与恢复。crates/octos-cli/src/peers/mod.rs 的首行文档是「Peer-agent staging, addressing, and parked-prompt plumbing.」(crates/octos-cli/src/peers/mod.rs:1),第二段注明它是从 api 门控的 ui_protocol 树里逐字提升出来的(Phase 3 peer-goal 提取,只改模块位置与可见性,逻辑未动)。peer 的并发原语角色体现在 PeerTaskBinding(:166):把一个 peer 绑定到一条 TaskLivenessLease 上,绑定与退役走 bind_peer_supervised_task(:241)等函数,peer 的存活因此与 supervisor 的租约一致。
fleet 侧的并发原语是两个小结构体。Lease(crates/octos-fleet/src/records.rs:250)只有 owner_epoch 与 expires_at_ms 两个字段:daemon 每次启动拿到新 epoch,外来 epoch 或已过期的租约会由 recovery reconciliation 回收。一句话,重启后的旧主人自动失权。Attempt(:256)给一次执行尝试建档案,fleet id 冗余存储,使 (child_id, attempt_id) 就能定位 child 行。
peer 与 subagent 的差异可以用一张对比图说清:
flowchart TB
subgraph SA["subagent ≈ 线程"]
A1["tokio::spawn 于父进程内"]
A2["生命周期 = 进程生命周期"]
A3["状态在 TaskSupervisor 内存+账本"]
end
subgraph PE["peer ≈ 进程"]
B1["独立工作目录 / 独立会话树"]
B2["TaskLivenessLease 租约保活"]
B3["serve 重启后 Parked,可重连收养"]
end
A1 --> A2 --> A3
B1 --> B2 --> B3
subagent 像线程:共享进程命运,轻量,随父进程蒸发。peer 像进程:有自己的地址(slug)、自己的工作目录,靠租约声明存活,进程重启后工作成果仍在磁盘上等待收养。
fleet 侧的租约语义值得在本章展开,因为它是「并发所有权如何跨进程传递」的最小完整样本。Lease 结构体本身只有两个字段(crates/octos-fleet/src/records.rs:250):owner_epoch 是本次 daemon 启动的身份,expires_at_ms 是绝对过期时刻。获取发生在派发时:crates/octos-fleet/src/store.rs 构造 Attempt 时把 status 置为 Leased、写入 lease { owner_epoch, expires_at_ms: now_ms + lease_ttl_ms }(crates/octos-fleet/src/store.rs:990-1001,TTL 加法用 checked_add 防溢出)。续期不是显式 API:worker 转入执行态走 mark_running(crates/octos-fleet/src/store.rs:1053),租约字段随 attempt 行持久存在,真正检查它的是所有写路径上的谓词。
让渡与回收靠同一个四部谓词。complete_child(crates/octos-fleet/src/store.rs:1158)提交结果前要求四个条件同时成立:child 的 current_attempt_id 指向本 attempt(attempt-id 栅栏,挡住同代重试)、attempt 状态为 Running、generation 等于 fleet 当前代(代栅栏,挡住重规划后的旧尝试)、lease.owner_epoch 等于调用者 epoch(租约栅栏,挡住重启后的旧主人),谓词本体在 :1230-1237。任一不满足就返回 Superseded,结果整个丢弃、账面零变动。回收则发生在 boot reconciliation:非终态 attempt 若租约的外来 epoch 不匹配或 expires_at_ms 已过(crates/octos-fleet/src/store.rs:2276-2277 的 stale 判定),被无条件置为 Interrupted 并释放预算预留;注释里记录了 #1973 修复:此前 Cancelled 的 fleet 被整体跳过,attempt 与预留被永久钉死。
这套设计的并发含义:旧进程崩溃后,新进程不需要与任何人协商:epoch 不同就自动失权,过期就自动回收,正确性不依赖崩溃方配合。租约与 supervisor 层的事件账本是同一命题的两个答案:账本用重放恢复「编排到哪了」,租约用栅栏废黜「不该再动的人」。Lease/Attempt 的完整状态机详见第 16 章,goal/peer 的编排语义详见第 18 章。
12.8 优雅关停:两个 AtomicBool
关停用两个原子布尔分层。global_shutdown 与 cancelled 是 SessionActor 的字段(crates/octos-cli/src/session_actor.rs:4500-4503):前者是进程级信号(Ctrl+C),后者只影响当前会话。actor 对外的 ActorHandle.shutdown(crates/octos-cli/src/session_actor.rs:2872)暴露的也是 Arc<AtomicBool>。
写入侧在 gateway 的 Ctrl+C 处理任务里(crates/octos-cli/src/commands/gateway/gateway_runtime.rs:1653):ctrl_c().await 返回后 shutdown.store(true, Ordering::Release)。Release 序保证此前的所有写入对获取者可见;读取侧用 Ordering::Acquire load,保证 actor 看到关停标志时也看到标志前的全部状态变更。一对 Release/Acquire 就是这条控制信号的全部内存序契约,没有锁。
广播式回收走另一条路:ActorRegistry::shutdown_all(crates/octos-cli/src/session_actor.rs:2795)直接 drop 全部 sender,actor 的 recv() 返回 None 后自然退出——信箱耗尽即关停,不需要每个 actor 轮询标志。两条路径互补:AtomicBool 给运行中的任务一个协作式检查点,drop sender 给等待中的 actor 一个确定性的终结。
12.9 小结
| 层 | 解决的并发问题 | 关键机制 | 代价 |
|---|---|---|---|
| Tokio 层 | 多会话并行、会话内一致 | session actor 独占状态、信号量限流、批次准入 | actor 生命周期管理 |
| supervisor 层 | 跨重启的长程编排 | 事件账本 + snapshot、dedupe 优先级续跑、outbox 轮询 | 近五千行持久化与重放代码 |
| peer/lease 层 | 多 agent 隔离与恢复 | 进程隐喻、epoch 租约、Parked 收养 | 状态机复杂度(详见第 16 章) |
三层不是替代关系而是叠加关系:supervisor 的续跑最终还是要派发回某个 session actor 执行;peer 的存活判断复用 TaskSupervisor 的状态机。并发原语的分层,本质是把「快」(内存态、微秒级)、「久」(磁盘态、跨重启)、「隔离」(进程边界)三种正交需求分开买单。
延伸阅读
- 第 5 章:Agent Loop 的六阶段生命周期与预算闸;续跑队列的消费侧视角。
- 第 10 章:harness events 的完整事件 ABI,本章的
McpServerCall是其中一个事件族。 - 第 16 章:fleet 的 Lease/Attempt/Recovery 状态机全貌。
- 第 18 章:goal 与 peer 的编排语义,
MasterContinuationScheduler的完整消费链。 - Tokio 官方文档 Graceful Shutdown 章节:运行时级优雅停机的任务取消与等待语义。
思考题
- 若把
max_concurrent_sessions从 10 调到 100,第 12.3 节的 permit 消费位置(消息处理层)会导致什么现象?如果 permit 改在会话创建层获取呢? TaskStatus::Parked不属于is_active也不属于is_terminal。设计一个并发场景,说明若把 Parked 误归为终态会破坏什么。- 混合批策略(#1766)先并行 Safe 再串行 Exclusive,为什么不能反过来?结果按「原始 LLM 调用顺序重组」这一约束,消费方依赖它做什么?
MasterContinuationScheduler的 TOCTOU 防护只记录 External 键。若一个新的 Internal 生产者也会在窗口内重复入队同键,会发生什么?注释里的 INVARIANT 要求新生产者怎么做?shutdown_all靠 drop sender 让 actor 退出。如果一个 actor 正在执行一个 30 分钟的工具调用,这条路径的表现与global_shutdown标志路径有何不同?
版本演化说明
- 本章分析基线:octos main @
9c157101(2026-09-02 检出),行号与数字均为该 commit 实测;事实表见assets/ch12-facts.md(2026-09-03)。 - 相对 v1 旧稿(原第 11 章)的变化:叙事从「per-session Mutex 序列化」纠正为 session actor 状态所有权模型;新增 supervisor 层(
autonomy/十文件)与 peer/lease 层两章内容;crates/octos-cli/src/autonomy/monitor_runtime.rs引入于c4f03647(2026-08-12),当前行号含后续e2f999a0(clippy 格式化)与0c44b26f(OLP control channel)两次提交的偏移。 - 事实表四条「待 writer 核」已在本会话亲测:
default_max_concurrent_sessions()返回字面量10;crates/octos-agent/src/task_supervisor.rs与crates/octos-cli/src/config.rs首行均有//!文档(事实表原记「无/未采集」有误);crates/octos-cli/src/autonomy/agent_orchestrator.rs骨架行号 606/746/1451/2471/9462 全部确认;crates/octos-agent/src/agent/execution.rs对外入口为pub(super) fn execute_tools(:2483),本章按此口径引用。
第 13 章:octos-pipeline:DOT 图驱动的工作流引擎
定位:本章对照
crates/octos-pipeline/src/,解释 octos 如何把 Graphviz DOT 图解析成带类型的PipelineGraph,执行器如何在九种 HandlerKind、十二种 IR 节点、静态并行、动态并行、检查点恢复、deadline 护栏和父会话资源继承之间落地,以及run_pipeline工具把 pipeline 暴露给 Agent 的方式。前置依赖:第 5 章、第 8 章、第 10 章、第 11 章。适用场景:需要理解多步骤 Agent 编排机制的开发者。
当任务已经不是"单个 Agent 循环 + 几次工具调用"能解决的问题时,就需要显式的工作流编排。典型例子是:先规划研究角度,再并发检索,再汇总分析,最后生成报告。octos-pipeline 解决的就是这类问题,但它的当前实现和"传统 DAG 调度器"的直觉并不完全一样:它既有图结构,也有运行时分支选择、并发汇合、模型路由和工具策略继承;同时还通过 PipelineHostContext 继承父 session 的文件缓存、子 Agent 输出路由、任务监督器和成本账本,让 pipeline 节点不再像早期实现那样孤立运行。
13.1 DOT 图如何进入运行时
13.1.1 为什么是 DOT
octos 用 Graphviz DOT 定义工作流,而不是 YAML/JSON。原因不是"DOT 更潮",而是它天然把节点和边作为一等语义:同一个文件既能被执行器解析,也能直接被 Graphviz 渲染成图。
digraph research {
graph [label="Deep Research", default_model="cheap"]
start [label="规划", handler="dynamic_parallel",
converge="analyze",
planner_model="strong",
worker_prompt="围绕 {task} 做资料检索,并保留来源",
tools="deep_search",
max_tasks="6"]
analyze [label="分析", handler="codergen",
model="strong",
tools="read_file,write_file"]
finish [label="结束", shape="Msquare"]
start -> analyze
analyze -> finish
}
这个例子已经体现了当前 parser 支持的几类关键能力:
- 图级属性:
graph [label=..., default_model=...] - 节点属性:
handler、model、tools、converge、planner_model等 - 边:
A -> B - 形状到 Handler 的隐式映射,例如
Msquare会映射到Noop
13.1.2 手写 parser,而不是第三方 DOT 库
入口是 crates/octos-pipeline/src/parser.rs:21-23 的 parse_dot(),真实工作发生在 DotParser::parse()(struct 定义在 crates/octos-pipeline/src/parser.rs:45,impl 在 :50)。这是一个手写 parser,不依赖外部 DOT 解析库。当前文件已增长到 1,445 行。
当前实现比"只支持一小撮语法"的简化说法要丰富一些:
digraph名称是可选的;如果模型生成了digraph { ... },parser 会把图 ID 设成"pipeline"(crates/octos-pipeline/src/parser.rs:63-65)- 支持图级属性
graph [key=value],除了label和default_model,现在还接受max_total_tokens(运行级 token 预算)、default_timeout_secs(每条 pipeline 的默认墙钟超时)和result_fidelity(结果体积降级注解),落点都在apply_graph_attrs()(crates/octos-pipeline/src/parser.rs:520-537) - 支持
subgraph name { ... },并把子图中的节点归档到PipelineGraph.subgraphs(crates/octos-pipeline/src/parser.rs:148-150、crates/octos-pipeline/src/graph.rs:48) - 支持边链式写法
a -> b -> c,属性会应用到链上的每一条边(crates/octos-pipeline/src/parser.rs:163) - 支持
//、/* */,以及额外的#行注释;后者明显是在为 LLM 生成的 DOT 做容错(crates/octos-pipeline/src/parser.rs:479-502) - 如果边引用了未显式声明的节点,parser 会自动补出默认节点定义(
crates/octos-pipeline/src/parser.rs:110-127)
这一层的结果不是"松散的 JSON 树",而是带语义的 PipelineGraph。其核心结构在 crates/octos-pipeline/src/graph.rs:10-54(图)和 :118-184(节点):
PipelineGraph有id、label、default_model、max_total_tokens、default_timeout_secs、result_fidelity、nodes、edges、subgraphs。后三个 graph 级字段是旧稿没有的PipelineNode除了prompt和handler,还包含model、context_window、max_output_tokens、max_iterations、tools、goal_gate、max_retries、timeout_secs、suggested_next、converge、worker_prompt、planner_model、max_tasks、deadline_secs、deadline_action、checkpoints,以及human_gate(:163)、resolver(:166)、artifact_refs(:169)、checkpoint_refs(:172)、continue_on_error(:184)
13.1.3 属性到节点语义的映射
节点构建发生在 build_node()(crates/octos-pipeline/src/parser.rs:579-691)。这里有几个实现细节决定了 DOT 的"作者体验":
- Handler 解析顺序是:显式
handler=优先,其次是shape=到 Handler 的映射(crates/octos-pipeline/src/graph.rs:282-297,字符串形式from_str在:270-279),最后默认Codergen(crates/octos-pipeline/src/parser.rs:581-586) tools="a,b,c"会被拆成字符串列表;如果用户写了tools="",解析结果是一个只含空字符串的 deny-all 标记,执行器把它当成"显式禁用所有工具"处理(crates/octos-pipeline/src/parser.rs:588-602)timeout_secs不只接受整数秒,也接受900s、15m、2h这类后缀写法(crates/octos-pipeline/src/parser.rs:551-570)goal_gate允许用true/false/yes/no/1/0表达(crates/octos-pipeline/src/parser.rs:619-623)deadline_secs支持ms/s/m/h和小数秒(crates/octos-pipeline/src/parser.rs:601-603,底层parse_duration_secs_f64在:665-689);deadline_action支持abort、skip、escalate、retry:N和retry(N)两种写法(crates/octos-pipeline/src/parser.rs:693-720)checkpoint="true"或checkpoint="name1,name2"会解析成MissionCheckpoint声明(crates/octos-pipeline/src/parser.rs:721-751)max_iterations现在是可写的节点属性(crates/octos-pipeline/src/parser.rs:620,字段在crates/octos-pipeline/src/graph.rs:140),见 13.2.1
这意味着 DOT 在 octos 里不是"纯拓扑描述",而是一个轻量的工作流 DSL。
13.2 九种 HandlerKind 与十二种 IR 节点
HandlerKind 的真实枚举在 crates/octos-pipeline/src/graph.rs:241-266。当前源码是 9 种,旧稿写的 6 种已经过时:
| 类型 | 运行时落点 | 关键属性 | 作用 |
|---|---|---|---|
Codergen | crates/octos-pipeline/src/handler.rs:641(impl 起) | prompt model tools context_window max_output_tokens | 派生完整子 Agent |
Shell | crates/octos-pipeline/src/handler.rs:1054 | prompt timeout_secs | 执行 shell 命令,但被验证规则封禁(见 13.2.2) |
Gate | crates/octos-pipeline/src/handler.rs:1190 | prompt | 计算条件,不做人机等待 |
Noop | crates/octos-pipeline/src/handler.rs:1226 | 无 | 透传输入 |
Parallel | crates/octos-pipeline/src/executor.rs:2749 | converge | 对已有下游节点做静态 fan-out |
DynamicParallel | crates/octos-pipeline/src/executor.rs:3122 | prompt worker_prompt planner_model max_tasks converge | 先规划任务,再动态 fan-out |
ShellCheck | crates/octos-pipeline/src/handler.rs:1247 | command timeout_secs | 跑固定命令串,编译期锁死 |
Notify | crates/octos-pipeline/src/handler.rs:1326 | message channel | 给用户发通知 |
Wait | crates/octos-pipeline/src/handler.rs:1365 | seconds until_condition | 等固定秒数或轮询条件 |
还有一个容易忽略但很重要的事实:Handler trait(crates/octos-pipeline/src/handler.rs:257)的实现现在是 7 个:CodergenHandler(:641)、ShellHandler(:1054)、GateHandler(:1190)、NoopHandler(:1226)、ShellCheckHandler(:1247)、NotifyHandler(:1326)、WaitHandler(:1365)。Parallel 和 DynamicParallel 不是独立 handler 类型,而是 PipelineExecutor::execute_graph() 里的专门分支(crates/octos-pipeline/src/executor.rs:2749 / :3122)。
handler 名到字符串的映射同步扩到 9 项,含 shell_check、notify、wait(crates/octos-pipeline/src/executor.rs:251-259)。
13.2.1 Codergen:节点就是一个子 Agent
CodergenHandler(struct crates/octos-pipeline/src/handler.rs:293,impl :641-1041)会为节点创建一个完整的 octos_agent::Agent,而不是做一次简化版 LLM 调用。这意味着节点天然继承了主 Agent 的很多能力:工具调用、循环式执行、token 统计、文件修改回传、进度事件上报。
它的关键行为有几层:
- Provider 解析。 如果节点声明了
model,并且执行器配置了ProviderRouter,handler 会走router.resolve(),然后再包一层 capability-compatible fallback provider。这不是单纯的"model name -> provider"映射,而是带回退链的解析。 - 上下文窗口覆盖。
context_window会包装成ContextWindowOverride。 - 工具注册。 节点初始工具集来自
ToolRegistry::with_builtins(),然后应用一次性缓存的 plugin registration,避免每个节点重复做插件 SHA 校验和可执行文件读取(crates/octos-pipeline/src/handler.rs:23-56)。 - 工具策略。 节点自己的
tools=决定 allowlist;handler 仍会额外 denyspawn、run_pipeline、send_file、message,避免子节点递归失控;随后还会清理 spawn_only 标记,让原本 spawn_only 的插件工具在 pipeline worker 内同步执行(crates/octos-pipeline/src/handler.rs:125-130)。 - 父会话资源继承。 如果
run_pipeline从 session actor 内触发,节点 worker 会继承父会话的FileStateCache、SubAgentOutputRouter、AgentSummaryGenerator、CostAccountant和 parent session key(crates/octos-pipeline/src/host_context.rs:34-60、from_tool_context在:78)。 - 系统提示词与任务输入分离。 执行器会先把
{input}从prompt中移除,只保留角色/约束类说明;真正的前驱输出通过TaskKind::Code.instruction传给子 Agent。
和旧稿差异很大的一点是迭代预算:早期版本把 AgentConfig.max_iterations 硬编码成 30,节点无法干预。现在 PipelineNode.max_iterations(crates/octos-pipeline/src/graph.rs:140)是可写的 DOT 属性,build_node() 直接解析(crates/octos-pipeline/src/parser.rs:620)。字段注释写明了动机:pipeline 默认 30 次迭代对"逐个读 findings 文件再综合"的 synthesize 节点太低,预算耗尽在导航文件上、结果没发出来就把整条 pipeline 拖死。None 保持默认值。
另外,max_output_tokens 的默认行为也不是"全局 4096"。如果节点没写这个属性,handler 会退回到 provider 自身的最大输出能力。这对长报告生成很关键。
13.2.2 Shell:实现了,但被验证规则封禁
ShellHandler 本身仍是完整实现(struct crates/octos-pipeline/src/handler.rs:1043,impl :1054-1135):命令来源是 node.prompt,非 Windows 下 sh -c、Windows 下 cmd /C,默认超时 300 秒,非零退出码映射成 OutcomeStatus::Fail,进程启动失败或超时映射成 OutcomeStatus::Error。
但当前版本里它有一条旧稿没写的封禁链:验证规则 rule_23_no_shell(crates/octos-pipeline/src/validate.rs:242)直接拒绝任何 handler="shell" 的图,理由是"shell 是任意代码执行";同时 build_handlers() 干脆不注册 Shell handler(crates/octos-pipeline/src/executor.rs:2409-2438),作为纵深防御:即使某个图绕过了验证,也没有 handler 可派发。真正给 pipeline 用的命令执行入口是 ShellCheck(13.2.7)。
"测试跑了但失败"和"命令根本没起来"的区分仍然重要,因为执行器只对 Error 做重试:
- "测试跑了但失败"是业务失败,不重试
- "命令根本没起来"或"超时"才是系统错误,可重试
13.2.3 Gate:当前是条件节点,不是人工审批节点
这是本章最容易写错的一块。
执行器注册的是 GateHandler(struct crates/octos-pipeline/src/handler.rs:1136,synthesize_predecessor_outcome 在 :1138,impl :1190-1222),真实语义是:
- 把
node.prompt当成条件表达式(为空时unwrap_or("true"),变成 pass-through gate,crates/octos-pipeline/src/handler.rs:1192) - 对直接前驱的
NodeOutcome求值;单前驱保持原始状态,多前驱按Error > Fail > Skipped > Pass聚合 - 返回
Pass或Fail,content直接透传,不发起人机交互
crates/octos-pipeline/src/human_gate.rs 的确存在,提供 HumanInputProvider、ChannelInputProvider 等抽象,默认超时 5 分钟(DEFAULT_INPUT_TIMEOUT,crates/octos-pipeline/src/human_gate.rs:14-15)。但我对照当前源码后可以明确说:这些抽象没有接进 build_handlers() 或 execute_graph() 主路径。所以"Gate = 人工审批节点"不是当前实现的准确说法。
更准确的表述是:
GateHandler是已接线的条件节点crates/octos-pipeline/src/human_gate.rs是 crate 已提供、但尚未接入默认执行器的人机输入抽象
13.2.4 Parallel:静态 fan-out,执行真实下游节点
Parallel 不是"动态生成 worker",而是把图里已经存在的下游节点并发跑掉。专门分支从 crates/octos-pipeline/src/executor.rs:2749 开始:
- 收集当前节点所有 outgoing edges 的 target,作为并发目标(
:2756-2763) - 要求当前节点必须声明
converge,否则报错"parallel node missing converge"(:2751-2754) - 派发前检查 pipeline 生命周期级 fan-out 总量上限,默认
MAX_PIPELINE_FANOUT_TOTAL = 500(常量在crates/octos-pipeline/src/executor.rs:102,闸门:2804);超过上限直接拒绝整批派发,避免半批 worker 已经启动 - 为每个目标节点克隆
PipelineNode,做变量替换,并在未显式声明模型时填入graph.default_model - 为每个 LLM-call 分支预留成本预算,然后查它自己的 handler 并并发执行(并发度受
ExecutorConfig.max_parallel_workers限制,字段:405、使用点:2849;run_pipeline工具默认 8) - 合并内容、token、summary 和 node outcome
- 把汇总后的文本写回"当前 parallel 节点"的
completed结果,再跳到converge节点(跳转逻辑:3108-3121)
两个实现细节值得记住:
- worker 的超时被
MAX_FANOUT_WORKER_SECS = 3600秒封顶(crates/octos-pipeline/src/executor.rs:121) - 执行器会用
parallel_executed记住那些已经在 fan-out 阶段跑过的真实图节点,后续顺序遍历遇到它们时只选边,不重复执行
此外,结果合并不只是字符串拼接。合并之后还会自动扫描 worker 输出里提到的研究目录,把 _search_results.md 的内容内联进 merge 结果。这说明当前实现已经针对"研究型 fan-out -> 汇总型 converge"做了专门优化。
13.2.5 DynamicParallel:先规划,再合成 worker 节点
DynamicParallel 和 Parallel 的根本区别是:它不直接跑现成的图节点,而是先让 LLM 规划出任务列表,再为每个任务合成一个临时 Codergen 节点。专门分支从 crates/octos-pipeline/src/executor.rs:3122 开始:
- 解析
planner_model -> node.model -> graph.default_model的 planner provider 选择链 - 用
node.prompt作为规划提示词;若为空则退回内置 planner prompt plan_dynamic_tasks()(crates/octos-pipeline/src/executor.rs:1158-1272)期望模型返回纯 JSON 数组;若解析失败或任务太少,退回fallback_tasks()(:1274-1300)- 把
worker_prompt里的{task}替换为具体任务说明,生成一批 syntheticCodergen节点 - 派发前同样检查 fan-out 总量上限,并为每个 synthetic worker 预留成本预算
- 并发执行这些 synthetic 节点,合并结果后跳到
converge
它还有一个旧稿写错的行为:旧稿说 DynamicParallel "没有像 Parallel 那样再套一层 semaphore"。现在所有 run 入口(run()、run_graph()、run_graph_with_handlers_throttled())都会先构造 pipeline 级 LLM semaphore(pipeline_llm_semaphore(),crates/octos-pipeline/src/executor.rs:1640),DynamicParallel 的 worker 同样要过这道闸。单次 fan-out 仍主要依赖 max_tasks(默认 8,字段注释见 crates/octos-pipeline/src/graph.rs:118-184),跨整个 pipeline 运行受 MAX_PIPELINE_FANOUT_TOTAL = 500 保护。
node.model 仍可写成逗号分隔的 model pool(例如 "cheap,strong,cheap"),执行器把 worker 轮询分配到不同模型上;这层分配现在由 crates/octos-pipeline/src/model_assignment.rs 承担(见 13.3.5)。
13.2.6 Noop:占位,但也很实用
NoopHandler(struct crates/octos-pipeline/src/handler.rs:1223,impl :1226-1243)就是把 ctx.input 原样返回。它有两个常见用途:
- 作为 start / finish 这类结构节点
- 作为某些条件分支的汇合点或透传点
顺带一提,build_handlers() 里 DynamicParallel 注册的也是 NoopHandler 占位(crates/octos-pipeline/src/executor.rs:2409-2438);真执行走的是 executor 分支,注册只是让 registry 查找不落空。
13.2.7 三个新 handler:ShellCheck、Notify、Wait
这三个变体是 IR 调色板落地时新增的真 handler,各自一句话定位:
ShellCheckHandler(crates/octos-pipeline/src/handler.rs:1247):执行一条固定的命令串,命令在编译期锁死,没有 LLM 工具面。与被禁的Shell(任意代码执行)相对,对应"跑测试、检查构建、数行数"这类只读命令场景,命令在 session workspace 里执行,输出作为节点结果NotifyHandler(crates/octos-pipeline/src/handler.rs:1326):给用户发通知,message加可选channel(如 telegram、slack),基本不涉及 LLMWaitHandler(crates/octos-pipeline/src/handler.rs:1365):等待固定秒数,或轮询一个until_condition条件直到为真或超时(默认 300 秒),无 LLM 调用
13.2.8 IR 调色板:十二种 IrNodeKind
直接给 LLM 暴露 9 种 HandlerKind 有一个问题:变体里混着执行机制(Parallel 是 executor 分支)和安全敏感面(Shell 被禁)。6b0de6ca 之后,octos-pipeline 引入了一层类型化 IR:IrNodeKind(crates/octos-pipeline/src/ir.rs:57,文件 673 行)当前实测 12 个变体:
$ sed -n '/pub enum IrNodeKind/,/^}/p' crates/octos-pipeline/src/ir.rs | grep -cE '^\s{4}[A-Z]'
12
| # | IrNodeKind | 落点 | 语义(源码注释) |
|---|---|---|---|
| 1 | Research | Codergen 类 | 只读研究(web + file reads),cheap 模型;带 max_iterations(:64) |
| 2 | Transform | Codergen 类 | 对前驱输出做纯变换,cheap 模型 |
| 3 | Synthesize | Codergen 类 | 最终综合(只读),strong 模型;带 max_iterations(:75) |
| 4 | Report | Codergen 类 | 综合并经 write_file 落盘,terminal 步;带 max_iterations(:84) |
| 5 | Gate | Gate | 纯路由门,无 LLM,出边条件决定分支 |
| 6 | Fanout | DynamicParallel | 规划 N 个 worker 并行跑后 converge(plan_prompt / worker_prompt / converge / max_tasks) |
| 7 | CodeReview | Codergen 类 | 只读代码分析(read/grep/glob),可带 scope |
| 8 | CodeEdit | Codergen 类 | 改代码,可带预期 files |
| 9 | ShellCheck | ShellCheck handler | 固定命令串、编译期锁死 |
| 10 | SubAgent | spawn 子代理 | 独立 LLM 会话的子代理节点(task / tools / model,模型默认 strong) |
| 11 | Notify | Notify handler | 发通知,message + 可选 channel |
| 12 | Wait | Wait handler | 等固定秒数或轮询 until_condition |
每种 IR 节点在 PaletteContract(crates/octos-pipeline/src/ir.rs:195,查表函数 contract_for 在 :204)里有代码拥有的能力契约:落到哪个 HandlerKind、显式非空的工具 allowlist(空列表意味着全部内建工具,所以契约里不允许空)、默认模型。注释写得很直白:这些字段 LLM 永远看不到也改不了,按 kind 在编译期查表。
IR 枚举尾注里有一句值得整段抄进书里(crates/octos-pipeline/src/ir.rs,IrNodeKind 定义末尾):human_gate 是故意不加的。因为 pipeline 执行器并不会把人工输入门路由到真正的审批 handler(裸 Gate 节点条件默认 true、自动通过),如果调色板里宣传一个 human_gate,等于提供了一条静默绕过审批的路径。尾注明确:只有等 HumanInputProvider 支撑的 handler 真正接线后才能加回来。
工具层对 LLM 暴露的也是 IR 而不是裸 handler:run_pipeline 的 input_schema() 有一个 ir 入参(typed-IR workflow program,JSON),但只在启用时出现在 schema 里,测试 input_schema_exposes_ir_only_when_enabled(crates/octos-pipeline/src/tool.rs:2917)固定了这个行为。
13.3 执行引擎不是"拓扑排序器",而是带路由的图遍历器
flowchart TD
DOT["DOT / IR / pipeline name / file path"] --> Parse["parse_dot() / IR 编译"]
Parse --> Validate["validate()"]
Validate --> Start["find_start_node()"]
Start --> Loop["execute_graph() loop"]
Loop --> Kind{node.handler}
Kind -->|Parallel| PFan["并发执行真实下游节点"]
Kind -->|DynamicParallel| DPlan["LLM 规划任务"]
Kind -->|其他| Normal["Handler::execute()"]
PFan --> Merge["合并结果并跳到 converge"]
DPlan --> Workers["合成 worker 节点并发执行"]
Workers --> Merge
Normal --> Select["select_next_edge()"]
Merge --> Select
Select -->|有后继| Loop
Select -->|无后继 / goal_gate 成功 / Error| Done["PipelineResult"]
图 13-1:当前 PipelineExecutor 的真实主路径。 它不是先做一次全图拓扑排序,再机械执行所有节点;而是从 start node 出发,在循环里按节点类型分流,并在每一步重新决定下一条边。
13.3.1 run() 的实际阶段
执行入口现在是一个家族:PipelineExecutor::run()(crates/octos-pipeline/src/executor.rs:1650-1672)、run_graph()(:1673)最终都收敛到 run_graph_with_handlers_throttled()(:1772),后者多做了两件事:构造 pipeline 级 LLM semaphore(:1640),以及开头那段 in-process 模型赋值(见 13.3.5)。整体流程仍可概括成七步:
parse_dot()解析 DOT(或把 typed-IR 编译成图)validate()跑 lint 规则build_handlers()构建 handler registry(crates/octos-pipeline/src/executor.rs:2409-2438)find_start_node()决定入口节点- 如有
CostAccountant,先打开 pipeline 级预算 reservation execute_graph()进入主循环(:2520起)- 若 pipeline 成功,跑 terminal validators;最终成功才提交 pipeline 级成本归因,否则 reservation 自动退款
这里最重要的纠偏是:第四步之后不是"拓扑遍历整个 DAG",而是 current_node_id 驱动的增量遍历。这也是为什么 suggested_next、条件边、label 匹配这些运行时路由策略都能生效。
13.3.2 验证规则:23 条,含 Shell 封禁
验证器在 crates/octos-pipeline/src/validate.rs(入口 validate() :181、validate_with_context() :186、find_start_node() :394)。规则清单已经扩到 23 条(:210-232),比较重要的几条:
- Rule 1:必须能找到 start node(
start节点,或唯一一个无入边节点) - Rule 2:不可达节点只是 warning,不是 error
- Rule 6:边条件必须能被 condition parser 解析
- Rule 13 / 14:
parallel和dynamic_parallel都必须声明有效的converge - Rule 18:模型名必须是已知键(
validate_model_name,crates/octos-pipeline/src/validate.rs:1046,旧稿没写这条) - Rule 23:
handler="shell"直接 Error(:242) - 图中不能有环;环检测发生在 validate 阶段,不等执行时才爆炸(
detect_cycles,crates/octos-pipeline/src/graph.rs:54)
这意味着 octos-pipeline 当前仍然要求 DAG,但执行方式不是"静态 DAG 调度器",而是"受 DAG 约束的动态图遍历器"。
13.3.3 条件语言和边选择顺序
条件表达式的 grammar 写在 crates/octos-pipeline/src/condition.rs。当前运行时真正支持的核心写法是:
outcome.status == "pass"outcome.status != "fail"outcome.contains("keyword")!expr、expr && expr、expr || expr
例如:
test -> deploy [condition="outcome.status == \"pass\""]
test -> rollback [condition="outcome.status == \"fail\""]
report -> refine [condition="outcome.contains(\"missing data\")"]
旧稿里那种 success / failure 简写已经不符合当前 parser。
执行器选边不是"第一个命中就走",而是一个 5 步算法(select_next_edge(),定义在 crates/octos-pipeline/src/executor.rs:4882):
- 先评估所有带条件的边
- 如果有多个条件命中,按
weight选最高权重 - 若无条件命中,检查节点的
suggested_next - 再看 edge label 是否出现在 outcome content 里
- 最后才在无条件边里按权重选;如果还没有,就退回目标名最小的边
还有一个微妙但重要的实现现状:crates/octos-pipeline/src/condition.rs 的 grammar 支持 context.key == "value"(:12-17、:32-34),模块也提供了带上下文的求值入口 evaluate_with_context()(:69);但执行主路径的 select_next_edge() 和 GateHandler 走的还是 evaluate()。也就是说,context.* 目前是"语法已定义、模块具备显式 context 入参、主路径未喂值"的状态;真正稳定可用的还是 outcome.* 相关条件。
13.3.4 进度事件:crates/octos-pipeline/src/events.rs 与事件 ABI
旧稿完全没提的一块:crates/octos-pipeline/src/events.rs(296 行,crates/octos-pipeline/src/lib.rs:12 导出、:36 再导出)定义了结构化的 PipelineEvent 枚举(crates/octos-pipeline/src/events.rs:14):
PipelineStarted:图 ID、节点数、边数NodeStarted:节点 ID、handler、模型、labelNodeCompleted:状态、耗时、token 用量EdgeSelected:从哪到哪、选择原因ParallelFanOut/ParallelConverged:fan-out 目标列表与汇合结果PipelineCompleted:成功与否、总耗时、总 token、执行节点数
消费侧是一个 PipelineEventHandler trait,crate 自带 TracingEventHandler(日志)和 CollectingEventHandler(收集)两个实现。这层事件是 f26d2291 引入的"结构化 per-node 进度 + ETA + previews 经 harness 事件通道"的载体:per-node 进度、预估剩余时间、内容预览不再靠字符串状态文案猜,而是按事件 ABI 往上送。事件协议本身的版本化与消费端语义见第 10 章,这里只强调一点:pipeline 的事件通道和第 10 章的 harness 事件 ABI 是同一条工程线。
13.3.5 当前生效的模型选择路径:throttled 入口 + crates/octos-pipeline/src/model_assignment.rs
旧稿写"模型选择由 execute_graph() 与 CodergenHandler 联合应用",这已经不准确。当前主路径是:
- 图级默认:
graph [default_model="cheap"] - 节点覆盖:
node [model="strong"] - LLM 没填的空位,由
run_graph_with_handlers_throttled()(crates/octos-pipeline/src/executor.rs:1772)开头的一次 in-process 模型赋值补齐(model_assignment::assign_from_catalog_dir调用在:1808)
这次赋值读 profile 数据目录下的 model_catalog.json / pipeline_models.json,逻辑在独立的 crates/octos-pipeline/src/model_assignment.rs(564 行;assign_from_catalog_dir :135、known_model_keys_from_catalog_dir :155,后者也喂给 Rule 18 的模型名校验)。源码注释解释了为什么从历史 pipeline-guard 插件的 before_tool_call 钩子搬进进程内:插件形态在 manifest 解析失败时会静默退化(daemon 启动时的加载顺序竞态),而 strong 与 fast 之间的成本/质量路由是正确性关键,搬进来才确定性的。DynamicParallel 的 model pool 轮询分配也归这层管。
ModelStylesheet 模块本身仍存在,支持 * / handler:codergen / node:critical_analysis 这类 selector(crates/octos-pipeline/src/stylesheet.rs:28)。我对照当前源码,它仍只被 crates/octos-pipeline/src/lib.rs:28/64 导出,PipelineExecutor、RunPipelineTool、PipelineDiscovery 都没有调用点。所以结论保持:
ModelStylesheet是 crate 已导出的能力- 当前默认执行路径用的是
default_model + node.model + model_assignment 补位
如果书里把 ModelStylesheet 写成主路径,会高估它在当前版本里的实际地位。
13.3.6 父会话继承、成本账本与 workspace policy
当前 run_pipeline 不是"在 pipeline 内部重新开一套孤立资源"。工具入口会从 TOOL_CTX 快照 PipelineHostContext(from_tool_context,crates/octos-pipeline/src/host_context.rs:78,调用点 crates/octos-pipeline/src/tool.rs:144),把父 session 的共享资源传给执行器:
FileStateCache:节点 worker 复用父会话的文件状态缓存,避免每个节点重新建立一套视图SubAgentOutputRouter/AgentSummaryGenerator:节点里再触发后台子 Agent 时,输出和摘要仍走父会话的路由TaskSupervisor:顺序节点注册成pipeline:<node_id>子任务(crates/octos-pipeline/src/executor.rs:2263-2289,合成工具名:2289),并在完成、失败或跳过时更新状态CostAccountant:pipeline 级 reservation 在运行开始打开,成功后以累计 token 提交;节点级 reservation 只用于派发前预算闸门,节点完成后形成node_costs给 UI / SSE 使用(PipelineContext持有账本,定义在crates/octos-pipeline/src/context.rs:59)
同时,RunPipelineTool 会从工作目录读取 workspace policy,自动构造 PipelineContext。这让 pipeline 节点继承 compaction policy,terminal 阶段跑 workspace validators;配置了 validators_by_node 时,单个节点完成后也会跑 per-node validators(ValidatorsByNode 经 crates/octos-pipeline/src/lib.rs:35 导出)。
这部分和第 8 章的上下文压缩、第 11 章的 TaskSupervisor 是同一条工程线:pipeline 不再只是"多节点 DAG",而是被纳入父 session 的观测、预算和 workspace contract 边界。
13.3.7 human_gate、checkpoint、run_dir 的真实位置
这一节三个模块容易被写成"默认能力",更准确的定位要拆成两层:crate 能力、执行器可选接线、默认工具路径。
crates/octos-pipeline/src/human_gate.rs:提供 channel-based human input 抽象,默认超时 5 分钟(:14-15),未接入PipelineExecutor;IR 调色板也故意不含 human_gate(13.2.8)crates/octos-pipeline/src/checkpoint.rs:提供CheckpointStoretrait(:127)与FileSystemCheckpointStore(:154);执行器有ExecutorConfig.checkpoint_store可选字段(crates/octos-pipeline/src/executor.rs:427,ExecutorConfig在:387),运行开始经build_resume_skip_set()(:272,调用点:2555)构造 resume 跳过集,节点成功后持久化 DOT 中声明的checkpoint(:4043)crates/octos-pipeline/src/run_dir.rs:提供RunDir(:17)、NodeStatus(:23)、PipelineRunSummary(:107),约定运行目录是{working_dir}/.octos/runs/{run_id}/...,但默认RunPipelineTool仍没有把它接成自动 run directory
所以不能说 checkpoint 完全是"未接线模块":它已经是执行器的可选能力,只是 RunPipelineTool 默认把 checkpoint_store 设为 None(默认路径判断 crates/octos-pipeline/src/executor.rs:1916)。准确结论是:默认 run_pipeline 不自动做人机审批、不自动写 run_dir,也不默认启用 checkpoint;但自定义 executor 配置可以启用 checkpoint resume / persist。
13.3.8 run_pipeline 工具如何把 pipeline 暴露给 Agent
对最终用户来说,最常见的入口不是直接 new PipelineExecutor,而是 RunPipelineTool(struct crates/octos-pipeline/src/tool.rs:109;文件已增长到 3,197 行)。它有几层很实际的工程化包装:
- 先尝试把输入当成 inline DOT;若 parse 失败,再尝试把图名解析成预置 pipeline
- 会自动修正常见的 LLM DOT 错误,比如
digraph{、缺图名、代码围栏包裹 - 可按名称、路径或内联 DOT 解析 pipeline。搜索路径由
PipelineDiscovery管理(structcrates/octos-pipeline/src/discovery.rs:84,new:98,resolve:184):项目级.octos/pipelines、用户级data_dir/pipelines、data_dir/skills,可再挂octos_home/skills;另有add_bundled_pipelines_dir()(:147)提供 bundled-pipelines 最低优先级机制,旧稿没写 - 对整个 pipeline 施加总超时钳制,区间 [60, 3600] 秒、默认 1800(
crates/octos-pipeline/src/tool.rs:534注释、:1099钳制处),不再是最早的 60-1800;默认值可被 DOT 图属性default_timeout_secs覆盖;结束后置位共享 shutdown flag,通知所有 worker 停止(:1118-1119) - 如果 pipeline 没产出 markdown 文件但有文本输出,工具会合成一个临时
.md报告(.md判定crates/octos-pipeline/src/tool.rs:1332,文件名模式run_pipeline_{ts}_{pid}_{seq}.md在:1405),保证spawn_only交付路径有可发送文件 node_costs投射到ToolResult.structured_metadata(:1259-1278),供 session actor 把 per-node cost 带回 UI/API completion metadatainput_schema()(:686)的提示词措辞已经大改:说明当前唯一 sanctioned 的 pipeline 名是deep_research,并建议多源研究优先用它而不是单次内联web_search;typed-IRir入参只在启用时暴露(:2917测试)
这里还有一个值得写进书里的"实现与提示词分离"现象:input_schema() 建议模型不要显式写 model=、系统会自动选模型,但运行时引擎本身依然支持 default_model / node.model,且 model_assignment 补位逻辑就建立在这些字段之上。也就是说,这是对 LLM authoring 的建议,不是底层引擎能力被移除了。
工程决策侧栏:为什么选 DOT 而不是 YAML/JSON
YAML(例如 GitHub Actions)
优势:人类熟悉,生态成熟。 劣势:图结构不是一等语义,
needs:这类依赖写法在分支和汇合场景下会越来越别扭。JSON(例如 Step Functions)
优势:结构化强,schema 友好。 劣势:对人类作者不友好,特别是当节点属性和分支条件越来越多时。
DOT(octos 的选择)
优势:
- 节点和边本身就是 DOT 的原生概念
- 同一份定义可直接被 Graphviz 渲染
handler/model/tools/converge这类属性自然落在节点上- 对 LLM 来说,生成一张图往往比生成层层嵌套的 YAML/JSON 更稳定
代价:
- 需要自己实现 parser 和验证器
- DOT 不是大多数工程团队的日常配置语言,学习成本略高
13.4 workflow 与 pipeline 的分工
旧稿完全没有 octos-workflows 这个词。543010be 之后,workflow 子系统已经从 pipeline crate 里抽出去,独立成 crates/octos-workflows/,被 octos-server(Cargo.toml:18)和 octos-cli(Cargo.toml:28)直接依赖。它的 src 布局是:
crates/octos-workflows/src/workflow_runtime.rs:workflow 运行时workflow_families/:crates/octos-agent/src/tools/registry.rs、crates/octos-workflows/src/workflow_families/deep_research.rs、crates/octos-workflows/src/workflows/research_podcast.rs、crates/octos-workflows/src/workflow_families/site.rs、crates/octos-workflows/src/workflow_families/slides.rsworkflows/:crates/octos-workflows/src/workflows/research_podcast.rs、crates/octos-workflows/src/workflows/research_report.rs、crates/octos-workflows/src/workflows/site_delivery.rs、crates/octos-workflows/src/workflows/slides_delivery.rs
分工可以这样表述:octos-pipeline 是通用的图执行引擎,管 DOT/IR 解析、handler 派发、fan-out、预算与事件;octos-workflows 是建在它上面的产品层,把"深度研究、播客、站点交付、幻灯片"这类领域流程编排成 workflow family,注册进 registry,由 server/CLI 的入口调度。换句话说,pipeline 回答"一张图怎么跑",workflow 回答"这条业务线由哪几张图、按什么顺序、带什么参数跑"。第 10 章 harness 与本章 13.3.4 的事件 ABI,是两层共享的观测底座。
13.5 本章回顾
octos-pipeline当前是 9 种HandlerKind,其中 7 种有Handlertrait 实现;Parallel/DynamicParallel是执行器分支,Shell有实现但被 Rule 23 封禁且不注册。IrNodeKind有 12 个变体,每种带编译期PaletteContract;human_gate是故意缺席的,防止静默绕过审批。Gate是条件节点,不是默认接线的人机审批节点;crates/octos-pipeline/src/human_gate.rs只是已存在但尚未接入执行主路径的抽象。PipelineHostContext让 pipeline 节点继承父 session 的缓存、输出路由、任务监督和成本账本;pipeline 是 session runtime 的一部分。- 模型选择的当前主路径是
graph.default_model + node.model + crates/octos-pipeline/src/model_assignment.rs 在 throttled 入口的补位;ModelStylesheet仍非主路径。CheckpointStore是执行器可选接线,默认工具路径未启用;RunDir仍是相邻模块。 - 进度走
crates/octos-pipeline/src/events.rs的结构化事件,workflow 编排在独立的octos-workflowscrate。
延伸阅读
- Graphviz DOT Language:https://graphviz.org/doc/info/lang.html
- DAG 调度:可以对照 Airflow / Prefect 看"静态 DAG 调度器"和 octos 这种"带运行时路由的图遍历器"之间的差异
思考题
crates/octos-pipeline/src/condition.rs已经支持context.*grammar 和evaluate_with_context(),但执行器主路径没有把上下文 map 接进去。你会把这层语义接到select_next_edge(),还是保留 outcome-only 的简单模型?ShellCheck用编译期锁死的命令串换来了可安全暴露给 LLM 的命令执行。如果要让图作者声明"可参数化的只读命令",参数面应该开多大才不退回Shell的任意执行问题?- IR 调色板故意不含
human_gate。若要真正接线人工审批,事件 ABI 和PipelineResult各需要补什么?
版本演化说明 本章分析基于 octos main @ 9c157101(2026-09-03)中
crates/octos-pipeline/src/的实现(src/*.rs约 25,134 行;crates/octos-pipeline/src/executor.rs5,591 行、crates/octos-agent/src/plugins/tool.rs3,197 行、crates/octos-pipeline/src/handler.rs2,140 行、crates/octos-pipeline/src/parser.rs1,445 行)。主要演化:6b0de6ca 把 DOT 调色板扩到 12 种 IR 节点并新增 ShellCheck / Notify / Wait handler;92175f53 引入 per-nodemax_iterations;f26d2291 引入结构化进度事件;543010be 把 workflow 子系统抽成独立 crate。书中凡是涉及Gate、ModelStylesheet、CheckpointStore、RunDir、PipelineHostContext、成本账本和 workspace policy 的地方,都应区分"模块存在""执行器可选接线"和"默认RunPipelineTool路径",而不是仅凭模块是否存在来下结论。
第 14 章:运行模式与配置体系
定位:本章解释 octos 的五种运行面(chat、gateway、serve、mcp-serve、acp)如何从同一个 CLI 入口分派出来,以及 11,944 行配置体系如何组织优先级、分层默认值与热加载。前置依赖:第 5 章(Agent Loop)、第 11 章(octos-bus)。适用场景:需要部署、选型运行姿态的运维者(读者 D),以及想理解控制面装配与配置传播路径的开发者(读者 B)。编排面子命令(goal、peer、ledger、steer)本章只点名定位,细节见第 18 章。
同一个 octos 二进制,至少有五种差异很大的运行姿态:给人用的终端会话、挂在十个消息频道后面的常驻进程、暴露 67 个 REST 端点的控制面、给外层编排器调用的 MCP server、给 Zed 这类编辑器用的 ACP agent。用户面对的是子命令选择,贡献者面对的是一组共享装配逻辑的入口文件:七个入口文件合计 19,485 行(crates/octos-cli/src/main.rs 268 行、crates/octos-cli/src/commands/mod.rs 468 行、crates/octos-cli/src/commands/chat.rs 4,143 行、crates/octos-cli/src/commands/gateway/ 目录 7,595 行、crates/octos-cli/src/commands/serve.rs 2,849 行、crates/octos-cli/src/commands/mcp_serve.rs 1,138 行、crates/octos-cli/src/commands/acp.rs 3,024 行)。
旧稿把这套体系概括为「四种运行模式」。以 2026-09-02 main 分支的 octos --help 实测,这个二进制有 28 个用户子命令,运行姿态也早已超过四类:octos serve 已经成为 AppState 与控制面的汇聚点,--stdio 让它同时是 UI Protocol 的挂载面;octos mcp-serve 把完整的 octos session 暴露为一个粗粒度 MCP 工具;octos acp 则面向 Agent Client Protocol 客户端。本章按「五种运行面」重写这一层,并给出配置体系(crates/octos-cli/src/config.rs 3,790 行 + crates/octos-cli/src/profiles.rs 7,003 行 + crates/octos-cli/src/config_watcher.rs 608 行 + crates/octos-cli/src/config_layer.rs 543 行)的完整图景。
14.1 一个入口,五种运行面
14.1.1 从 crates/octos-cli/src/main.rs 到 Command 分派
所有子命令共用一条启动路径。fn main() 位于 crates/octos-cli/src/main.rs:61,做四件事:安装错误钩子、解析 clap 参数、合并分层默认值(octos_cli::config_layer::apply,crates/octos-cli/src/main.rs:80)、执行子命令(args.command.execute(),crates/octos-cli/src/main.rs:101)。
子命令清单是 pub enum Command(crates/octos-cli/src/commands/mod.rs:114),28 个变体每个对应一个命令结构体。Executable for Command 的 match 分派在 crates/octos-cli/src/commands/mod.rs:381 起:
#![allow(unused)] fn main() { // crates/octos-cli/src/commands/mod.rs:381-399(节选) match self { Self::Account(cmd) => cmd.execute(), Self::Acp(cmd) => cmd.execute(), // …其余子命令同构… Self::McpServe(cmd) => cmd.execute(), #[cfg(feature = "api")] Self::Serve(cmd) => cmd.execute(), }
两处细节值得注意。其一,Self::Serve 被 #[cfg(feature = "api")] 门控(crates/octos-cli/src/commands/mod.rs:398-399):不开 api feature 编译出的二进制没有 serve 子命令,crates/octos-cli/src/main.rs:85-86 的 serve 日志目录逻辑同样被门控。编译期裁剪是运行面体系的第一道边界,完整列表见附录 D。其二,ACP、MCP、chat --json 这类在 stdout 上输出机器可读流的命令,控制台日志被路由到 stderr(commands::reserve_stdout),一行杂散日志就会污染协议流。
octos --help 实测列出 28 个用户子命令:account acp admin auth channels chat config cron doctor docs init inbox mcp memory profile mcp-serve serve skills status steer update gateway goal ledger clean completions office peer。其中直接定义「运行面」的是五个常驻或会话型命令,其余是生命周期管理(auth、profile、account)、诊断(doctor、status)、以及编排面(goal、peer、ledger、steer,详见第 18 章)。
14.1.2 五种运行面一览
| 运行面 | 入口 | 规模 | 首行文档摘要 | 关键符号 |
|---|---|---|---|---|
| chat(单机会话) | crates/octos-cli/src/commands/chat.rs | 4,143 行 | interactive multi-turn conversation | ChatCommand:37、execute:1043、run_chat_peer:828 |
| gateway(多频道常驻) | crates/octos-cli/src/commands/gateway/ | 7,595 行 | persistent messaging daemon | GatewayCommand:45、Gateway::init:226、runtime run:1789 |
serve(控制面 + --stdio) | crates/octos-cli/src/commands/serve.rs | 2,849 行 | start the REST API server | ServeCommand:320、execute:539、run_async:549、build_router:1778 |
| mcp-serve(协议服务) | crates/octos-cli/src/commands/mcp_serve.rs | 1,138 行 | M7.2 — octos mcp-serve | McpTransport:72、McpServeCommand:79、run_session:485 |
| acp(协议服务) | crates/octos-cli/src/commands/acp.rs | 3,024 行 | run octos as an ACP agent over stdin/stdout | AcpCommand:100、execute:160、run_async:1163 |
行号均出自 assets/ch14-facts.md(基准 9c157101),文件均在 crates/octos-cli/src/ 下。
14.1.3 五种运行面的拓扑
flowchart LR
subgraph CLI入口
MAIN["octos (crates/octos-cli/src/main.rs:61)"] --> LAYER["config_layer::apply<br/>合并 cli.<cmd> 分层默认值"]
LAYER --> DISPATCH["Command::execute<br/>crates/octos-cli/src/commands/mod.rs:381"]
end
subgraph 五种运行面
DISPATCH --> CHAT["octos chat<br/>终端 readline"]
DISPATCH --> GW["octos gateway<br/>ChannelManager 多频道"]
DISPATCH --> SERVE["octos serve<br/>REST/UI Protocol 控制面"]
DISPATCH --> MCPS["octos mcp-serve<br/>run_octos_session"]
DISPATCH --> ACP["octos acp<br/>ACP over stdio"]
end
subgraph 共享装配
CONFIG["Config / ProfileStore"]
LLM["LlmProvider 链"]
TOOLS["ToolRegistry + ToolPolicy"]
SESS["SessionManager / EpisodeStore"]
end
CONFIG --> LLM
CHAT & GW & SERVE & MCPS & ACP --> 共享装配
图 14-1:五种运行面共享同一套装配。 差异在接入层(终端、频道、HTTP、MCP、ACP),共享的是配置解析、Provider 链、工具注册表与会话存储。这个「共享面」正是第 1 章 workspace 分层在 CLI 层的投影:入口文件全部位于 octos-cli,能力全部来自下层 crate(依赖方向见第 1 章 1.3 节与附录 A)。
14.1.4 怎么选运行面
五个面不是五个产品,而是同一个 Agent 能力栈的五类接入方式。选择的主要变量是「谁来发起交互」和「进程活多久」:
| 维度 | chat | gateway | serve | mcp-serve / acp |
|---|---|---|---|---|
| 交互发起方 | 终端里的人 | 消息频道里的人 | Web/HTTP 客户端 | 外层程序 |
| 进程生命周期 | 一次会话 | 常驻守护 | 常驻服务 | 随客户端会话 |
| 网络暴露 | 无 | 出站到频道平台 | 监听端口或 stdio | stdio 或本地端口 |
| 配置消费 | 单份 config | 多 profile + 子账号 | profile 注册表 + 分层 | 按 profile 逐任务加载 |
| 典型场景 | 开发调试、单机使用 | 社群 bot、多频道助理 | 自建面板、API 集成 | 被编辑器或编排器调用 |
两条经验法则。第一,面可以叠加但进程不合并:一个部署完全可以同时跑一个 gateway(消息入口)和一个 serve(面板入口),它们读同一份配置体系但各持各的 data dir 锁;「在同一进程里同时跑 gateway 和 serve」不是当前支持的形态。第二,从最小面开始:先用 chat 验证配置与工具行为,需要常驻再上 gateway 或 serve,需要被程序调用才引入 mcp-serve 或 acp。每个面引入的运维面(频道凭证、端口、token、锁文件)是单向递增的。
14.2 chat:单机会话面
octos chat 是最接近用户直觉的面:一个终端里的多轮对话。ChatCommand(crates/octos-cli/src/commands/chat.rs:37)支持 --cwd、--provider、--model、--max-iterations、--verbose 等覆盖项;execute(crates/octos-cli/src/commands/chat.rs:1043)构造 Tokio 多线程运行时后进入会话循环;run_chat_peer(crates/octos-cli/src/commands/chat.rs:828)是真正的对话驱动逻辑,Agent Loop 的展开见第 5 章,此处不重复。
chat 的价值在于它是最薄的验证面:配置解析、Provider 构造、工具注册、沙箱策略在这一层全部生效,但没有任何网络暴露。诊断配置问题时,先在 chat 里复现,再排查 gateway 或 serve,是成本最低的路径。
chat 也是三个参与分层默认值的命令之一(LAYERED_COMMANDS 里的 "chat"):config.cli.chat 里预置的 --provider、--model 等默认值,会在用户未显式给参数时生效。这让「这台机器上 octos chat 默认走哪个模型」成为一份可提交进版本库的配置,而不是每个人 shell 历史里的别名。run_chat_peer(crates/octos-cli/src/commands/chat.rs:828)与 gateway/serve 使用同一套 Agent 装配,所以第 5 章讲的循环行为在 chat 里的观察结果可以直推到其他面。
14.3 gateway:多频道常驻面
octos gateway 的首行文档说得很直白:persistent messaging daemon(crates/octos-cli/src/commands/gateway/mod.rs,目录共 7,595 行)。它启动 ChannelManager 监听多个消息频道,把到达的消息路由给对应 profile 的 Agent。频道抽象本身(Telegram、Discord、Matrix 等)是第 10 章的内容;本节只讲 gateway 作为运行面的两个结构特征。
第一,profile 与子账号继承是结构化的。Gateway 的多用户不是「每人一份顶层 config.json」,而是 ProfileConfig(crates/octos-cli/src/profiles.rs:181)承载结构化 sections:子账号继承父 profile 的 config.llm contract(LlmProfileConfig,crates/octos-cli/src/profiles.rs:814),缺省时继承 search、deep_crawl 等 sections,同时把父级 env_vars 作为 base 层(经 keychain 解析,crates/octos-cli/src/commands/gateway/profile_factory.rs:108/149)。旧稿中「子账号继承顶层 provider/model 字段」的写法已过时:顶层字段是 legacy 路径,结构化 sections 才是当前主路径。
第二,gateway 是热加载的主要消费者。常驻进程改配置的成本最高(重启会中断所有频道),所以 ConfigWatcher 的事件循环主要服务这一面(见 14.6.3)。GatewayCommand::execute(crates/octos-cli/src/commands/gateway/mod.rs:159)→ run_async(:178)→ Gateway::init(:226)→ runtime 主循环 run(crates/octos-cli/src/commands/gateway/gateway_runtime.rs:1789),热加载状态(system_prompt、max_history、config_rx)挂在 GatewayRuntime 的状态里。
14.4 serve:控制面汇聚点
把 octos serve 理解成「chat 的 HTTP 外壳」是最常见的误读。serve 是控制面(control plane)的汇聚点:一个进程装配出 REST API、UI Protocol WebSocket、事件广播、profile 管理、工具策略和 swarm 调度状态,chat 只是它承载的诸多能力之一。
14.4.1 启动装配
ServeCommand::execute(crates/octos-cli/src/commands/serve.rs:539)建 Tokio 运行时后进入 run_async(crates/octos-cli/src/commands/serve.rs:549)。装配序列的关键节点(行号见事实表):
ProfileStore::open(crates/octos-cli/src/commands/serve.rs:673/818):profile 注册表与模型目录EventBroadcaster::new(256)(crates/octos-cli/src/commands/serve.rs:750):全局事件广播SessionManager::open(crates/octos-cli/src/commands/serve.rs:764):会话存储init_metrics()(crates/octos-cli/src/commands/serve.rs:775):Prometheus 指标SwarmState构建(crates/octos-cli/src/commands/serve.rs:1316起):swarm 调度状态,其中DispatchPolicy::from_agent_gates(tool_policy, true)(crates/octos-cli/src/commands/serve.rs:1969)把config.tool_policy与注入型环境变量 denylist 投影成调度门,避免 swarm backend 绕过 native tool policy(swarm 细节见第 17 章)build_router(state)(crates/octos-cli/src/commands/serve.rs:1778):把 AppState 变成 axum 路由
flowchart TD
S["octos serve (run_async:549)"] --> LOCK["acquire_serve_data_dir_lock<br/>fs2 flock, 单写者"]
LOCK --> PS["ProfileStore::open :673/818"]
PS --> EB["EventBroadcaster::new(256) :750"]
EB --> SM["SessionManager::open :764"]
SM --> M["init_metrics :775"]
M --> SW["SwarmState + DispatchPolicy :1316/1969"]
SW --> R["build_router :1778"]
R --> STDIO{"--stdio?"}
STDIO -->|是| UI["stdio_connection<br/>UI Protocol over stdin/stdout"]
STDIO -->|否| HTTP["HTTP/WS 监听<br/>默认 127.0.0.1:50080"]
图 14-2:serve 的控制面装配。 注意单写者锁在装配最前面:redb 是单写者单进程数据库,两个 serve 指向同一 data dir 会撞库。锁冲突时进程输出机器可检索的 OCTOS_DATA_DIR_LOCKED 标记(DATA_DIR_LOCKED_MARKER,crates/octos-cli/src/commands/serve.rs:487 附近),octoscode 等客户端靠 grep 这个 token 识别冲突并停止重启循环,而不是每 5 秒静默崩溃一次。
14.4.2 --stdio:UI Protocol 挂载面
crates/octos-cli/src/commands/serve.rs:1541 的分支决定了 serve 的两种形态:
#![allow(unused)] fn main() { // crates/octos-cli/src/commands/serve.rs:1541-1546 if self.stdio { crate::api::ui_protocol_transport::stdio_connection(state).await?; tracing::info!("stopping all gateway child processes"); let _ = process_manager.stop_all().await; return Ok(()); } }
--stdio 模式不绑定 HTTP:bind_http_listener(crates/octos-cli/src/commands/serve.rs:464-471)在 stdio 下直接返回 None listener,UI Protocol 的 JSON-RPC 流量走 stdin/stdout。这不是测试后门,而是生产路径:octoscode 的默认 stdio 链就是
#![allow(unused)] fn main() { // octoscode/src/cli.rs:118 pub const DEFAULT_STDIO_COMMAND: &str = "octos serve --stdio --solo"; }
配套机制是 --instance-data-dir:多个 stdio 实例共享一个 config/profile 的 state home,各自持有私有的 runtime 数据目录(redb、sessions、serve lock),所以「一个用户开多个编辑器窗口」不会互相撞库。stdio 模式还复用了 serve 的整套控制面装配(watcher、事件、profile),只是传输层换成了管道。
--stdio 还重新定义了 stdout 的所有权:stdout 被协议独占后,任何写向它的内容都会被客户端当成 JSON-RPC 帧去解析,一行调试打印就是一次协议破坏。tracing 日志、panic 信息、进度提示必须全部改走 stderr;同理,serve 在 stdio 链上派生的子进程若继承 stdout,其输出也会混进协议流,需要显式重定向。这也是前文 reserve_stdout 机制存在的根本原因:机器可读流与人读日志的信道分离,要在入口处一次划清。
14.4.3 serve 门禁
octos serve --help 暴露的安全开关构成一组刻意设计的门禁(实测输出与 assets/ch14-facts.md 一致):
| 开关 | 语义 | 环境变量 |
|---|---|---|
--solo | 单人本地免密登录(POST /api/auth/solo*),仅 loopback 直连 + Local-mode 生效,带反向代理头的请求永不生效 | OCTOS_SOLO_LOGIN=1 |
--danger-full-access | 默认 FULL-ACCESS 权限档(沙箱关、网络开、免审批),必须绑 --solo | OCTOS_DANGER_FULL_ACCESS=1 |
--no-network | 从「默认允许网络」回退为默认拒绝 | OCTOS_NO_NETWORK=1 |
--swarm-backend <stdio|http|cli> | swarm 后端传输;未设时 /api/swarm/* 返回 503 | — |
默认绑定是安全优先的:--host 默认 127.0.0.1,--port 默认 50080(crates/octos-cli/src/commands/serve.rs:324,端口落在 IANA 动态区段以避开 Tomcat/Jenkins 这类常见服务,见 issue #417)。本地会话默认 Workspace-Write 且网络放行(文件系统仍在沙箱内,npm install、git 开箱可用),--no-network 是显式退出;云/租户部署恒为网络拒绝。
flowchart TD
REQ["serve 启动参数"] --> SOLO{"--solo?"}
SOLO -->|否| NO{"--danger-full-access?"}
NO -->|是| REJECT["拒绝启动<br/>danger 必须绑 solo"]
NO -->|否| OK1["正常启动<br/>默认 Workspace-Write + 网络放行"]
SOLO -->|是| LOOP{"loopback 直连<br/>且无反向代理头?"}
LOOP -->|是| OK2["solo 免密登录生效"]
LOOP -->|否| NEVER["solo 永不生效"]
OK2 --> DANGER{"--danger-full-access?"}
DANGER -->|是| FULL["FULL-ACCESS 默认档<br/>沙箱关/网络开/免审批"]
DANGER -->|否| OK1
OK1 --> NN{"--no-network?"}
NN -->|是| DENY["默认网络拒绝"]
图 14-3:serve 门禁决策流。 会话内显式 /permissions 选择永远优先于这些默认档。
14.4.4 coding/autonomy capability 与工具契约
serve 不只是「能聊天」:UI Protocol 的 SessionOpened.capabilities 会按客户端声明的 feature 投影出一组 coding 能力(常量在 crates/octos-cli/src/api/ui_protocol_transport.rs:2037-2060,经 has_ui_feature 逐项投影):
coding.tool_contract.v1(契约常量crates/octos-cli/src/api/coding_tool_contract.rs:12,契约 idcodex-compatible-coding-v1,:13)coding.autonomy.v1(:2037)、coding.agent_control.v1(:2042)、coding.goal_runtime.v1(:2047)、coding.loop_runtime.v1(:2052),另有coding.monitor_runtime.v1(:2057)
工具契约由后端根据当前 ToolRegistry、deferred 工具集、policy 视图与已知模型可见工具动态生成,AppUI 因此能区分六种工具状态(crates/octos-cli/src/api/coding_tool_contract.rs:19-28):available、aliased、deferred、disabled_by_policy、missing、unimplemented。其中 deferred 表示工具已注册但处于 LRU 惰性卸载集,模型可以通过 activate_tools 恢复,对契约而言仍算可用。
边界要说清楚:这些 autonomy capability 描述的是 backend-supervised orchestration(目标、循环、Agent 控制等原语都在后端监督下运行),不是无人值守的自演化运行时。goal/loop 运行时的内机制是第 18 章的主题,本章只定位它们挂在 serve 的 capability 面上。
14.4.5 REST 面:67 个端点
以 grep -rhoE '\.route\("[^"]+"' crates/octos-cli/src/api/*.rs | sort -u | wc -l 口径统计(2026-09-02 复跑两次均为 67),serve 暴露 67 个 REST 端点,分布在 api/ 目录的 42 个 .rs 文件里。旧稿的「91 个」作废:端点在持续裁剪,任何具体数字都必须附口径。端点级参考见附录 C,Web Dashboard 的前端实现不在本书范围。
14.5 协议服务面:mcp-serve 与 acp
14.5.1 mcp-serve:只暴露 run_octos_session
octos mcp-serve(crates/octos-cli/src/commands/mcp_serve.rs,1,138 行)把 octos 暴露成 MCP server,供外层编排器调度。MCP 实现在 crates/octos-agent/src/mcp_server.rs(1,044 行):McpServer:169、handle_request:201、HTTP 传输 streamable_http_service:268。
边界设计是这一面的核心:server 只暴露一个 session 级工具 run_octos_session(常量 RUN_OCTOS_SESSION_TOOL,crates/octos-agent/src/mcp_server.rs:66)。每次调用跑一个完整的 octos session(加载 profile、构造 LLM、创建 Agent、执行 prompt、校验 artifact),外层拿到的是聚合结果:它看不到内部 tool calls、iteration events 或进度流。octos 的内部工具目录(59 个工具,见第 6 章)不直接外翻,这是「粗粒度任务边界」对「细粒度工具代理」的取舍:外层编排器负责分解任务,octos session 负责在边界内自主执行。
flowchart LR
ORCH["MCP client / 外层编排器"] -->|"tools/call<br/>run_octos_session"| SRV["McpServer<br/>handle_request:201"]
SRV --> SES["run_session<br/>crates/octos-cli/src/commands/mcp_serve.rs:485"]
SES --> AGENT["完整 octos session<br/>profile→LLM→Agent→artifact 校验"]
AGENT --> OUT["聚合结果(outcome)"]
OUT -->|"无内部 tool calls / 迭代事件"| ORCH
图 14-4:mcp-serve 的 session 级分派。 传输层二选一(McpTransport,crates/octos-cli/src/commands/mcp_serve.rs:72):默认 stdio,认证模型是 parent-trust(父进程可信,不设 token);http 传输绑定 127.0.0.1:4033(--bind 默认值),且必须设置 OCTOS_MCP_SERVER_TOKEN,缺失或为空直接拒绝启动(crates/octos-cli/src/commands/mcp_serve.rs:185-186)。
14.5.2 acp:面向编辑器的 agent
octos acp(crates/octos-cli/src/commands/acp.rs,3,024 行)按 Agent Client Protocol 在 stdin/stdout 上运行一个 agent,服务 Zed 这类编辑器内置的 agent 客户端。AcpCommand:100、execute:160、run_async:1163。与 mcp-serve 的差异在协议与粒度:ACP 是编辑器与 agent 的会话协议(文件上下文、权限申请、diff 展示),MCP 是任务级工具调用;两者共享同一套配置与 Agent 装配,stdout 同样保持纯协议流。
把 acp 与 mcp-serve 放在同一个「协议服务面」里,是因为它们有共同的结构约束:stdin/stdout 是协议信道,一行杂散日志就是一次协议破坏,所以两者的控制台日志都路由到 stderr(commands::reserve_stdout);两者都不维护面向人的 UI 状态,会话的呈现(diff、进度、审批)完全交给客户端渲染。差异则在于会话归属:ACP 的会话由编辑器用户拥有,权限申请会以协议消息形式弹给用户;mcp-serve 的会话由外层编排器拥有,权限边界在启动配置里一次性定死。
14.6 配置体系:四个文件,11,944 行
| 文件 | 行数 | 职责 | 关键符号 |
|---|---|---|---|
crates/octos-cli/src/config.rs | 3,790 | 配置文件解析与合并 | Config:26、load_with_context:1740、load_resolved:1770 |
crates/octos-cli/src/profiles.rs | 7,003 | 多用户 profile 管理 | ProfileConfig:181、LlmProfileConfig:814、LlmRouteConfig:881 |
crates/octos-cli/src/config_watcher.rs | 608 | 热加载轮询 | ConfigChange:17、spawn:87、diff_and_emit:241 |
crates/octos-cli/src/config_layer.rs | 543 | 启动期分层默认值 | LAYERED_COMMANDS:40、apply:48 |
14.6.1 优先级链
文件层的解析优先级(crates/octos-cli/src/config.rs 的 load_resolved 注释):
- 项目本地
<cwd>/.octos/config.json(仅 default context 读取) <config_home>/config.json- legacy
~/.octos/config.json
显式 --config 或租户上下文不读项目本地文件。本地优先允许不同项目用不同 provider 与工具策略,代价是「为什么这台机器行为不一样」的诊断要多查一个文件。
启动期还有第二条优先级链,作用于 CLI 参数本身(crates/octos-cli/src/config_layer.rs:5-8 的模块文档):
显式 CLI flag > env var > config.json `cli.<cmd>` > built-in default
clap 已解决「CLI > env > default」,config_layer::apply(crates/octos-cli/src/main.rs:80 调用)补上中间层:用户没在命令行给、也没从环境变量来的字段,落到 config.cli.<cmd>。参与的子命令只有三个:LAYERED_COMMANDS = ["serve", "gateway", "chat"](crates/octos-cli/src/config_layer.rs:40)。判断「是否显式」靠 clap::ArgMatches::value_source,这是唯一同时覆盖标量默认值(--port)和裸布尔开关(--solo)的机制。危险开关(--danger-full-access、--yolo)与密钥字段被显式排除在分层之外,只能来自命令行。
flowchart TD
START["octos <cmd> 启动"] --> PARSE["clap 解析<br/>CLI > env > default"]
PARSE --> APPLY["config_layer::apply<br/>补入 config.cli.<cmd>"]
APPLY --> CTX{"显式 --config / 租户?"}
CTX -->|是| SKIP["跳过项目本地文件"]
CTX -->|否| P1["① cwd/.octos/config.json"]
P1 --> P2["② config_home/config.json"]
P2 --> P3["③ legacy ~/.octos/config.json"]
SKIP --> MERGE
P3 --> MERGE["load_resolved 合并 + typed schema 校验"]
MERGE --> RUNTIME["运行时 Config"]
RUNTIME -.->|"每 5s SHA-256 比对"| WATCH["ConfigWatcher"]
WATCH --> DIFF["diff_and_emit:241"]
DIFF --> HOT["HotReload"]
DIFF --> RESTART["RestartRequired"]
图 14-5:配置解析与热加载链路。 3a567a4c 引入的 typed schema 让推理参数(profile/llm/*)从「静默丢弃未知字段」改为「拒绝」:配置里拼错的键名现在会在启动时报错,而不是悄悄退回默认值(crates/octos-cli/src/config.rs:273 附近注释)。这是配置体系近年最重要的一次收紧。
14.6.2 profile 结构化配置
crates/octos-cli/src/profiles.rs(7,003 行)是配置体系最大的文件,承载多用户部署:ProfileConfig:181 定义结构化 sections,ProfileConfigPatch:741 支持 patch 语义,LLM 相关的三件套是 LlmProfileConfig:814、LlmModelSelectionConfig:824、LlmRouteConfig:881。Gateway 子账号继承的就是这些结构化 sections 加 env_vars base(见 14.3),敏感变量经 keychain 解析(crates/octos-cli/src/commands/gateway/profile_factory.rs:108/149)。
crates/octos-cli/src/config.rs 里的 mcp_servers: Vec<McpServerConfig>(:110)与 sub_providers: Vec<SubProviderConfig>(:184,定义在 :618)只在本章点名:前者是外部 MCP 工具的挂载配置(见第 9 章),后者是子 provider 链,两者完整的字段参考都在附录 C。
14.6.3 热加载 watcher
ConfigWatcher(crates/octos-cli/src/config_watcher.rs:28)每 5 秒轮询配置文件,用 SHA-256 哈希比对检测变更(首行文档即此语义)。变更分类由 ConfigChange 枚举表达(:17):
#![allow(unused)] fn main() { // crates/octos-cli/src/config_watcher.rs:17-25 pub enum ConfigChange { /// Fields that can be applied without restart. HotReload { system_prompt: Option<String>, max_history: Option<usize>, }, /// Fields changed that require a restart. Log warning only. RestartRequired(Vec<String>), } }
diff_and_emit(:241)逐字段比对后分流:HotReload 只有两个成员,system_prompt 与 max_history(gateway 子字段);其余落入 RestartRequired(Vec<String>),仅告警。当前的重启项清单:base_url、api_key_env(影响 HTTP 客户端构造)、sandbox、mcp_servers、hooks(外部连接需重建)、format_after_edit(启动期烘焙进 AgentConfig,#1774)、plugins(签名门只在加载时消费)、gateway.queue_mode、gateway.channels(影响消息主循环)。
watcher 的完整工作流分四步。启动时 ConfigWatcher::new(:44)接收要监视的路径集合;profile 模式下 with_profile_defaults(:67)会把全局 profile-defaults.json 一并纳入监视,这样编辑共享默认值层也能触发同一条 reload 路径;spawn(:87)起后台任务进入 5 秒轮询循环;每次轮询读入全部现存文件、拼接后算一次 SHA-256,哈希不变就直接跳过,变了才解析新配置并交给 diff_and_emit 分类。分类结果通过 tokio::sync::watch 通道发出,消费端(主要是 gateway 主循环)收到 HotReload 后把 system_prompt 写进 RwLock<String>、把 max_history 写进原子计数,两条写路径都不需要停顿消息处理。
两个健壮性细节值得单独记下。其一,defaults 层保留 last-known-good:一个此前合法的 profile-defaults.json 被改坏时,watcher 沿用上一次的正确解析,而不是把整个 base 层静默丢掉;从未合法过的新文件则保持 None。其二,解析失败的主配置同样保留旧值并告警,坏配置不会击穿正在运行的进程。
provider/model 的文件变更两条路都不走:watcher 既不把它归入 HotReload 自动应用,也不再报 RestartRequired;运行中切换走显式路径(model_check 工具触发 SwappableProvider.swap,见第 3 章)。安全的心智模型是:改 system_prompt/max_history 五秒内生效;改 provider/model 要么会话内显式切换,要么重启进程;改 base_url/hooks 等一定重启。
profile 的 policy 翻转有防误报回归注释(crates/octos-cli/src/config_watcher.rs:136-150):父 profile defaults 层的存在会让朴素的 diff 把继承值误判为变更,watcher 对这一层做了专门处理。轮询而非 inotify 的选择与跨平台有关:inotify 是 Linux 特有的,macOS 用 kqueue,Windows 用 ReadDirectoryChangesW,5 秒 SHA-256 轮询在三个平台行为一致且开销可忽略。此外 watcher 一次读入全部文件再统一哈希,避免了先检查再读取的 TOCTOU 竞态;解析失败时保留上一份有效配置并告警,不会带着坏配置崩溃。
工程决策侧栏:为什么
--danger-full-access必须绑--solo
--danger-full-access把每个新会话默认成 FULL-ACCESS:沙箱关、网络开、审批全免。这样的档位如果暴露在网络上,任何能到达端口的人就获得了主机的执行权。所以它的生效前提与--solo完全同构:--solo本身只在 loopback 直连、Local-mode、且请求不带反向代理头时生效,两个开关共享同一把「本地单用户」的钥匙。把免沙箱档绑在单人本地登录上,等价于把它绑在「攻击者必须先登录这台机器的操作系统」这一前提上。环境变量(OCTOS_DANGER_FULL_ACCESS=1)绕不开这一点:不带--solo的 serve 启动直接拒绝。这条绑定的可推广形式:危险能力的门禁不应该是独立的布尔开关,而应该复用一条已经过审计的信任链。单独的
--danger-full-access迟早会被写进某个 systemd unit 暴露在 0.0.0.0 上;绑定之后,「远程 + 免沙箱」这个状态在入口处就不合法。
工程决策侧栏:热加载与全重启的边界
能热加载的只有
system_prompt与max_history,前者是无状态文本(下一次 LLM 调用生效),后者是一个计数(原子替换)。这个清单之所以短,是因为每多一项热加载,就要回答「进行中的会话用旧值还是新值」「替换失败怎么回滚」两个问题。base_url与api_key_env影响连接池与 TLS,运行时替换可能撕裂进行中的请求;hooks携带熔断计数器,只换命令不重置状态会让被熔断的 hook 永不恢复;format_after_edit在启动期烘焙进 AgentConfig,三处入口(chat/gateway/serve)各自拷贝,没有共享的可变点。边界划分的原则:无状态或单值的字段热加载,有连接、有状态、有烘焙的字段重启。provider/model 处在中间,所以走的是第三条路:不热加载文件,但提供受控的运行时切换接口。
14.7 Feature Flags:编译期运行面
crates/octos-cli/Cargo.toml:142 起 [features] default = []:默认编译是最小集。api(:154)拉入 axum/rustls/prometheus 等,并连带 octos-bus/api 与 matrix;每个消息频道有自己的门(telegram、discord、dingtalk、slack、whatsapp、email、feishu、twilio、wecom、line、matrix、wecom-bot),对应 octos-bus 的通道编译;embed-llama/-metal/-cuda(:147-149)控制本地嵌入模型。Command::Serve 的存在性本身就被 #[cfg(feature = "api")] 门控(crates/octos-cli/src/commands/mod.rs:398)。
这与五种运行面的关系是双层的:运行面选择发生在运行期(子命令分派),能力裁剪发生在编译期(feature gates)。部署一个只接 Telegram 的 gateway,不需要编译 REST 服务器;反之 serve 部署不必编译未使用的频道。完整列表见附录 D。
14.8 本章回顾
- 五种运行面:chat(单机会话)、gateway(多频道常驻)、serve(控制面汇聚 +
--stdio挂载面)、mcp-serve/acp(协议服务面),从Command::execute(crates/octos-cli/src/commands/mod.rs:381)统一分派,共享配置、Provider 链、工具注册表与会话存储;入口文件合计 19,485 行。 - serve 是控制面汇聚点:装配 ProfileStore、EventBroadcaster、SessionManager、metrics、SwarmState 与 67 个 REST 端点(口径命令见事实表);
--stdio是 octoscode 的默认生产链(octos serve --stdio --solo),单写者锁用OCTOS_DATA_DIR_LOCKED标记冲突。 - serve 门禁:默认 127.0.0.1:50080;
--solo仅 loopback 生效;--danger-full-access强制绑--solo;网络默认放行可--no-network回退;coding/autonomy capability 是 backend-supervised orchestration 的投影。 - mcp-serve 只暴露
run_octos_session:session 级聚合结果,内部工具目录不外翻;stdio 是 parent-trust,HTTP 必须带 token。 - 配置体系 11,944 行四件套:文件优先级链(项目本地 > config_home > legacy)、启动分层链(CLI > env >
cli.<cmd>> 默认,仅 serve/gateway/chat 参与)、typed schema 拒绝未知字段、watcher 5 秒 SHA-256 轮询,热加载仅system_prompt/max_history。
延伸阅读
- MCP 规范(Model Context Protocol):https://modelcontextprotocol.io —
run_octos_session所遵循的 tools/call 语义。 - Agent Client Protocol:https://agentclientprotocol.com — Zed 等编辑器与 agent 的会话协议。
- 12-Factor App 的 Config 章:https://12factor.net/config — 对照本章「显式 CLI > env > config > 默认」的四层链。
- 本书附录 C(配置字段参考)与附录 D(Feature Flags 一览)。
思考题
--stdio模式复用 serve 的全部控制面装配但只服务一个客户端。如果要支持「一个 data dir、八个编辑器窗口」,--instance-data-dir的共享/私有边界应该划在哪里?哪些 store 必须私有,哪些可以共享?- watcher 的
RestartRequired只告警不动作。如果要在 gateway 里把gateway.channels变更做成「计划内重启」(drain 在途消息后自重启),需要哪些前提才能保证不丢消息? - mcp-serve 的粗粒度边界把工具选择权留给了 octos session 内部。如果一个外层编排器需要「逐步审批每次工具调用」,这个边界还成立吗?应该在哪一层加审批原语?
config_layer用value_source区分「用户显式给」与「默认值」。如果不用这个机制,改用Option<T>字段自己判空,28 个命令结构体要付出什么代价?
版本演化说明 本章分析基于 octos main @
9c157101(2026-09-02 提交,2026-09-03 统计),全部行号与数字的复现命令见assets/ch14-facts.md。相对 v1 旧稿(第 13 章)的主要更新:其一,「四种运行模式」重构为「五种运行面」,acp 与 serve--stdio升为一等公民,octos --help的 28 个子命令逐字核对;其二,REST 端点数由 91 修订为 67(口径命令随附,旧数作废);其三,补充 serve 门禁(--solo/--danger-full-access/--no-network/--swarm-backend)、coding/autonomy capability 投影与工具契约六状态、OCTOS_DATA_DIR_LOCKED单写者锁、config_layer 分层默认值链与 typed schema 拒绝语义。旧稿的SwappableProvider代码细讲移至第 3 章,本章只保留其与热加载边界的关系。
第 15 章:生产化:存储、服务、运维面与多租户
定位:本章分析 octos 从开发工具走向生产系统时抽出的三块基础设施:octos-store(持久化)、octos-services(支撑服务)、octos-diagnostics(诊断),以及租户隔离与 frpc 隧道部署路径。前置依赖:第 14 章(运行模式)。适用场景:需要把 octos 部署到生产环境的运维人员(读者 D),以及想理解「巨型 CLI crate 如何安全拆分」的开发者(读者 B)。
一个系统能跑起来,和它能被交给别人长期运维,中间隔着一批不性感的东西:凭据怎么存、审计怎么留、用量怎么记、坏了怎么诊断、多个租户怎么互相看不见。这些功能没有一个是用户主动要求的,但缺了任何一个,部署都会在某一天停下来。
octos 的生产化经历了三次抽取。第一次把散在 octos-cli 里的持久化代码拉出来,成为 octos-store;第二次把支撑服务拉出来,成为 octos-services;第三次把诊断和自更新计划逻辑拉出来,成为 octos-diagnostics。本章按这三个 crate 展开,再看多租户的四层隔离与 frpc 隧道部署路径,最后把运维面(指标、事件流、告警、自更新、doctor)拼成一张完整的生产控制面。
认证三流(OAuth PKCE、device code、paste-token)与 Hooks 生命周期的完整走读分别在旧章位置已有覆盖,本章只保留结论性引用:凭据存储在 ~/.octos/auth.json,Unix 下 0o600 权限(crates/octos-cli/src/auth/store.rs:59-71);bearer token 比较用常量时间实现,避免时序侧信道(crates/octos-cli/src/api/router.rs:1124-1233);Hooks 的 shell 协议(stdin JSON + exit code)与熔断器详见第 10 章。这样安排的目的是把篇幅留给「这次抽取到底改变了什么」。
15.1 为什么要拆:三个 crate 的抽取史
15.1.1 抽取前的状态
生产化相关的代码原本全部住在 octos-cli 里:admin token 存储是 crates/octos-store/src/admin_token_store.rs,setup 向导状态是 crates/octos-store/src/setup_state_store.rs,SMTP 密码是 crates/octos-store/src/smtp_secret_store.rs,租户管理是 crates/octos-services/src/tenant.rs,自更新是 crates/octos-services/src/updater.rs。它们都是叶子模块,不依赖 CLI 的命令层,却和几千行的命令代码一起编译、一起测试。
问题在依赖方向。这些存储模块只依赖 serde 和文件系统,却因为住在 octos-cli 里,被间接绑定了整个 CLI 的依赖闭包。想做单元测试,得拉起半个 crate 的编译产物;想在别的二进制(比如 octoscode 的 doctor)里复用审计或安装方式探测,只能复制代码。
拆分前后的形状变化可以用三个切面说清。第一是依赖边方向:抽取后两个 crate 在 Cargo 依赖图上只指向 octos-core(services 再加 octos-llm、octos-bus),不再有任何边指回 octos-cli;抽取前这些模块与命令层之间没有隔离,octos serve 改一行 AppState 定义,八个 store 全部重编。第二是测试输入:拆前测 AdminTokenStore 要在 octos-cli 的测试树里搭 tempdir 夹具;拆后 crate 内自带 #[cfg(test)],crates/octos-store/src/admin_token_store.rs 文件尾部的测试直接断言 0o600 权限位(crates/octos-store/src/admin_token_store.rs:182),不依赖任何外部环境。第三是编译产物粒度:CLI 二进制之外的工具(诊断、报表、未来的 CLI-agent 适配)现在可以只链 store/services,不必拖进整个 CLI。octos-diagnostics 的抽取动机更直接:octoscode 的 doctor/install_method 模块与 octos 服务端要做同一套探测,复制两份必然漂移,于是把「产品无关」部分下沉,产品差异收窄到 ProductSpec 一个入口(ADR 见 crate 文档,octoscode#182)。
15.1.2 octos-store:持久化层,9 文件 2,664 行
抽取后的 crates/octos-store/ 是一个纯持久化 crate,9 个源文件共 2,664 行(crates/octos-store/src/lib.rs 18 行 + 8 个 store 模块,行数来自事实表对 main @ 9c157101 的统计)。crates/octos-store/src/lib.rs 的模块文档直接说明了出身:"Self-contained persistence / state stores extracted from octos-cli"。
| 文件 | 行数 | 职责 | 关键符号 |
|---|---|---|---|
crates/octos-store/src/admin_token_store.rs | 184 | admin token 的 salt+hash 存储 | AdminTokenRecord(:14) AdminTokenStore(:56) |
crates/octos-store/src/admin_audit_store.rs | 270 | admin 操作审计(redb) | AdminAuditStore(:109) |
crates/octos-store/src/approvals_audit.rs | 414 | 审批决策审计(JSON-Lines 追加) | ApprovalsAuditLog(:119) |
crates/octos-store/src/login_allowlist.rs | 258 | 登录白名单(带锁重试) | LoginAllowlistStore(:28) |
crates/octos-store/src/setup_state_store.rs | 151 | 首启向导状态 | SetupStateStore(:24) |
crates/octos-store/src/smtp_secret_store.rs | 133 | SMTP 密码文件 | SmtpSecretStore(:17) |
crates/octos-store/src/usage_ledger.rs | 901 | 用量台账(redb) | UsageEvent(:42) PersistentUsageLedger(:323) |
crates/octos-store/src/user_store.rs | 335 | 多用户管理(每用户一 JSON) | UserStore(:40) |
八种存储覆盖了生产部署的全部状态面:身份(token、user、allowlist)、生命周期(setup)、密件(smtp secret)、审计(admin audit、approvals audit)、计费(usage ledger)。注意存储介质分了两种:简单状态用单文件 JSON,高追加量数据(usage ledger 901 行、admin audit 270 行)用 redb 嵌入式 KV。这个分界本身就是一条工程判断,详见本章侧栏。
以 crates/octos-store/src/admin_token_store.rs 为例看抽取后的形态。文件首页文档写明:"Hashed admin auth token stored at {data_dir}/admin_token.json. Replaces the static config/env bootstrap token once rotated." 记录结构只有四个字段(crates/octos-store/src/admin_token_store.rs:14-21):
#![allow(unused)] fn main() { pub struct AdminTokenRecord { /// 16 random bytes, base64 (URL-safe, no padding). pub salt: String, /// sha256(salt_bytes || token_bytes), base64 (URL-safe, no padding). pub hash: String, pub created_at: DateTime<Utc>, pub created_by: String, } }
校验函数 verify() 先从 salt 重建期望 hash,再用 constant_time_eq 比较(crates/octos-store/src/admin_token_store.rs:39-45);保存路径是临时文件 + rename,Unix 下设 0o600(crates/octos-store/src/admin_token_store.rs:96-104)。文件里没有一行 CLI 代码,测试用 tempdir 就能跑完。
规模最大的 crates/octos-store/src/usage_ledger.rs(901 行)是给「每个 LLM run 记一行」的台账,UsageEvent 结构在 :42,redb 持久化入口 PersistentUsageLedger 在 :323。字段设计有一个值得留意的点:cache 读取 token 被单独记录,文档注释明确说明 provider 报告的 prompt 总数里含缓存命中,而 TokenUsage 在 provider 边界就把缓存部分减掉了,所以完整 prompt 数是 input_tokens + cache_read_tokens(crates/octos-store/src/usage_ledger.rs:52-66)。这让「缓存到底省了多少钱」成为事后可查的数据,而不必现场探测 API。
15.1.3 octos-services:支撑服务层,8 文件 3,223 行
第二个抽出的是 crates/octos-services/,8 个源文件共 3,223 行。crates/octos-store/src/lib.rs 文档同样点明出身:"Self-contained support services extracted from octos-cli"。模块共 7 个:
| 文件 | 行数 | 职责 | 关键符号 |
|---|---|---|---|
crates/octos-services/src/cli_agent_adapter.rs | 587 | 未来 CLI-agent 适配的进程边界 | CliAgentProcess(:145) |
crates/octos-agent/src/agent/compaction.rs | 320 | 会话压缩 | maybe_compact(:37) |
crates/octos-services/src/config_context.rs | 698 | 配置/凭据/数据路径的唯一解析入口 | resolve_config_context(:151) |
crates/octos-services/src/persona_service.rs | 479 | 定期生成沟通风格画像 | PersonaService(:97) |
crates/octos-services/src/soul_service.rs | 139 | 每用户 personality 存储 | read_soul(:24) |
crates/octos-services/src/tenant.rs | 512 | 租户隧道管理 | TenantStore(:82) render_frpc_config(:255) |
crates/octos-services/src/updater.rs | 473 | 自更新:下载、校验、备份、替换、回滚 | Updater(:33) |
两个 crate 的依赖关系经过裁剪:octos-store 与 octos-services 都只依赖 octos-core(加上 serde/eyre 等基础设施);services 因为要调 LLM 与消息总线,额外依赖 octos-llm 与 octos-bus(事实表 §2 的 Cargo 依赖核对)。抽取后的依赖图里,它们都是叶子方向,不会再把 CLI 的重量传染给别人。
crates/octos-services/src/config_context.rs(698 行)是这批模块里最值得停下来的一个。文档自称 "Canonical config/auth/data path resolver — the single source of truth":所有配置路径、凭据路径、数据目录路径必须经 resolve_config_context 解析,不允许各模块自行拼接。这条规则直接回应了旧版的一个痛点:同一路径在多个模块各拼一遍,运行模式一多就漂移。
15.1.4 octos-diagnostics:诊断与更新计划,8 文件 2,243 行
最新的抽取是 crates/octos-diagnostics/,8 个文件 2,243 行,分两个阶段进入主干:Stage 1(commit 7f81fa5e)交付 crate 本体与 octos doctor;Stage 2(commit 1801a9e9)加入 GitHub client 与 update --check、Network check。
这个 crate 的模块文档里有一个反复强调的词:product-agnostic。它不为 octos 专有,同一个诊断内核要同时服务 octos 与 octoscode 两个二进制。实现这条复用的接缝是 crates/octos-diagnostics/src/spec.rs 里的 ProductSpec(:173 行文件,事实表 §3):
Callers describe their product once via a
ProductSpec; everything else (install-method labels/upgrade hints, PATH/shadow locating, asset selection) reads from it.
文档接着给出一条硬规则(ADR「Traps」节):当前版本号必须由调用方传入,这个 crate 永远不读自己的 CARGO_PKG_VERSION 来描述产品,否则诊断报告会打出 diagnostics crate 自己的版本而不是被诊断二进制的版本(crates/octos-diagnostics/src/spec.rs:7-9)。
文件清单:crates/octos-diagnostics/src/checks.rs 400 行(终端环境、config/data 目录等本地检查)、crates/octos-diagnostics/src/github.rs 321 行(blocking GitHub Releases client)、crates/octos-diagnostics/src/install_method.rs 505 行(安装方式探测)、crates/octos-diagnostics/src/locate.rs 278 行(PATH 解析与 shadow 检测)、crates/octos-diagnostics/src/report.rs 312 行(报告模型,CheckStatus/Check)、crates/octos-diagnostics/src/spec.rs 173 行(ProductSpec 接缝)、crates/octos-diagnostics/src/update.rs 198 行(semver 解析与纯函数更新计划器)。
15.1.5 抽取的判据
三个 crate 放在一起看,抽取判据是稳定的:模块不依赖 CLI 命令层、有独立测试价值、或者要被第二个二进制复用。反过来,依赖 AppState 或路由的代码(handler、middleware)留在 octos-cli,没有跟着搬。这是一条可复用的边界:按依赖方向拆,而不是按领域名词拆。
工程决策侧栏:redb 还是 JSON 文件?
octos-store 里两种存储介质并存:login/setup/admin_token/smtp_secret/user 是单文件 JSON,usage_ledger 与 admin_audit 用 redb。选择依据是写入形态。前者是「整体读、整体写」的小状态,一份数据一个文件,人工可读、备份就是复制文件;后者是「只追加、按查询条件读」的流水,JSON 文件追加写要么整文件重写,要么退化成自己维护索引,redb 提供的持久 B 树正好落在中间。判断一个新存储该用哪种介质时,先问写入形态,再问数据量,最后才是查询复杂度。
15.2 多租户:四层隔离
octos 的多租户不是一个 tenant ID 字段,而是 Profile、Account、User、Session 四层叠出来的隔离模型。代码侧的 tenancy 词汇分布很能说明问题:全词 tenant 在 crates/ 下命中 2,261 次,其中最多的不是任何 tenant 模块,而是 crates/octos-cli/src/autonomy/agent_orchestrator.rs(921 次),其次才是 crates/octos-cli/src/api/admin.rs(138)、crates/octos-cli/src/api/auth_handlers.rs(130)与 crates/octos-cli/src/api/ui_protocol_transport.rs(102)(事实表 §4 的 grep 统计)。租户概念已经渗透进编排、管理 API、认证与传输各层。
15.2.1 边界的定义位置
隔离规则的定义在 crates/octos-core/src/session_scope.rs:46-68。文档写得很直接:多租户模式(octos serve + AppUI web client)下,多个租户共享一个 octos 进程,每个租户有自己的 profile 目录 <config_dir>/profiles/<tenant_id>/;跨租户访问无条件拒绝;跨 session 写在 workspace 层拒绝;跨 session 读需要显式用户动作(/resume、recall),不做隐式 CWD 扫描。solo 模式(octos chat)则没有租户边界,session 与 workspace 合并为用户选定的 CWD,跨 session 连续性是特性。
15.2.2 子账号:继承什么,不继承什么
租户之下还有子账号层。创建入口是 create_sub_account(crates/octos-cli/src/profiles.rs:2048),规则有三条:父 profile 必须存在;父自己不能已是子账号(禁止多层嵌套);数量受 MAX_SUB_ACCOUNTS_PER_PARENT = 10 上限约束(crates/octos-cli/src/profiles.rs:16)。子账号 ID 形如 {parent_id}--{sub},创建时 config.llm 置 None(crates/octos-cli/src/profiles.rs:2092 附近),等运行时解析继承。
继承发生在 resolve_effective_profile(crates/octos-cli/src/profiles.rs:2403-2450),规则按字段分三档:
#![allow(unused)] fn main() { // Inherit the LLM contract from parent. ec.llm = pc.llm.clone(); if ec.review.is_none() { ec.review = pc.review.clone(); } // ...(search / deep_crawl / apps / email / tool_policy 同为 None 才继承) // Merge env_vars: parent as base, sub-account overrides win let mut merged_env = pc.env_vars.clone(); merged_env.extend(ec.env_vars.clone()); ec.env_vars = merged_env; }
一档是 llm:无条件整体覆盖,父的 LLM contract 就是子的 LLM contract。二档是 review/search/deep_crawl/apps/email/tool_policy:子未配置(None)才继承,子自己配了就以子为准。三档是 env_vars:父作 base、子覆盖同名变量后合并。
有一项明确不继承:customer-installed skills。子账号的 skills 目录严格按当前 account 解析,不随 resolve_effective_profile 的 config 继承链传递(事实表 §4 引 crates/octos-cli/src/profiles.rs:360-407 的 skills layer 语义)。安全含义直白:父账号装了一个 skill,不代表每个子账号都自动获得执行它的能力。
这个分层的心智模型:父账号提供共享能力契约(LLM、搜索、邮件策略),子账号提供接入面与差异化覆盖(频道凭据、公开子域、少量环境变量)。继承是显式的、逐字段的,不存在「子账号天然等同父账号」的隐含语义。
15.2.3 Session actor 与进程级隔离
同进程内的并发隔离靠 session actor:crates/octos-cli/src/session_actor.rs:1-3 的文档说明每 session 一个 tokio task 的 actor 替换了旧的 spawn-per-message 模型;gateway 侧由 crates/octos-cli/src/commands/gateway/profile_factory.rs:1-4 按 profile 构建 dedicated ActorFactory,自带 LLM stack、tool registry、skills 与 system prompt。
这次替换修的是一个具体竞态,文件首页文档点破了它:旧的 spawn-per-message 模型里共享的 tools 会通过 set_context() 切换会话上下文,两条消息并发到达时,后到的 set_context 可能把前一条消息路由到错误的聊天(crates/octos-cli/src/session_actor.rs:1-4 的注释原文)。actor 模型的解法是把「谁拥有工具」从共享可变状态改为每 task 私有:消息进 mailbox,task 串行处理,context 不再跨消息存活。隔离边界因此从「约定」变成「结构」:两个 session 之间没有共享的 tool registry 实例,误路由在类型层面就没有发生的位置。profile_factory 在其上加一层:收到指向特定 profile 的消息(比如 Matrix 子 bot)时,当场构建带该 profile 自己 LLM 栈与工具注册表的 factory,子账号会话拿到的运行时与父账号完全独立。
进程级隔离是另一条路径:process-manager 模式下,每个 profile 启动独立的 octos gateway 子进程,通过 --profile、--data-dir、--cwd 等参数传递配置。两层并不互斥,生产管理面通常走 process-manager。
隔离层次的边界也要说清:即使拆成了子进程,只要没有 cgroup 或系统级限额,一个 profile 的 CPU/内存压力仍会影响同宿主的其他 profile。进程边界挡住的是文件系统与配置串扰,挡不住资源竞争。这点在规划多租户宿主机容量时要单独补系统层手段。
flowchart TD
subgraph 宿主["宿主机(需要系统级限额补足)"]
PM["ProcessManager"]
subgraph GW1["gateway 子进程: profile A"]
A1["session actor 1"]
A2["session actor 2"]
end
subgraph GW2["gateway 子进程: profile A--sub1"]
B1["session actor"]
end
SRV["octos serve(AppUI 多租户)"]
TS["TenantStore<br/>data_dir/tenants/*.json"]
end
PM --> GW1
PM --> GW2
SRV --> TS
SRV -.->|"同进程内 profile 目录隔离"| GW1
图 15-1:租户与进程两层隔离。 serve 进程内按 profile 目录隔离;process-manager 之下再加每 profile 一个 gateway 子进程;资源边界需系统级限额补足。
15.3 部署路径:frpc 隧道
多租户落地到真实部署,octos 选择的是 frp 隧道方案。载体是 crates/octos-services/src/tenant.rs(512 行),模块文档自称 "Tunnel tenant management for self-hosted Mac Mini deployments",整体拓扑:管理面跑在 VPS,租户的 octos 实例跑在各自机器上,中间用 frp 隧道连接。
15.3.1 TenantConfig 与端口池
租户配置的核心结构在 :23,字段包括 id(slug)、subdomain(隧道子域)、tunnel_token(每租户隧道令牌,UUID)、ssh_port(VPS 侧分配的 SSH 隧道端口)、local_port(租户机器上的 serve 端口,缺省 8080)、auth_token、owner 与状态时间戳。
端口池是显式的常量(crates/octos-services/src/tenant.rs:14-15):
#![allow(unused)] fn main() { pub const SSH_PORT_START: u16 = 6001; pub const SSH_PORT_END: u16 = 6999; }
分配算法在 next_ssh_port(:185-194):列出现有租户,收集已占端口,从 6001 起顺序找第一个空闲口,池满时报错退出。查询族有四个入口,find_by_tunnel_token(:197)、find_by_subdomain(:203)、find_by_ssh_port(:209)、find_by_owner(:219),分别服务于隧道认证、子域路由、端口去重与按属主列举。
租户 ID 的校验也值得一看:测试用例覆盖了拒绝空 ID、拒绝大写、拒绝首尾连字符、拒绝路径穿越(../etc、..、foo/bar、.hidden 全部拒绝,crates/octos-services/src/tenant.rs:270-310 附近的测试组)。因为租户 ID 会直接拼进 profile 目录路径与文件名,ID 校验就是路径安全的第一道闸。
15.3.2 render_frpc_config:模板替换
隧道配置的生成入口在 :255:
#![allow(unused)] fn main() { pub fn render_frpc_config( tenant: &TenantConfig, frps_server: &str, frps_port: u16, tunnel_domain: &str, frps_token: &str, ) -> String { let template = include_str!("../../../scripts/frp/tenant-frpc.toml.template"); template .replace("{{FRPS_SERVER}}", frps_server) .replace("{{FRPS_PORT}}", &frps_port.to_string()) .replace("{{FRPS_TOKEN}}", frps_token) .replace("{{SUBDOMAIN}}", &tenant.subdomain) .replace("{{LOCAL_PORT}}", &tenant.local_port.to_string()) .replace("{{SSH_REMOTE_PORT}}", &tenant.ssh_port.to_string()) .replace("{{TUNNEL_DOMAIN}}", tunnel_domain) } }
实现是编译期内嵌 TOML 模板加占位符替换,七个占位符对应 frps 服务端、端口、令牌、子域、本地端口、远端 SSH 端口与隧道域名。include_str! 保证模板随二进制分发,部署侧不需要额外携带模板文件。tunnel_token 的用途在 TenantConfig 字段注释里写明:作为 frpc.toml 的 auth.token,frps 插件在 Login 阶段校验 md5(tunnel_token + timestamp)(crates/octos-services/src/tenant.rs:29-30)。
flowchart LR
subgraph Tenant["租户机器(Mac Mini 等)"]
Serve["octos serve<br/>local_port 8080"]
Frpc["frpc(render_frpc_config 产出)"]
end
subgraph VPS["VPS(frs 服务端)"]
Frps["frps"]
Admin["octos 管理面<br/>TenantStore"]
end
Browser["浏览器 xxx.tunnel-domain"]
Browser -->|"subdomain 路由"| Frps
Frps <-->|"隧道<br/>tunnel_token 认证"| Frpc
Frpc --> Serve
Admin -->|"分配 ssh_port 6001-6999<br/>渲染 frpc.toml"| Frpc
图 15-2:租户隧道部署。 管理面在 VPS 上维护 TenantStore,为每个租户分配子域与 SSH 远端端口,渲染 frpc 配置;租户机器上的 serve 实例经 frp 隧道对外暴露。
这个方案的适用场景是自托管小规模部署:每租户一台机器,管理面集中分配子域与端口。规模再往上,端口池上限 999 个、每租户一条隧道的模型就需要重新设计。
15.4 运维面:指标、事件流、告警、自更新、诊断
把三个 crate 和 API 层拼起来,得到的是一张完整的运维面。按「读状态」到「动手修」排列:Prometheus 指标与 harness 事件流负责观察,monitor 负责告警与重启,updater 与 doctor 负责修复。
15.4.1 Prometheus 指标:单进程与多源聚合
指标端点实现在 crates/octos-cli/src/api/metrics.rs(1,554 行),核心只有几行:metrics_handler 从全局 PrometheusHandle 渲染文本(:29-34),句柄在 init_metrics(:18)注册一次。埋点入口按事件类型分:record_tool_call(工具调用计数与耗时,:929)、record_llm_tokens(token 计数,:937)、record_routing_decision(路由决策,:947)。
这个文件的大头不是暴露指标,而是聚合:OperatorSummary 系列(:106-139)把多个 gateway 子进程的 /metrics 抓回来合并成运维摘要。OperatorSummaryCollection 的字段设计暴露了一个细节:配置错误的 gateway 被单列为 configuration_error_gateways,刻意不计入 running_gateways,注释解释是防止坏配置被读成活进程。多进程部署下,「哪些 profile 活着、哪些带 API 端口、抓取失败几个」就是运维要的第一屏数据。
聚合的设计判据在 build_operator_summary_from_sources(:139-193)的实现里:输入不是数据库而是已经抓回来的 metrics_text 文本,函数先 parse_metric_samples 解析出样本,再按 source.scope == "gateway" 分桶计数——running 的才计入 api_port 统计与 scrape_failures,非 running 的单走 configuration_error 分支(:161-178)。也就是说聚合层不信任单一信号:进程活着(process manager 的 running 位)、带 API 端口、抓取成功、样本非空,四个条件各自独立记录。一个 gateway 可以 running 但 scrape failed(进程在、指标端点挂了),也可以有 api_port 但样本为空(端点在、埋点没注册)。运维排障时这四列能直接定位问题在哪一层,而不是只得到一个「不健康」。这个判据也解释了为什么聚合逻辑放在 crates/octos-cli/src/api/metrics.rs 而不是 Prometheus 侧:跨进程的 running 状态只有 process manager 知道,Prometheus 抓不到「该活着但没起来」的进程。
15.4.2 harness 事件流:typed SSE
crates/octos-cli/src/api/events_harness.rs(183 行)提供 GET /api/events/harness,订阅共享 EventBroadcaster 并按 SSE 逐帧转发;可选 kinds 查询参数按帧的 kind 字段过滤,大小写与下划线不敏感,兼容既有调用方。文件头注释里留了一段历史:这个端点曾被描述为 dashboard 的不变量,却从未注册进 router,请求落到静态文件 fallback 返回 307 跳转,把 Playwright 的 apiRequestContext 困在重定向循环里,直到回归测试抓到才补上注册。认证继承 user_auth_middleware(admin Bearer 或已认证 session),handler 只读不写。
15.4.3 UI Protocol:能力协商作为门控
前端控制走 UI Protocol(crates/octos-core/src/ui_protocol.rs,7,221 行)。能力协商的机制在 :1658-1700 的 for_negotiated_features:请求的 feature 列表与服务器已知注册表取交集,未知 feature 直接丢弃;方法级门控与 feature 集保持一致,task/list、task/cancel、task/restart_from_node 只有协商到 harness.task_control.v1(常量在 :113)才会出现在 supported_methods 里。注释给出了理由:如果广告了方法却不广告 feature,客户端会调用然后被 method_not_supported 拒绝,不如两边一致。
first_server_slice(:1630)是无 feature header 的兼容基线:V2 能力服务器已知,但不放进首个响应,否则会改变老客户端看到的字节级 session/open 响应。兼容性与演进在这里的取舍是:新能力必须显式请求才出现。
15.4.4 monitor:告警与看门狗
crates/octos-cli/src/monitor.rs(518 行)的 Monitor(:120)持有 profile store、process manager、告警通道与开关位,run()(:164)启动循环,add_sender(:159)注册发送器,内置 TelegramAlertSender(:357)与 FeishuAlertSender(:390)。它把「进程死了要有人知道」和「死了要试着拉起来」合并成一个组件:健康检查按 health_interval 轮询,重启次数受 max_restart_attempts 约束,告警经 sender 分发到 IM。
15.4.5 updater 与 doctor:两条修复路径
应用层自更新在 crates/octos-services/src/updater.rs(473 行),流程在 update(:140):download 到 verify 到 backup 到 replace,失败走 rollback。check_latest(:76)查 GitHub Releases,Updater::new 支持 GITHUB_TOKEN 环境变量鉴权(:40-49)。
诊断路径独立于更新:octos doctor 走 octos-diagnostics 的检查集(终端环境、config/data、PATH/shadow、网络),按 [✓]/[!]/[✗] 产出报告,支持 JSON support-bundle 输出;update --check 走 Stage 2 的 GitHub client 与纯函数计划器(crates/octos-diagnostics/src/update.rs 的 plan)比对 semver,只报告不执行。两条路径合起来覆盖了「升级前确认环境」与「升级本身」两步。
flowchart TD
Metrics["/metrics<br/>crates/octos-cli/src/api/metrics.rs 1554 行"]
SSE["/api/events/harness<br/>crates/octos-cli/src/api/events_harness.rs 183 行"]
UIP["UI Protocol<br/>crates/octos-core/src/ui_protocol.rs 7221 行"]
Monitor["crates/octos-cli/src/monitor.rs<br/>Monitor + AlertSender"]
Ledger["usage_ledger<br/>octos-store 901 行"]
AdminAudit["admin_audit + approvals_audit"]
Updater["crates/octos-services/src/updater.rs<br/>download-verify-backup-replace"]
Doctor["octos-diagnostics<br/>doctor + update --check"]
Metrics -->|"Prometheus 抓取"| Grafana["运维仪表盘"]
SSE -->|"kind 过滤"| Dash["dashboard LiveView"]
UIP -->|"feature 协商"| AppUI["AppUI 前端"]
Monitor -->|"Telegram / Feishu"| OnCall["值班 IM"]
Monitor -->|"watchdog 重启"| GW["gateway 子进程"]
Ledger --> AdminUI["用量/成本报表"]
AdminAudit --> Audit["合规取证"]
Doctor --> Report["诊断报告"]
Updater --> Bin["octos 二进制"]
图 15-3:运维面全景。 观察通道(Prometheus、SSE、UI Protocol)只读;动作通道(monitor 重启、updater 替换、doctor 报告)各管一段;存储与审计落在 octos-store。
15.4.6 控制面状态文件
最后把 serve 进程持有的持久状态列成一张部署清单,全部位于 data_dir 下:admin_token.json(admin 凭据,salt+hash,0600)、setup_state.json(向导进度)、smtp_secret.json(SMTP 密码,替代环境变量)、tenants/*.json(租户配置)、users/*.json(用户)、usage_ledger.redb(用量)、approvals-*.log 与 admin audit(审计)。部署一个 serve 实例,等于部署这组文件的生命周期:备份策略、权限、轮转(审批日志默认 10 MiB 轮转、90 天保留,crates/octos-store/src/approvals_audit.rs:23-27)都要逐一配套。
工程决策侧栏:为什么 smtp_secret 要单独成文件?
SMTP 密码最常见的存法是塞进 launchd plist 或 systemd unit 的环境变量。这种存法的问题不在加密强度,在暴露面:unit 文件通常全员可读、会进配置仓库、被
ps e与崩溃转储顺带捞走。crates/octos-store/src/smtp_secret_store.rs的首页文档直接点名这个动机(替代SMTP_PASSWORD环境变量)。单文件方案把密件收窄到 data_dir 下一个 0600 文件,配置仓库里只剩 host/port/username。同样的思路也适用于其他 secret:环境变量适合传启动参数,不适合长期存密件。
15.5 与相邻章的边界
本章讲的存储与服务是横向基础设施,几处容易与专章混淆的边界:安全机制全景(沙箱 fail-closed、注入防御、WorkerGrant)见第 7 章,本章只引用其结论(admin token 常量时间比较);Harness 事件 ABI、校验器与 schema 版本化见第 10 章,本章的 harness SSE 是它的传输层消费端;运行模式与配置体系见第 14 章,本章的 serve 控制面建立在其上;第 1 章的 26 crate 地图给出了三 crate 在全局依赖图中的位置,它们都挂在 octos-core 之下、octos-cli 之旁。另外,spec 提到的 agent lifecycle、goal/loop primitive 与 supervisor 持久化属于 orchestration substrate,是第 18 章的主题;本章只在 crates/octos-cli/src/autonomy/agent_orchestrator.rs 的 tenancy 命中统计里与它擦肩,不展开。
15.6 本章回顾
- 三次抽取:octos-store(9 文件 2,664 行,持久化)、octos-services(8 文件 3,223 行,支撑服务)、octos-diagnostics(8 文件 2,243 行,诊断与更新计划),判据是依赖方向与复用需求,不是领域名词。
- 多租户四层:Profile 目录隔离、子账号显式继承(llm 全继承、其余 None 才继承、env_vars 合并)、User 文件隔离、Session actor 隔离;进程级隔离靠 process-manager,资源限额要系统层补。
- frpc 隧道:TenantStore 管端口池(6001-6999)与子域,
render_frpc_config用编译期内嵌模板渲染每租户配置。 - 运维面:Prometheus 多源聚合、typed SSE 事件流、UI Protocol 能力协商门控、monitor 告警与看门狗、updater 五步自更新、doctor 两阶段诊断。
- 控制面状态文件是部署清单的核心:备份、权限、轮转逐一配套。
延伸阅读
- frp 项目:https://github.com/fatedier/frp — crates/octos-services/src/tenant.rs 隧道方案依赖的底层转发工具
- redb:https://github.com/cberner/redb — octos-store 用于 usage ledger 与 admin audit 的嵌入式 KV 存储
- Prometheus 文本格式:https://prometheus.io/docs/instrumenting/exposition_formats/ — crates/octos-cli/src/api/metrics.rs 渲染与 OperatorSummary 解析的线协议
- RFC 7636(PKCE):https://datatracker.ietf.org/doc/html/rfc7636 — 认证三流的 challenge 机制
思考题
- usage_ledger 用 redb、user_store 用单文件 JSON。如果 admin_audit 的写入量增长一百倍,你会保持 redb 还是引入独立数据库?判断依据是什么?
resolve_effective_profile的继承分三档(None 才继承、无条件继承、合并)。如果要加一个「父禁用则子必禁用」的字段,应该放哪一档?字段语义如何写才不与现有三档冲突?- 端口池上限 999 个租户。改成分片多 VPS 时,TenantStore 的哪些方法签名必须变?
next_ssh_port的顺序分配在并发创建下有什么竞态? - UI Protocol 拒绝广告未协商 feature 的方法,注释的理由是避免 method_not_supported。如果反过来(广告全部方法、运行时拒绝),会对哪些客户端造成什么伤害?
版本演化说明 本章分析基于 octos main @
9c157101(2026-09-02 提交,2026-09-03 复核)。相对旧版第 14 章:三个存储已从 octos-cli 迁至crates/octos-store/,多租户实现迁至crates/octos-services/src/tenant.rs(render_frpc_config:255 是新的部署路径),诊断独立成crates/octos-diagnostics/(Stage 17f81fa5e、Stage 21801a9e9)。本章编号由 14 改为 15,认证三流详见本章 15.1,Hooks 详见第 10 章。所有行号来自事实表assets/ch15-facts.md或本次会话对源码的只读核对。
第 16 章:Fleet:可恢复的计划执行内核
定位:本章分析 v2 新增的 fleet 内核:
crates/octos-fleet(7 文件 16,888 行)与crates/octos-fleet-worker(6 文件 6,842 行)如何用 redb 事务、attempt/lease/generation 状态机与持久 outbox,把一组长任务的执行进度从对话上下文搬进可恢复的存储。前置依赖:第 7 章(WorkerGrant 能力授予)、第 12 章(supervisor 事件账本与租约)。适用场景:要构建多 agent 编排运行时的开发者,以及关心「进程崩溃后接着跑」如何工程化的读者。
16.1 执行状态放在哪
第 18 章的 goal 功能把一个持久目标放进单个 agent 的对话里推进。对话会压缩,压缩有损,于是目标的执行状态(计划、决策、做完什么、还剩什么)随任务变长而流失。目标本身还挂在系统提示上,进度却在腐烂。这是 docs/FLEET-RUNTIME-ADR.md(176 行)开篇点名的失败模式:objective survives, progress rots。
写 ADR 时,仓库里已有三套互不知情的编排栈:peers 是持久的交互式会话,octos-swarm 是对外部一次性 agent 的批量派发,octos-pipeline 是 DOT 图工作流引擎(第 13 章)。如果直接把 goal 架在 peers 上,会出现第四套不兼容的账本、预算、重试与生命周期模型。ADR 的决定是先造一个共用的持久内核:子任务状态账本、预算预留、状态机、验收门都在内核里,批量、DAG、动态编队三种拓扑作为内核之上的规划器存在;worker 分两种,一次性任务 worker 先落地,可停靠的会话 worker 留给后续阶段。内核的实现就是本章的两个 crate,规格书是 docs/FLEET-KERNEL-V1-SPEC.md(347 行)与 docs/FLEET-KERNEL-FOUNDATION-SPEC.md(219 行)。
「执行状态腐烂」不是抽象担忧,可以落到具体物件上。goal 的计划是一份任务列表,决策是若干条带理由的判断,进度是「哪个任务到哪一步」。这三样东西如果只存在于对话历史里,一次压缩就可能丢失中间任务的结论,重试时 agent 只能凭摘要猜。内核把它们物化成 redb 里的行:计划是 plans 表的 DurablePlan,决策是 decision_log 表的追加条目,进度是 child 与 attempt 的状态字段。对话可以随便压缩,行不会;重试不需要猜,读表就行。这是「可恢复」三个字在数据层的全部含义,后面三节讲的是如何让这些行在并发与崩溃下仍然自洽。
两个 crate 的体量与分工:
| crate | 文件 | 行数 | 职责 |
|---|---|---|---|
| octos-fleet | 7 | 16,888 | 持久事务内核:记录、CAS 状态转移、预算、outbox、恢复 |
| octos-fleet-worker | 6 | 6,842 | 执行半边:封闭工具注册表、单次 attempt 执行器、有界池、escalate 阀 |
crates/octos-fleet/src/lib.rs 的模块文档给了一条硬边界:这个 crate 只依赖 redb、serde、tokio、uuid、eyre 与 octos-core,零 LLM 依赖、零 octos-agent 依赖,所有行为都能对着 tempdir 里的 redb 做单元测试。执行侧的依赖复杂性被整体推给了 worker crate。
本章沿三条支柱展开:记录模型(16.2)、事务与 outbox(16.3)、状态机与恢复(16.4),然后看 worker 侧的装配与执行(16.5、16.6),最后交代 Fleet 表层与相邻章的边界。
16.2 记录模型:一切先落盘
内核的世界由六张 redb 表构成,全部定义在 crates/octos-fleet/src/store.rs:46-51:fleets(fleet 行)、fleet_children(子任务行)、attempts(尝试行)、plans(持久计划)、decision_log(追加式决策日志)、outbox(事件发件箱)。键都是字符串,值是 JSON。记录类型集中在 crates/octos-fleet/src/records.rs(808 行),关键锚点如下:
| 符号 | 行号 | 内容 |
|---|---|---|
SCHEMA_VERSION = 3 | crates/octos-fleet/src/records.rs:33 | 每行携带的版本号 |
FleetBudget | crates/octos-fleet/src/records.rs:119 | token_budget / tokens_reserved / tokens_committed / hard |
FleetRecord | crates/octos-fleet/src/records.rs:145 | fleet 行,含 generation 围栏与 keeper 唤醒元数据(:152) |
Lease | crates/octos-fleet/src/records.rs:250 | owner_epoch + expires_at_ms |
Attempt | crates/octos-fleet/src/records.rs:256 | 尝试行,含 generation 与 lease |
DurablePlan | crates/octos-fleet/src/records.rs:313 | 计划,内含 PlanTask:333、AcceptanceCriterion:356、Verifier:367、EvidenceRef:378 |
OutboxEvent | crates/octos-fleet/src/records.rs:524 | 持久事件,FleetEventKind:545 |
计划任务是全模型的重点。PlanTask 不只有标题与依赖,还带 acceptance 验收条件列表与逐任务的 grant(worker 级能力授予,#[serde(default)] 使旧行缺省为最小授权)。验收条件由 Verifier 定义,目前四种:Manual、FileExists、CommandExit、ValidatorRef。完成的定义是数据加校验器,不是布尔值。EvidenceRef 把证据内容寻址(sha256),验收断言与实际观察到的内容无法漂移。
classDiagram
class DurablePlan {
+revision
+tasks: Vec~PlanTask~
}
class PlanTask {
+task_id
+deps: Vec~String~
+acceptance: Vec~AcceptanceCriterion~
+grant: WorkerGrant
}
class FleetRecord {
+status: FleetStatus
+generation: u64
+budget: FleetBudget
}
class FleetChildRecord {
+status: ChildStatus
+current_attempt_id
+pending_escalation
}
class Attempt {
+status: AttemptStatus
+generation: u64
+lease: Lease
+reserved_tokens
}
class OutboxEvent {
+sequence
+kind: FleetEventKind
+claim_token
+acked
}
DurablePlan "1" --> "*" PlanTask
FleetRecord "1" --> "*" FleetChildRecord
FleetChildRecord "0..1" --> "*" Attempt
FleetRecord "1" --> "*" OutboxEvent
图 16-1:记录模型。 计划描述意图,child 与 attempt 记录执行,budget 与 generation 挂在 fleet 行上,outbox 承担对外通知。
版本迁移的策略值得单说。每行持久化数据都带 schema_version,加载时只丢弃版本更高的行(crates/octos-fleet/src/records.rs:571 的高版本守卫),旧二进制遇到新行是「不认识就丢」,永远不会错解析。SCHEMA_VERSION 从 2 升到 3 的原因写在 crates/octos-fleet/src/records.rs:25 的注释里:PR B 给 ChildStatus 增加了 Blocked 变体。加字段可以用 #[serde(default)] 向前兼容,不算升级;加枚举变体是破坏性变更,旧二进制解码 "Blocked" 会报未知变体错误而不是优雅跳过,所以必须升版本号,让旧二进制把这样的行按高版本丢弃。
这个判据值得展开,因为它是所有持久化 schema 的通用问题。字段新增是「行变宽」:旧代码读不到新键,serde 用默认值补上,数据仍在,只是新功能不可见。枚举变体新增是「值域变宽」:旧代码的 match 没有那个分支,反序列化直接失败,而且失败发生在整行解码时,一行坏会拖垮一次扫描。octos 的选择是把失败的粒度收进行级:版本守卫在完整解码前只探测 schema_version(crates/octos-fleet/src/records.rs 的 VersionProbe 结构),高版本行按 Ok(None) 丢弃,扫描继续。代价是丢数据,但丢的是「新二进制才理解的行」,旧二进制本来也无法正确处理它们,丢弃与失明等价,好过失明装作看见。
16.3 事务与 outbox:一次转移一个写事务
FleetKernelStore(crates/octos-fleet/src/store.rs:195)是事务原语层。模块文档(crates/octos-fleet/src/store.rs:6-8)给出每个方法的固定形状:一个 begin_write 内完成读当前行、检查 CAS 谓词、写下一态,预算结算与 outbox 追加也在同一个事务里,不存在跨存储的窗口。CAS 分区从 crates/octos-fleet/src/store.rs:880 的注释块开始,三个核心转移:
launch_child(crates/octos-fleet/src/store.rs:889)启动一个 Ready 子任务。谓词有四道:child 必须 Ready、无在飞 attempt、fleet 非终态(#1973 修复轮加入的终态围栏)、预算放行。全部通过后,一个事务写入 child 的 Launching、新的 Leased attempt(AttemptStatus::Leased 在 crates/octos-fleet/src/store.rs:997,Lease{owner_epoch} 在 :998)、预算预留与一条 ChildLaunching outbox 事件。任何拒绝都不留半状态:预算拒绝的 child 仍是 Ready,不会被晾在 Launching。
mark_running(crates/octos-fleet/src/store.rs:1053)把 Leased 推到 Running,四段谓词含 attempt.generation == fleet.generation(crates/octos-fleet/src/store.rs:1122)。complete_child(crates/octos-fleet/src/store.rs:1157)要求 child 处于 Running、generation 相等(:1236)、lease.owner_epoch 匹配(:1237),成功才写结果并结算预算。generation 是 fleet 行上的成员纪元,replan 时递增;旧世代的迟到事件被围栏拦下。
CAS 拒绝被建模为值,不是错误。LaunchOutcome / CompleteOutcome / MarkRunningOutcome 的 Superseded 变体(crates/octos-fleet/src/store.rs:59、75、102)是普通控制流,调用方据此推理;Err 只留给真实的基础设施故障。这个区分在 worker 侧有直接后果,16.6 会看到。
预算在事务内结算,FleetBudget::admits(crates/octos-fleet/src/records.rs:132)用 checked 加法求和再比较:
#![allow(unused)] fn main() { pub fn admits(&self, projected: u64) -> bool { match self .tokens_reserved .checked_add(self.tokens_committed) .and_then(|s| s.checked_add(projected)) { Some(total) => total <= self.token_budget, None => false, } } }
溢出的和不可能是合法预算,直接拒绝;饱和算术会把 MAX + MAX + 1 <= MAX 静默判为放行(P2-5 修复)。v1 的 hard = false 是软准入:拒绝的是下一次启动,不打断在飞运行。
outbox 解决「状态变了要通知谁」。append_event(crates/octos-fleet/src/store.rs:2518)在各转移自己的写事务内追加(crates/octos-fleet/src/store.rs:2806 注释),事件类型四种:ChildLaunching、ChildRunning、ChildDone、FleetDrained。消费协议是真实的 claim/ack:claim_next(crates/octos-fleet/src/store.rs:2547)领取最低序号未确认事件,盖 claimed_by、新铸 claim_token、设 claim_expires_at;ack(crates/octos-fleet/src/store.rs:2603)必须出示匹配的 (claimed_by, claim_token),不匹配返回 StaleClaim。这个令牌围栏(P1-3)挡住一种具体竞态:消费者的租约已过期、事件已被别人重领,旧消费者迟到的 ack 不会污染新消费者的进度。这套唤醒骑的是第 12 章的 MasterContinuationScheduler,调度细节详见第 12 章,keeper 语义详见第 18 章。
消费侧值得单独走一遍,因为它把 outbox 的持久语义补成了完整一环。crates/octos-cli/src/autonomy/fleet_wake.rs(1,807 行)的 drain_fleet_outbox_once(:235)是可测试的核心:吃 store、时钟、批量上限与一个 commit_wake 回调,循环 claim_next 直到领空或达批量。ack 的门槛是一个两值枚举 WakeCommit(:70):唤醒持久化为 Durable,可以 ack;只在内存里或持久化失败为 NotDurable,不 ack,事件留在 claimed 状态,租约(30 秒,:56)到期自动重投。唤醒永不静默丢失,代价是最多重投一次。消费循环 spawn_fleet_outbox_consumer(:343)每 3 秒一 tick,单 tick 上限 64 条(:63),防止积压的 outbox 独占消费者或堆出无限续跑队列,余量留给下一 tick。三个特殊分支各自有编号:#1973 修复轮规定已取消 fleet 的 ChildDone 直接 ack 不唤醒(goal 已清,唤醒会用陈旧元数据复活一个死控制器;Complete / Failed 的迟到事件仍唤醒,keeper 借此自检完成);fleet 行消失也直接 ack,不让 outbox 卡在缺失记录上;非终态生命周期事件不含唤醒语义,领了就 ack。唤醒内容不是事件裸转发:render_fleet_snapshot(:99)先异步读计划与就绪集,渲染成 FleetKeeperSnapshot(:84,objective、每任务一行、就绪 id 列表),同步的提示渲染器只做格式化不再碰 I/O。fleet 侧的结论是:outbox 保证事件必达,消费环保证唤醒必持久,两层各管一段崩溃窗口。
一次 attempt 从启动到落账的完整时序:
sequenceDiagram
participant P as FleetWorkerPool
participant S as FleetKernelStore
participant W as run_attempt
participant O as outbox 消费者
P->>S: launch_child(projected_tokens, owner_epoch)
S-->>P: Launched(attempt_id)
Note over S: 同一事务:child→Launching<br/>新 Leased attempt+预算预留+事件
P->>W: spawn(escalation_slot, tracker)
W->>S: mark_running
S-->>W: Running(generation 谓词通过)
W->>W: agent 执行 + 验收门
W->>S: complete_child(verdict, snapshot, actual_tokens)
alt 谓词通过
S-->>W: Completed
Note over S: 结果落账+预算结算+ChildDone 事件
O->>S: claim_next / ack(claim_token)
else 谓词落败
S-->>W: Superseded(结果丢弃,无状态变化)
end
图 16-2:一次 attempt 的时序。 每个矩形注释都是单个写事务;CAS 落败走值返回,不走错误通道。
取消读侧的隐患靠 io_gate 处理:所有操作(含决策读取与扫描)先取 gate 的 owned guard,再把 guard 移进 spawn_blocking 闭包。被取消的调用者的不可中断阻塞写,不会乱序落在后续读取之后(spec v1.1 的修正)。
工程决策侧栏:为什么用 redb 事务,而不是内存状态加日志
备选方案是进程内维护状态机、把变更写日志,重启时重放。它有三个缺口。其一,「读、判定、写」三步在内存里天然分离,判定与写入之间可能插入崩溃,重放后的状态与判定时的假设不一致,要么给每个转移写补偿逻辑,要么接受脏状态。其二,预算结算是读改写,内存方案需要自己的锁协议,锁协议本身要被崩溃测试覆盖。其三,多表一致性(fleet 行、child 行、attempt 行、outbox)没有原生答案,要引入两阶段提交或接受窗口。redb 的单写事务把这三个缺口一起关掉:一次
begin_write内的全部写入要么都可见要么都不见,崩溃恢复交给存储引擎。代价是所有访问串行化,这对 fleet 内核不构成瓶颈,它的写频率由任务生命周期决定,不在热路径上。docs/FLEET-KERNEL-V1-SPEC.md把这套形状称为 one-write-transaction CAS,并列明它是从 octos-swarm 的 redb 持久层逐字抬升的。
16.4 状态机与恢复协调
三层状态各管一段:fleet 整体是 FleetStatus(Active / Draining / Complete / Failed / Cancelled,crates/octos-fleet/src/records.rs:41-47);子任务是 ChildStatus(Planned / Ready / Launching / Running / Blocked / Succeeded / Failed / Cancelled,crates/octos-fleet/src/records.rs:64);尝试是 AttemptStatus(Leased / Running / Done / Interrupted,crates/octos-fleet/src/records.rs:94)。Blocked 是 PR B 加入的非终态:attempt 让位去请求更宽的授权,等 keeper 决断(16.5)。
stateDiagram-v2
[*] --> Planned: add_child(crates/octos-fleet/src/store.rs:503)
Planned --> Ready: 依赖满足(resolve_and_collect_ready:676)
Ready --> Launching: launch_child CAS(889)
Launching --> Running: mark_running CAS(1053)
Running --> Succeeded: complete_child 谓词通过(1157)
Running --> Failed: 验收落败 / goal_deny
Launching --> Blocked: record_escalation(1336)
Running --> Blocked: record_escalation(1336)
Blocked --> Ready: goal_grant(放宽授权重跑)
Blocked --> Failed: deny_escalation(2025)
Launching --> Ready: reconcile 回收(2191)
Running --> Ready: reconcile 回收(2191)
Ready --> Cancelled: cancel_fleet(838)
Succeeded --> [*]
Failed --> [*]
Cancelled --> [*]
图 16-3:child 状态机。 每条边都落在 crates/octos-fleet/src/store.rs 的一个 CAS 方法上;reconcile 的回收边只在租约过期或外来纪元时触发。
恢复协调是 reconcile(crates/octos-fleet/src/store.rs:2191,报告类型 ReconcileReport 在 :160)。启动时以当前 owner_epoch 与时钟扫描所有 Launching / Running 的 child:活 fleet 的 attempt 只在租约失效(外来 owner_epoch 或 expires_at_ms 已过)时回收;终态 fleet 的在飞 attempt 无条件结算(#1973 修复轮:此前 Cancelled 的 fleet 被整体跳过,attempt 与预算预留被永久钉死)。回收动作是一个事务三件事:attempt 记 Interrupted、从 fleet 预算释放该 attempt 的预留(checked_sub,下溢即账务不变量破坏,直接报错)、child 清空 current_attempt_id。活 fleet 的 child 回到 Ready 等待本次启动重发;终态 fleet 的 child 记 Cancelled,永不复活(P2-7)。旧 attempt 永远不复活,重跑一律开新 attempt。
计划修订走 revision CAS:replan(crates/octos-fleet/src/store.rs:1512)带 expected_revision,递增 revision 并把任务的声明依赖同步到 child 行的反规范化副本;retitle_task(:1804)与 set_task_grant(:1898)同型。决策日志(append_decision:2648、list_decisions:2697)按序号追加,DecisionKind 是内核发出的封闭集合,与 Finding 的开放 kind 字符串(crates/octos-fleet/src/records.rs:447,一条可证伪的声明,component 字段做聚类键)形成对照:内核决策封闭,探索发现开放。
恢复之后还有第二个问题:重启的 keeper 凭什么了解「之前发生了什么」。读全部 worker 的转录既不可行也无意义,crates/octos-fleet/src/digest.rs(861 行)就是为此存在的有界读侧。它的首页文档把失败模式说得很直白:goal 的瓶颈不是缺执行器,是控制器的上下文窗口成为问题复杂度的上限,若综合进度意味着读完每个 worker 的产出,架构师就退化成在 worker 之间传话的办事员。所以控制器从不直接读 finding,只读 digest(findings, opts)(crates/octos-fleet/src/digest.rs:175)的产物,一个体积不随项目增长的视图。纯函数设计:不吃 store、不吃 LLM、不吃时钟,水位线(since_seq,控制器已综合到的最大序号)、当前配置、字符预算全部是输入,整份逻辑可以表驱动测试。预算即设计:max_chars 默认 4,000(crates/octos-fleet/src/digest.rs:56),超限按节丢弃并如实声明(Dropped 结构 crates/octos-fleet/src/digest.rs:126 记录哪节丢了多少条),静默截断会读成「没有更多可看」,比显式砍列表更糟。每节排序最新在前,预算从尾部砍,该牺牲的是最旧的。产出五个分区:新 finding、翻案(overturn)、过期(finding 的 config 与当前构建漂移,drift 函数 crates/octos-fleet/src/digest.rs:156 比对)、聚类提示(component 被多少条路径引用,cluster_min_paths 默认 2)与按路径成本,外加返回给控制器作下一轮 since_seq 的水位线。digest 在恢复协调里的位置由此确定:reconcile 把账本状态修回自洽,digest 保证修完之后控制器以恒定成本重新进入。
16.5 worker 装配:封闭注册表与常开安全阀
worker 侧的第一个模块回答「一个 worker 到底能用什么工具」。crates/octos-fleet-worker/src/closed_registry.rs(705 行)的 build_fleet_worker_registry(:92)从空注册表出发,按 WorkerGrant.sorted_tools() 装配封闭工具集,ALLOWED(:43)直接引用 octos_fleet::BASE_TOOLS(crates/octos-fleet/src/grant.rs:27)。grant 外的工具在注册表里不存在,重放时装配结果一致。grant 的四个类型在 crates/octos-fleet/src/grant.rs:NetworkGrant(:76,None / Hosts / Full)、FsGrant(:127,Workspace / Host)、WorkerGrant(:151,network / tools / fs / write_paths / create_only)、GrantError(:359);validate() 在 :247。这些类型与第 7 章逐行一致,语义与校验规则详见第 7 章,本章只消费它的结论。
crates/octos-fleet-worker/src/lib.rs 的模块文档补了一条容易被误读的边界:封闭注册表是工具名的拒绝表,删掉的是停靠、扇出与网络工具;它本身不是网络或进程边界。幸存的 shell 在 Full 授权下仍能触网,仍能用 shell 内部后台化脱离子进程,字符串检查抓不住这些。真正的边界是沙箱及其进程组回收,所以 AgentFactory::new 要求显式传入沙箱工厂,不提供静默的空实现;传空实现会被 tracing::warn! 标记,生产必须给网络隔离沙箱。这条边界的要求写在 API 文档里,类型系统无法强制,是操作员责任。
第二个模块是 escalate 阀(crates/octos-fleet-worker/src/escalate.rs,293 行)。封闭 worker 缺某个能力(一个主机、一个工具、文件系统访问)时不能自扩授权,也不能停靠等人:它调用 escalate 工具,把 EscalationRequest(请求的 grant 加理由)写进共享槽(EscalationSlot:34,EscalateTool:37)立即返回。turn 结束后 run_attempt 读槽,若有值则调 record_escalation(crates/octos-fleet/src/store.rs:1336,同样校验 generation :1409 与 lease :1410),child 进 Blocked,交 keeper 决断:goal_grant 把 child 拨回 Ready 用更宽授权重跑,goal_deny 经 deny_escalation(crates/octos-fleet/src/store.rs:2025)把 Blocked 推到 Failed。deny_escalation 的返回类型在同一次写事务里顺带算出 fleet_un_completable(:2094 附近的设计注释):fleet 是否已无法自动完成,直接从 deny 后的持久状态推导,keeper 不必再做一次可能被跳过的读。工具永远注册、不随 grant 门控:最小授权的 worker 也必须能喊出「我被卡住了」。请求是 advisory,keeper 可以批得比要的少。
16.6 run_attempt 与有界池
crates/octos-fleet-worker/src/worker.rs(2,575 行)的 run_attempt(:177)执行一次 attempt,五个终态(AttemptOutcome:146):
#![allow(unused)] fn main() { pub enum AttemptOutcome { Completed { verdict: AcceptanceVerdict }, Superseded, Aborted { reason: String }, RecordError { reason: String }, Escalated { request: EscalationRequest }, } }
第一步就是 mark_running,两种落败分开处理:Superseded 说明 attempt 确实不是本次的,返回 Aborted,池解除守卫;Err 是基础设施故障,Launching 的 attempt 可能仍是本次的,返回 RecordError,守卫保持武装,交给 Drop 清理与恢复协调。这两条路径的区分(round-4 P1 修复)直接建立在 16.3 的「值与错误分离」上。执行体包在硬 tokio::time::timeout 里,AgentFactory 把每工具超时钳到 deadline,worker shell 每命令有硬上限,验收阶段也受剩余时间约束。worktree 模式(WorktreeContext:67)下交付物的定义是一个分支上的 commit,空分支算无交付。
crates/octos-fleet-worker/src/pool.rs(2,186 行)的 FleetWorkerPool(:109)是有界启动器,配置在 PoolConfig(:58):全局并发、单 fleet 并发、deadline、owner_epoch、租约 TTL、预留 token、工作区根、keeper profile、后端是否支持 repo 写。dispatch(:233)对同一 (fleet, task) 持 preflight 锁,跨「活性检查、worktree 准备、launch、spawn」全程,两个并发 dispatch 同一 Ready 任务时,输家看到赢家的在飞 attempt,被 launch 的 double-launch 拒绝兜住。worktree 是条件路径:仅当授权同时满足 Host 文件系统与 Full 网络、fleet 在 git 仓库上、后端支持 .git 写,三条全真才在任务稳定分支上开真 worktree,断点续跑从死者的最后 commit 开始;任一为假走 scratch 目录兜底。全信任门槛的推理写在 dispatch 的注释里:Host 文件系统加受限网络的组合并非真隔离(全文件系统能桥接任何网络围栏),干脆要求全网络,让两种逃逸都失去意义。提交锚点:eadee2ae(#1875)引入 WorkerGrant,8fc66202(#1881)落地 worktree worker。
有界池的「界」由两把信号量把守,global_sem 是全进程的总量上限,per_fleet 的 HashMap 按需建每 fleet 的信号量,条目永不修剪(上限是任务数,不是运行数)。这个设计把并发控制的真相放进了池而非内核:store 的 CAS 只保证不重不漏,至于同时跑几个、每 fleet 跑几个,是宿主的策略选择,内核不预设。拿不到许可的 dispatch 在信号量上排队,不产生任何持久状态;真正进入执行才走 launch 的事务。反过来,重启后池是空的,所有许可自然可用,不存在「许可表也要恢复」的次生问题:许可是内存里的信号量,恢复由 reconcile 对着持久状态完成,两者职责不重叠。
16.7 Fleet 表层与边界
crates/octos-fleet/src/fleet.rs(3,062 行)的 Fleet(:190)在 store 之上组合 CAS 操作成整计划 API:create(:210)、bind(:300)、view(:324)、ready_tasks(:375)、apply_edit(:402)、record_outcome(:542)、is_complete(:583)、summary(:595)。它不改变 store 语义,是未来 keeper 与 goal_get / goal_update 工具编程的表层。
crate 里最大的文件不是 store,是 crates/octos-fleet/src/sqlite_ledger.rs(6,360 行,占两 crate 合计 23,730 行的四分之一强),GoalLedger(:13)落地于此。为什么一份代码里同时有 redb 和 sqlite?文件头三行注释给出理由:redb 是单写者单进程模型,SQLite 经 WAL 支持多进程访问,适合 master 与各 peer 作为独立进程共享同一份账本的 peer 架构。两个持久层的分工按访问者切:fleet 内核的六张表(fleet、child、attempt、plan、decision、outbox)由单一 serve 进程独占写,单写事务的 CAS 形状与 redb 的模型严丝合缝;goal 账本(goals、tasks、findings、escalations 表,findings 表带 goal 与 task 双索引,crates/octos-fleet/src/sqlite_ledger.rs:409-410)要被 keeper、worker 与 transition sync 多方读写,SQL 的行级内容寻址(append_finding:1623、list_findings_since:2132)与索引查询是更自然的形状。crates/octos-fleet/src/sqlite_ledger.rs:13 的 GoalLedger 属于第 18 章的主题,这里只留一个工程细节:并发打开同一份新 WAL 库有毫秒级初始化竞态(database is locked,busy handler 不覆盖),open(:222)用 rusqlite 默认 5 秒 busy_timeout,goal 状态迁移专用的 open_with_busy_retry(:245)才做 3 次有界重试并把超时压到 1 秒,这条路径只在阻塞线程上跑。ledger 与 store 互不替代:一个管多进程共享的 goal 账本,一个管单进程独占的执行状态机。
swarm 的扇出拓扑详见第 17 章;supervisor 的事件账本与唤醒调度详见第 12 章;WorkerGrant 的完整权限模型详见第 7 章。
16.8 本章回顾
- 一个内核承载三种拓扑:记录模型(六张表、
SCHEMA_VERSION = 3的高版本丢弃策略)、事务 CAS(launch_child/mark_running/complete_child的谓词与值式拒绝)、恢复协调(reconcile的租约回收与永不复活)三支柱,把执行进度从对话上下文搬进 redb。 - CAS 拒绝是值不是错误:
Superseded是控制流,Err只留给基础设施故障,worker 侧据此区分 Aborted 与 RecordError。 - 预算与 outbox 都在转移的写事务内:预留、结算、事件追加无跨库窗口;claim/ack 用
claim_token围栏挡住过期消费者的迟到确认。 - worker 从空注册表按 grant 装配,重放一致;沙箱才是边界,注册表只是工具名拒绝表;escalate 常开,Blocked 等 keeper 决断。
- 池有界且串行 preflight:同任务并发 dispatch 被锁与 double-launch 双重拦截;worktree 需要全信任三条件,否则 scratch 兜底。
延伸阅读
- redb:https://github.com/cberner/redb — fleet 内核的嵌入式 KV 存储,单写事务是本章 CAS 形状的地基
docs/FLEET-RUNTIME-ADR.md(176 行)— 一个内核、两种 worker、三种拓扑的架构决策记录docs/FLEET-KERNEL-V1-SPEC.md(347 行)— one-write-transaction CAS 与 io_gate 取消安全的实现规格docs/FLEET-KERNEL-FOUNDATION-SPEC.md(219 行)— 内核的地基设计,ADR 之下的落地层
思考题
SCHEMA_VERSION对加字段与加枚举变体采取不同策略。若要给AttemptStatus增加Paused变体,同时给Attempt加paused_by字段,哪些改动需要升版本,哪些用#[serde(default)]就够?给出旧二进制在新库上的行为推演。claim_token围栏挡住了过期消费者的迟到 ack。若消费者在 ack 前崩溃,事件如何重投?重投后的claimed_by与 token 如何变化,迟到的旧消费者 ack 会得到什么返回?reconcile对终态 fleet 的在飞 attempt 无条件结算,child 记 Cancelled 永不复活。若改为「终态 fleet 的 child 也回 Ready」,会在哪些路径上违反哪条不变量?- worktree 路径要求 Host 文件系统与 Full 网络同时满足。若要支持「网络隔离的 worktree worker」,注释里提到的
.git停靠围栏要防什么攻击?为什么全文件系统能绕过网络围栏? - 预算
hard = false只拒绝下一次启动。若改成硬预算(打断在飞运行),complete_child的谓词与run_attempt的终态枚举各要加什么?
版本演化说明 本章为 v2 新增章,分析基于 octos main @
9c157101(2026-09-03 采集复核)。两个 crate 均为新代码:octos-fleet与octos-fleet-worker不存在于 v1 旧稿;WorkerGrant随eadee2ae(#1875)进入内核,worktree worker 随8fc66202(#1881)落地。所有行号与行数来自事实表assets/ch16-facts.md或本次会话对源码的只读核对;fleet 状态机在此前章节中曾被第 12 章以「详见第 16 章」前向引用,本章即其落点。
第 17 章:Swarm:契约扇出与聚合门禁
定位:本章分析
crates/octos-swarm如何把「supervisor 写 N 份契约、分发给外部 agent 后端、聚合产物、过校验门」收敛为一个可重入的编排原语:三种执行拓扑、三道门禁、一份幂等账本。前置依赖:第 10 章(校验器与事件 ABI)、第 16 章(Fleet)。适用场景:需要把一批子任务委派给外部 agent(如 claude、codex),并要求结果可信、成本可算、重放一致的开发者。
octos 的 harness 一直在跑一种手工模式:一个 PM 型 supervisor 把任务拆成 N 份,分发给若干 sub-agent,收回产物后人工比对、人工汇总、人工决定哪些要重跑。octos-swarm 的 crate 文档第一行写得很直白:Swarm orchestration primitive for octos (harness M7.5)(crates/octos-swarm/src/lib.rs:1),次行说明它把这套「PM + swarm supervisor」的手工模式形式化。手工模式的痛处在三处:分发不可控(谁拿到了哪份任务没有账)、产物不可信(sub-agent 说完成了就算完成)、重跑不一致(同一批任务第二次执行的边界和第一次不同)。这三处分别对应本章的三条主线:契约扇出、聚合门禁、幂等与恢复。
这个 crate 不大:7 个源文件 2,505 行,3 个集成测试文件 2,475 行,合计 4,980 行(事实表 assets/ch17-facts.md 对 main @ 9c157101 的统计,统计日期 2026-09-03)。测试行数几乎与源码持平(1:0.99),36 个集成用例覆盖了拓扑、门禁、幂等、恢复四个面,这个比例本身就是本章反复出现的设计信号的预演:每一条不变量都有对应的名字和对应的测试。
| 文件 | 行数 | 职责 |
|---|---|---|
| crates/octos-swarm/src/dispatcher.rs | 1,261 | 编排核心:dispatch 循环、门禁、聚合、幂等 |
| crates/octos-swarm/src/result.rs | 395 | 类型化结果:SwarmResult / SubtaskOutcome / AggregateArtifact |
| crates/octos-swarm/src/persistence.rs | 275 | redb 持久化:DispatchStore 幂等账本 |
| crates/octos-swarm/src/topology.rs | 229 | 拓扑与契约:SwarmTopology / ContractSpec / FanoutPattern |
| crates/octos-swarm/src/ledger.rs | 115 | 成本账本适配(M7.5):CostLedger trait + Noop 实现 |
| crates/octos-swarm/src/lib.rs | 111 | crate 门面与 re-export |
| crates/octos-swarm/src/gate.rs | 119 | 门禁粘合层:enforce_or_outcome |
17.1 契约:扇出的最小单位
Swarm 对「一次子任务」的全部理解收在一个结构体里。契约(ContractSpec,crates/octos-swarm/src/topology.rs:37)有四个字段:
#![allow(unused)] fn main() { pub struct ContractSpec { pub contract_id: String, pub tool_name: String, pub task: serde_json::Value, pub label: Option<String>, } }
contract_id 是重试去重与持久化的稳定键,调用方必须保证它在一次 dispatch 内唯一;task 对原语完全透明,逐字转发为 MCP tools/call 的参数;label 只给人看的运维界面,不参与关联。这个设计把「原语需要知道的」压缩到最小:身份、目标工具、负载,三样。契约里写什么、怎么验收,是 supervisor 与 sub-agent 之间的协议,原语不解释。
一次 dispatch 的契约总量有硬上限:MAX_CONTRACTS_PER_DISPATCH: usize = 128(crates/octos-swarm/src/topology.rs:27),在 dispatch 入口强制(crates/octos-swarm/src/dispatcher.rs:246)。上限防的是扇出模式的无界展开:FanoutPattern(crates/octos-swarm/src/topology.rs:59)把一份种子契约克隆成 N 份兄弟契约,expand(crates/octos-swarm/src/topology.rs:72)给每份的 contract_id 追加 ::variant 后缀、往 task 里注入 variant 字段。原语不看 payload,只盖一个章,远端 agent 自己按 variant 切换行为。展开后的契约表由 resolve_contracts(crates/octos-swarm/src/topology.rs:133)统一产出:Fanout 拓扑用展开表覆盖种子表,其余拓扑原样保留。
17.2 编排三原语:Parallel、Sequential、Pipeline
SwarmTopology(crates/octos-swarm/src/topology.rs:98)有四个变体,但执行形状只有三种(Parallel 与 Fanout 共用扇出执行路径,区别只在契约表怎么来)。这三种就是编排三原语:扇出、顺序、流水线。
- Parallel:
run_parallel_round(crates/octos-swarm/src/dispatcher.rs:428)用JoinSet把待重试的契约并发派发,先填满max_concurrency个槽位,每完成一个立刻补位。聚合按到达顺序。$n$ 份契约、并发度 $c$ 的总派发墙钟时间约等于 $\lceil n/c \rceil$ 乘单次后端往返,后端慢的时候并发度是唯一可调的杠杆。 - Sequential:
run_sequential_round(crates/octos-swarm/src/dispatcher.rs:478)一次跑一个,首个硬失败(TerminalFailed)即中止,后续契约保持not_run。适合有依赖顺序但产物不互相消费的批次,例如「先审事实表、再审草稿、最后审引用」这类前序失败即全盘无意义的链。 - Pipeline:
run_pipeline_round(crates/octos-swarm/src/dispatcher.rs:518)把契约 i 的产物折叠进契约 i+1 的pipeline_input字段;前驱不是Completed就不派发后继。折叠的具体形状分两支(crates/octos-swarm/src/dispatcher.rs:549-558):契约本身是 object task 时直接注入pipeline_input键;否则把 task 包成{"original_task": …, "pipeline_input": 前驱输出}的对象,前驱输出以字符串形态注入,远端 agent 按约定字段解读。
flowchart LR
subgraph Parallel["Parallel / Fanout(run_parallel_round :428)"]
A["契约池"] -->|"JoinSet 填满 max_concurrency"| B["并发槽位"]
B -->|"完成即补位"| B
B -->|"到达顺序聚合"| PA["聚合产物"]
end
subgraph Sequential["Sequential(run_sequential_round :478)"]
S1["契约 1"] --> S2["契约 2"] --> S3["契约 3"]
S2 -.->|"TerminalFailed 中止"| SX["契约 3 保持 not_run"]
end
subgraph Pipeline["Pipeline(run_pipeline_round :518)"]
P1["stage-1"] -->|"pipeline_input"| P2["stage-2"] -->|"pipeline_input"| P3["stage-3"]
end
图 17-1:三种执行拓扑。 Sequential 与 Pipeline 的并发度恒为 1(max_concurrency,crates/octos-swarm/src/topology.rs:143),它们的差异不在并发,在失败语义:Sequential 中止整批,Pipeline 只斩断链条并保留前段产物。
三种形状为什么这样切,而不是合并成一个带参数的执行器。Parallel 的设计压力在吞吐:后端往返是整批的瓶颈,槽位补位模型(完成一个补一个)让慢契约不阻塞快契约,代价是聚合顺序等于到达顺序,调用方如果需要稳定顺序就要自己在 contract_id 上重排。Sequential 的设计压力在浪费:当后序契约的输入蕴含前序结果(不是内容消费,而是「前序失败则后序必然无效」),并发只会生产一堆注定丢弃的产物,恒为 1 的并发度把浪费压到零,首个硬失败即中止把判定点提前到失败发生的那一刻。Pipeline 的设计压力在数据依赖:后继的输入字段就是前驱的输出,任何「后继带着空输入完成」的情况都是静默损坏,所以它的不变量最严:前驱不是 Completed 就不派发,宁可整轮空转也不冒污染链的风险。三个原语各绑定一条不变量(吞吐可调、失败即停、依赖即序),合并成一个参数化执行器会让这三条不变量变成互斥的配置组合,出错的配置就是静默损坏的配置。这也是 SwarmTopology 做成 enum 而不是 struct 的原因:调用方在类型层面就只能选一种形状,没有「Sequential 但并发 3」这种自相矛盾的组合。
Fanout 单独做成变体而不是调用方自己克隆契约表,理由在幂等:::variant 后缀的生成规则由原语控制,每份展开契约的 contract_id 稳定可复现,同一 pattern 两次展开得到同一组 id,重试与恢复才能对上 redb 里的行。调用方手写克隆一旦改了 id 格式,恢复路径就找不回旧状态。测试 should_expand_fanout_pattern_into_variant_contracts(crates/octos-swarm/tests/swarm_dispatch.rs:707)钉住展开规则。
Pipeline 的失败语义值得单独说。早期的实现里,若 stage-2 可重试失败,stage-3 会在同一轮被无输入派发,然后被记为 Completed,链条 静默断裂。修复(#1717,测试 should_stop_pipeline_round_at_retryable_stage_and_resume_with_input,crates/octos-swarm/tests/swarm_dispatch.rs:763,下文测试引用均省略 crates/octos-swarm/tests/ 前缀)加了一条前置条件:一个 stage 只有在前驱 Completed 时才可能被派发,恢复轮同样适用。测试断言 stage-2 两次尝试都链接到 stage-1 的输出、stage-3 恰好运行一次且拿到恢复后的 stage-2 输出。
Sequential 的中止语义同样跨崩溃成立。run_sequential_round 在处理每个待重试槽位前,先扫一遍它之前的所有子任务:只要发现任何一个 TerminalFailed,整轮立即返回中止标记(crates/octos-swarm/src/dispatcher.rs:478)。这条检查表面看是多余的(中止时循环早就 break 了),但它守护的是恢复路径:一份带着硬失败标记的记录被重启后的新进程重新 dispatch,尾部的契约必须保持 not_run,不能因为「这轮刚开始」就被派发出去。测试 should_not_resume_sequential_tail_after_persisted_terminal_failure(crates/octos-swarm/tests/swarm_dispatch.rs:1213)与 Pipeline 版本(:1163)钉住了这个边界。
重试预算由 SwarmBudget(crates/octos-swarm/src/dispatcher.rs:66)控制,默认上限 MAX_RETRY_ROUNDS: u32 = 3(crates/octos-swarm/src/dispatcher.rs:60),且被钳在 3 以内:调用方给更大的值也会被压回。预算的消耗规则经过一次修正(#1717 的 review finding 5):只有零进展的轮才消耗预算;完成过至少一个新契约的轮不计数,因为这类轮天然被契约总数约束,循环仍会终止。预算检查也从「轮后」改到「轮前」(review finding 2):一条停在预算上限的记录被恢复时,一个轮都不派发,否则每次崩溃恢复都白送一轮,反复恢复就能绕过上限。
17.3 一次 dispatch 的完整时序
Swarm::dispatch(crates/octos-swarm/src/dispatcher.rs:246)接收五个参数:dispatch_id、契约表、拓扑、预算、上下文(SwarmContext 携带 session_id / task_id / workflow / phase,供事件路由)。构造走 Swarm::builder(crates/octos-swarm/src/dispatcher.rs:206)返回的 SwarmBuilder,可选挂载成本账本、聚合校验器、事件汇与 dispatch 策略,不挂则用 Noop 默认值。完整时序如下:
sequenceDiagram
participant S as Supervisor
participant D as Swarm::dispatch (:246)
participant G as InFlightGuard (:833)
participant P as DispatchStore (crates/octos-swarm/src/persistence.rs:93)
participant B as McpAgentBackend
participant V as 校验器(M4.3)
S->>D: dispatch(id, contracts, topology, budget, ctx)
D->>G: acquire(id)(同 id 并发则拒绝)
D->>P: load(id)
alt 记录已 finalized
D->>S: 原样返回 final_result(#1718)
else 新记录或未完
D->>P: store(初始记录,崩溃可恢复)
loop 直到无待重试或预算耗尽
D->>D: enforce_or_outcome(crates/octos-swarm/src/gate.rs:25,门禁一道)
D->>B: dispatch(DispatchRequest)
B-->>D: DispatchResponse
D->>D: gate_subtask_validators(:593,门禁二道)
D->>P: store(整份记录)
end
D->>V: run_aggregate_validator(:693,门禁三道)
D->>D: 账本汇总 + finalized + final_result 快照
D->>P: store(finalized 记录)
D-->>S: SwarmResult + 类型化事件
end
图 17-2:一次 dispatch 的时序。 每一步都能在 crates/octos-swarm/src/dispatcher.rs 与 crates/octos-swarm/src/persistence.rs 的具名方法里找到,并与 crates/octos-swarm/tests/swarm_dispatch.rs 的用例对应:入口幂等对应 should_survive_process_restart_mid_dispatch(:560),并发拒绝对应 should_reject_concurrent_dispatch_with_same_id(:1100),逐字重放对应 should_replay_finalized_result_verbatim_including_validator_verdicts(:821)。
每轮结束都把整份记录写回 redb,而不是攒到最后一次性写。代价是每轮一次序列化提交;收益是崩溃点任意移动,恢复都从最近的轮快照续跑。事件在 finalize 之后发出:HarnessSwarmDispatchEvent 带 SWARM_DISPATCH_SCHEMA_VERSION(固定为 1),失败结果的事件会把第一个未完成子任务的错误顶到 message 字段,supervisor 不必翻完整记录就能拿到可行动的线索。事件 ABI 与 schema 版本化的机制详见第 10 章。
17.4 后端抽象:MCP sub-agents 与 CLI 接线
分发目标是一个 trait:McpAgentBackend(crates/octos-agent/src/tools/mcp_agent.rs:411),核心方法 dispatch(DispatchRequest) -> DispatchResponse。请求(:266)携带工具名与契约 task;响应(:218)携带文本输出与 files_to_send;单次结果枚举 DispatchOutcome(:182)区分 Success、Timeout、TransportFailure 等类。Swarm 对后端结果的理解压缩在一个映射函数里(SubtaskStatus::from_dispatch,crates/octos-swarm/src/result.rs:20):
#![allow(unused)] fn main() { pub fn from_dispatch(outcome: DispatchOutcome) -> Self { match outcome { DispatchOutcome::Success => Self::Completed, DispatchOutcome::RemoteError | DispatchOutcome::Timeout => Self::RetryableFailed, DispatchOutcome::TransportError | DispatchOutcome::ProtocolError | DispatchOutcome::SsrfBlocked => Self::TerminalFailed, } } }
三类终态(crates/octos-swarm/src/result.rs:20)对应三种处置:Completed 进入聚合;RetryableFailed 留在下一轮的待重试集合;TerminalFailed 永不重派。远端报错和超时可重试,传输错误、协议错误、SSRF 拦截是终态,这个划分把「值得重试」限定在网络抖动一类,不给安全拦截留重试通道。子任务的完整画像由 SubtaskOutcome(crates/octos-swarm/src/result.rs:58)记录:状态、尝试次数(1 起计,首试加重试不超上限)、最后一次派发的结果标签、输出文本、文件清单与错误信息;预算耗尽前没轮到的槽位记为 not_run。
接线面在 CLI。octos serve 暴露四个标志(crates/octos-cli/src/commands/serve.rs:420、:427、:433、:439):
为什么走外部 MCP 后端而不是复用第 12 章的本地 spawn。本地 spawn 的子 agent 跑在同一进程树里,共享 harness 的工具注册表、上下文管理器与证据账本,优点是零协议开销、状态天然可见;代价是子 agent 的故障域就是宿主的故障域:一个子 agent 的内存膨胀或死循环直接拖垮 supervisor,而且子 agent 被锁死在 octos 自己的运行时上。swarm 面对的是另一类问题:执行体可能是 claude、codex 这类异构 agent,各自有自己的工具面与计费模型,supervisor 对它们的信任必须按「外部系统」对待,派出去的请求要过门禁、回来的产物要过校验、每一步要落账。MCP 把这条边界变成协议:tools/call 的请求响应形状是全部耦合,后端换实现不影响 dispatcher 一行代码,超时与传输错误有独立的结果类别可映射成重试语义。选择的标准由此清楚:需要共享进程内状态、执行体就是 octos agent 时用本地 spawn;执行体异构、需要硬信任边界时用 swarm。
--swarm-backend <stdio|cli|http> 后端种类
--swarm-backend-cmd <path> stdio/cli 后端的命令
--swarm-backend-arg <arg> 可重复的参数
--swarm-backend-url <url> http 后端端点
build_swarm_state_from_flags(crates/octos-cli/src/commands/serve.rs:1867)把标志装配成 Swarm 状态:stdio 挂 MCP 子进程、cli 走一次性 agent CLI、http 连远端 MCP 端点,未知取值直接报错;不传 --swarm-backend 返回 Ok(None),处理器对 swarm 请求回 503。这条接线补上了审计 #713 的生产化要求,依赖声明在 crates/octos-cli/Cargo.toml:32。顺带一提,dispatch_once(crates/octos-swarm/src/dispatcher.rs:1053)会给每次派发打上 DispatchContextContract 标签(backend_kind=mcp、agent_id 取 contract_id、risk=medium),让证据账本无需解析自由文本就能识别每笔不受管的分发(#1021/M17-C);派发失败且输出为空时,错误体会被抄进输出字段,下一轮重试不会拿到一份过期的空负载。真正的派发经 dispatch_with_budget(crates/octos-swarm/src/dispatcher.rs:971)包装:先过门禁、再按预算保留(reserve)、然后调用后端、完成后提交(commit)归因,预算不足会转为失败结果而不是 panic。
三种后端的选择各有适用面。stdio 适合把 swarm 挂在一个本地 MCP 子进程上(例如 claude mcp serve),生命周期随 serve 进程;cli 适合一次性命令行 agent(每次派发起一个进程,退出码非零按可重试处理,测试 should_dispatch_through_cli_backend_with_retry_on_nonzero_exit,crates/octos-swarm/tests/swarm_dispatch.rs:1462);http 适合共享的远端 MCP 端点,多 serve 实例复用同一池子。四标志缺一项时装配函数直接报错(stdio/cli 缺 cmd、http 缺 url),不静默回退到某种默认后端。
17.5 门禁三道
手工模式里「sub-agent 拿到任务就跑」;原语化的第一个要求是把「能不能跑」变成显式判定。Swarm 的门禁有三道,位置不同、对象不同、失败处置也不同。
第一道在派发之前,判定对象是「这次派发本身」。粘合层 enforce_or_outcome(crates/octos-swarm/src/gate.rs:25)调用 octos-agent 的共享判定 enforce_dispatch_gates(crates/octos-agent/src/dispatch_policy.rs:292,后端感知变体 :303),把 GateDenial 折叠成 swarm 本地的 SubtaskOutcome(TerminalFailed,last_dispatch_outcome 记为拒绝原因标签)。crates/octos-swarm/src/gate.rs 的模块文档点明动机:Swarm 的 dispatcher 与 SpawnTool 的 agent_mcp 分支走同一套检查,使单一绕过面不存在,这正是审计 #701 与 #714 的修复要求。判定内部按固定顺序跑五项检查:沙箱要求(最便宜,只看配置)、工具策略(同步无 I/O)、env 黑名单、env 白名单(黑名单先于白名单,宽松白名单放不进已知坏键)、审批(最后,可能阻塞等用户)。五项检查归并为四类策略面(工具策略、审批、env 黑白名单、sandbox 要求),覆盖外部后端最现实的四类风险。
flowchart TD
C["契约到达派发点"] --> G1{"enforce_dispatch_gates<br/>crates/octos-agent/src/dispatch_policy.rs:292"}
G1 -->|"sandbox_required / policy_denied<br/>env_forbidden / approval_*"| DEN["GateDenial 折叠为<br/>SubtaskOutcome::TerminalFailed<br/>后端零调用"]
G1 -->|放行| B["McpAgentBackend.dispatch"]
B -->|"Success"| SC["SubtaskStatus::Completed"]
B -->|"Timeout / RemoteError"| SR["RetryableFailed,下轮重试"]
B -->|"Transport / Protocol / SSRF"| ST["TerminalFailed,永不重派"]
SC --> G2{"gate_subtask_validators<br/>crates/octos-swarm/src/dispatcher.rs:593"}
G2 -->|"required 校验器失败"| DEMO["降级 TerminalFailed"]
G2 -->|通过| AG["进入聚合"]
AG --> G3{"run_aggregate_validator<br/>crates/octos-swarm/src/dispatcher.rs:693"}
G3 -->|verdicts| FIN["写进 SwarmResult,随事件外发"]
图 17-3:门禁三道决策流。 第一道管派发资格,第二道管单个产物,第三道管合并产物;拒绝标签(policy_denied、approval_unavailable、sandbox_required 等)稳定进入事件与指标。
第二道门在产物回收处。gate_subtask_validators(crates/octos-swarm/src/dispatcher.rs:593)只对状态为 Completed 的子任务跑 completion 相位的校验器;required 校验器失败会把 Completed 降级为 TerminalFailed 并把原因写进 error。已经失败的子任务不再受罚,不双重惩罚。Pipeline 对这道门最敏感:一个没过校验的上游产物会污染每个下游 stage 的 pipeline_input。测试 should_run_completion_validators_in_swarm_subtask(crates/octos-swarm/tests/subtask_contracts.rs:123)用一个要求 required.txt 存在的校验器面对空工作区,断言全部子任务被降级。
第三道门在全部子任务到达终态之后。run_aggregate_validator(crates/octos-swarm/src/dispatcher.rs:693)对聚合产物跑 M4.3 的 ValidatorRunner,配置由 AggregateValidator(crates/octos-swarm/src/dispatcher.rs:92)携带:runner、invocation、validators 三样。校验结果不改变子任务状态,而是作为 validator_results 写进 SwarmResult(crates/octos-swarm/src/result.rs:145),随返回值与事件一起交给 supervisor 裁决。校验器体系本身(声明式 spec、evidence 台账、SafePolicy)详见第 10 章,本章只用它的结论。
门禁的行为由 crates/octos-swarm/tests/swarm_dispatch_policy.rs 的 9 个用例钉死:本地与远端后端的工具策略拒绝(:134、:180)、审批缺请求方时 fail-closed(:224)、审批拒绝阻断派发(:264)、审批放行(:319)、env 白名单拦截禁用键(:366)、require_sandboxed 拦截未沙箱后端(:428)、无策略保持旧行为(:469)、Sequential 在门禁拒绝时中止(:506)。其中 :469 是回归锚点:默认构建(无策略)时后端照常派发、结果 Success,门禁的引入不改变未配置策略的调用方。
17.6 幂等与恢复:一份账本管三件事
幂等的全部状态在 DispatchStore(crates/octos-swarm/src/persistence.rs:93),一个 redb 库(默认文件 swarm-state.redb),每条 dispatch 一行 DispatchRecord(:36),记录携带全部子任务状态迁移,崩溃后重开账本即可续跑。schema 版本 DISPATCH_RECORD_SCHEMA_VERSION: u32 = 1(crates/octos-swarm/src/persistence.rs:27),读到更高版本的行按不存在处理:向前兼容交给「丢弃」而不是「猜测」。redb 是同步库,所有读写经 spawn_blocking 派发,并用 io_gate 互斥串行化,使被取消的写也能在门内落盘后再被下一次读看见(open/load/store 在 :110、:147、:178)。
幂等由三件东西合起来保证:
- finalized + final_result(#1718):finalize 时把计算出的
SwarmResult逐字快照进记录;重放已 finalize 的 dispatch 直接返回快照,校验器判定与成本一并原样带回。旧行没有快照时退回从子任务状态重算。修复动机很具体:旧实现重放时用空校验结果重算,会把一个校验失败的Partial在重放时升级成Success。 - contracts_fingerprint(#1719):记录里存契约表指纹,同 id 换契约表、换 task 负载、换拓扑都被
ensure_record_matches_dispatch(crates/octos-swarm/src/dispatcher.rs:856附近,与InFlightGuard相邻定义)拒绝。修复前的行为要么越界 panic,要么把槽位产出记到错误契约头上。 - InFlightGuard(
crates/octos-swarm/src/dispatcher.rs:833):RAII 登记 in-flight 的 dispatch id,构造时注册、任何退出路径(含错误路径)析构释放;第二个并发调用者直接被拒,不再竞态 load/store 对。
三条各有测试钉住:逐字重放(should_replay_finalized_result_verbatim_including_validator_verdicts,crates/octos-swarm/tests/swarm_dispatch.rs:821)、改负载被拒(should_reject_same_id_with_changed_task_payload,:1364)、同 id 并发被拒(should_reject_concurrent_dispatch_with_same_id,:1100,且断言首个完成后 id 可复用)。恢复语义的边界同样有测试:恢复时契约数变少报错不 panic(:924)、契约 id 不匹配报错(:1008)、Sequential/Pipeline 在持久化硬失败后不续跑尾巴(:1163、:1213)、停在预算上限的恢复不再多跑一轮(:1259)。
聚合判定由 build_aggregate(crates/octos-swarm/src/result.rs:228)与 SwarmOutcomeKind(crates/octos-swarm/src/result.rs:117)给出:全完成且聚合校验通过为 Success,部分完成为 Partial(聚合产物仍可用,调用方可自行决定外部重派),零完成为 Failed,Sequential/Pipeline 硬失败提前中止为 Aborted。聚合产物 AggregateArtifact(crates/octos-swarm/src/result.rs:100)只有三样:合并文本、合并文件表、自由 metadata。原语不解释 metadata,路由信息、supervisor 批注都由调用方自行存放。
17.7 成本账本:薄适配层
crates/octos-swarm/src/ledger.rs 只有 115 行,是一个刻意的薄层:CostLedger trait(attribute 记一笔、summarize 汇总)加 NoopCostLedger 默认实现。全量 CostAttributionEvent schema 与持久化在 octos-agent 的 PersistentCostLedger(M7.4),swarm 侧只留窄接口,让 dispatcher 不必依赖 redb 与完整归因事件形状。serve 接线时才换上持久账本(crates/octos-cli/src/commands/serve.rs:1867 内 PersistentCostLedger::open(data_dir));不接线则 total_cost_usd 恒为 None。测试 should_record_cost_attribution_via_ledger_stub(crates/octos-swarm/tests/swarm_dispatch.rs:1527)用 spy 账本验证归因记录确实经过 trait 派发。子任务级预算隔离走 reserve/commit API(crates/octos-swarm/tests/subtask_contracts.rs:225、:306)。
工程决策侧栏:为什么 Swarm 与 Fleet 并列,而不是叠在 Fleet 上
Fleet(第 16 章)是 redb 事务化的可恢复计划内核:attempt/lease/generation 状态机、outbox 事件、worker 按 grant 装配封闭工具集。直觉上 swarm 扇出可以建在它上面:每个子任务当一个 attempt。octos 没这么做,理由有三。第一,失败模型不同:Fleet 的 attempt 是可无限重试的可恢复执行单元,swarm 的子任务是契约,终态三档(可重试/终态失败/中止)映射自 MCP 后端结果,重试预算是轮级而非 attempt 级,硬套会把两套语义搅在一起。第二,信任边界不同:Fleet worker 在进程内跑封闭注册表,swarm 的执行体在进程外的 MCP 后端,门禁必须管「派给谁、带什么环境、要不要审批」这类外部后端特有的问题。第三,演化节奏不同:swarm 的契约、拓扑、校验门各自独立演化,叠在 Fleet 状态机上会让每次 swarm 侧改动都惊动计划内核的 schema(Fleet 的
SCHEMA_VERSION已到 3)。并列的代价是两套持久化文件,收益是两个内核可以各自按自己的不变量演化,这是可接受的交换。
17.8 与相邻章的边界
本章的门禁第一道复用 DispatchPolicy,它与第 6 章的工具策略(ToolPolicy,deny-wins)是什么关系:第 6 章讲的是 agent 内联工具调用的策略面,本章是同一策略体系在外部 MCP 后端派发上的执行点,DispatchPolicy 把工具策略、审批、env 白名单、沙箱要求打包后由 swarm 在派发前强制。第 12 章的 supervisor 层解决「进程重启后编排状态怎么办」(事件账本与续跑调度),swarm 解决的是「一批外部契约的分发与聚合」,两者共享 supervisor 这个词但不共享机制:swarm 的账本是契约账本,不是事件账本。第 16 章的 Fleet 管可恢复执行,本章管扇出与聚合,边界见上面的侧栏。校验器与事件 ABI(M4.3、schema 版本化)属于第 10 章,本章只消费 ValidatorRunner 与 HarnessEventPayload::SwarmDispatch。第 1 章的 crate 地图里,octos-swarm 挂在 octos-agent 之旁、octos-cli 之下。
17.9 本章回顾
- 规模:7 源文件 2,505 行 + 3 测试文件 2,475 行,36 个集成用例;测试与源码近乎 1:1,每条不变量都有名字和测试。
- 契约是扇出最小单位:
ContractSpec四字段,task 对原语透明;单次派发上限 128 份契约;FanoutPattern用::variant后缀展开种子。 - 三原语:Parallel(JoinSet 补位、到达序聚合)、Sequential(首个硬失败中止,恢复后 not_run 保持)、Pipeline(前驱 Completed 才派发,
pipeline_input折叠);重试预算 3 轮,只有零进展轮消耗,轮前检查。 - 门禁三道:派发前
enforce_or_outcome过共享enforce_dispatch_gates(五项固定顺序,#701/#714 单一绕过面);回收处gate_subtask_validators降级未过校验的 Completed;终态后run_aggregate_validator对聚合产物跑 M4.3 校验。 - 幂等三件套:finalized + final_result 逐字重放(#1718)、contracts_fingerprint 拒改负载(#1719)、InFlightGuard 拒同 id 并发;账本 schema 版本 1,高版本行按不存在处理。
- 成本走薄适配层:
CostLedgertrait + Noop 默认,serve 接线才换PersistentCostLedger。
延伸阅读
- MCP 规范(Model Context Protocol):https://modelcontextprotocol.io —
tools/call请求/响应形状与 stdio/http 传输的规范来源 - redb:https://github.com/cberner/redb — swarm-state.redb 底层的嵌入式 KV 存储,第 15 章亦有使用
- 第 10 章 10.1 至 10.3 —
ValidatorRunner五步时序、evidence 台账与 schema 版本化,本章第三道门的完整背景 - 第 16 章 — Fleet 内核的 attempt/lease/generation 状态机,与本章侧栏的对位阅读
思考题
SubtaskStatus::from_dispatch把 SSRF 拦截划为终态不重试。如果某后端把 SSRF 误报率较高的场景接入,你会加「可配置的终态降级」还是保持硬编码?两种选择分别把运维负担放在哪里?- 幂等三件套里,
contracts_fingerprint拒绝同 id 改负载。若 supervisor 想对同一批契约做参数微调后重跑,正确的做法是换 id 还是提供「显式失效」API?换 id 会让历史账本膨胀,显式失效会引入新的事务边界,你如何取舍? - 重试预算只对零进展轮计数。构造一个场景,使恶意或病态的后端在每轮恰好完成一个子任务,从而让 dispatch 跑远超 3 轮。这个行为是缺陷还是设计?如果是缺陷,最小的修补是什么?
DISPATCH_RECORD_SCHEMA_VERSION升到 2 时,旧实现按不存在处理(丢弃旧行)。对一个已经跑了一周、账本里有数千条 finalized 记录的部署,升级意味着什么?你会改为「迁移」还是「保留只读」,判据是什么?
版本演化说明 本章分析基于 octos main @
9c157101(2026-09-03 统计与复核)。octos-swarm属 harness M7.5 阶段新增 crate:M7.4 的持久成本账本(PersistentCostLedger)已接入 serve 接线,swarm 内CostLedger仍是薄适配层。文中所有行号来自事实表assets/ch17-facts.md或本次会话对源码的只读核对;--swarm-backend*四标志的行为以crates/octos-cli/src/commands/serve.rs:1867的装配函数为准。
第 18 章:Goal 与 Peer:把目标从上下文里搬出来
定位:本章分析 v2 新增的 goal 与 peer 两条线:goal 线 9 个文件合计 52,445 行(
crates/octos-cli/src/goal_tool.rs3,028、crates/octos-fleet/src/sqlite_ledger.rs6,360 等),peer 线 9 个文件合计 5,969 行(crates/octos-cli/src/peers/mod.rs3,186 等),合计近六万行新代码。它们回答同一个问题:长程目标放在模型的对话上下文里会腐烂,放在哪里才安全。前置依赖:第 5 章(§5.10 续跑边界)、第 12 章(supervisor 与租约)、第 16 章(Fleet 内核与 GoalLedger 所属 crate)。适用场景:要构建多 agent 长任务系统的开发者,以及关心「目标如何跨会话存活」的架构读者。本章也是第四部分外环(第 20 章)的前置:外环靠这些原语观测与驱动。
18.1 为什么把目标从上下文里搬出来
Claude Code 式的 goal 实现把目标文本每个 turn 注入上下文,依赖模型在微观执行中持续遵守它。实践中模型经常不遵守:跑几轮就停下来,或者被眼前的工具输出带偏。goal 文本挂在系统提示上,进度却记录在会腐烂的对话历史里,一次压缩就可能丢掉中间结论。octoscode/docs/PEER_GOAL_ARCHITECTURE.md 给出的失败模式描述是:目标活着,进度在腐烂(objective survives, progress rots)。
octos 的做法是把 goal 从微观上下文中取出来,交给服务端的 keeper 在宏观层持有和推进:keeper 定期用 GoalContinue 续跑 tick 派活、收账、决定下一步;派出去的 peer 在自己的微观上下文里只看到一份自包含的任务契约(brief),里面没有 goal 文本。goal 的持续性由结构保证,单个 peer 停了不影响 goal 前进。
这条设计线落在两个系统的分工上。goal 线持有与推进目标:crates/octos-cli/src/goal_tool.rs(3,028 行,首行文档注明它取代脆弱的提示词方案 #1696)、commands/{goal,ledger}.rs(1,116 + 240 行)、autonomy/{goal_loop_runtime,master_continuation_scheduler,supervisor_store,fleet_wake}.rs(1,562 + 1,416 + 3,277 + 1,807 行)、crates/octos-fleet/src/sqlite_ledger.rs(6,360 行)。peer 线执行:crates/octos-cli/src/peers/mod.rs(3,186 行)、crates/octos-cli/src/peers/host.rs(502 行)、crates/octos-cli/src/commands/peer.rs(211 行)与 octos-agent 里六个 peer 工具文件(合计 2,070 行)。goal 线 9 文件合计 52,445 行,peer 线 9 文件合计 5,969 行,体量差约九倍:持有目标的机器比执行目标的机器复杂得多。账本一个文件就 6,360 行,因为目标的每次状态转移、每条发现、每个升级请求、每条决策都要留审计痕迹;而 peer 的全部协议只是目录下的十来个小文件。
分层还有一个容易误判的细节:goal 的持有方不是某个独立守护进程,而是 crates/octos-cli/src/autonomy/agent_orchestrator.rs(33,639 行)里的 keeper 角色,continuations 字段(:11783)持有调度器,goal 的续跑、fleet 派发、账本归集都发生在 master 会话的编排器里。所谓「服务端持有」的准确含义是:持有者不是模型的对话上下文,而是编排器进程里的持久状态加磁盘上的两本账。这也是外环(第 20 章)能观测它的原因:状态在服务端,就有稳定的查询面。
| 线 | 文件数 | 行数 | 核心文件 |
|---|---|---|---|
| goal 线 | 9 | 52,445 | crates/octos-fleet/src/sqlite_ledger.rs 6,360、crates/octos-cli/src/goal_tool.rs 3,028、crates/octos-cli/src/autonomy/supervisor_store.rs 3,277 |
| peer 线 | 9 | 5,969 | crates/octos-cli/src/peers/mod.rs 3,186、六 peer 工具 2,070 |
与第 16 章的分工:fleet 内核(redb)管理计划与 attempt 的执行状态,GoalLedger(SQLite)管理目标、发现与升级的审计账本,两本账互不替代(第 16 章 §16.7 已划界)。与第 12 章的分工:supervisor 管会话内的子任务存活,peer 是跨会话的进程级实体。
18.2 GoalLedger:目标的第一本账
crates/octos-fleet/src/sqlite_ledger.rs:13 定义 pub struct GoalLedger,模块首行注释写明它是 goals、tasks、findings、escalations 的持久账本。它是 WAL 模式的 SQLite:master、进程管理器、peers 是独立进程,共享同一个 <data_dir>/goal-ledgers/<goal_id>.db 文件。impl GoalLedger 从 :206 到 :2241,共 39 个 pub fn,按用途分五组:
| 组 | 代表方法 | 行号 |
|---|---|---|
| 打开 | open / open_with_busy_retry | :222 / :245 |
| goal | create_goal :714、upsert_goal :811、cas_goal_status :899、get_goal :976、update_goal_status :1498、stamp_goal_cleared :2009 | — |
| task | create_task :1066、create_task_if_absent :1124、get_task :1166、update_task_status :1232、settle_task_status :1267、tasks_for_goal :1290 | — |
| 追加事件 | append_finding :1623、append_escalation :1635、resolve_escalation :1702、list_open_escalations :1745、append_decision :1807、commit_state_with_audit :1908 | — |
| 统计 | list_findings_since :2132、task_status_counts :2178、total_cost_tokens :2232 | — |
两个方法值得展开。cas_goal_status(:899)不是裸的 CAS,UPDATE 语句里嵌了一条预算规则:目标是 active 且 tokens_used >= token_budget 时,状态直接写成 budget_limited 而不是调用者要的新状态。预算检查在唯一的状态转移点执行,不依赖调用方自觉。open_with_busy_retry(:245,#1865)解决一个实测到的 WAL 初始化竞态:两个连接同时初始化同一个新库时,wal-index 恢复锁的争用不被 busy handler 覆盖,一次性 open 可能输掉毫秒级竞赛并静默丢审计行;该变体最多重试 3 次,每次间隔 50ms,并把 busy_timeout 压到 1 秒,只在错误被归类为 BUSY/LOCKED 时重试。
状态机的操作员出口在 crates/octos-cli/src/commands/goal.rs(1,116 行,#25):octos goal reopen 承认 blocked|paused|budget_limited 回 active,octos goal archive 承认任何状态进 archived,且 archived 是终态不可逆。观察出口在 crates/octos-cli/src/commands/ledger.rs:octos ledger tail(:80)直读 goal-ledgers 目录,零 serve 依赖。
预算是软限制。budget_limited 停掉的只是自我推进的 GoalContinue tick,在跑的 peer 继续跑完,外部唤醒仍然触发续跑。架构文档把这条权衡写得很清楚:不半路杀死正在工作的 peer,用户看到部分结果后再决定是否加预算;代价是预算可能超支,操作员要理解 budget limited 不是 stopped。
18.3 goal 工具族:keeper 的十个把手
crates/octos-cli/src/goal_tool.rs 定义 10 个工具,7 个 goal_* 加 3 个 monitor_*(#1977 零 token 监视器,keeper 门控:peers 不能布防)。每个工具的结构体与 fn name() 行号都列在事实表里:
| 工具 | 结构体 | 行号 | 职责 |
|---|---|---|---|
goal_get | GoalGetTool | :350 / :404 | 读目标快照,折叠发现与升级 |
goal_plan | GoalPlanTool | :561 / :575 | 把目标分解到持久 fleet |
goal_dispatch | GoalDispatchTool | :766 / :780 | 把就绪任务派发到 worker 池 |
goal_grant | GoalGrantTool | :861 / :875 | 批准 Blocked 任务的能力扩张请求 |
goal_deny | GoalDenyTool | :1024 / :1050 | 拒绝升级请求,任务转 Failed |
goal_update | GoalUpdateTool | :1163 / :1266 | 状态迁移,完成主张要过独立 verifier |
goal_create | GoalCreateTool | :1495 / :1509 | 建目标,准入检查跨两次调用串行化 |
monitor_create / monitor_list / monitor_delete | :1634 / :1824 / :1893 | — | 事件监视器三件套 |
接线在 crates/octos-cli/src/runtime/profile.rs:GoalGetTool :1323(带 .with_data_dir)、GoalCreateTool :1326、GoalUpdateTool :1329-1337(若配置了 verifier 车道则挂上)、GoalPlanTool :1341、GoalDispatchTool :1342、GoalGrantTool :1349、GoalDenyTool :1353。peer 会话侧的接线在 crates/octos-cli/src/commands/chat.rs:859 与 :1574-1577:goal 绑定的 peer 只拿到 goal_get 与 goal_update,能看到目标并把发现记回 master 的账本,拿不到 plan 与 dispatch,无权改写计划。
goal_get 是三条回流通道的汇聚点(:357、:491、:501):它先扫 <data_dir>/peers/ 下的活体 peer(data_dir.join("peers")),再读 <data_dir>/goal-ledgers/<goal_id>.db 的持久 findings,分别以 peer_findings、ledger_findings、open_escalations 三个键注入快照(#1967),顺带附上账本摘要(#1945)。keeper 不需要知道 peer 在哪,goal_get 一次调用把账收齐。
完成判定在 crates/octos-cli/src/autonomy/goal_loop_runtime.rs:GoalCompletionVerdict(:282,Done / NotDone)由独立的廉价车道 verifier 给出,#1935 的注释直引 loop-engineering 的原则:agent 自己的完成主张只是主张,verifier 独立返回 Done 才允许转 Completed,NotDone 保持 Active。运行时类型同文件:GoalId(:10)、GoalRuntimePolicy(:239,cadence 加 max_continuations)、GoalRuntimeState(:265,Active/Paused/Completed/Failed)、GoalRuntime(:305)。预算耗尽的收尾指令装在 GoalBudgetResolution(:298)的 wrap_up_prompt 字段里,原文直传。
verifier 车道是在接线处注入的:crates/octos-cli/src/runtime/profile.rs:1329-1337 注册 GoalUpdateTool 时,若 profile 配置了 goal_verifier_llm,就以 .with_verifier_provider 挂上。没有配置时完成判定退回无 verifier 的路径,这条配置项因此是生产部署里「完成要不要双验」的总开关。plan 与 dispatch 两个工具同批接线(:1341、:1342,#1857 PR 5a):goal_plan 把目标分解到持久 fleet,goal_dispatch 把就绪任务发到活的 worker 池,升级决断 goal_grant(:1349)与 goal_deny(:1353)消费 Blocked 任务的出口,deny 若使 fleet 无法完成会顺带同步 per-goal 账本(#1964)。这四个工具把第 16 章的 fleet 内核接进 keeper 的工具面,本章只关心它们在 goal 叙事里的位置,状态机细节回到第 16 章。
18.4 续跑:GoalContinue 与 GoalWrapUp
第 5 章 §5.10 从 loop 视角看过 MasterContinuationScheduler,这里补 goal 侧的全部语义。crates/octos-cli/src/autonomy/master_continuation_scheduler.rs(1,416 行)的 MasterContinuationReason(:137)有六变体,goal 占两个:GoalContinue(:141)是自我推进的续跑,GoalWrapUp(:147,#1131)是预算耗尽后的收尾 turn,携带 wrap_up_prompt,渲染时必须原文使用,不走标准的推进模板。
priority()(:152)把两个变体映射到同一车道(:161),注释给出理由:wrap-up 是会话暂停前的最后一个 goal turn,不是特权 turn。优先级 rank(:190-199)四档:External=0、GoalContinue=10、ChildOrScatterJoinComplete=20、LoopFire=30,数值越小越先出队。stable_name()(:169-176)给去重与持久化一个稳定字符串:goal_continue(:171)与 goal_wrap_up(:172)。
入队点在 crates/octos-cli/src/autonomy/agent_orchestrator.rs:12963:构造 MasterContinuationRequest::new("coding-autonomy-goal", ...) 并以 .with_goal_id 携带目标身份,metadata 里带 objective 与 status。调度器本体 MasterContinuationScheduler(:477)持有方在 crates/octos-cli/src/autonomy/agent_orchestrator.rs:11783 的 continuations 字段;enqueue_at(:532)入队,drain_ready(:687)与 drain_ready_for_session(:706)按优先级出队。一个细节:30 秒的 RECENT_CLAIM_GUARD_WINDOW(:33-35)用于挡同键 External 的重复投递,而 LoopFire、GoalContinue、ChildCompleted 这些循环原因逐 tick 重入队,被刻意排除在设防之外,永不拒绝。
外部事件也能唤醒 keeper。crates/octos-cli/src/autonomy/fleet_wake.rs(1,807 行)是 fleet 内核 outbox 的消费者(PR 4a):后台任务认领持久 outbox 事件,把它转成 keeper 的续跑请求。耐久性规则写在模块文档里:只有唤醒已被持久化(WakeCommit::Durable)才 ack 事件,未持久化的事件留在认领态,租约过期后重投,崩溃夹在中间也不会丢唤醒。文档同时诚实标注:截至该 commit,生产路径还没有写入真实 fleet 事件,这个消费者靠单元测试的合成事件证明正确性,dormant but correct。它唤醒的前提也写得很窄:keeper 的工作区已加载,即交互式或已连接的 goal 会话;无头会话的再水化留给 PR 4b。纯函数核心 drain_fleet_outbox_once(:235)不持有单例,进程循环 spawn_fleet_outbox_consumer(:343)调编排器的 drain_fleet_outbox,后者供给的 commit 回调只在锁定运行时状态的一瞬做持久化。
supervisor 侧的支撑是 crates/octos-cli/src/autonomy/supervisor_store.rs(3,277 行):SupervisorStore(:697)持久化受监管 agent 组的状态,load_state(:780)在重启时把状态读回。第 12 章讲过它与租约的分工,这里只需要记住 goal 的续跑请求走的是同一条持久化路径,fleet_wake 的文档明说它的 commit 回调复用 peer 与 goal 唤醒共用的持久化管线。
把这一节的状态拼起来,goal 的状态机在两个层面运转。账本层面:cas_goal_status 与 update_goal_status(:1498)管理 active、complete、blocked、budget_limited、paused、cleared 这些字符串状态(crates/octos-fleet/src/sqlite_ledger.rs:39 注释状态集,终态保护为 complete 与 cleared 两种,:914/:1512 的 WHERE 子句可证);archived 不在账本状态集里,它是 supervisor 事件流侧的终态标记(crates/octos-cli/src/commands/goal.rs:12-14 注释明写 goal state 的权威源在 supervisor 事件流而非 SQLite goals 表),两本账的状态集不应混写,转移规则分操作员可达与模型可达两类——crates/octos-cli/src/commands/goal.rs 的注释写明 complete 与 blocked 只有模型可到,archived 只有操作员可到,reopen 只认 blocked、paused、budget_limited 三种入口。运行时层面:GoalRuntimeState 的四态(Active、Paused、Completed、Failed)是内存里的推进视图,GoalRuntimePolicy 的两个构造函数给出两种节奏:fixed_interval 按固定间隔心跳,self_paced 由信号驱动,两者都受 max_continuations 封顶。两层的状态由 keeper 对齐:每次 GoalContinue tick 读账本、比预算、决定入队下一个 tick 还是转 wrap-up。goal 侧入队测试(:1185)还验证了去重键携带 goal_id,同一会话两个不同的 goal 不会在队列里互相挤掉。
18.5 Peer 生命周期:六个阶段
peer 的心智模型来自操作系统:subagent(SpawnTool/DelegateTool)像线程,在父 turn 内派生,共享父的上下文与工作区,父 turn 结束就终止;peer 像 fork 出去的进程,有自己的 session、工作目录、turn 循环与预算,parent 挂了它照跑。两者之间不共享内存,通信走黑板文件,等价于 IPC。
sequenceDiagram
participant H as 操作者
participant M as Master agent
participant S as octos server
participant C as octoscode client
participant P as Peer session
H->>M: 宏观指令(/goal 或 prompt)
M->>S: peer_handoff(brief, name, worktree?, goal_id?)
S->>S: 落盘 peers/<slug>/{wt?, originator, goal?, brief.md}
S-->>C: peer/staged 通知
C->>S: 后台打开 peer-<slug> 会话
S->>P: boot:读回 brief/goal/originator
loop peer 自治执行
P->>P: turn 循环(自己的工具与预算)
end
P-->>S: result.md(+ goal-ledgers 若 goal-scoped)
S-->>C: peer/closed 通知
M->>S: peer_gather / goal_get
六个阶段各有落点:
- Stage。
stage_peer(crates/octos-cli/src/peers/mod.rs:1563)在peers/<slug>/下按固定顺序落盘:worktree(:1598)、originator(:1702)、goal(:1729)、brief.md(:1738)、name(:1752)。顺序本身就是发布不变量,18.6 展开。模型侧的入口是peer_handoff工具(crates/octos-agent/src/tools/peer_handoff.rs:133),它只做参数校验(brief 上限、name 上限 64 字符、重复名直接拒绝而不自动加后缀),staging 落盘由 host 回调执行。 - Staged 通知。server 只负责 stage,session 由 client 打开(session 与 client 连接耦合)。
peer/staged通知的意思是已落盘、请 client 打开,client 收到后在后台打开peer-<slug>会话。这层职责切分是刻意的:server 不知道 client 的窗口管理,client 不知道 staging 的文件协议。 - Client 打开。peer 会话在自己的 topic 下建立,session key 以
peer-<slug>前缀标识,这个前缀后来被 depth-1 治理复用。 - Boot 读回。
crates/octos-cli/src/peers/host.rs:96的read_peer_boot从黑板读回执行上下文::99-116解析 goal 文件(第一行 goal_id,第二行 task_id,goal_id 为空则把孤立的 task_id 丢弃,不给 agent 塞一条悬空子任务),originator 只在 boot 读一次,防止运行中被重绑定;brief 按 1 MiB 上限读取。每个字段独立可选:没有 goal 或没有 brief 的 peer 是降级而非致命,只有 slug 不是真实 staged 目录才返回空。 - 运行。peer 有完整的 turn 循环与工具集。终局产物由
crates/octos-cli/src/api/ui_protocol_transport.rs:14279的write_peer_result_if_peer_session写回黑板:frontmatter 四字段(slug/outcome/updated_unix/turn,:14334),turn 号由已有的result-<n>.md计数加一推出(:14328-14329),正文上限 256 KiB(:14306)。预算耗尽的 peer 写的是另一份五字段检查点副本(crates/octos-agent/src/agent/budget.rs:584:status/completed/iteration_budget/iterations_used/checkpoint_commit),工作进度以 commit 形式留在分支上,未完成三个字明示,提示重新派发时提高上限或拆小任务。若 peer 持有写权(.result-owner: peer侧车,#27f/#27h),runtime 不得覆盖它的终局,peer 手写的内容自由,不受四字段模板约束。 - Closed 与 gather。
closed墓碑文件标记终局(peer_is_closed,:1317),peer/closed通知 client 更新界面;master 用peer_gather拉取 result.md,或直接goal_get收账。命令行观察面是octos peer list(crates/octos-cli/src/commands/peer.rs:54),同样直读黑板目录,零 serve 依赖。
18.6 黑板:文件布局就是协议
peer 与 master 之间没有共享内存,peers/<slug>/ 下的文件就是协议本体:
<data_dir>/
├── peers/<slug>/
│ ├── brief.md # 任务契约,≤64KB,peer 看到的全部输入
│ ├── name # 显示名,主寻址键
│ ├── originator # master session key,boot 读一次
│ ├── goal # 两行:goal_id 与 task_id,goal-scoped 才有
│ ├── result.md # 活体成果,会被覆盖
│ ├── result-<n>.md # 版本化副本(#435),count_peer_result_versions :2592
│ ├── result.checkpoint.md # 预算检查点副本(#27e)
│ ├── turns.txt # 行式 turn 索引,parse_peer_turns_index :2649
│ ├── closed # 墓碑,peer_is_closed :1317
│ ├── model # 车道键,read_peer_model_lane :2189
│ └── wt/ # worktree 模式下的隔离 clone
└── goal-ledgers/<goal_id>.db # 持久账本:findings 与状态转移
stage_peer 的写入顺序承载两条不变量。其一,brief.md 是可见性门:staged_peer_dir(:417)以 brief.md 存在判定 peer 是否存在,所以 brief.md 必须最后写,写完 peer 才「存在」;此前任何失败都走 cleanup_staged_peer 整体回收,不会留下一个可见但不完整的 peer。其二,originator 在 brief.md 之前写入,保证任何能看到该 peer 的 fleet 所有权扫描也能读到它的 owner,不出现「可见但无主」的窗口——无主窗口的害处具体:兄弟 peer 完成时触发的合成会把这个 peer 静默漏掉。goal 文件(:1729)同样先于 brief.md 落盘。name(:1752)是显示名与主寻址键,staging brief 同时记为第一轮指令历史,让指令历史从工作开始处起步,brief.md 本身保持权威。
叶子不止这些。result-<n>.md 是版本化副本(#435):count_peer_result_versions(:2592)数一遍 result- 前缀文件就得出版本号,持久 peer 跑多轮不会静默覆盖旧成果;result.checkpoint.md 是 #27e 预算检查点的侧车,peer 持有写权时由 peer 自己写;turns.txt 是行式 turn 索引,parse_peer_turns_index(:2649)逐行解析;closed 是墓碑(peer_is_closed,:1317);model 记车道键(read_peer_model_lane,:2189)。
读路径有对称的防备。所有文件读取都锚定在 fd 上(openat 加 renameat 的原子写),拒绝符号链接;读上限分档:小文件 64 KiB(PEER_FILE_READ_CAP_SMALL,:462),brief 与 result 1 MiB(PEER_FILE_READ_CAP_LARGE,:457);brief 写入上限 64 KB(PEER_BRIEF_MAX_BYTES,:1422)。寻址以 name 为主键:resolve_peer_name_to_slug(:1329)把人类可读名解析到 slug,大小写不敏感。黑板的行视图是 PeerBlackboardRow(:2676,slug、name、brief、result、turn 历史、车道、closed、有无 worktree),read_peer_blackboard(:2714)枚举目录组装,compose_peer_list_text(:2802)渲染成 peer_list 的输出。这层文件协议的每个读写都有上限、有原子性、有版本化,是「黑板」二字能承担进程间通信职责的全部理由。
18.7 Worktree 的真相:clone,不是 worktree
--worktree 名义上叫 worktree,实现是 git clone --no-hardlinks(crates/octos-cli/src/peers/mod.rs:1638 与 :1640,clone 与 --no-hardlinks 两个参数各占一行)。源码注释(:1624 起)记录了为什么必须如此:git worktree add 建出的 .git 是一个文件,指向 <repo>/.git/worktrees/<name>,那个目录在 peer 的沙箱之外,于是 peer 里跑任何 git 命令都报 fatal: not a git repository,模型还会用 git init 自救,把栅栏分支彻底毁掉,交付物永远落不了盘。clone 把完整 .git 放进 peers/<slug>/wt,git 正常工作,无需放宽沙箱,一个 peer 也够不到另一个的 refs。
--no-hardlinks 的理由同样写在注释里:默认的本地 clone 优化会让子库与源仓共享对象文件的 inode,peer 写自己的 .git 时可能腐蚀源仓对象;隔离是这个特性的全部意义,所以宁可付出每个 peer 一次真实对象拷贝的成本。代价明示:如果 staging 延迟成为问题,凭证据再重新评估。克隆之后,peer 的栅栏分支在自己的克隆里 checkout(peer/<slug> 分支),克隆也不继承源仓的 LOCAL config。
18.8 绑定与三条回流通道
goal 与 peer 的连接点在 handoff 参数与 goal 文件。PeerHandoffRequest(crates/octos-agent/src/tools/peer_handoff.rs:35)携带可选的 goal_id 与 task_id;master 在活跃 goal 下 handoff 时传入,server 把它们原子写入 peers/<slug>/goal 两行文件。没有 goal 文件的 peer 行为与普通 peer 完全一致。peer 侧不拥有 goal:boot 读回绑定后,它的 goal_get 按记下的 goal_id 与 originator session 直接按 id 解析到 master 的 goal,把发现记进 master 的账本。
flowchart LR
subgraph MASTER["Master session(keeper)"]
GG["goal_get :350"]
end
subgraph PEERDIR["peers/<slug>/"]
RM["result.md<br/>(live,会被覆盖)"]
GF["goal(两行绑定)"]
end
subgraph LEDGER["goal-ledgers/<goal_id>.db"]
LF["append_finding :1623"]
ES["append_escalation :1635"]
end
PEER["goal-scoped peer"]
PEER -- "通道1 peer_findings" --> RM --> GG
PEER -- "通道2 ledger_findings" --> LF --> GG
PEER -- "通道3 open_escalations<br/>(park 时写)" --> ES --> GG
GF -. "boot 读回" .-> PEER
三条回流通道在 goal_get 汇聚:live 通道读 peers/<slug>/result.md,快但是会被覆盖;durable 通道走 GoalLedger::append_finding(:1623),每个 goal-scoped peer 完成 turn 时落盘,重启后仍在,是权威历史;escalation 通道在 peer park 在审批或提问时写 append_escalation(:1635),master 错过实时通知也能在 goal_get 的 open_escalations 里看到谁在等,批准走 peer_respond 或 goal_grant。设计意图是把「错过通知」从异常路径变成普通读路径。
工程实测的陷阱必须写进文档:模型不会自动传 goal_id。master 没有活跃 goal,或 prompt 没有要求绑定时,handoff 出来的是普通 peer,产出不进 goal 账本。测试回流链路时要显式要求绑定。这正是架构文档把它标注为实测陷阱的原因:工具的默认值是「不绑定」,而绑定的收益(durable 历史、goal_get 收账)只在显式传参后才存在。
车道参数在同一份请求里:model(crates/octos-agent/src/tools/peer_handoff.rs:59)是 master profile 里 sub_provider 的键(与第 9 章互引),host 校验键存在性,未知车道降级主模型并以 PeerHandoffStaged.model_note(:86)如实回告;匹配的键记录进 model 文件(record_peer_model_lane,:2205),peer 的 turn 按该车道解析 provider。
18.9 治理:三条硬约束
peer 机制给了模型 fork 的能力,治理约束全部在 serve 接线处强制,不靠提示词:
- Depth-1:peer session 的 topic 以
peer-前缀标识,peer_handoff_allowed_for_session(crates/octos-cli/src/peers/mod.rs:1934)看到该前缀直接拒绝,peer 的工具注册表里根本没有peer_handoff,模型看不见它。peer 不能再 fork peer,递归在工具可见性层面就被切断(crates/octos-agent/src/tools/peer_handoff.rs:12-13注释自证)。 - Per-turn 上限:
PEER_HANDOFFS_PER_TURN_MAX = 4(:1928),强制点在:2062,超出即返回错误。一个 turn 最多派四个 peer,扇出不失控。 - Brief 上限:
PEER_HANDOFF_BRIEF_MAX_BYTES = 64 * 1024(crates/octos-agent/src/tools/peer_handoff.rs:27),注释的口径是 brief 是任务契约,不是 blob 存储;server 侧PEER_BRIEF_MAX_BYTES(crates/octos-cli/src/peers/mod.rs:1422)同值镜像。
master 侧的工具族共六个,覆盖 peer 的完整操作面:peer_handoff(staging,:133)、peer_list(roster,:56)、peer_gather(收成果,:64)、peer_respond(回答 park 住的 peer,:148,控制通道)、peer_close(:62)、peer_send_input(:90)。
工程决策:peer 是进程,subagent 是线程 subagent 在父 turn 内派生,共享上下文与工作区,结果作为工具结果直接回到父上下文,父 turn 被打断则随之终止:这是线程语义,轻量,命运与父进程绑定。peer 有独立 session、可隔离的工作目录、自己的 turn 循环与 token 预算,parent 挂了它照跑:这是进程语义,隔离换开销。选择标准是失败域:任务崩了要不要拖垮父会话,交付物要不要在重启后仍在磁盘上。黑板文件是两者的 IPC:不共享内存,契约(brief)与成果(result)都是普通文件,任何一侧崩溃后另一侧照常读取。
PeerTaskBinding(crates/octos-cli/src/peers/mod.rs:166)把 peer 绑到 supervisor 的租约上(bind_peer_supervised_task,:241;retire_peer_supervised_task,:264),peer 存活与 supervisor 租约一致,第 12 章的租约语义在此复用。
18.10 边界与回顾
与第 5 章:MasterContinuationScheduler 的数据结构第 5 章 §5.10 已从 loop 视角讲过,本章补的是 goal 侧的全部变体与外部唤醒;第 5 章末尾「第 18 章:MasterContinuationScheduler 的消费侧与 goal 续跑全貌」的前向引用至此落地。与第 12 章:supervisor 的事件账本与 SupervisorStore(:697/:780)管会话内子任务,PeerTaskBinding 是 peer 借用该机制的生命线;lease 的完整状态机留在第 16 章。与第 16 章:fleet 内核的六张 redb 表管计划执行,GoalLedger 管目标审计,goal_plan/goal_dispatch 在 keeper 之上把两者接起来;escalation 的 grant 决断(goal_grant/goal_deny)消费第 16 章 Blocked 状态机的出口。外环协议本身(第 20 章)与 TUI 显示(第 19 章)不在本章范围。
本章回顾:
- goal 持久化在服务端:
GoalLedger39 个 pub fn 分五组管目标、任务、事件与统计,cas_goal_status把预算规则嵌进唯一的状态转移点,budget_limited 是软限制。 - keeper 的十个把手:7 个 goal_* 工具加 3 个 monitor_*,peer 侧只接线
goal_get与goal_update,计划权不下放。 - 续跑有优先级:GoalContinue 与 GoalWrapUp 同车道,wrap-up 是最后一个 turn 而非特权 turn;fleet_wake 的唤醒先持久化再 ack,不丢事件。
- peer 的协议是文件:写入顺序(brief.md 最后)承载可见性不变量,result 有活体与版本化两份,终局四字段 frontmatter,检查点五字段。
- worktree 实为
git clone --no-hardlinks:.git必须完整落在沙箱内,共享 inode 会腐蚀源仓,隔离优先于 staging 延迟。 - 绑定显式、回流三通道:
goal两行文件 + goal_id 必须显式传,成果经 peer_findings、ledger_findings、open_escalations 汇聚到goal_get。 - 治理三条硬约束在接线处强制:depth-1 靠工具不可见,per-turn 上限 4,brief 上限 64KB。
数据库文件 goal_ledger.sqlite 由 keeper 进程独占持有,worker 与 transition 只经 WAL 侧读写,这一持有关系是恢复语义的地基。
延伸阅读
octoscode/docs/PEER_GOAL_ARCHITECTURE.md— 设计渊源、绑定与回流、budget 软限制的原始架构文档,本章多张图取材于此并按源码校验crates/octos-fleet/src/sqlite_ledger.rs— GoalLedger 全部 39 个公开方法与 #1865 竞态注释crates/octos-cli/src/peers/mod.rs:1624-1640— clone 而非 worktree 的完整论证注释- 第 16 章
docs/FLEET-RUNTIME-ADR.md— goal 与 fleet 分层的架构决策记录
思考题
cas_goal_status把预算判定嵌进 UPDATE 的 CASE 表达式。若把同样的判定移到工具层(先读 tokens_used 再写状态),什么并发窗口会让超预算的目标继续转?WAL 模式下两个进程同时执行这条 UPDATE 会发生什么?- brief.md 是可见性门,必须最后写。若 name 文件写在 brief.md 之后,staged 通知发出前进程崩溃,会出现什么状态的 peer?
resolve_peer_name_to_slug会怎样处理它? --no-hardlinks付出每个 peer 一次完整对象拷贝。若源仓有 2 GB 对象、要派 20 个 peer,staging 成本如何估算?有什么方案能把成本降下来而不重新引入共享 inode 的腐蚀风险?- 模型不自动传
goal_id是实测陷阱。若要在peer_handoff里加一个「master 有活跃 goal 时默认绑定」的策略,需要改哪些校验?这个默认值反过来会伤害哪类「与 goal 无关的委派」场景? - GoalWrapUp 与 GoalContinue 同优先级车道。若给 wrap-up 单开一个更高优先级,预算耗尽瞬间的收尾 turn 会以什么顺序与其他会话的 GoalContinue 竞争?为什么设计者认为它不该是特权 turn?
版本演化说明 本章为 v2 新增章,分析基线 octos main @
9c157101(2026-09-03 采集复核)。goal 与 peer 两条线均为新代码:goal 工具族源自 #1696(取代提示词方案),peer 机制源自 #1801 v3,续跑调度源自 M15,预算收尾源自 #1131,monitor 族源自 #1977,peer-goal 绑定与回流源自 #1967 与 #1945。所有行号与行数来自事实表assets/ch18-facts.md(commit 8a008e5 同批入库)或本次会话对源码的只读核对;第 5 章 §5.10 与第 12 章对本章的前向引用在此落地。result.md frontmatter 的实测口径(runtime 副本四字段、检查点副本五字段)以源码为准,不沿用早期 spec 的六字段表述。
第 19 章:octoscode:终端客户端与 UI Protocol
定位:本章解剖 octoscode,octos 的终端客户端:它不跑 agent、不执行工具、不持有账本,只经 UI Protocol 与
octos serve通信。启动链、传输层、reducer、协议契约、goal/peer 的客户端侧与外环观测面在这里汇成一条线。前置依赖:第 14 章(serve 控制面与 stdio 挂载面)、第 18 章(goal/peer 服务端机制)。适用场景:要为 octos 编写客户端、把 TUI 接入自己的观测系统,或思考「客户端到底应该有多薄」的开发者。
19.1 客户端边界:一个不执行任何东西的终端
先给结论:octoscode 在协议模式下是一个哑客户端。octoscode/docs/ARCHITECTURE.md:3 的 Scope 节用一句话划定了边界:
octoscode is a standalone terminal client for the Octos UI Protocol. In protocol mode it does not run the Octos agent, execute tools, approve commands, maintain the durable ledger, or own provider/model configuration. Those responsibilities belong to the
octos serveprocess.
翻译成清单:不运行 agent、不执行工具、不做审批决定、不维护持久账本、不持有 provider/model 配置,这五件事全部属于 octos serve 进程。TUI 拥有的只有呈现:终端渲染与键盘、本地视图状态(焦点、滚动、展开、composer 草稿)、用户 prompt 的乐观显示、本地 slash 命令(/ps、/stop、/help),以及把用户交互翻译成稳定的 AppUiCommand 值。服务端拥有的则是会话创建与 cwd 校验、agent 运行时、shell/工具执行与沙箱策略、审批请求与决定、task supervisor 与后台任务注册表、持久 UI 事件 ledger 与 replay、diff 预览与任务输出数据源。
这份边界的价值由架构不变量兜底(octoscode/docs/ARCHITECTURE.md:713 起的 Architectural Invariants 一节):octoscode 不得直接调用 Octos 运行时内部;不得依赖 AppUI 之外的 M9 服务端内部;必须把服务端视为任务、审批、diff、工具结果、cwd 策略、沙箱策略与 replay 的权威;服务端不得要求 TUI 特有行为才能保证协议正确;新的客户端可见能力必须先落进 crates/octos-core 的 UI Protocol 类型,再落 TUI 渲染;面向编码体验的提示词整形属于服务端 profile 或 harness 提示词契约,不属于客户端。换句话说,客户端与运行时之间只允许存在一条合同:协议。
体量上,octoscode 的 src/ 顶层有 26 个文件共 96,124 行(含内嵌测试),最大的是 octoscode/src/store.rs(43,935 行,reducer,内嵌测试),其次是 octoscode/src/model.rs(12,884 行)、octoscode/src/transport.rs(12,214 行)、octoscode/src/event_loop.rs(8,655 行)、octoscode/src/app.rs(6,286 行);子目录还有 app/ 5 个文件、cmd/ 8 个、menu/ 10 个。入口之外另有几件周边设施:octoscode/src/backend_ensure.rs(1,317 行)管后端自动安装,octoscode/src/autonomy.rs(1,192 行)管 goal/peer 的客户端侧解析,octoscode/src/tui_terminal.rs(1,171 行)是从 codex-rs 裁剪移植的内联视口终端,octoscode/src/insert_history.rs(1,602 行)负责把定稿输出写回终端原生回滚区。一个「什么都不执行」的客户端接近十万行,这个反差正是本章要解释的:薄的是职责,不是代码量,把交互做厚、把权限做薄,本身是重活。
对照物有助于理解这条边界画在哪里。架构文档末尾的对照模型(octoscode/docs/ARCHITECTURE.md:666)把 Codex 式本地 CLI 与 Octos 的拆分并列成一张表:Codex 式客户端进程自己拥有运行时(工具执行在本地沙箱、审批策略在本地、会话与重放在本地),模型服务只提供推理;Octos 把这些全部移进 octos serve,客户端只剩渲染与交互。两种都能做出好的编码体验,差别在可替换性与观测面:Octos 的终端应用可以整体替换,所有客户端说同一套 AppUI API,而运行时状态集中在服务端,外部观测器不必侵入客户端就能读到底牌。
工程决策:哑客户端的取舍 哑客户端牺牲了本地自治:没有服务端,octoscode 连一次 shell 都不能替你跑(mock 模式只是渲染测试夹具)。换来的是三样东西。第一,安全边界唯一化:工具执行、沙箱、审批全部在服务端强制,客户端被攻破也拿不到执行权,策略不需要在两端各实现一遍。第二,客户端可替换:
octoscode/docs/ARCHITECTURE.md:666的对照模型把 Codex 式本地 CLI(运行时就在 CLI 进程里)与 Octos 的拆分并列,结论是终端应用可整体替换,所有客户端说同一套 AppUI API。第三,观测可外置:状态都在服务端与协议流里,外部进程不必侵入客户端就能观测(第 21 章的 herdr 正是利用这一点)。代价是协议本身必须足够厚,7,221 行的 wire 类型(见 19.5 节)就是这笔账单。
19.2 启动链:57 行的 main 到 4.4 万行的 reducer
octoscode 的二进制入口极小,octoscode/src/main.rs 全文 57 行,fn main() 在 octoscode/src/main.rs:4:
fn main() -> Result<()> { color_eyre::install()?; install_terminal_restoring_panic_hook(); // 拦截 update/doctor 子命令,命中则直接退出 if let Some(code) = cmd::dispatch(std::env::args())? { std::process::exit(code); } let mut cli = Cli::parse()?; // 本地 stdio 启动且未安装 octos 时,先装好后端 backend_ensure::ensure_octos_backend(&mut cli)?; octoscode::splash::play(&cli); event_loop::run(cli) }
四步的顺序都有讲究。第一步拦截 update/doctor 子命令(octoscode/src/main.rs:10):这两个命令要在终端还是普通模式时输出,不能进事件循环。第二步 ensure_octos_backend(octoscode/src/main.rs:22 调用,实现在 octoscode/src/backend_ensure.rs:113)必须在进入 raw mode 之前执行,注释写明原因:安装器的输出要能干净地打印在终端上。第三步播放启动 splash。第四步才进入 event_loop::run(octoscode/src/event_loop.rs:192)。
ensure_octos_backend 的生效条件相当克制,读 octoscode/src/backend_ensure.rs:113 起的实现可以列出四个提前返回:非 Protocol 模式(mock 不拉子进程)直接返回;stdio_command 为空(WebSocket 启动,没有本地后端可装)返回;命令不是裸 octos(用户自管路径、PATH 覆盖或非 octos 命令,不去碰)返回;已在 PATH 上解析到也返回。只有「协议模式 + 裸 octos + 本地未装」这一种情形才触发安装,新装完成后把 cli.stdio_command 改写为 ~/.octos/bin/octos 的显式路径。首启体验由此接通:用户拿到一个新 octoscode,敲下去,它自己把服务端装好。
默认的拉起命令是常量,octoscode/src/cli.rs:118:
#![allow(unused)] fn main() { pub const DEFAULT_STDIO_COMMAND: &str = "octos serve --stdio --solo"; }
缺省回填发生在 octoscode/src/cli.rs:415 与 octoscode/src/cli.rs:849。这条命令的语义在第 14 章已经拆过:--stdio 让 serve 不绑定 HTTP,UI Protocol 的 JSON-RPC 走 stdin/stdout;--solo 限定单会话姿态。整条启动链如下。
sequenceDiagram
participant U as 用户终端
participant M as octoscode/src/main.rs
participant BE as octoscode/src/backend_ensure.rs
participant EL as octoscode/src/event_loop.rs
participant T as octoscode/src/transport.rs
participant S as octos serve 子进程
U->>M: octoscode
M->>M: 拦截 update 或 doctor
M->>BE: ensure_octos_backend
BE->>BE: 必要时装 octos 并改写命令
M->>EL: run
EL->>T: build_backend
T->>S: 拉起 octos serve --stdio --solo
S-->>T: AppUiSnapshot
T-->>EL: bootstrap 返回快照
EL->>EL: Store::from_snapshot 后进渲染循环
19.3 传输层:一个 trait,两种驱动
传输层的全部抽象是三个方法,octoscode/src/transport.rs:238:
#![allow(unused)] fn main() { pub trait AppUiBackend { fn bootstrap(&mut self) -> Result<AppUiSnapshot>; fn send(&mut self, command: AppUiCommand) -> Result<()>; fn next_event(&mut self) -> Result<Option<ClientEvent>>; } }
build_backend(octoscode/src/transport.rs:244)按模式二选一:mock 走 MockAppUiBackend(octoscode/src/transport.rs:4542,实现在 octoscode/src/transport.rs:4699),协议走 ProtocolAppUiBackend(octoscode/src/transport.rs:311,实现在 octoscode/src/transport.rs:1585,AppUiBackend 实现块在 octoscode/src/transport.rs:2235)。mock 的定位在架构文档里写得很硬:确定性渲染夹具,不得用来验证服务端策略、沙箱、provider 配置、账本持久化或工具执行。
连接目标由 AppUiEndpoint(octoscode/src/transport.rs:192)表达,两个变体:WebSocket { url, auth_token, profile_id } 与 Stdio { command }。对应的驱动分别是 WebSocketTransportDriver(octoscode/src/transport.rs:556)与 StdioTransportDriver(octoscode/src/transport.rs:566)。选择权在 CLI:--endpoint 指定 UI Protocol v1 WebSocket 端点(octoscode/src/cli.rs:126,解析在 octoscode/src/cli.rs:204 起的参数定义),不指定则走 stdio 子进程。两条路的差别不止传输介质:WebSocket 重连只是重连,服务端与其中会话还活着;stdio 的「重连」意味着重新拉起子进程,好在 solo 模式的数据目录由 OCTOSCODE_SHARED_INSTANCE 环境变量锚定,重拉会重新附着到同一目录而不是分叉出新库(注释见 octoscode/src/transport.rs:997 附近)。数据目录被占用时,服务端输出 OCTOS_DATA_DIR_LOCKED 标记,客户端靠识别这个 token 停止重启循环,第 14 章已从 serve 侧讲过同一机制的重放。
重连退避是指数级的:首次重试前有基础延迟,连续失败逐次翻倍,封顶有上限(常量与注释在 octoscode/src/transport.rs:354 起)。所有驱动停止的报错文案都带同一句式「reconnect will retry on next send/read」,把重连时机统一推迟到下一次读写,而不是在后台空转。
bootstrap(octoscode/src/transport.rs:2235 起的实现)做的事按序排列很有信息量:先建连,--readonly 启动连不上时不报错,而是返回一个只读离线快照供检视;接着发 capabilities 请求(协议握手,见 19.5 节);带了 profile 就发 ProfileLlmList;带了 session_id 就发 OpenSession(SessionOpenParams 携带 session_id、profile_id、请求的 cwd 与已知的 replay 游标);最后用启动参数拼出首帧 AppUiSnapshot 返回。事件循环拿到快照,Store::from_snapshot(octoscode/src/event_loop.rs:230 调用)完成冷启动水合。
19.4 Store:4.4 万行的 reducer
客户端的状态机是一个函数式 reducer:octoscode/src/store.rs:287 定义 pub struct Store,唯一字段是 AppState;octoscode/src/store.rs:423 的 from_snapshot 把服务端快照折叠成初始状态。此后一切状态变化走两个入口,签名都返回 Option<AppUiCommand>:
apply_client_event(octoscode/src/store.rs:8241):消费ClientEvent,即传输层解码后的事件;apply_event(octoscode/src/store.rs:9063):消费AppUiEvent,服务端通知与本地事件的统一形状。
这个文件是全仓最大的单体源文件,43,935 行,比第二名的 octoscode/src/model.rs 还多三万行,而且这个体量是结构性的,不是债务堆积。把它的职责按 AppState 的字段族拆开看,reducer 要折叠的东西覆盖客户端可见的全部世界:会话与线程树、消息与推理增量的逐字累积、工具卡片与 diff 预览、审批请求的等待与解除、后台任务注册表与输出流、composer 草稿与多条 pending 消息、菜单栈与 Dock 布局、能力集与权限 profile、goal 面板与 peer 名册。每一族都对应 UiNotification 全集里的一组通知,服务端发出的事件形态有多少种,apply_event 的 match 臂就有多少类。这解释了为什么 reducer 不能小:它是 wire 契约的完整消费者,协议有多厚,状态机就有多厚。
事件折叠之外,store 还负责把用户的原始输入翻译成命令。架构文档的 Client Layers 表给它的职责描述是「snapshots, RPC results, notifications, local commands, approvals, diffs, task output and queued prompts folded into AppState」:快照、RPC 结果、通知、本地命令、审批、diff、任务输出与排队 prompt,八类输入双入口折叠。slash 命令的分派链也住在这里,Store::dispatch_slash_command 往下分到 dispatch_command_entry 与 dispatch_autonomy_slash,octoscode/src/store.rs:291 附近的文档注释专门定义了分派结果 SlashCommandOutcome:一条命令可以解析成已知动词但仍在分派时被拒(readonly 模式拦下变更方法、没有活动 turn 时拒 /turn、autonomy 解析报错),历史记录只在真正接受后才写入,这是 composer 命令历史的正确性前提。排队 prompt(pending messages)的入队与释放同样在 reducer 内完成,网络断开时用户的输入不丢,重连水合后按序补发。
架构上有一个值得点名的反向约束:store 的两个入口不仅改状态,还产生续发命令,这让 reducer 同时是「状态机」和「跟进命令构造器」。设计者显然清楚 4.4 万行的风险,应对方式不是拆文件,而是把全部状态迁移收敛到单点:任何旁路直接改 AppState 的写法都会让回放与重连的水合逻辑出现第二个真相源。第 18 章服务端 GoalLedger 用 39 个方法把状态转移收进一个 impl 块,是同一种纪律在服务端的镜像:状态迁移的入口必须唯一,哪怕 impl 块因此变得巨大。
返回值可续发命令是这套设计的关键:reducer 不是纯折叠,它还能产生下一步动作。架构文档的 Turn Flow 给出标准例子:用户在 composer 按回车,compose_command 生成新的 turn_id、本地先乐观追加用户消息、返回 AppUiCommand::SubmitPrompt;传输层发 turn/start;服务端持续发出 turn、message、tool、approval、task、progress、warning 事件;每个事件经 apply_client_event 折叠,折叠可能触发后续命令,例如 diff 审批请求到达后自动补发 diff/preview/get。
ClientEvent(octoscode/src/client_event.rs:25)是客户端内部的事件全集,第一个变体 App(Box<AppUiEvent>) 承载来自 crates/octos-core/src/app_ui.rs:172 的 AppUiEvent(五个变体:Snapshot、Protocol、Progress、Status、Error),其余变体是传输层本地解码的 RPC 结果:capabilities、diff 预览、模型列表、MCP 状态、权限 profile、会话水合、会话列表、launch 决议等。From<AppUiEvent> for ClientEvent(octoscode/src/client_event.rs:132)把两者接起来。值得注意的分工:wire 层的 UiNotification 在传输层解码,ClientEvent 里没有它的身影,服务端通知统一以 AppUiEvent::Protocol 的形状进入 store,客户端状态机不感知传输介质的差异。
事件循环对这两个入口的驱动只有一种形状(octoscode/src/event_loop.rs:755 的 drain_backend_events):每 tick 最多排空 MAX_BACKEND_EVENTS_PER_TICK 个事件,逐个交给 apply_client_event_and_send_followup(octoscode/src/event_loop.rs:816):折叠,若有续发命令就 send_command。排空后还会调 drain_pending_autonomy_hydration(octoscode/src/event_loop.rs:830 附近),把重连后 store 暂存的水合命令(如 session 重开)按队列上限补发出去。发送失败绝不静默(octoscode/src/event_loop.rs:833 起的 send_command):#27 外环评审的结论是必须把失败按方法名报给用户,否则 TUI 显示「已重连」而输入其实无处可去。
首帧之前还有一个只发生一次的等待:drain_initial_startup_events(octoscode/src/event_loop.rs:786 附近)在首次启动时给 capabilities 握手一个有界的机会,因为首启 onboarding 是能力门控的,握手未到就画首帧会闪出空 composer;常规启动(本地已有 profile 或 --profile-id 钉死)直接跳过等待立即画帧。这个细节体现了事件循环对「服务端权威」的信任程度:宁可短等,也不在本地伪造一个能力已知的假象。
flowchart LR
K[键盘与 composer] --> CE[ClientEvent]
N[服务端 UiNotification] --> AE[AppUiEvent]
CE --> AC["apply_client_event :8241"]
AE --> AP["apply_event :9063"]
AC --> ST[(Store 与 AppState)]
AP --> ST
ST --> FU[续发 AppUiCommand]
ST --> RD[ratatui 渲染]
FU --> TR[transport.send]
reducer 里最容易漏看的是「本地状态在重放时的保全」。apply_event 的 Snapshot 分支(octoscode/src/store.rs:9063 起)在用新快照重建状态前,显式克隆携带一批纯本地字段:command history 是客户端侧的环形队列,服务端从不回显;sub_providers 列表(research 车道)重连时服务端不会重推,不携带就会在重放后清空;composer 草稿与 pending 消息同理。服务端是权威,但权威不拥有的东西,重放时必须由客户端自己记住,这是哑客户端边界在 reducer 里的镜像。
一个提醒:octoscode/docs/ARCHITECTURE.md 的 Client Layers 表为每个源文件列了行数(如 event_loop 7,047 行、store 37,021 行),但该表已滞后于实测(8,655 行、43,935 行,见 assets/ch19-facts.md)。引用行数以事实表与仓库实测为准,文档表格反映的是写作时点。
19.5 UI Protocol:7,221 行的 wire 契约
客户端与服务端共享的合同全部住在 octos 主仓的 crates/octos-core/src/ui_protocol.rs(7,221 行,首行文档「Draft client/runtime protocol types for M9」),规模是 24 个 pub enum、223 个 pub struct、47 个 pub const。这一节从客户端视角读它,类型系统的完整分析见第 2 章。
线上信封是标准 JSON-RPC 2.0:RpcRequest<T>(crates/octos-core/src/ui_protocol.rs:684)、RpcResponse<T>(crates/octos-core/src/ui_protocol.rs:708)、RpcNotification<T>(crates/octos-core/src/ui_protocol.rs:730)、RpcError(crates/octos-core/src/ui_protocol.rs:752);事件侧有统一信封 EventEnvelope<P>(crates/octos-core/src/ui_protocol.rs:68),携带 session_id、topic、thread_id、单调递增的 event_seq、event_type 与载荷,这个单调序号是 replay 游标能够成立的前提。协议标识是常量 UI_PROTOCOL_V1,值为 octos-ui/v1alpha1(crates/octos-core/src/ui_protocol.rs:20)。
方向上,UiCommand(crates/octos-core/src/ui_protocol.rs:4197)是客户端到服务端的方法全集,变体从 ProfileLocalCreate、SessionOpen、TurnStart、TurnInterrupt、ApprovalRespond 一路排到 task 读写、权限 profile、diff 预览、快照、线程与 goal 相关方法,serde 按标签 kind 蛇形命名;UiNotification(crates/octos-core/src/ui_protocol.rs:6616)是服务端到客户端的通知全集,SessionOpened、TurnStarted、消息与推理增量、可视化任务、语音事件等尽在其中;UiRpcResult(crates/octos-core/src/ui_protocol.rs:4760)收拢应答。octoscode 侧不直接拼这些 wire 值,它持有自己的稳定值类型 AppUiCommand(octoscode/src/model.rs:790),变体形状与 wire 侧对应(OpenSession、SubmitPrompt、InterruptTurn、RespondApproval 等),经 crates/octos-core/src/app_ui.rs:146 的 into_protocol 转成 UiCommand 发出;AppUiCommand::method()(octoscode/src/model.rs:915)给每个命令一个稳定方法名字符串。这层间接让客户端值类型可以携带本地语义(比如 LocalShellExec 这种永远不会上线的方法,传输层在 octoscode/src/transport.rs:2280 的实现里先拦截它),线上的方法集则完全由 octos-core 定义。
版本与能力协商是协议的自保机制。UiProtocolVersion(crates/octos-core/src/ui_protocol.rs:1553)携带协议族、schema 版本与 jsonrpc 版本,is_supported_by_current_runtime 校验三者;UiProtocolCapabilities(crates/octos-core/src/ui_protocol.rs:1577)在握手里声明 supported_methods、supported_notifications 与 supported_features,full_protocol 构造器还会带上一整串 feature 常量(approval typed、pane snapshots、session cwd、sandbox、harness task control、artifacts、hydrate、thread graph、coding autonomy、goal runtime 等);比较器 compare_protocol(crates/octos-core/src/ui_protocol.rs:1814)输出三态的 ProtocolCompat(crates/octos-core/src/ui_protocol.rs:1777):协议族不同或服务端 schema 更旧判 SchemaIncompatible(服务端更新是允许的,加法式前向兼容),族与版本兼容但缺必需 feature 判 MissingFeatures,全部满足才 Compatible。注释特别强调比较器是纯函数,客户端与服务端复用同一个,skew 判定不会分叉成两套逻辑,octos-diagnostics 只是把结果包装成报告行。
flowchart TD
subgraph C[octoscode 进程]
A[AppUiCommand 稳定值] -->|into_protocol| W[UiCommand wire 值]
R[capabilities 握手 compare_protocol]
end
subgraph S[octos serve 进程]
N[UiNotification 通知]
L[(持久事件 ledger)]
end
W -->|JSON-RPC 帧 stdio 或 WebSocket| N
N --> L
L -->|replay 游标 event_seq| C
持久化与重放的责任同样按边界切分,架构文档 Durability and Replay 一节写成两条清单。服务端必须:先落账再发事件;按客户端游标重放错过的持久事件;永不用过期的磁盘重放覆盖更新的活动快照;用 protocol/replay_lossy 如实报告有损投递。客户端必须:开 session 时带最新已知游标请求重放;容忍重复的持久通知;把 protocol/replay_lossy 当作刷新信号而不是聊天消息;重连后不从本地乐观 UI 状态推断运行时真相。这四条客户端纪律与前节 reducer 的本地状态保全互为表里。
19.6 goal 与 peer 的客户端侧:autonomy 解析
第 18 章讲了服务端的 goal/peer 机制,本章只看客户端那一半:octoscode/src/autonomy.rs(1,192 行,首行文档「M15-E autonomy command parsing for /agents, /goal, and /loop」)。它是一个纯解析层,把用户敲进 composer 的 slash 命令变成结构化值:parse_autonomy_slash(octoscode/src/autonomy.rs:248)接受可选前导斜杠的输入,按头部分派到六个命令族,枚举全部定义在同一文件:AgentsCommand(:35)、TaskCommand(:66)、ThreadCommand(:83)、TurnCommand(:90)、GoalCommand(:97)、LoopCommand(:131,含节奏参数 LoopCadence :120),汇入 AutonomyCommand(:158),解析失败给出 AutonomyParseError(:170)。
解析产物最终要变成 AppUiCommand 发往服务端,goal 的推进、peer 的派发与收账全部由服务端执行(第 18 章)。客户端另外负责两件呈现:goal 面板显示目标与预算状态,Peer Dock 显示各 peer 的存活与最新产出,数据都来自 UiNotification 的 peer 系列通知。选择器枚举(AgentArtifactSelector :59、TaskArtifactSelector :76)支持对产出物的指名引用,让 /agents、/task 命令能精确指向某个 peer 的某份成果。
19.7 被 herdr 看见:识别契约
第 21 章的主角 herdr 要从终端画面反推 agent 状态,它对 octoscode 的识别契约是一份 40 行的清单文件 herdr/src/detect/manifests/octoscode.toml,共三条规则:
approval_blocked(state=blocked,priority=1100,region=whole_recent)在herdr/src/detect/manifests/octoscode.toml:8:关键词包括Approval | y once | s session | n deny、Waiting on you、Ctrl+R/Alt+A answer与中文变体「审批 | y 本次 | s 本会话」;statusbar_working(state=working,priority=1000,region=bottom_non_empty_lines(6))在:21:正则匹配state·Working或含Esc interrupt;statusbar_idle(state=idle,priority=900,同 region)在:32:正则匹配 state Idle 或 Done,或含Tab agents | Ctrl+O expand、Ask Octos to change code。
这些提示串与 octoscode/src/keymap.rs:1 的 pub const HELP(「Tab agents | Esc chat | PgUp/PgDn scroll | y/s/n approval ...」)同源,状态栏文案既是给人看的,也是外环机器的观测接口。哑客户端架构在这里产生一个连带效果:既然客户端只是协议状态的渲染器,那么屏幕上的稳定文案就忠实反映服务端状态,外环靠 OCR 式的文本匹配即可观测,不必侵入任何进程。三条规则的完整语义与 herdr 引擎的通用机制留给第 21 章。
19.8 边界与回顾
与第 10 章:harness 的事件 ABI(HarnessEvent 及其 schema 常量)是服务端运行时内部的事件契约,客户端看到的只是经 UiNotification 投影后的通知;两层的版本化机制不同,本章不重复展开。与第 14 章:serve 侧的 stdio 挂载、stdout 所有权与数据目录锁本章只从客户端侧引用。与第 18 章:goal/peer 的服务端机制(账本、keeper、回流通道)全部在第 18 章,本章只有客户端解析与呈现。与第 20、21 章:OLP 协议与 herdr 运维实务分别在各自章。
本章回顾:
- 边界先于一切:octoscode 不跑 agent、不执行工具、不做审批、不持账本、不管 provider 配置,五项全在
octos serve,由架构不变量强制。 - 启动链五步有序:拦截子命令、确保后端(raw mode 前)、splash、
event_loop::run;DEFAULT_STDIO_COMMAND是octos serve --stdio --solo(octoscode/src/cli.rs:118)。 - 传输层一个 trait 三方法,两种驱动:stdio 子进程与 WebSocket,重连退避指数级,发送失败按方法名上报。
- 状态机是双入口 reducer:
apply_client_event(octoscode/src/store.rs:8241)与apply_event(:9063)都能续发命令;重放时纯本地状态由客户端自行保全。 - 协议契约 7,221 行住在 octos-core:JSON-RPC 信封、
UiCommand/UiNotification两个全集、三态ProtocolCompat能力协商,客户端与服务端共用同一比较器。 - goal/peer 的客户端侧是纯解析层(
parse_autonomy_slash),执行权不下放。 - 外环观测面:状态栏文案与 herdr 三条识别规则同源,哑客户端让屏幕成为机器可读接口。
延伸一点字数预算内值得写透的事:这套哑客户端架构对多人协作的含义。所有客户端消费同一份 UiNotification 全集,服务端新增能力只要先扩 octos-core 类型再投影通知,TUI、未来的 Web 客户端、外环观测器就同时受益,不需要逐个客户端发版;反之,若把任何执行逻辑漏进客户端,第一个替代客户端出现时就会被迫复刻这段逻辑,协议边界名存实亡。docs/ARCHITECTURE.md 把「新客户端可见能力先落 octos-core」列为不变量,防的正是这种侵蚀。这也是本书第四部分的立论:外环(第 20、21 章)之所以能对内环做观测与驱动,前提是内环的一切状态都已经在协议与账本里,而不藏在某个客户端的内存里。
哑客户端的厚度全在 reducer 与协议层:权限薄到零,交互厚到十万行。
延伸阅读
octoscode/docs/ARCHITECTURE.md(724 行)— Scope、Runtime Topology、各 Flow 与 Architectural Invariants 的原始表述,本章多处转述并按源码校验assets/ch19-facts.md— 本章全部行数与行号的事实来源,逐条附复跑命令crates/octos-core/src/ui_protocol.rs— wire 契约全文;类型设计视角见第 2 章- 第 14 章 14.4 节 — serve 侧的 stdio 挂载面与 stdout 所有权
- 第 18 章 — goal/peer 服务端机制全貌
思考题
ensure_octos_backend对「用户自管的 octos 路径」直接返回不碰。若改为也检测并升级这类安装,会在什么场景下破坏用户的部署?这个克制与「首启自动安装」如何共存?apply_event的 Snapshot 分支要手工克隆 command history 与 sub_providers 才能在重放后保住。若新增一种纯本地状态忘记加入携带清单,用户会看到什么症状?怎么用测试把这份清单锁住?compare_protocol允许服务端 schema 比客户端新(加法式前向兼容)。若客户端依赖一个新版本才有的通知,而服务端其实更旧,缺陷会在握手上暴露还是在运行中沉默发生?MissingFeatures能否兜住这种情况?- stdio 驱动的重连是重拉子进程,WebSocket 的重连只是重连套接字。两者的 replay 语义有什么差别?为什么 stdio 场景下
OCTOSCODE_SHARED_INSTANCE锚定数据目录是必要的? - herdr 靠状态栏文案识别状态。若一次重构把
state·Working改成state: Working,哪些环节会失配?客户端、herdr 清单与 keymap 帮助串三处文案如何保持同源?
版本演化说明 本章为 v2 新增章,分析基线:octoscode main @
1129fa33(2026-09-03)、octos main @9c157101(2026-09-03),herdr 仅引用识别契约清单 @fefe5c4f。全部行数与行号取自assets/ch19-facts.md并经作者在上述基线复跑核对;octoscode/docs/ARCHITECTURE.md内嵌的行数表滞后于实测,文中已注明。octoscode 与 UI Protocol 均在活跃开发中,行号随版本漂移,引用时以符号名为锚。
第 20 章:OctoLoop:外环协议 OLP v2
定位:本章分析 v2 新增的外环协议 OLP(OctoLoop)的全部载体:30 个文件,其中协议文档 7 件(
octoscode/docs/OUTER_LOOP_PROTOCOL.md403 行等)、workspace 脚手架 2 件、Rust 源码 9 件(octoscode/src/olp_mcp.rs406、octoscode/src/outer_duty.rs476 等)、契约测试 3 件(25 个用例)、脚本与技能 5 件、octos 主仓交叉面 4 件。核心论点只有一个:这套协议的大部分内容没有运行时实现,它是「协议条款 + Markdown 约定 + 契约测试」三层的叠加,少数有代码的部分(第五信道、主审权锁)恰好是没法靠文档钉住的部分。前置依赖:第 18 章(goal/peer 的服务端机制,本章只从外环视角观测它们)。适用场景:想让一个外部强模型长期监督、指导、验收另一群便宜模型干活的系统搭建者。
20.1 双环模型:贵模型只花在判断上
OctoLoop 把一个长程任务拆给两圈 agent 加一个人,三者的模型档位刻意错开。octoscode/docs/OUTER_LOOP_PROTOCOL.md:19 的角色表:operator(人)负责宏观指令与终审;runtime(octos serve + master/peers)负责长程执行,跑在便宜档车道上;outer agent(本协议的对象,Claude Code、Codex 或任意脚本化 agent)负责计划、事件驱动监控、交付审查与指导,跑在强档。设计动机写在 octoscode/docs/OCTOLOOP_GUIDE.md:248:内环便宜、可反复重跑,出错成本低;外环贵,只花在判断上,每个 commit 过两双眼睛,推送权只在外环手里。协议与具体模型无关:换一个外环 CLI 或换一批内环车道,协议不变。
双环不共享内存,一切协作走可审计的持久信道。下行(outer → runtime)五条:AGENTS.md 常驻约束(每个 session boot 时由 prompt layer 自动注入)、黑板任务级指导、原子 git commit 即既成事实、inbox 门铃(阅后即焚,只放事件通知与黑板指针)、herdr TUI 注入(文本落在 composer 即用户消息层级,等价 operator 亲手输入)。上行(runtime → outer)六条:serve 日志事件流、peers/<slug>/result.md 交付物、goal-ledgers/<goal_id> 权威账本、escalation 求助、git log/diff、以及方向相反的主动问询,即后文的第五信道。完整矩阵在 octoscode/docs/OUTER_LOOP_PROTOCOL.md:27(下行 :29、上行 :39)。
flowchart TB
OP["operator(人)<br/>宏观指令 / 终审 / 审批"]
subgraph OUTER["outer agent(强档)"]
PLAN["计划 / 监控 / 审查 / 指导"]
end
subgraph INNER["runtime(octos serve + master/peers,便宜档)"]
KEEPER["goal keeper"]
PEERS["peers 并行干活"]
end
BB[(" .octos/OUTER_LOOP_REVIEW.md ")<br/>黑板:权威账本]
MCP["~/.octos/outer/mcp/<br/>第五信道信箱"]
OP -->|"/goal 指令"| OUTER
OP -->|"审批 / 终审"| OUTER
OUTER -- "下行①AGENTS.md ②黑板 ③commit ④门铃 ⑤TUI 注入" --> INNER
INNER -- "上行①事件流 ②result.md ③ledger ④escalation ⑤ask_outer ⑥git diff" --> OUTER
OUTER <--> BB
INNER <--> MCP <.-> OUTER
观测本身分三层,这是外环最容易误判的地方。octoscode/docs/OLP_OUTER_BOOT.md:60 的规则:投递(notes 文件被清空)不等于消费(turn prompt 读到)不等于执行(交付/ACK 落地),只看一层必误判。实战记录里的两个「读而不执行」事故直接促成了门铃模式定型:inbox 只放指针,内容一律去黑板取。
本章用三层视角读这 30 个载体:协议条款层(角色、信道、R 规则,纯文档)、Markdown 约定层(黑板、ACK、loop.md,无任何 Rust 实现)、契约测试层(三个测试文件 25 个用例,唯一的机械强制)。少数机制跨层存在:ACK 是条款加契约,主审锁是条款加实现加契约。
20.2 R1–R7:七条纪律与它们各自的教训
协议语义核心是七条规则,octoscode/docs/OUTER_LOOP_PROTOCOL.md:52-103。每条规则背后都有一次真实事故,协议文档把事故与规则写在同一份文件里,这是它与其他「先写规范后补实现」文档最大的区别。
R1 ACK 义务(:52)。黑板 Active 区的每条意见,runtime 执行后必须在条目下补一行 ACK,无 ACK 视为未读,外环有权打回交付。v1 起 ACK 行用定式语法 ACK(done|wontdo|blocked): <说明>,三态各有语义:done 写做了什么与证据(commit hash 或测试结果);wontdo 是带证据的异议,分歧规则限定外环对 wontdo 只能接受或升级 operator,不得对同一条目再次打回;blocked 写阻塞原因与解除条件。案例:2026-08-23 的 v0 实验(:196 起)黑板十条评审项全部带 ACK 收口,其中第 9 条内环以证据拒绝重派指令(ACK wontdo),外环复核后接受,事后证明内环是对的。这一条是纯条款加契约:语法由 octoscode/tests/olp_contract.rs:96 的 olp_ack_lines_match_v1_grammar 钉住,豁免边界由 :120 的 olp_ack_exemption_is_bounded_whitelist(v1 语法只约束 2026-08-24 起新增的 ACK 行,历史行不重写)与 :159 的 olp_ack_rejects_unknown_status 钉住。
R2 诚实验证声明(:67)。每个交付必须声明 verified(跑过 cargo test --all-targets + clippy + fmt)、partially-verified(列出实际跑了什么)、unverified(说明原因)三档之一;声称 verified 但复验不符视为协议违例。案例来自 v0 实验与后续性能战役(:212 起)的两类反例:其一,peer 在沙箱里声明「本机无工具链」,真相是权限档 1-4 的 bwrap 沙箱不挂载 ~/.cargo 与 ~/.rustup,任何构建命令都 command not found;其二,测试全绿不等于真机正确,快照机制单测全过但对存量大账本零收益,writer 超时重驱动的内存 duplex 测试全过但真管道字节流损坏。R2 的实战增补因此写成:凡涉 IO/并发,外环终审要求真 OS 原语复验(真管道、真文件),内存替身只配当冒烟。
R3 升级分级(:71)。escalation 三级:runtime 自决(重试换法)、outer 裁决(技术取舍、批不批方案)、operator 裁决(权限审批、范围变更、对外动作)。外环不得代替 operator 按下审批;operator 缺席时 escalation 保持 park。案例:v0 实验第 10 条,新依赖 signal-hook 走了升级请求;更重要的是裁决审计面(:322 起),goal 模式下 master 不会为「自认为解决了」的事再问外环,历史样本里 goal_03 测量方法错误,master 与 peer 均无自觉、零上报,外环审文档才抓住,所以外环必须主动审计 ledger 的 decisions/escalations 表。
R4 工作区共存(:74)。同一工作区多写者各自只 git add 自己改的文件,禁止 git add -A;改动即原子 commit;来源不明的 dirty 文件保留并报告,不得自动清理。案例:性能战役里外环在共享工作区做探针实验,与内环修复混编译断、diff-preview 竞态 SIGBUS,此后定型为外环诊断一律用独立 git worktree。
R4b 树主权与自动围栏(:77)。多 goal 撞同一棵树时,主工作树只属一个 goal:自动围栏谓词(active goal 大于 1、peer 目标分支与主树分支不一致、主树有未围栏在途 peer)命中即自动开 worktree;树主权第一个落非默认分支的 goal 持久化进 goal-ledger,重启恢复;不属 owner goal 的会话在主树做跨分支 checkout 一律拒绝并提示开围栏。这条把防撞从「外环 steer 盯着」降级为系统默认机制,外环只在谓词未覆盖的边界补位(octos #20-20c 移交,作为 R4 子条款,不升协议版本)。
R5 指导幂等(:89)。外环意见带日期与唯一编号,只在 Active 区可执行;ACK 后移入历史区永不重放,重复投递以 ACK 为去重依据。这条直接回应 inbox 阅后即焚曾吞掉指导的已知局限(:339):R 系列规则一律不依赖 inbox。
R6 版本协商(:103)。协议文档头部声明 protocol: olp/v2(octoscode/docs/OUTER_LOOP_PROTOCOL.md:7),octoscode/AGENTS.md:3 引用同版本,信道语义变更必须升版本。双处一致由 octoscode/tests/olp_contract.rs:215 的 olp_version_consistent_across_docs 钉住,这是六条规则里唯一有机械验证兜底的版本纪律。
R7 主审权 OS 独占锁(:91,olp/v2 起,#38-r1)。多外环并存时主审权以 per-project 会话寿命 OS 锁为准,详见 20.5 节。条款本身就写明范围:Linux-only,单机 flock + PDEATHSIG + /proc,NFS 不适用,Windows LockFileEx 另立条目。
条款之外,协议文档还固化了多外环协作规则:黑板批注一律带署名(无署名视为历史兼容的默认外环);每个进行中条目只有一个主审外环,他人只能留署名意见;两外环意见相左时不在黑板上互相打回,各自写署名意见升级 operator(:253 起)。v1 到 v2 的变更记录在 :12-15,附录 A 固化 result.md frontmatter v1 六字段(:348),附录 B 固化 sub_providers 车道模板与双环搭配矩阵(:369/:387):分析与验证走 cheap 车道,实施与 keeper 留主档,理由是前者的产出被外层审查兜底,后者的产出直接进主线与账本。
20.3 黑板与 ACK:没有运行时的协议核心
黑板是 OLP 最核心也最容易被误解的机制。必须先说清它是什么:一块每仓库一个的 Markdown 文件(<repo>/.octos/OUTER_LOOP_REVIEW.md),Active 区放带编号的审查条目,Historical 区放已 ACK 的历史。它没有 Rust 实现,没有数据库,没有服务进程。追加写 入的唯一正道是 octoscode/scripts/olp-board-append.sh(24 行),核心就三行:exec 9>"$LOCK"(:20)打开锁文件、flock -x 9(:21)互斥、整条目一次性 cat >> "$BOARD"(:22)落板,条目正文从 stdin 喂入。flock 保证多外环并发追加不撕裂;一次性 append 保证条目原子可见。
内环侧的对应物是 .octos/loop.md 维护循环契约:读黑板 Active 区、取编号最小未 ACK 条目执行到完成、只 commit 不 push、补 ACK(done|wontdo|blocked) 定式。本书仓库自己的这块循环契约只有 13 行(octos-book/.octos/loop.md),外环审查黑板在本书 v2 动笔时点为 465 行、29 个编号条目、71 处 ACK 行(活文档,数字随批次增长,以 .octos/OUTER_LOOP_REVIEW.md 实时为准)。这本书的 v2 重写就是按这套协议驱动的:外环把每章的整改意见编号落板,内环按号执行并 ACK,主审复验后代推。黑板内容本身不进入本章,机制已经足够说明问题。
ACK 三态构成一个小状态机:条目发出后处于 pending,内环执行后落 ACK 进入 done、wontdo 或 blocked 终态;wontdo 分支外环只能接受或升级 operator,不存在再次打回的边。这个状态机的全部强制力量来自契约测试:octoscode/tests/olp_contract.rs(367 行,8 个用例)对签入的黑板快照做 grep 型校验,olp_ack_lines_match_v1_grammar(:96)校验 ACK 行符合 v1 定式,豁免白名单(:120)把生效日期分界写死,未知 status 拒绝(:159)。换句话说,协议违约不会被运行时拦截,只会在下一次跑测试时变成红灯。
stateDiagram-v2
[*] --> Pending: 外环落板(带编号+日期)
Pending --> Done: ACK(done): 做了什么+证据
Pending --> Wontdo: ACK(wontdo): 带证据异议
Pending --> Blocked: ACK(blocked): 原因+解除条件
Wontdo --> Accepted: 外环接受
Wontdo --> Operator: 升级 operator 裁决
Done --> Historical: ACK 后移入历史区,永不重放
Accepted --> Historical
Operator --> Historical
Blocked --> Pending: 解除条件满足后重新可执行
Historical --> [*]
R5 的幂等语义依赖 ACK 行做去重键:同一编号再投递,已有 ACK 即跳过。这解释了为什么 ACK 必须是定式语法而说明部分自由文本非空即可:机器只 grep 状态词与编号,不解析自然语言。
这层约定的价值要在事故里看。协议文档的已知局限一节(octoscode/docs/OUTER_LOOP_PROTOCOL.md:339)记录了 inbox notes 阅后即焚曾实测吞掉指导,所以 R 系列一律不依赖它;日志按进程启动日期滚动而非自然日,tail 要同时跟两天的文件;session-hash 用 DefaultHasher 跨 Rust 版本不保证稳定,外环不得用它持久寻址。三个局限全部指向同一个设计结论:凡是会被静默丢掉的信道,都不能承载协议义务;义务只落在黑板、git 与 ledger 这三个重启幸存的载体上。
20.4 第五信道:内环在 turn 内反向外呼
前四条上行信道(事件流、result.md、ledger、escalation)与第 6 条代码信道都是「外环拉取」模型:内环留下痕迹,外环循事件来读。第五信道方向相反,内环主动推。它是 OLP 里少数有完整 Rust 实现的机制:octoscode/src/olp_mcp.rs(406 行,纯标准库,#31 起 Python 原型归档 octoscode/scripts/reference/)暴露恰好两个 MCP 工具(tools_schema 在 :328):ask_outer(question, context, tried)(:174)与 report_blocked(:255)。
三个防滥用的参数都写死在常量里。ASK_TIMEOUT_SECS = 90.0(octoscode/src/olp_mcp.rs:25):turn 内同步发问,超时返回 DEGRADED_GUIDANCE(:30)降级指引而非卡死 turn,轮询间隔 ASK_POLL_INTERVAL_SECS = 0.5(:26)。ASK_QUOTA_PER_SLICE = 3(:27):每切片最多问三次,超出返回 QUOTA_REFUSAL(:31),防思考外包。tried 必填:空 tried 直接拒绝(:184-187 返回 TRIED_REFUSAL,:32),模型必须先写自己试过什么才有资格问。传输走信箱目录 ~/.octos/outer/mcp/(outer_root 在 :36,OLP_MCP_OUTER_ROOT 可覆盖)下的 questions、answers、consumed 三级流转,取答后归档 consumed,全程审计落 OUTER_LOOP_MCP.md(BOARD_RELATIVE 在 :28),署名固定 MCP(ask_outer)(SIGNATURE 在 :24)。
sequenceDiagram
participant I as 内环 turn(模型)
participant S as olp-mcp-serve<br/>(octoscode/src/olp_mcp.rs:46 serve)
participant Q as ~/.octos/outer/mcp/
participant O as 外环(强档)
I->>S: ask_outer(question, context, tried)
S->>S: tried 为空? 拒绝(:184-187)
S->>S: 本片已问 ≥3 次? 拒绝(:189-196)
S->>Q: questions/ 落问题(flock)
O->>Q: 轮询发现问题,写 answers/
S->>Q: 0.5s 轮询(:26),取答归档 consumed/
S-->>I: 答案(≤90s,:25)
S-->>I: 超时 → 降级指引(:30)
S->>Q: OUTER_LOOP_MCP.md 审计署名 MCP(ask_outer)(:24)
挂载方式是内环 profile(~/.octos/profiles/<id>.json)的 config.mcp_servers:command 指向 octoscode 二进制,args 为 ["olp-mcp-serve"]。协议文档接入清单(octoscode/docs/OUTER_LOOP_PROTOCOL.md:131-137)记录了三个实测坑:配置落点是 profiles 下的 JSON 而非 instances 的 config.toml(后者无人加载);profile JSON 时间戳必须 RFC3339 带 Z,缺时区解析全挂;工具注册表在会话建立时快照,改配置或换二进制后必须新建会话才见新工具。CLI 接线在 octoscode/src/cmd/olp_mcp.rs:6(OLP_MCP_TIMEOUT_SECS 环境变量可覆盖默认超时,:8-10),子命令注册在 octoscode/src/cmd/mod.rs:28。行为契约由 octoscode/tests/olp_mcp_contract.rs(290 行,7 个用例,真实子进程 stdio)钉住:握手(:98)、恰好两工具(:115)、往返(:144)、超时降级(:209)、限额拒绝(:227)、tried 必填(:246)、report_blocked 只落板(:266)。
20.5 主审权锁:Linux-only 的诚实收缩
多外环并存的最大风险是双头指令:单外环的注入都会落后一个 turn,两个外环必然打架。olp/v2 的答案是 R7 主审权锁,实现是 octoscode/src/outer_duty.rs(476 行)。模块第一条编译期声明就是 #:整个模块在 macOS 上不编译,非 Linux 平台的 CLI 显式 unsupported 退出。代码注释把这称为 honest shrink,与其在 macOS 上假装锁存在,不如诚实地不可用。
锁的设计密度值得逐符号读。DutyState(:34)三态枚举 Vacant/Held/Error,as_str(:43)输出单行机器可读的 VACANT/HELD/ERROR,契约明确 Error 绝不伪装成 VACANT,坏输入宁可报错也不让调用方误判可上岗。锁文件路径 ~/.octos/outer/duty/<sha256>.lock(lock_path 在 :63,HOME 缺失 fail-closed),摘要用 lock_digest(:83)算 SHA-256,域前缀 LOCK_DOMAIN = "octoscode/outer-duty/v1"(:55);注释明确不用 DefaultHasher,因为它跨 Rust 版本不稳定(octoscode/docs/OUTER_LOOP_PROTOCOL.md:343-345 的已知局限同源)。DutyHold(:99)让 fd 即锁:唯一持有者,CLOEXEC 保持置位不外泄。acquire(:162)flock 持锁并收紧文件权限;check(:220)仅观察、绝不夺取。metadata sidecar(write_metadata/read_metadata 在 :259/:311)与一切 TTL 只作诊断,绝不参与裁定,防止「锁还在但旁路数据说它过期」的裁决分裂。
死亡耦合是这条锁的灵魂:spawn_holder_child(:345)用 setpgid 加 PR_SET_PDEATHSIG(SIGKILL) 把 agent 与 wrapper 绑死,并处理 fork 与 prctl 之间的竞态(:345-380)。wrapper 是唯一锁 fd 持有者,wrapper 亡则 agent 必亡,锁即 VACANT,不存在 agent 活着而锁显示空闲的脑裂;反过来 agent 退出而孙辈进程长驻,锁同样归 VACANT,孙辈不持有 fd。活锁接管只归 operator:终止旧 holder 后再 acquire,无 agent 自助强夺。
flowchart LR
A["octoscode outer-duty hold<br/>--project P --signature S --<br/><agent>(octoscode/src/cmd/outer_duty.rs:45)"] --> B["acquire(:162)<br/>flock + CLOEXEC fd"]
B --> C["wrapper(唯一 fd 持有者)"]
C -->|"setpgid + PDEATHSIG(SIGKILL)<br/>spawn_holder_child(:345)"| D["agent 子进程"]
B --> E["metadata sidecar(:259)<br/>仅诊断,不参与裁定"]
F["octoscode outer-duty check<br/>(:220 仅观察,绝不夺取)"] -.->|"stdout 恰一态<br/>VACANT/HELD/ERROR(:34)"| C
C -->|"wrapper 亡 ⇒ agent 必亡"| G["锁 VACANT"]
D -->|"agent 亡,孙辈长驻不持 fd"| G
十条契约测试(octoscode/tests/outer_duty_contract.rs,745 行)把这个不变量集钉死:两个竞争者恰一个赢(:174)、wrapper 亡杀 agent 并释放(:288)、孙辈长驻仍 VACANT(:371)、check 不惊扰持有者(:446)、metadata 损坏不影响所有权(:475)、权限收紧(:526)、坏输入永不 VACANT(:579)、敌意 metadata 下 stdout 仍单行(:605)、digest 黄金值收敛(:650)、建目录失败即 Error(:709)。接线一致性(协议、boot 文档、技能三面)由 octoscode/tests/olp_contract.rs:303 与 :329 两个用例守护。
平台边界不藏着:octoscode/docs/OCTOLOOP_GUIDE.md:221 的矩阵写明 Linux 全功率(:227),macOS 两缺口(:229),其一就是 outer-duty 不可用,多外环协作退回值班簿纪律层加 operator 裁决;值班簿在任何平台都只是提示性目录,不构成所有权。macOS 的进程收割(reaper)方案已立项,需求文件在 octoscode 仓 knowledge/requirements/req-olp-duty-macos.md。当前切片的 fencing 只到文档层纪律,写入面校验 lease 的硬 gate 是后续条目。
回退到值班簿之后,运维手法要跟着换。Linux 上外环上岗前跑一次 outer-duty check 就能拿到机器裁定的 VACANT 或 HELD,macOS 上这个动作消失,替代物只有三个弱信号:黑板最近条目的署名、herdr 窗格的进程与闲置状态、以及值班簿里的登记时间。三者都可能是陈旧的——octoscode/docs/OCTOLOOP_GUIDE.md:233 把话说死:值班簿在任何平台都只是提示性目录,不构成所有权。因此 macOS 的多外环协作实际靠两条纪律撑住:每个进行中条目单一主审,他人只署名批注;交接班必须经 operator 显式确认,旧主审先在黑板写明离场与交接对象,新主审才能接手。锁没了,裁定权从 OS 层上收到 operator 层,这是 R7 平台矩阵的完整含义。
20.6 octos 侧落点:预算与四个观测 CLI
协议文本大部分落在 octoscode 仓,但外环真正要观测和驱动的对象在 octos 主仓。交叉面四个 CLI 加一个预算落点。crates/octos-cli/src/commands/steer.rs:1 头注释自证身份:octos steer — external reviewer steer channel (OLP P2, slice 1),STEER_MAX_BYTES(:22)限 64KB,超限入队即拒,走 .reviewer-notes sidecar、user-message 层级而非系统指令。crates/octos-cli/src/commands/ledger.rs:1 头注释同样自证:octos ledger tail — read-only goal-ledger tail (OLP L1, slice 4),外环用它审计 master 的 decisions 与 escalations 表(R3 的裁决审计面)。crates/octos-cli/src/commands/peer.rs 的 list_peers(:54)与 crates/octos-cli/src/commands/goal.rs 的 GoalStatusArgs(:340)构成 L1 只读观测三件套。
goal 预算是约定与实现的组合:外环在任务书里按档位给条目定预算(修订 5-10M、切片 10-20M、战役 30-50M token,octoscode/docs/OLP_OUTER_BOOT.md:44),octos 侧的落点在 crates/octos-cli/src/autonomy/goal_loop_runtime.rs:GoalRuntimeState(:265)是四态枚举(Active/Paused/Completed/Failed),budget_limited 不在运行时枚举里——它是 SQLite 账本侧的字符串状态,由 cas_goal_status 的预算规则写入;预算耗尽时运行时经 GoalBudgetResolution(:298)走 GoalWrapUp 收口(与第 18 章账本/运行时两层状态机的划界一致)(crates/octos-cli/src/autonomy/master_continuation_scheduler.rs:147)。这些机制的内部实现属于第 18 章的范围,本章只强调外环视角的一个后果:预算耗尽不是事故,是有名字、可恢复的现场,外环回线后按账本收账即可。
协议还固化了驱动机制选型(octoscode/docs/OUTER_LOOP_PROTOCOL.md:289 起):外环在线盯着交互式攻坚时直驱 master,herdr 注入 user-message,切片化后每片一个 turn,比 handoff 到 peer 再 gather 少两跳;外环离线的长程任务用 /goal 承载,keeper 跨 turn 自动推进,escalation 走 goal 账本 durable 兜底。两种形态共用黑板与 ACK 契约层,选型只看外环在不在线。
工程决策:为什么黑板是 Markdown 而不是数据库 黑板的需求清单:多写者并发追加不撕裂、条目可编号可去重、历史可审计、git 可版本化、任何 agent 不装依赖就能读写。Markdown 文件加 flock 追加脚本满足全部五条,而且第 18 章的
GoalLedger(crates/octos-fleet/src/sqlite_ledger.rs:13,6,360 行)已经证明结构化账本要付出多大工程代价:39 个公开方法、WAL 多进程并发、状态机嵌进 SQL。黑板的消费者是外环强模型,它能读自然语言,不需要查询计划;需要的机械性质只有追加原子性与 grep 可校验性,前者 24 行 shell 脚本(octoscode/scripts/olp-board-append.sh:22-24)解决,后者由octoscode/tests/olp_contract.rs:96的快照 grep 解决。把黑板做成数据库,等于为一个每天几十次写入、消费方只有一个模型读者的文件,引入一个需要迁移、需要备份、git 看不见 diff 的存储层。协议文档把这层取舍隐含在设计里:结构化的事实(目标状态、发现、升级)进 ledger,叙述性的判断(审查意见、异议、裁决)留在黑板,两类数据各走各的存储。
20.7 边界与回顾
与第 18 章的边界:goal 与 peer 的服务端机制(GoalLedger、keeper、续跑调度、peer 生命周期)是第 18 章的内容,本章只从外环视角取用它们的观测面(ledger tail、peer list、goal status)与预算落点;R4b 自动围栏依赖的 peer_handoff 与 worktree 机制在第 18 章讲过,本章只讲协议层语义。与第 21 章的边界:herdr 的安装、驾驶舱配置、注入通道的运维细节属下一章,本章的 herdr 只作为下行信道之一出现。与第 19 章的边界:octoscode 的 UI Protocol 与终端客户端架构已在第 19 章,本章的 octoscode 只取它的三个 OLP 载体(olp_mcp、outer_duty、协议文档)。
本章回顾:
- OLP v2 是三层叠加:协议条款(角色、信道、R1-R7)纯文档;黑板、ACK、loop.md 是 Markdown 约定,无 Rust 实现;机械强制只有三个契约测试文件 25 个用例对快照的 grep 与真实子进程验证。
- 黑板追加唯一正道是
octoscode/scripts/olp-board-append.sh:22-24的 flock 三行;ACK 定式三态 done/wontdo/blocked,wontdo 只能被接受或升级,不得再次打回。 - 第五信道是内环主动外呼的唯一通道:
octoscode/src/olp_mcp.rs恰好两个工具,90 秒超时降级(:25)、每片三次限额(:27)、tried 必填(:184-187)。 - 主审权锁诚实收缩:
octoscode/src/outer_duty.rs:23整模块 Linux-only,fd 即锁,PDEATHSIG 死亡耦合消灭 agent 活而锁闲的脑裂,check 只观察不夺取,活锁接管归 operator;macOS 退值班簿纪律层。 - 贵模型只花在判断上:双环的信道全部是文件、git 与 CLI,协议模型无关,换厂牌不换协议。
延伸阅读
octoscode/docs/OUTER_LOOP_PROTOCOL.md— 协议唯一权威文本,R1-R7 条款、附录 A/B、v0 实验与两次战役的全部教训octoscode/docs/OLP_OUTER_BOOT.md— 外环上岗卡:重启硬清单、观测三层、安全红线、内环选型octoscode/docs/OCTOLOOP_GUIDE.md— 上手与机制双篇,平台支持矩阵与 goal 状态机octoscode/tests/outer_duty_contract.rs— 锁语义的十条不变量,读它比读文档更快理解设计意图
思考题
- ACK 定式只 grep 状态词,不解析说明文本。如果内环在说明里写「已执行但测试没跑」却标 done,现有的契约测试能拦住吗?要让 R2 验证级别进入 ACK 行,语法与豁免边界要怎么改才不破坏历史条目?
ASK_QUOTA_PER_SLICE = 3的「片」是 turn 还是任务书里的切片?如果按 turn 计数,一个长任务跨多个 turn,内环最多能问多少次?这个限额防的是什么形态的滥用,为什么 90 秒超时降级比无限等待更安全?lock_digest放弃 DefaultHasher 改用 SHA-256,理由是跨 Rust 版本不稳定。假设锁文件因为升级 Rust 而全部失配,现场会发生什么:旧 holder 还活着但新 check 算出的路径不同?如何用duty_lock_digest_golden_and_convergence的思路防止这类事故?- 把黑板换成 SQLite 需要哪些额外机制才能保住现在的五条性质(并发追加、编号去重、历史审计、git 版本化、零依赖读写)?逐条估算工程量,再判断值不值。
- macOS 上没有 outer-duty 锁,多外环靠值班簿纪律层共存。设计一个不改 Rust 代码的 flock 等价方案(shell 脚本即可),并说明它为什么达不到 PDEATHSIG 死亡耦合的保证。
版本演化说明:本章分析基于 octos 主仓 main @ 9c157101、octoscode main @ 1129fa33、herdr feat/octoscode-agent @ fefe5c4f(2026-09-03 取基准,行号与常量逐条复核自
assets/ch20-facts.md及其生成命令)。OLP v2 于 2026-08-30 生效(#38-r1 新增 R7);v1 的 ACK 定式与 frontmatter v1 六字段自 2026-08-24 生效。协议演进以octoscode/docs/OUTER_LOOP_PROTOCOL.md:7的版本头与octoscode/AGENTS.md:3的引用为权威,双处一致性由octoscode/tests/olp_contract.rs:215守护;硬 gate(写入面校验 lease)与 macOS reaper(knowledge/requirements/req-olp-duty-macos.md)为在途条目,落地后本章 20.5 节需更新。
第 21 章:herdr 与外环运维实务
定位:本章讲 herdr,外环的驾驶舱:一个后台常驻的终端工作区服务,内环窗格住在它里面,外环通过 CLI 与 socket API 发现、注入、观测它们。识别契约、三原语、上岗与重启清单、双哨配方、平台坑与降级,在这一章汇成一份可执行的运维手册。前置依赖:第 19 章(octoscode 客户端,识别文案的来源)、第 20 章(OLP 协议条款)。适用场景:要搭建或运维双环系统、编写 agent 驱动终端的工具链,或评估「让 agent 操作终端」这条路线的工程师。
21.1 驾驶舱:终端住在服务里
前两章把双环的两端讲完了:第 19 章的 octoscode 是内环的终端客户端,第 20 章的 OLP v2 是外环的行为契约。中间还缺一件东西。外环自己是一个 agent 进程,没有眼睛也没有键盘,它怎么看到内环窗格里的画面,怎么把一段提示敲进内环的输入框?答案是给终端配一个服务端,让终端从「进程的附属品」变成「服务的住户」。
herdr 的 README 把自己定义为 the runtime your coding agents live on(herdr/README.md:29)。这个定义拆开是四句承诺(herdr/README.md:31 至 herdr/README.md:34):
- always running:herdr 是后台 server,终端住在里面。合盖、断网、重启机器,agent 继续干活,会话可恢复,从任意终端或 ssh 重新 attach;
- never hunt for the stuck one:每个窗格都被标记 working / blocked / idle,agent 停下来等人回答时,herdr 直接标出来,不用逐个翻找;
- agent-native:agent 通过 CLI 与 socket API 驱动 herdr,开窗格、互相 prompt、等另一个 agent 真正 blocked;
- runs what you already run:不包装、不替换 claude code、codex、cursor、opencode、grok 等工具,只拥有它们的终端。
第 4 句对本书最重要。herdr 不理解 octos 的 goal、账本、黑板,它只做终端宿主;对 agent 的全部「理解」收敛为两个问题:这个窗格里跑的是什么?它现在什么状态?这两个问题的答案分别住在 detect 模块与一份 40 行的识别清单里,是本章后两节的主角。键盘与鼠标是双一等公民(herdr/README.md:35),整个程序是 one rust binary, no electron(herdr/README.md:37),源码构建用 cargo build --release,单测 just test,维护检查 just check(herdr/README.md:71 起的 development 一节)。
体量先立在这里:herdr 的 src/ 共 245 个 .rs 文件、229,696 行;顶层 37 个文件 29,927 行;最大的子目录是 app(54 个文件 65,400 行,其中 herdr/src/app/mod.rs 6,606 行、herdr/src/app/actions.rs 6,155 行、herdr/src/app/api.rs 2,303 行),其后是 server(16 个文件 19,166 行,含无头模式的 herdr/src/server/headless.rs)、ui(17 个文件 12,416 行)、pane(9 个文件 10,540 行)、integration(13 个文件 9,822 行)、api(22 个文件 8,957 行)。与外环打交道的 CLI 层反而薄:herdr/src/cli/agent.rs 949 行,herdr/src/cli/pane.rs 2,108 行。薄的原因是 CLI 不做业务,只做参数解析与转发:每个子命令函数把参数装进 schema 类型,经 send_request(herdr/src/cli.rs:762)发到 unix socket,等 server 回话。
拓扑如图:
flowchart LR
subgraph outer["外环侧"]
OP["外环 agent(Claude Code / Codex)"]
end
subgraph herdrbox["herdr(常驻 server)"]
CLI["herdr CLI:agent / pane 子命令"]
SRV["server 事件循环 + detect 引擎"]
SOCK["unix socket(HERDR_SOCKET_PATH)"]
end
subgraph panes["窗格(PTY)"]
P1["octoscode 内环(标准形态)"]
P2["claude 快轨内环"]
P3["codex 快轨内环"]
end
OP -->|"spawn 调用"| CLI
CLI -->|"Method 枚举请求"| SOCK
SOCK --- SRV
SRV -->|"写文本 + Enter"| P1
SRV -->|"写文本 + Enter"| P2
SRV -->|"写文本 + Enter"| P3
P1 -->|"尾部文本周期采样"| SRV
socket 的寻址有一套降级链:active_api_socket_path(herdr/src/session.rs:173)优先读环境变量 HERDR_SOCKET_PATH(常量定义在 herdr/src/api/mod.rs:20),没设则按会话名推导 <config_dir>/sessions/<name>/herdr.sock(api_socket_path_for,herdr/src/session.rs:169);config 目录尊重 XDG_CONFIG_HOME(herdr/src/config/io.rs:30)。这条链解释了多实例共存的机制:每个会话一个 socket,外环脚本只要能定位会话名就能定位通道。thin client 模式另有专用的 herdr-client.sock(client_socket_path_for,herdr/src/session.rs:183),客户端模块的自我描述是 connects to the server's client socket(herdr/src/client/mod.rs:1)。
server 内部的事件流通读一下模块首行就能拼出图景:后台任务不直接碰 UI,而是把事件经 channel 交给主循环(herdr/src/events.rs:1,internal app events delivered via channel);事件枚举 AppEvent 定义在 herdr/src/events.rs:56,与外环相关的四个变体是 PaneDied(:58)、AgentProcessDetected(:60)、StateChanged(:66)、HookStateReported(:76)。窗格排布由 BSP 树负责(herdr/src/layout.rs:1,BSP tree layout for tiling panes),无头模式把事件循环跑在无真实终端的环境里(herdr/src/server/headless.rs:1)。把这几个模块串起来看,herdr pane read 返回的屏幕、agent list 返回的状态、agent wait 的达成信号,背后都是同一条事件总线:StateChanged 由 detect 引擎的裁决触发,wait 族(21.3 节)订阅它来判定等待何时完成。
与相邻两章的边界在这里划清:第 19 章讲被观测对象 octoscode 本体,本章只消费它的屏幕文案;第 20 章讲协议条款(R1 至 R7、ACK 语法、黑板、主审锁),本章不复述条款,只讲这些条款靠 herdr 落地执行时的操作面。herdr 的插件系统与远程 ssh 场景不在本章范围。
21.2 识别契约:从屏幕反推状态
herdr 对 agent 的认知全部来自屏幕。detect 模块的文档写得直白(herdr/src/detect/mod.rs:1):每个窗格的活动缓冲区尾部文本被周期性读取,与已知 agent 的输出模式匹配,得出状态。状态机是四态的 AgentState(herdr/src/detect/mod.rs:13):Idle(:15)、Working(:17)、Blocked(:18)、Unknown(:19)。前三个对应 README 的承诺,第四个留给普通 shell 与未识别程序。
识别的目标程序枚举在 Agent 里,共 24 个(Agent::ALL,herdr/src/detect/mod.rs:71 起),octoscode 是其中之一:Agent::Octoscode(herdr/src/detect/mod.rs:67)。注意版本事实:这个变体只存在于 herdr 的 feat/octoscode-agent 分支(本章基线 fefe5c4f,提交标题就是 feat(detect): add octoscode as a supported agent),上游 master 尚未合入。走屏幕 manifest 匹配的 agent 共 22 个(SCREEN_MANIFEST_AGENTS,herdr/src/detect/mod.rs:98,Self::Octoscode 在 :120),每个 manifest 是一份内嵌的 TOML,在 herdr/src/detect/manifest.rs:256 以 ("octoscode", include_str!("manifests/octoscode.toml")) 编进二进制。标签与可执行名各有映射:agent_label 把变体渲染成 octoscode(herdr/src/detect/mod.rs:124,:149),interactive_agent_executable 返回启动可执行名(:153,:184),parse_agent_label 反向解析标签,octoscode 与别名 octos-tui 都指向同一变体(herdr/src/detect/mod.rs:188,:225)。
manifest 的引入还解决了一个工程问题:识别规则本质上是数据,不是代码。每支持一个新 agent,不需要改 detect 引擎,只需要加一份 TOML、在名单里挂一次枚举变体。规则本身带版本号(octoscode 这份是 2026.08.23.1)与 min_engine_version(1),manifest 更新模块(herdr/src/detect/manifest_update.rs)可以独立于引擎演进。对本书读者更相关的是另一面:识别规则住在被识别方的对端,octoscode 团队改一次状态栏文案,herdr 侧(以及任何做同样事情的工具)就要跟着改规则,这份耦合没有任何编译期保护,唯一的守护是冒烟清单里的那一步 herdr agent list。
octoscode 的识别契约全文 40 行(herdr/src/detect/manifests/octoscode.toml),头 5 行是清单元数据,正文三条规则:
id = "octoscode"
version = "2026.08.23.1"
min_engine_version = 1
updated_at = "2026-08-23T00:00:00Z"
aliases = ["octos-tui"]
[[rules]]
id = "approval_blocked"
state = "blocked"
priority = 1100
region = "whole_recent"
visible_blocker = true
any = [
{ contains = ["Approval | y once | s session | n deny"] },
{ contains = ["Waiting on you"] },
{ contains = ["Ctrl+R/Alt+A answer"] },
{ contains = ["审批 | y 本次 | s 本会话"] },
]
[[rules]]
id = "statusbar_working"
state = "working"
priority = 1000
region = "bottom_non_empty_lines(6)"
visible_working = true
any = [
{ line_regex = ['state\s*[·,]\s*Working'] },
{ contains = ["Esc interrupt"] },
]
[[rules]]
id = "statusbar_idle"
state = "idle"
priority = 900
region = "bottom_non_empty_lines(6)"
any = [
{ line_regex = ['state\s*[·✓,⚠]*\s*(Idle|Done)'] },
{ contains = ["Tab agents | Ctrl+O expand"] },
{ contains = ["Ask Octos to change code"] },
]
三条规则的结构值得逐项拆。id 是规则的稳定标识;state 是命中后写入窗格的状态;priority 决定多规则同时命中时的仲裁,blocked 的 1100 压过 working 的 1000 与 idle 的 900,这符合直觉:等待审批的画面比状态栏更紧急;region 限定匹配范围,blocked 规则看 whole_recent(整个近期缓冲,因为审批框可能出现在任何位置),两条状态栏规则只看 bottom_non_empty_lines(6)(底部 6 条非空行,状态栏的家);any 数组是或语义,任一子句命中即成立。三个布尔里有两个是仲裁信号:visible_blocker 与 visible_working 声明「屏幕上真的看得见这个状态」,它们参与 detect 内部的来源仲裁(AgentDetection 结构,herdr/src/detect/mod.rs:25 起),屏幕证据可以覆盖集成上报的非 blocked 状态。
为什么 octoscode 的识别可以这么薄?第 19 章给过答案:哑客户端架构让屏幕文案忠实反映服务端状态,状态栏既是给人看的界面,也是机器的观测接口。同一串 Working 文案,人眼读出「在干活」,herdr 的正则读出 state=working。这份契约的维护责任也随之清楚:octoscode 侧任何状态栏改版,都必须同步这份 manifest,否则外环的眼睛立刻失明。快速验证手册依赖的正是它:冒烟一节要求 herdr agent list 显示 octoscode | <pane> | idle(octoscode/docs/OLP_QUICKSTART.md:151),这个 idle 就是 statusbar_idle 规则命中的结果,规则失配时冒烟第一步就过不去。
21.3 外环三原语:发现、注入、观测
CLI 的分发结构很短。总入口 maybe_run(herdr/src/cli.rs:95)按第一个参数路由,"agent" 在 herdr/src/cli.rs:125,"pane" 在 herdr/src/cli.rs:127。run_agent_command(herdr/src/cli/agent.rs:12)再分发 11 个子命令:list、get、read、send-keys、prompt、rename、focus、wait、attach、start、explain(:19 至 :29);run_pane_command(herdr/src/cli/pane.rs:12)分发 list、get、read、split、send-keys、wait-output、run 等。外环的日常全部落在其中三五个命令上,可以归纳为三个原语。
发现:herdr agent list,实现是 agent_list(herdr/src/cli/agent.rs:438,usage 行 :440),发 Method::AgentList(herdr/src/api/schema.rs:107)。返回每个窗格的 agent 标签、窗格号与状态。外环上岗第一件事就是跑它,把「哪个窗格是我的内环」这个问题变成一行输出。
注入:herdr agent prompt <target> <text> [--wait] [--until STATUS]... [--timeout MS],实现是 agent_prompt(herdr/src/cli/agent.rs:771,usage 行 :774)。--until 与 --timeout 必须配 --wait 使用(:825、:828 各有一道检查),请求经 Method::AgentPrompt(herdr/src/api/schema.rs:127)进 server,参数类型 AgentPromptParams 只有三个字段:target、text、可选的 wait 选项(herdr/src/api/schema/agents.rs:176,字段在 :177 至 :180)。
观测:herdr pane read <pane_id> [--source visible|recent|recent-unwrapped|detection] [--lines N] ...,实现是 pane_read(herdr/src/cli/pane.rs:455,USAGE 常量 :473),走 Method::PaneRead(herdr/src/api/schema.rs:175)。--source detection 直接返回 detect 引擎的裁决,是排查识别失灵的第一现场。
三原语之外还有三个配角。herdr pane split ... --direction right|down --cwd PATH(pane_split,herdr/src/cli/pane.rs:623,usage :733)开新窗格,函数头还会读 HERDR_PANE_ID 环境变量(:624),让运行在窗格里的程序能以自己为锚分屏。herdr pane run <pane_id> <command>(pane_run,herdr/src/cli/pane.rs:1047,usage :1049)在窗格里执行命令,实现朴素得近乎透明:把命令文本与一个 Enter 键打包成 Method::PaneSendInput 发下去(:1050 至 :1056)。herdr agent wait <target> [--until STATUS]... [--timeout MS](agent_wait,herdr/src/cli/agent.rs:506,usage :508)阻塞到状态达成,server 侧由 wait_for_agent(herdr/src/api/wait.rs:132)实现;同族还有 wait_for_output(:22)、prompt_agent(:177)与 wait_for_event(:661)。
一次注入的完整时序如下,重点在 server 侧的四道门:
sequenceDiagram
participant O as 外环 agent
participant C as herdr CLI
participant S as herdr server
participant T as 内环终端(octoscode)
O->>C: herdr agent prompt <pane> '<text>' --wait --until idle
C->>S: agent.prompt(AgentPromptParams)
S->>S: 门1 空文本 → empty_agent_prompt
S->>S: 门2 blocked → agent_blocked(要求交互输入)
S->>S: 门3 未识别或启动未就绪 → agent_not_ready
S->>S: 门4 前台进程名匹配失败 → agent_not_ready
alt 四门全过
S->>T: 写入文本(按终端模式编码)
S-->>T: 300ms 后补发 Enter
T-->>S: 状态栏变化,detect 命中新状态
S-->>C: AgentPrompted;wait_for_agent 达成
C-->>O: 成功返回
else 任一门拒绝
S-->>C: 对应错误码
C-->>O: 非零退出
end
四道门是 handle_agent_prompt(herdr/src/app/api/agents.rs:62)按序执行的:空文本拒绝(:63 至 :65,错误码 empty_agent_prompt);目标窗格 blocked 时拒绝(:81 至 :89,错误码 agent_blocked,理由是要交互输入而不是新任务);agent 未识别或托管启动未就绪返回 agent_not_ready(:95 至 :98);第四道最值得展开:runtime_hosts_agent 校验窗格前台进程名(herdr/src/app/api/agents.rs:105 至 :112),不符则报 agent is no longer the pane foreground process。这个函数在 herdr/src/app/agents.rs:421,实现是取窗格子进程的前台作业(foreground_job),识别其中的 agent,与期望比对。第四道门就是外环文档里「注入静默丢失」双重门的第二重:第一重是窗格必须在 named-agent 名单里(known agent),第二重是此刻前台进程必须就是那个 agent(octoscode/docs/OLP_QUICKSTART.md:162)。缺一即丢,而且丢得安静。
四门全过后,写入分两步:先按终端模式编码文本写入,再延时 300 毫秒补发 Enter(AGENT_PROMPT_SUBMIT_DELAY 常量在 herdr/src/app/api/agents.rs:13,使用在 :126)。延时的理由是给 TUI 一拍时间把文本吃进输入框,立即回车可能抢在渲染前。Copilot 有个特例可以反证这套机制的材质:它聚焦丢失后会忽略合成 Enter,所以 herdr 在给它注入前要先发一个 focus gained 事件再写文本(herdr/src/app/api/agents.rs:112 起)。octoscode 不需要这步,但这个特例说明 herdr 的注入通道对 TUI 的具体行为有细粒度适配,适配的开关就在 server 侧按 agent 分派。
工程决策:注入为什么走 TUI 用户消息层级,而不是给 octoscode 开一个 API 第 19 章的结论在这里兑现:octoscode 是哑客户端,本来就没有「接收外部文本」的服务端 API,它的输入框是唯一公共入口。herdr 若为 octos 特制 API,就得理解 session、游标、重放这一整层,违背 runs what you already run 的承诺;走 TUI 输入框,则对 claude、codex、octoscode 一视同仁,herdr 对 agent 的认知永远止步于屏幕。对照另一条下行通道更清楚:
octos steer也把外环文本作为独立 user 消息注入,但它面向 session、经 serve 内部通道(octoscode/docs/OLP_OUTER_BOOT.md第 2 节),适合内环 turn 进行中插话;herdr prompt 面向窗格、走屏幕,适合内环空闲时唤醒。同是用户消息层级,方向与时机不同,两者互补而不是替代。代价也要写明:这条通道依赖识别契约的文案稳定,还要过双重门,任何一环漂移,注入就从「成功」变成「静默」。
状态值在两层的集合不同,排查时容易踩空。CLI 与 API 层的 AgentStatus(herdr/src/api/schema/common.rs:151)是五态:idle、working、blocked、done、unknown(:152 至 :156),CLI 解析函数同样收五个值(herdr/src/cli.rs:897,分支在 :899 至 :903);detect 层的 AgentState 只有四态,没有 done。pane 上报侧的解析 parse_pane_agent_state(herdr/src/cli.rs:910 至 :915)也只收四态。用 --until done 等一个 detect 层根本不产生的状态,等到的一定是超时。
21.4 从三原语到运维:上岗、重启与内环开设
三原语只是零件,OLP 文档把它们组装成清单。外环上岗卡(octoscode/docs/OLP_OUTER_BOOT.md)的开篇原则先记住:环境细节会漂移,窗格号、实例哈希、会话键都不要硬编码,清单教的是发现方法。
上岗清单(综合 octoscode/docs/OLP_QUICKSTART.md 第 1、2 节与 OLP_OUTER_BOOT.md 第 0 节):
- 依赖体检:herdr 是「否,推荐」级依赖(
octoscode/docs/OLP_QUICKSTART.md:35),不装可退化为 tmux send-keys;要用 octoscode 窗格识别,当前需从feat/octoscode-agent分支构建; - 项目目录跑 olp-init 脚手架,生成
.octos/loop.md与黑板模板; - serve 起步永远是 operator 亲手执行,免沙箱授权是信任决策,不外包给 agent;
/loop resume由外环代办:先/loop list取 id,再带 id resume,裸 resume 会被拒;- 双哨挂载(下一节展开);
- fallbacks 配置核对,且确认新会话已快照:改配置不重启只是纸面保险。
内环重启后的巡检是一份四步硬清单(octoscode/docs/OLP_OUTER_BOOT.md 第 0b 节,:16 至 :32),禁止「记一笔稍后补」:第一步 serve 起,operator 亲手;第二步 /loop resume 外环必代;第三步双哨挂载;第四步 fallbacks 与新会话快照核对。清单背后有一次实打实的事故:维护心跳 paused 一整天无人察觉,直到夜间主道断供、goal 熔断、哨盲区三层同时失守才暴露,停摆 8 小时。教训被写进清单本身:主机制健康时,兜底层的瘫痪完全不可见,兜底的健康只能靠巡检,不能靠事故。
内环的开设是 agent 无关的。内环契约只有四条(octoscode/.octos/loop.md:5 起):读黑板 Active 区,执行编号最小的未 ACK 条目,只 commit 不 push,完成后落 ACK(done|wontdo|blocked) 定式。任何能读文件、跑命令、被 herdr 驱动的 agent 都能当内环。三种形态按需选(octoscode/docs/OLP_OUTER_BOOT.md 第 6 节):octoscode 加便宜模型是标准形态,goal、peer、ledger、steer、事件流全套机械都有;Claude Code 免审批窗格质量高,适合快轨难切片;codex 窗格与主审厂牌隔离,利于互审。开设动作就是 herdr pane run:
# 标准形态:octoscode 挂 octos serve
herdr pane run <pane> 'cd <repo> && octoscode --stdio-command "octos serve --stdio --solo --danger-full-access"'
# 快轨形态:claude 免审批(operator 亲手执行)
herdr pane run <pane> 'cd <repo> && claude --dangerously-skip-permissions'
开设后发内环上岗词,内容指向 loop.md 与黑板 Active 区。此后内环的维护循环完全由 herdr 注入的 prompt 触发:一次 prompt 就是一轮「读板、取单、执行、落 ACK」。外环不轮询内环内部状态,只在外环自己需要推进时叫醒它。
唤醒与纠偏的分工(octoscode/docs/OLP_OUTER_BOOT.md 第 2 节,:47 至 :58):内环空闲时用 herdr agent prompt 唤醒,一句话指向黑板新条目编号;内环正在跑 turn 时改用 octos steer 插话,不打断动作,下一拍被消费。steer 按 cwd 找实例,必须在项目目录下执行,会话键从 ls <repo>/.octos/octos/sessions/ 的文件名 URL 解码得到。用哪个通道的判据只有一个:看 herdr agent list 报的状态。
21.5 侦听哨配方:三层观测与双哨
观测上行有三层,缺一不可(octoscode/docs/OLP_OUTER_BOOT.md 第 3 节,:60 至 :72):第一层现场屏幕,herdr pane read <pane>;第二层事件流,tail -f .../data/events.jsonl,看 steer_consumed、escalation、goal_transition 等事件;第三层结构面,octos goal status 与 octos peer list。文档的口吻来自教训:投递不等于消费,消费不等于执行,只看一层必误判。三层分别回答三个问题:屏幕说「它现在长什么样」,事件流说「协议上发生了什么」,结构面说「goal 与 peer 的账对不对」。
双哨是重启硬清单的第三步:正信号哨盯 ACK 落板,负信号哨盯 events.jsonl 里的 goal_transition blocked 与 escalation。只挂正哨时,goal 熔断的沉默与「还在干活」不可区分;负哨补的正是这块盲区。events.jsonl 这条负哨链还有一层运维语义要写明:文件按实例与 profile 分目录存放(路径形如 ~/.octos/instances/<实例>/profiles/<档>/data/events.jsonl),实例名是项目 cwd 的哈希,不想要就 ls -t ~/.octos/instances/ 按修改时间对号;哨进程死了不会有人替你重启,文件也不保证永久保留,所以负哨的正确形态是「重挂载时可追认」的 tail,而不是「假设无断档」的解析器——外环自己的存活,同样要在重启硬清单里核对。双哨的状态空间:
stateDiagram-v2
[*] --> mounted
mounted : 双哨挂载(正哨 + 负哨)
mounted --> healthy : 正哨有 ACK 负哨静默
mounted --> visible_stall : 正哨静默 负哨报 blocked
mounted --> blind : 双哨皆静默
visible_stall --> healthy : 外环按第 2 节唤醒或纠偏
blind --> mounted : 重跑重启硬清单重新挂哨
三个状态里最危险的是双哨皆静默:内环可能只是没事做,也可能整条观测链断了,而后者从外环看与前者毫无差别。把它与三层观测合起来用是唯一解:哨静默时去读第三层结构面,octos goal status 会告诉你 goal 是 active 还是早就 blocked,octos peer list 会告诉你派出去的 worker 还在不在。把这套配方写进重启硬清单的第三步,是让「兜底瘫痪不可见」这堂 8 小时课不再重上的最省力手段。
21.6 常见坑与降级
运维章节最后留给坑。第一个坑就是 21.3 节的第四道门:注入静默丢失。症状是 herdr agent prompt 发出去了,内环毫无反应,命令却未必报错。原因两重:窗格不在 named-agent 名单,或前台进程名与期望 agent 不符(octoscode/docs/OLP_QUICKSTART.md:162)。典型时序是内环刚好在跑一个 shell 命令,前台进程瞬时是 bash 而不是 octoscode,注入恰落在窗口里。修法两层:重试前先 herdr agent list 确认状态;确认环境没有 herdr 或门持续拒绝时,降级 tmux send-keys,首字符为 - 的文本用 -- 分隔。降级要清醒:send-keys 只是无门控的裸键入,双重门、状态等待、--until 语义全部丢失,等于从驾驶舱退回遥控器。
第二个坑是平台差异:OLP 的主审权锁(outer-duty)依赖 flock 与 PDEATHSIG,是 Linux-only,macOS 上不编译,多外环并存退回值班簿纪律层(第 20 章已展开,octoscode/docs/OLP_OUTER_BOOT.md 第 3.5 节)。这不是 herdr 的缺陷,而是整条工具链的平台边界:在 mac 上跑双环,主审互斥靠约定不靠锁。
第三个坑在安装侧:首启自动下载 octos server 失败,多半是离线或代理问题,手装 npm i -g @octos-org/octos,设 OCTOSCODE_NO_AUTO_INSTALL=1 关自动安装(octoscode/docs/OLP_QUICKSTART.md 第 6 节故障表,源头在 octoscode/src/backend_ensure.rs:60 的 OPT_OUT_ENV)。同表还有一条 Linux 专属:构建大项目时链接器 SIGBUS 或 EDQUOT,是 tmpfs 配额问题,把 TMPDIR 指到 home 盘。第四个坑是版本:octoscode 识别在 fork 分支,--kind octoscode 用了上游 master 构建的 herdr 会静默不识别,窗格状态永远是 unknown,冒烟那步 octoscode | <pane> | idle 过不去,顺着这条症状反查最快。这个坑的教训在于「静默降级」的代价:上游构建的 herdr 对 octoscode 窗格照常分配 PTY、照常回显画面,唯一缺席的是识别——没有安装警告,没有版本协商,没有 unknown 状态的告警旗。运维者若先跑 herdr agent prompt 而不是 herdr agent list,第一反馈会是 agent_not_ready,很容易误判成四道门里的进程匹配问题,在双重门上白耗排查时间。规避办法是把依赖表那句「从 feat/octoscode-agent 分支构建」(octoscode/docs/OLP_QUICKSTART.md:35)写进环境准备清单,并约定:合入 master 的日子就是全机重装 herdr 的日子。
排查这些坑时,herdr agent explain(herdr/src/cli/agent.rs:41,usage :91)与 pane read --source detection 是两件趁手工具:前者解释窗格为什么被判成当前状态,后者直接给出 detect 引擎的原始裁决,把「识别错了」与「画面真的变了」区分开。CLI 的完整入口表可以随时用 herdr agent help 打印,帮助总表硬编码在 print_agent_help(herdr/src/cli/agent.rs:922,正文 :923 至 :941);报错信息也带路:任何一个子命令参数敲错,usage 字符串原样回显(usage 行与源码逐字一致,本章引用的 usage 全部来自事实表的 sed 抽查),照着改就行。
21.7 边界与回顾
与第 19 章:octoscode 的状态栏文案是识别契约的源头,客户端架构决定了屏幕可观测;本章只消费这份契约,不再重复客户端内部机制。与第 20 章:黑板、ACK、R1 至 R7、主审锁的条款语义全在那章,本章只讲操作面,即这些条款靠哪几条 herdr 命令落地。与第 18 章:goal 与 peer 的服务端机制不在本章,观测只用到 octos goal status 与 octos peer list 两个只读命令。
回顾本章脉络:herdr 是终端宿主而非 agent 框架,对 agent 的认知收敛为 detect 引擎加一份 manifest;外环三原语(发现、注入、观测)映射到 agent list、agent prompt、pane read 三个 CLI 子命令,server 侧四道门决定注入的成败;上岗、重启、双哨、三形态内环把零件组装成可执行的运维手册;静默丢失与平台差异是这套实务里最贵的两份学费。双环的完整图景至此闭合:内环的一切状态在协议与屏幕里(第 19 章),外环的一切行为在条款里(第 20 章),连接两者的手是 herdr(本章)。
延伸阅读
herdr/README.md— 定位承诺与开发流程的原始表述herdr/src/detect/mod.rs— detect 引擎:AgentState、Agent 枚举、manifest 装配herdr/src/detect/manifests/octoscode.toml— 本章识别契约全文,40 行octoscode/docs/OLP_OUTER_BOOT.md— 外环上岗卡:重启硬清单、三层观测、内环开设octoscode/docs/OLP_QUICKSTART.md— 依赖清单、冒烟验证、故障速查表assets/ch21-facts.md— 本章全部数字与行号的事实来源,逐条附复跑命令
思考题
- 若 octoscode 一次改版把状态栏文案从
state·Working改为state: Working,按本章的链路推演,哪些环节会在什么时刻失配?给出一份让文案与 manifest 不漂移的工程方案。 - 双重门的第二重(前台进程名匹配)为什么要存在?设想没有它的攻击或事故场景:一个恶意或故障的 shell 循环输出伪状态栏,能否骗过只有第一重门的系统?
AgentStatus有 done 而AgentState没有。查 herdr 源码找出 done 从哪来(集成上报还是屏幕推断),并论述两层状态集合不一致的设计代价。- tmux send-keys 降级丢掉了哪三样东西?若要给降级通道补一个最小可用的心跳验证,你会复用本章哪个原语?
- 双哨皆静默的盲区状态,单靠外环自身无法与「内环空闲」区分。若允许你在内环侧加一个低成本的周期信号,加在哪一层(屏幕、事件流、结构面)最便宜且最难被伪造?
版本演化说明 本章为 v2 新增章,分析基线:herdr
feat/octoscode-agent@fefe5c4f(版本 0.8.2,2026-08-29)、octos main @9c157101(2026-09-02)。全部行数、行号与 usage 字符串取自assets/ch21-facts.md(2026-09-03)并经作者在上述基线复跑核对;octoscode 文档引用标注相对路径。octoscode 的窗格识别(Agent::Octoscode与herdr/src/detect/manifests/octoscode.toml)当前仅在 fork 分支,合入上游 master 后应以 master 为准。herdr 迭代较快,CLI usage 与 manifest 规则可能随版本调整,引用时以符号名与规则 id 为锚。
附录 A:octos 完整 Crate 依赖图
定位:本附录是全书 21 章共用的地图页,给出 octos workspace 26 个顶层条目、23 个
octos-*crate 的完整依赖关系:63 条内部依赖边的全量图、逐 crate 的层数/行数/依赖矩阵,以及含 feature-gate 标注的外部依赖明细。前置依赖:第 1 章(1.3 节 workspace 拓扑与三处事实澄清)。适用场景:读任何一章想知道「这个 crate 在全局什么位置、它依赖谁、谁依赖它」时翻到这里;为 octos 贡献代码前想确认依赖方向时对照这里。
本附录数据与第 1 章同源同口径:统计基线为 octos main @ 9c157101,唯一数据源是仓库事实表 assets/appendixA-facts.md(与 assets/ch01-facts.md 六项汇总数字交叉核对全部一致,见 A.1)。依赖边只取各 crate Cargo.toml 的 [dependencies] 段中的 octos-* 条目,排除 [dev-dependencies] 与 [build-dependencies];行数统计口径为 find crates/<name> -name '*.rs' | xargs wc -l | tail -1。两个口径的完整复现命令都收录在 assets/appendixA-facts.md 中,本附录不重复罗列,只给结论。
A.1 汇总数字
| 指标 | 数值 |
|---|---|
依赖边总数([dependencies] 段 octos-*) | 63 |
ls crates | wc -l 顶层条目 | 26 |
其中 octos-* crate | 23 |
根 Cargo.toml 的 workspace members | 38 |
| 分层数(严格最长路径,L0–L7) | 8 |
| 行数合计(26 个顶层条目) | 700,915 |
机器核对命令(在 octos 源码仓库根执行):
awk '/^\[dependencies\]/{f=1;next} /^\[/{f=0} f && /^octos-/' crates/*/Cargo.toml | wc -l # 63
ls crates | wc -l # 26
find crates -name '*.rs' | xargs wc -l | tail -1 # 700915 total
六个数字与 assets/ch01-facts.md 的 §1/§3/§4/§5 逐项一致,本附录是在其之上的全量展开。
A.2 内部依赖全图:63 条边
graph BT
subgraph L0["L0 基础层"]
core[octos-core]
plugin[octos-plugin]
sandbox[octos-sandbox<br/>平台助手二进制]
end
subgraph L1["L1 原语层"]
bus[octos-bus]
llm[octos-llm]
memory[octos-memory]
diag[octos-diagnostics]
store[octos-store]
fleet[octos-fleet]
wasm[octos-wasm]
end
subgraph L2["L2 运行时层"]
agent[octos-agent]
services[octos-services]
embed[octos-embed-llama]
end
subgraph L3["L3 编排层"]
swarm[octos-swarm]
dora[octos-dora-mcp]
fworker[octos-fleet-worker]
pipeline[octos-pipeline]
end
subgraph L4["L4 工作流层"]
workflows[octos-workflows]
end
subgraph L5["L5 集成层"]
server[octos-server]
cli[octos-cli]
end
subgraph L67["L6/L7 嵌入与绑定层"]
ffi[octos-ffi]
uniffi[octos-uniffi]
pyo3[octos-pyo3]
end
subgraph CAP["能力层 / 前端(孤立,不计层数)"]
appskills[app-skills<br/>14 个能力二进制]
platskills[platform-skills/voice<br/>能力二进制]
web[octos-web<br/>前端 TypeScript<br/>不在依赖图]
end
bus --> core
llm --> core
memory --> core
diag --> core
store --> core
fleet --> core
wasm --> core
agent --> core
agent --> bus
agent --> memory
agent --> llm
agent --> plugin
services --> core
services --> llm
services --> bus
embed --> llm
swarm --> agent
dora --> agent
fworker --> agent
fworker --> core
fworker --> fleet
fworker --> llm
fworker --> memory
pipeline --> core
pipeline --> agent
pipeline --> plugin
pipeline --> llm
pipeline --> memory
workflows --> core
workflows --> agent
workflows --> pipeline
server --> core
server --> agent
server --> llm
server --> bus
server --> store
server --> services
server --> workflows
server --> pipeline
server --> plugin
cli --> core
cli --> bus
cli --> llm
cli --> memory
cli --> agent
cli --> diag
cli --> store
cli --> services
cli --> workflows
cli --> pipeline
cli --> swarm
cli --> plugin
cli --> fleet
cli --> fworker
cli --> embed
ffi --> core
ffi --> agent
ffi --> llm
ffi --> memory
ffi --> cli
ffi --> embed
uniffi --> ffi
pyo3 --> ffi
图与第 1 章 1.3.4 节同构,63 条边与 assets/appendixA-facts.md §3 的边清单逐条一致,一条不多、一条不少。方向为 A --> B 即 A 依赖 B;graph BT 自底向上读,越靠上的 crate 越靠近用户。逐 crate 边数:octos-cli 15、octos-server 9、octos-ffi 6、octos-agent 5、octos-pipeline 5、octos-fleet-worker 5、octos-services 3、octos-workflows 3,其余各 1 条。图中的短名是 mermaid 节点别名,实际包名一律以 octos- 开头(如 diag 即 octos-diagnostics)。
四点读图提示。其一,octos-core 被 15 个 crate 依赖,是唯一的根,它自己零内部依赖(第 2 章);octos-agent 被 8 个 crate 依赖,是第二枢纽(第 5 至 10 章)。其二,依赖全部指向下方、无环,分层是 cargo 在编译期强制的不变量,不是文档约定。其三,octos-sandbox、app-skills、platform-skills/voice、octos-web 四个孤立节点不在 63 条边内:能力二进制与前端通过进程边界而非 Cargo 依赖接入平台。其四,spec 特意澄清的两点:app-skills 与 platform-skills 虽是 workspace 成员(作为目录成员计入 38),但它们是能力二进制集合,不展开进核心 crate 图;近期 goal/peer/agent 编排的新模块落在 crates/octos-cli/src/autonomy/ 与 crates/octos-agent/src/ 的工具层,不存在也不应画出 octos-autonomy 这样的 crate 节点(第 18 章)。
A.3 逐 crate 数据表
「层」= 严格最长路径层数:1 + max(其 octos-* 依赖的层),零内部依赖为 L0。「外部依赖数」= [dependencies] 段非 octos-* 条目数,含 feature-gated 条目;名称.workspace = true 继承写法按包名归一计数。
| crate | 层 | Rust 行数 | 依赖的 octos-*([dependencies]) | 外部依赖数 |
|---|---|---|---|---|
| octos-core | L0 | 22,313 | —(零内部依赖) | 7 |
| octos-plugin | L0 | 5,165 | —(零内部依赖) | 7 |
| octos-sandbox | L0 | 1,468 | —(零内部依赖) | 2 |
| octos-bus | L1 | 42,767 | octos-core | 31 |
| octos-llm | L1 | 35,087 | octos-core | 14 |
| octos-memory | L1 | 6,428 | octos-core | 11 |
| octos-diagnostics | L1 | 2,243 | octos-core | 4 |
| octos-store | L1 | 2,664 | octos-core | 12 |
| octos-fleet | L1 | 16,888 | octos-core | 8 |
| octos-wasm | L1 | 883 | octos-core | 5 |
| octos-agent | L2 | 191,985 | octos-core, octos-bus, octos-memory, octos-llm, octos-plugin | 47 |
| octos-services | L2 | 3,223 | octos-core, octos-llm, octos-bus | 11 |
| octos-embed-llama | L2 | 911 | octos-llm | 5 |
| octos-pipeline | L3 | 32,799 | octos-core, octos-agent, octos-plugin, octos-llm, octos-memory | 10 |
| octos-swarm | L3 | 4,980 | octos-agent | 11 |
| octos-dora-mcp | L3 | 11 | octos-agent | 0 |
| octos-fleet-worker | L3 | 6,842 | octos-agent, octos-core, octos-fleet, octos-llm, octos-memory | 5 |
| octos-workflows | L4 | 1,059 | octos-core, octos-agent, octos-pipeline | 6 |
| octos-server | L5 | 21 | octos-core, octos-agent, octos-llm, octos-bus, octos-store, octos-services, octos-workflows, octos-pipeline, octos-plugin | 22 |
| octos-cli | L5 | 307,299 | octos-core, octos-bus, octos-llm, octos-memory, octos-agent, octos-diagnostics, octos-store, octos-services, octos-workflows, octos-pipeline, octos-swarm, octos-plugin, octos-fleet, octos-fleet-worker, octos-embed-llama | 55 |
| octos-ffi | L6 | 1,372 | octos-core, octos-agent, octos-llm, octos-memory, octos-cli, octos-embed-llama | 4 |
| octos-uniffi | L7 | 465 | octos-ffi | 1 |
| octos-pyo3 | L7 | 756 | octos-ffi | 1 |
三个非 octos-* 顶层条目不在上表中,以文字说明结论(详见 assets/ch01-facts.md §1.3/§3.2):crates/app-skills 是 14 个能力二进制目录,共 12,098 行,无顶层 Cargo.toml;crates/platform-skills 是 1 个技能二进制 voice,1,188 行;crates/octos-web 是 TypeScript 前端,0 个 .rs 文件,也不是 workspace 成员。三者的 [dependencies] octos-* 依赖均为 0。逐 crate 行数用一例说明复现方式,其余同式:find crates/octos-core -name '*.rs' | xargs wc -l | tail -1 → 22313。
A.4 L0–L7 分层导览
层不是按模块大小或重要性的主观分类,而是从 63 条边机械推导出的严格最长路径:一个 crate 的层等于它到 L0 的最长依赖链长度。这一节逐层给出导览,并标注展开该层的章节。
L0 基础层:octos-core、octos-plugin、octos-sandbox
三个零内部依赖的 crate,整个依赖树的根。octos-core 用类型系统定义平台领域语言:会话、消息、记忆、配置的统一数据契约(第 2 章),被 15 个 crate 依赖,任何接口变动都会波及全库,因此它的稳定性纪律最严格。octos-plugin 定义插件契约与可执行文件发现逻辑(第 9 章),同样保持零内部依赖,让扩展机制可以独立于核心演进。octos-sandbox 是一个独立的 Windows AppContainer 助手二进制,不被任何 crate 依赖,它是平台助手工具而非沙箱子系统,真正的沙箱实现在 octos-agent 内部(第 7 章),这是 v2 重写作了显式澄清的一处常见误读。
L1 原语层:octos-bus、octos-llm、octos-memory、octos-diagnostics、octos-store、octos-fleet、octos-wasm
七个只依赖 L0 的领域原语,每个封装一类外部世界:octos-bus 是 17 个频道源文件组成的多频道消息抽象(第 11 章),也是全库外部依赖最多的 L1 crate(31 项,含 11 项 gated 的频道集成);octos-llm 驯服 LLM Provider 的混乱,统一补全、流式与凭据管理(第 3 章);octos-memory 实现混合搜索的工作记忆与长期记忆(第 4 章);octos-store 提供会话持久化(第 15 章);octos-diagnostics 承担诊断上报(第 15 章);octos-fleet 是 Fleet 内核的持久化与计划数据层(第 16 章);octos-wasm 面向浏览器场景提供 wasm 绑定。这一层的共性是:彼此之间互不依赖,全部横向平行,改动任何一个不会波及其余六个。
L2 运行时层:octos-agent、octos-services、octos-embed-llama
第一个纵向聚合点。octos-agent 是全库最大的 crate(191,985 行),聚合 agent loop、59 个工具源文件、沙箱、上下文管理与 harness 模块(第 5 至 10 章的全部主题),5 条内部依赖汇聚 core/bus/memory/llm/plugin。octos-services 依赖 core/llm/bus 三者,提供生产环境的服务侧能力(第 15 章)。octos-embed-llama 只依赖 octos-llm,把本地 llama.cpp 嵌入推理接到统一的 LLM trait 之后,且默认不编译(gated)。这一层的边界意义在于:L1 之上开始出现「组合多个原语」的代码,但仍然不感知具体产品形态。
L3 编排层:octos-pipeline、octos-swarm、octos-dora-mcp、octos-fleet-worker
四个 crate 都直接依赖 octos-agent,把单个 Agent 组合成更大的执行结构。octos-pipeline 是 DOT 驱动的流水线引擎,12 种 IR 节点(第 13 章);octos-swarm 实现契约扇出与聚合门禁(第 17 章);octos-fleet-worker 是 Fleet 计划执行内核的 worker 侧,5 条内部依赖全部为 workspace 继承写法(第 16 章);octos-dora-mcp 是 Dora/MCP 工具桥,只有 11 行 Rust、零外部依赖,是全库最小的 crate。编排层的代码不直接面对用户,它们是 L4/L5 的备料。
L4 工作流层:octos-workflows
单节点的一层,依赖 core/agent/pipeline 三者,把流水线执行封装为可复用的工作流单元。它的存在使 octos-server 与 octos-cli 可以用同一种方式消费 pipeline 能力,而不必各自编排 handler。这是典型的「加一层换解耦」决策:层多了一跳,但集成方少了一份重复。
L5 集成层:octos-server、octos-cli
用户与外部世界接触的两个入口。octos-cli 是全库第二大 crate(307,299 行),15 条内部依赖横跨所有层,goal/peer 编排(第 18 章)、coding autonomy(第 19 章)与外环 OctoLoop(第 20 章)的运行时模块都在它的 src/autonomy/、src/peers/ 之下;55 项外部依赖也是全库之最。octos-server 只有 21 行 Rust,它是从 cli 拆出的 HTTP/WebSocket 服务端薄壳,核心能力仍在其依赖的 crate 中,HTTP 层整体装进 api feature 门后(第 15 章)。
L6 嵌入核心层:octos-ffi
C ABI 稳定层。依赖 6 个 crate(core/agent/llm/memory/cli/embed-llama),把平台的常用能力面收敛为一组跨语言函数边界。注意它依赖 L5 的 octos-cli 而非更底层:嵌入方拿到的是「完整产品能力」而非「原语拼装件」,这个取舍让绑定层变薄、让 ffi 层变重,是有意的。
L7 绑定层:octos-uniffi、octos-pyo3
最外的两个叶子,各自只依赖 octos-ffi:octos-uniffi 面向 Swift/Kotlin 移动端,octos-pyo3 面向 Python(pyo3@0.23,配置见 crates/octos-pyo3/Cargo.toml,整体 gated)。每新增一种语言绑定只需在 L7 加一个叶子 crate,不触碰任何内层。L6/L7 拆两层正是为了让绑定扩展的改动半径最小。
能力层(不计层数)
app-skills(14 个能力二进制)、platform-skills/voice、octos-web 通过进程边界或静态资源方式接入,零 octos-* 依赖边,因此不出现在 A.3 的表与 A.2 的 63 条边中。它们享受 workspace 的统一工具链与 lint 配置,却不进入核心编译图,发布与编译互不拖累。
A.5 外部依赖明细表
下表列出每个 crate [dependencies] 段的全部外部依赖,格式为 名称@版本要求;加 * 后缀 = feature-gated(optional = true,由该 crate 的 [features] 控制,默认构建不编译)。版本要求逐字摘自基准 commit 的 [workspace.dependencies] 继承或 crate 内联写法;名称.workspace = true 继承条目已解析为实际版本要求。全库外部依赖合计 279 项,与 A.3 的「外部依赖数」列合计一致。
| crate | 外部依赖(名称@版本,* = gated) |
|---|---|
| octos-core | serde@1, serde_json@1, chrono@0.4, uuid@1, eyre@0.6, tracing@0.1, sha2@0.10 |
| octos-plugin | serde@1, serde_json@1, eyre@0.6, tracing@0.1, which@7, tokio@1, metrics@0.24 |
| octos-sandbox | clap@4, eyre@0.6 |
| octos-bus | tokio@1, lru@0.16, async-trait@0.1, serde@1, serde_json@1, chrono@0.4, chrono-tz@0.10, uuid@1, cron@0.15, eyre@0.6, tracing@0.1, metrics@0.24, futures@0.3, reqwest@0.12, serde_yml@0.0.12, subtle@2, sha2@0.10, aes@0.8, cbc@0.1, base64@0.22; gated: teloxide@0.17*, serenity@0.12*, tokio-tungstenite@0.26*, axum@0.8*, async-imap@0.11*, tokio-rustls@0.26*, rustls@0.23*, rustls-native-certs@0.8*, webpki-roots@0.26*, lettre@0.11*, mailparse@0.16* |
| octos-llm | async-trait@0.1, reqwest@0.12, tokio@1, serde@1, serde_json@1, eyre@0.6, futures@0.3, secrecy@0.10, tracing@0.1, base64@0.22, chrono@0.4, redb@2, metrics@0.24, jsonwebtoken@9 |
| octos-memory | regex@1, redb@2, tokio@1, serde@1, serde_json@1, chrono@0.4, uuid@1, eyre@0.6, tracing@0.1, hnsw_rs@0.3, bincode@1 |
| octos-diagnostics | serde@1, serde_json@1, eyre@0.6; gated: reqwest@0.12* |
| octos-store | chrono@0.4, eyre@0.6, serde@1, serde_json@1, sha2@0.10, base64@0.22, tracing@0.1, redb@2, uuid@1, tokio@1, getrandom@0.2, constant_time_eq@0.3 |
| octos-fleet | eyre@0.6, redb@2, rusqlite@0.32, serde@1, serde_json@1, tokio@1, tracing@0.1, uuid@1 |
| octos-wasm | serde@1, serde_json@1, wasm-bindgen@0.2, serde-wasm-bindgen@0.6, js-sys@0.3 |
| octos-agent | async-trait@0.1, tokio@1, serde@1, serde_json@1, toml@0.8, chrono@0.4, eyre@0.6, tracing@0.1, metrics@0.24, glob@0.3, globset@0.4, shlex@1, which@7, dunce@1, regex@1, ignore@0.4, futures@0.3, reqwest@0.12, rmcp@1.8, tokio-util@0.7, oauth2@5, reqwest_rmcp@0.13(内联表,package = "reqwest" 重命名,default-features = false + features = ["rustls"]), tiny_http@0.12, webbrowser@1, keyring@3, url@2, htmd@0.5, dirs@5, sha2@0.10, flate2@1, tar@0.4, libc@0.2, base64@0.22, chromiumoxide@0.9, pdf-extract@0.9, tempfile@3, lettre@0.11, redb@2, hound@3; gated: gix@0.79*, similar@2*, tree-sitter@0.24*, tree-sitter-rust@0.23*, tree-sitter-python@0.23*, tree-sitter-javascript@0.23*, tree-sitter-typescript@0.23*, symphonia@0.5* |
| octos-services | chrono@0.4, eyre@0.6, serde@1, serde_json@1, tokio@1, tracing@0.1, reqwest@0.12, flate2@1, tar@0.4, dirs@5, futures@0.3 |
| octos-embed-llama | eyre@0.6, async-trait@0.1, tracing@0.1; gated: llama-cpp-2@0.1*, self_cell@1* |
| octos-pipeline | async-trait@0.1, tokio@1, serde@1, serde_json@1, eyre@0.6, futures@0.3, tracing@0.1, chrono@0.4, regex@1, glob@0.3 |
| octos-swarm | async-trait@0.1, chrono@0.4, eyre@0.6, metrics@0.24, redb@2, serde@1, serde_json@1, tokio@1, tracing@0.1, uuid@1, sha2@0.10(sha2.workspace = true 继承,crates/octos-swarm/Cargo.toml:21) |
| octos-dora-mcp | (无外部依赖,仅 octos-agent) |
| octos-fleet-worker | async-trait、eyre、serde_json、tokio、tracing——五条均为 名称.workspace = true 继承写法(crates/octos-fleet-worker/Cargo.toml:12-16),版本要求在根 [workspace.dependencies] |
| octos-workflows | chrono@0.4, eyre@0.6, serde@1, serde_json@1, tokio@1, tracing@0.1 |
| octos-server | async-trait@0.1, serde@1, serde_json@1, tokio@1, tracing@0.1, chrono@0.4, eyre@0.6, uuid@1, metrics@0.24; gated(HTTP 层 api feature): axum@0.8*, tower-http@0.6*, tokio-util@0.7*, futures@0.3*, tokio-tungstenite@0.26*, rustls@0.23*, rustls-native-certs@0.8*, rust-embed@8*, metrics-exporter-prometheus@0.16*, lettre@0.11*, rand@0.8*, sysinfo@0.34*, subtle@2* |
| octos-cli | async-trait@0.1, clap@4, clap_complete@4, dirs@5, serde@1, serde_json@1, colored@2, chrono@0.4, iana-time-zone@0.1, tokio@1, eyre@0.6, uuid@1, color-eyre@0.6, tracing@0.1, tracing-subscriber@0.3, tracing-appender@0.2, rustyline@15, reqwest@0.12, url@2, sha2@0.10, fs2@0.4, getrandom@0.2, constant_time_eq@0.3, percent-encoding@2, open@5, zip@2, quick-xml@0.37, image@0.25, regex@1, tempfile@3, base64@0.22, toml@0.8, agent-client-protocol@1.2.0, keyring@3, flate2@1, qrcode@0.14, chacha20poly1305@0.10, argon2@0.5, tar@0.4, metrics@0.24, redb@2(redb.workspace = true 继承,crates/octos-cli/Cargo.toml:112); gated: subtle@2*, axum@0.8*, tower-http@0.6*, tokio-util@0.7*, futures@0.3*, tokio-tungstenite@0.26*, rustls@0.23*, rustls-native-certs@0.8*, rust-embed@8*, metrics-exporter-prometheus@0.16*, lettre@0.11*, rand@0.8*, teloxide@0.17*, sysinfo@0.34* |
| octos-ffi | tokio@1, serde@1, serde_json@1, libc@0.2 |
| octos-uniffi | uniffi@0.29 |
| octos-pyo3 | gated: pyo3@0.23*(features = ["abi3-py39"]) |
feature-gated 依赖全库共 50 项外部条目,分布:octos-agent 8、octos-bus 11、octos-cli 14、octos-server 13、octos-diagnostics 1、octos-embed-llama 2、octos-pyo3 1,其余 crate 为 0(全仓 optional = true 命中 52 行,其中 cli 与 ffi 各多出的 1 行是内部依赖 octos-embed-llama 的 optional 条目,不计入外部 gated)。三组值得记住的 gate 设计:octos-bus 的 11 项 gated 全部是频道集成,默认构建不拉入任何一个聊天网络栈;octos-server 的 13 项 gated 构成完整的 HTTP 层 api feature,server-core 保持 ungated(见 crates/octos-server/Cargo.toml:7 的 description);octos-agent 的 gix/tree-sitter 系列把 Git 与 AST 能力做成显式开关(第 6 章)。Feature flag 的完整传播关系见附录 D。
A.6 把本附录当工具用
三种典型用法。查位置:拿到一个 crate 名,先在 A.3 查它的层与依赖列,再看 A.2 图里它的上下游,30 秒内确定改动波及面。查依赖:接入一个新频道前,到 A.5 看它落在哪个 crate、是否 gated、默认构建会不会被拉重。查历史:任何一章引用 crate 间的数字口径,以本附录与 assets/appendixA-facts.md 为准;数字与某章正文出现出入时,先核对统计口径(是否排除 dev-dependencies、是否含 gated)再判断对错。
延伸阅读
- Cargo Workspaces 官方文档:https://doc.rust-lang.org/cargo/reference/workspaces.html ,members 继承与
[workspace.dependencies]机制。 - Cargo Features 官方文档:https://doc.rust-lang.org/cargo/reference/features.html ,
optional = true与 feature 统一推导规则,对照 A.5 的 gated 标注。 - UniFFI 用户指南:https://mozilla.github.io/uniffi-rs/ ,L7 绑定层的生成原理。
- pyo3 用户指南:https://crates.io/crates/pyo3 (条目页含文档入口),
abi3-py39的稳定 ABI 取舍。 - 本书第 1 章 1.3 节:workspace 拓扑的推导过程与三处事实澄清。
思考题
- A.3 中
octos-cli307,299 行、外部依赖 55 项,是octos-server的一万多倍。如果明天要把 cli 拆成octos-cli-core与octos-cli-app两层,A.2 的 63 条边里哪些边要移动?分层会变成几层? octos-server只有 21 行却占 L5。把「层」定义为依赖深度而非代码量,给读架构图的人带来什么好处、什么误导?- octos-bus 把 11 项频道依赖全部 gated,而 octos-llm 的 reqwest 不 gated。判断一条依赖该不该进默认构建,你的标准是什么?
- 假设要新增 Kotlin 绑定 crate
octos-kotlin:它应该放 L7 依赖octos-ffi,还是直接依赖octos-core?两种选择分别改变了 63 条边中的什么?
版本演化说明
本章分析基于 octos main @
9c157101(2026-09-03 统计)。全部数字(63 边、26 顶层条目、23 个 octos-* crate、38 members、8 层、700,915 行、279 项外部依赖、52 行 optional)均出自assets/appendixA-facts.md,在该 commit 上实测,复现命令随数据收录。相对 v1 旧稿,本附录做了三类更新。其一,依赖图从 v1 的 11-crate 核心图扩为 23 个 octos-* crate 全量:fleet、swarm、workflows、diagnostics、services、store 六个 crate 在旧稿成文后已独立成节点,旧图中的边数与节点集均作废。其二,事实纠正:
octos-sandbox是平台助手二进制而非沙箱子系统;app-skills/platform-skills是 workspace 成员的能力二进制集合,不展开进核心图;最新 goal/peer/autonomy 模块落在octos-cli与octos-agent内部,不存在octos-autonomycrate,图中不为其设节点。其三,数据口径升级:外部依赖表从「关键依赖举例」改为 279 项全量清单,并逐项标注 feature-gate;行数与层数均按第 1 章同口径复算。
附录 B:工具速查表
定位:本附录是第 6 章工具系统的数据面:逐一列出 octos 当前 main 分支注册的 80 个工具注册名(核心 60 + admin 20),按 10 个能力域给出导览,并配套 P0 coding 合约十项与 coding shim 的对应关系、fleet worker 的两张工具子表与 4 个 feature 门。前置依赖:第 6 章(工具系统)、第 7 章(
group策略与沙箱)。适用场景:配置 profile、WorkerGrant 或排查"某个工具为什么不可见"的读者 B/C/D,把它当索引用,不当教程读。
B.0 口径:按注册名组织,不按固定总数组织
octos 的工具面没有一个稳定的"总工具数"。同一个 crates/octos-agent/src/tools/ 目录,从三个口径数会得到三个数字,本附录全部采用实测口径(commit 9c157101,2026-09-03):
- 目录口径:
crates/octos-agent/src/tools/共 58 个条目(57 个.rs文件加 1 个admin/子目录),与第 6 章一致; - 注册名口径:这 58 个条目在不同构造路径下注册出 80 个模型可见的注册名,即本表的核心 60 + admin 20。
crates/octos-agent/src/tools/coding_tools.rs一个文件就承载 15 个注册名,而crates/octos-agent/src/tools/replacer.rs、crates/octos-agent/src/tools/write_grant.rs这类支撑模块一个注册名都没有,所以"文件数不等于工具数"在两个方向上同时成立; - 分组口径:策略分组以
crates/octos-agent/src/tools/policy.rs:186的TOOL_GROUPS(10 组)为准。
旧稿按固定数量罗列内置工具的写法已废弃:profile 过滤、feature 编译门、spawn_only 标记和 chat 网关注册都会改变一次会话实际可见的工具集合,记住分层注册模型比记住一个数字有用。表中的"一句话职责"取自各文件首行 //! 文档或 fn description() 返回串,逐条可复核。
B.1 主表:80 个注册名
每行给出注册名、一句话职责、所属域与门。"门"列仅在工具受编译期 feature 或运行期环境变量约束时标注,— 表示默认构建即可见(仍受 profile 与策略过滤)。所属域沿用第 6 章的 10 个能力域。
| 注册名 | 一句话职责 | 所属域 | 门 |
|---|---|---|---|
read_file | 读取文件内容 | 文件系统 | — |
write_file | 创建或覆写文件 | 文件系统 | — |
edit_file | 精确字符串替换编辑 | 文件系统 | — |
diff_edit | 应用 unified diff 格式补丁 | 文件系统 | — |
apply_patch | Codex 风格多文件补丁(#1773),P0 成员 | 文件系统 | — |
list_dir | 列出目录内容 | 文件系统 | — |
glob | 按模式查找文件路径 | 文件系统 | — |
grep | 按正则搜索文件内容 | 文件系统 | — |
workspace_log | 只读查看工作区 git 历史 | 文件系统 | — |
workspace_show | 按 commit 读取文件历史版本 | 文件系统 | — |
workspace_diff | 查看两个 commit 之间的差异 | 文件系统 | — |
shell | 在策略与沙箱约束下执行 shell 命令 | Shell 与执行 | — |
exec_command | 长任务执行,会话化 stdin 与分段回收输出,P0 成员 | Shell 与执行 | — |
write_stdin | 向运行中的 exec 会话写入 stdin 并回收输出,P0 成员 | Shell 与执行 | — |
bash | Codex 命名对齐的一次性 shell 别名(#1172) | Shell 与执行 | — |
check | 项目静态检查(#1772,lite 范围) | Shell 与执行 | — |
web_search | 多供应商网页搜索 | Web 与研究 | — |
web_fetch | 抓取 URL 内容并抽取文本 | Web 与研究 | — |
browser | 基于 chromiumoxide 的无头浏览器自动化 | Web 与研究 | — |
search | 深度搜索:网页搜索加并行爬取并落盘 | Web 与研究 | — |
synthesize_research | 读取搜索产物文件并生成综述 | Web 与研究 | — |
deep_crawl | 基于 CDP 的递归站点爬取 | Web 与研究 | — |
memory_note | 追加式记录值得记住的观察 | 记忆 | — |
record_memory_use | 申报哪些记忆条目实际影响了回答 | 记忆 | — |
recall | 取回被上下文压缩替换的工具输出(#2131) | 记忆 | — |
recall_memory | 加载记忆库的完整实体页 | 记忆 | — |
save_memory | 写入或更新记忆库实体页 | 记忆 | — |
message | 跨频道发送消息 | 消息与交互 | — |
send_file | 向聊天频道投递文件 | 消息与交互 | — |
send_app_card | 投递结构化 mini-app 卡片载荷 | 消息与交互 | — |
ask_user_question | 结构化的中途用户提问(UPCR-2026-023) | 消息与交互 | — |
spawn | 派生子代理,同步等待或后台执行 | Peer 与后台任务 | — |
delegate_task | 同步委派工具(delegate 是 coding 侧的别名) | Peer 与后台任务 | — |
peer_handoff | LLM 发起的 peer 编组(#1801 v3) | Peer 与后台任务 | — |
peer_send_input | master 向 peer 注入跨会话输入(#436) | Peer 与后台任务 | — |
peer_gather | 读取 peer 黑板(#1801 v3 扇入) | Peer 与后台任务 | — |
peer_list | 调用者 peer 的紧凑状态索引 | Peer 与后台任务 | — |
peer_respond | 回答处于 BLOCKED 的 peer(人在环) | Peer 与后台任务 | — |
peer_close | 退役自己创建的 peer | Peer 与后台任务 | — |
check_background_tasks | 会话级后台任务检查 | Peer 与后台任务 | — |
read_task_output | 选择性查看后台任务输出 | Peer 与后台任务 | — |
code_structure | 基于 tree-sitter 的代码结构分析 | 代码与结构 | feature = "ast"(编译期) |
spawn_agent | 启动受监督的 Codex 兼容子代理,P0 成员 | 代码与结构 | — |
delegate | spawn_agent 加 wait_agent 的一站式 wrapper(#1172) | 代码与结构 | — |
view_image | 工作区内图片元数据检视(#972) | 代码与结构 | — |
tool_search | 从 live catalog 动态发现工具(#1148) | 代码与结构 | — |
tool_suggest | 按任务描述推荐工具(#1148) | 代码与结构 | — |
image_generation | 图像生成入口,当前返回 typed coding_tool_unsupported(#1149) | 代码与结构 | — |
update_plan | 更新可见任务计划,P0 成员 | 代码与结构 | — |
request_user_input | 请求宿主 UI 的结构化输入,P0 成员 | 代码与结构 | — |
send_input | 向子代理发送输入,P0 成员(非实时对话投递) | 代码与结构 | — |
resume_agent | 恢复子代理句柄,P0 成员 | 代码与结构 | — |
wait_agent | 巡检或等待子代理,P0 成员 | 代码与结构 | — |
close_agent | 关闭子代理句柄,P0 成员 | 代码与结构 | — |
git | 纯 Rust(gix)原生 git 集成 | Git | feature = "git"(编译期) |
manage_skills | 常规网关的技能管理 | 技能与平台 | — |
mofa_make | 内容生成调度器(RFC-1,#1290),spawn_only 注册 | 技能与平台 | — |
check_workspace_contract | 工作区合约状态的只读检查 | 技能与平台 | — |
configure_tool | 每工具的持久配置存取 | 技能与平台 | — |
model_check | 模型与供应商清点(chat 网关注册) | 技能与平台 | — |
admin_platform_skills | server 级 ASR/TTS 引擎管理(ominix-api) | admin | — |
admin_list_profiles | 列出 profile | admin | — |
admin_profile_status | 查询 profile 状态 | admin | — |
admin_start_profile | 启动 profile | admin | — |
admin_stop_profile | 停止 profile | admin | — |
admin_restart_profile | 重启 profile | admin | — |
admin_enable_profile | 启用 profile | admin | — |
admin_update_profile | 更新 profile | admin | — |
admin_manage_skills | 按 profile 安装或移除 GitHub 技能 | admin | — |
admin_list_sub_accounts | 列出子账号 | admin | — |
admin_create_sub_account | 创建子账号 | admin | — |
admin_view_logs | 查看 server 日志 | admin | — |
admin_system_health | 系统健康检查 | admin | — |
admin_provider_metrics | 供应商指标 | admin | — |
admin_manage_watchdog | 看门狗管理 | admin | — |
admin_system_metrics | 系统指标 | admin | — |
admin_view_sessions | 查看会话 | admin | — |
admin_cron_status | 定时任务状态 | admin | — |
admin_check_config | 配置检查 | admin | — |
admin_update_octos | 经 serve API 检查并应用 octos 更新 | admin | — |
计数核对:文件系统 11 + Shell 与执行 5 + Web 与研究 6 + 记忆 5 + 消息与交互 4 + Peer 与后台任务 10 + 代码与结构 13 + Git 1 + 技能与平台 5 = 核心 60,admin 20,合计 80。
B.2 十个能力域导览
B.2.1 文件系统(fs)
文件系统域是全部写作与仓库勘察能力的地基:从逐字节的 read_file/write_file,到精确替换的 edit_file 与补丁式的 diff_edit/apply_patch,再到目录与内容检索三件(list_dir/glob/grep)和只读的 workspace 历史三件;策略分组 group:fs 覆盖其中 5 个写侧工具(read_file、write_file、apply_patch、edit_file、diff_edit,crates/octos-agent/src/tools/policy.rs:191-196)。
B.2.2 Shell 与执行(runtime)
执行域的四个注册名共用同一套命令策略与沙箱,所以任何一条路径被 deny,其余路径同生共死;group:runtime 因此固定包含 shell、exec_command、write_stdin、bash 四名(crates/octos-agent/src/tools/policy.rs:206),check 是并列的静态检查入口,与 shell 共享会话沙箱。
B.2.3 Web 与研究(web/search/research)
这一域对应三张策略分组:group:web 的搜索、抓取与浏览器三件(crates/octos-agent/src/tools/policy.rs:211),group:search 的本地检索三件(crates/octos-agent/src/tools/policy.rs:216),以及 group:research 的多轮深度研究三件 search、synthesize_research、deep_crawl(crates/octos-agent/src/tools/policy.rs:251)。
B.2.4 记忆(memory)
group:memory 四件(recall_memory、save_memory、memory_note、record_memory_use)构成跨会话记忆的读写闭环(crates/octos-agent/src/tools/policy.rs:239-246),recall 则是同一域里方向相反的工具:它把被压缩丢弃的会话内工具输出重新物化回来。
B.2.5 消息与交互
消息域把 Agent 的对外面收拢为四个口:跨频道文本(message)、文件投递(send_file)、结构化卡片(send_app_card)与结构化提问(ask_user_question);后者是 request_user_input 的同步、答案可路由超集(UPCR-2026-023)。
B.2.6 Peer 与后台任务(sessions)
group:sessions 收录全部子代理入口:spawn、spawn_agent、send_input、resume_agent、wait_agent、close_agent、delegate(crates/octos-agent/src/tools/policy.rs:228-236),本域再加上 peer 生命周期五件与后台任务检查两件,构成多代理协作的完整索引。
B.2.7 代码与结构(coding)
这一域是 Codex 兼容工具面的主体:crates/octos-agent/src/tools/coding_tools.rs 单文件承载 15 个注册名,其中 9 个进入 P0 合约,其余 6 个是 #972/#1148/#1149 的扩展入口,加上 feature 门控的 code_structure,共 13 个注册名;所有 15 名都在 crates/octos-agent/src/tools/registry.rs:1254 的 with_builtins_and_permissions(注册体自 1283 起)注册。
B.2.8 Git
git 是唯一的 VCS 注册名,走纯 Rust 的 gix 实现而非 shell 外调,受编译期 feature = "git" 门控(crates/octos-agent/src/tools/mod.rs:861),并在 crates/octos-agent/src/tools/registry.rs:1466-1469 随 CWD 绑定列表重绑。
B.2.9 技能与平台
技能与平台域混合了两类注册:常规网关的 manage_skills、configure_tool 与 chat 网关注册的 model_check(三者构成 group:admin,crates/octos-agent/src/tools/policy.rs:253-257),以及 spawn_only 注册的内容生成调度器 mofa_make 和工作区合约检查 check_workspace_contract。
B.2.10 admin 子目录
admin 域的 20 个注册名全部来自 crates/octos-agent/src/tools/admin/ 的 7 个文件,面向 Serve/Admin API 的运维面:profile 生命周期七件、系统可观测八件、子账号两件、技能与平台更新三件,普通 profile 默认不可见。
B.3 P0 required 十项与 coding shim 的关系
Codex 兼容合约声明的 P0 必备集定义在 crates/octos-cli/src/api/coding_tool_contract.rs:85 的 CODING_P0_REQUIRED_TOOL_NAMES,共十项:apply_patch、exec_command、write_stdin、update_plan、request_user_input、spawn_agent、send_input、resume_agent、wait_agent、close_agent。十项中九项由 crates/octos-agent/src/tools/coding_tools.rs 提供,唯一例外 apply_patch 是原生文件工具(crates/octos-agent/src/tools/apply_patch.rs),不在 crates/octos-agent/src/tools/coding_tools.rs 内。
graph LR
subgraph P0["P0 required 10(crates/octos-cli/src/api/coding_tool_contract.rs:85)"]
AP["apply_patch<br/>crates/octos-agent/src/tools/apply_patch.rs"]
C9["crates/octos-agent/src/tools/coding_tools.rs 内 9 名:exec_command、write_stdin、update_plan、request_user_input、spawn_agent、send_input、resume_agent、wait_agent、close_agent"]
end
OUT["P0 之外的 6 个 headline shim:bash、delegate、view_image、tool_search、tool_suggest、image_generation"]
AP --> P0
C9 --> P0
OUT -.->|不属于 P0| P0
十个 headline shim(简报口径九个,加上宏注册的六个里的代表)与 P0 的逐条关系如下表;前三个是 P0 成员,其余六个各有明确的不入选理由:
| 注册名 | 与 P0 的关系 | 依据(全路径) |
|---|---|---|
exec_command | P0 成员 | crates/octos-agent/src/tools/coding_tools.rs:382;crates/octos-cli/src/api/coding_tool_contract.rs:85 |
write_stdin | P0 成员 | crates/octos-agent/src/tools/coding_tools.rs:739 |
spawn_agent | P0 成员 | crates/octos-agent/src/tools/coding_tools.rs:1284 |
bash | P0 之外,#1172 命名对齐别名,与 shell/exec_command 共享策略与沙箱,三者一处 deny 处处 deny | crates/octos-agent/src/tools/coding_tools.rs:1946;crates/octos-agent/src/tools/registry.rs:1281-1290 |
delegate | P0 之外,#1172 一站式 wrapper(DelegateAliasTool,绑定 spawn_agent,group:sessions 成员) | crates/octos-agent/src/tools/coding_tools.rs:2429;crates/octos-agent/src/tools/registry.rs:548,566,1284 |
view_image | P0 之外,#972/M14-B P1 图像检视,只读并受 filesystem_scope 约束 | crates/octos-agent/src/tools/coding_tools.rs:2801;crates/octos-agent/src/tools/registry.rs:1461-1464 |
tool_search | P0 之外,#1148 的动态发现入口,读 live catalog | crates/octos-agent/src/tools/coding_tools.rs:3150 |
tool_suggest | P0 之外,#1148 的按任务推荐入口 | crates/octos-agent/src/tools/coding_tools.rs:3251 |
image_generation | P0 之外,#1149/M14-B P2;当前返回 typed coding_tool_unsupported(wire 合约完整,尚无生成后端绑定) | crates/octos-agent/src/tools/coding_tools.rs:3391;crates/octos-agent/src/tools/registry.rs:1368-1370 注释原文 |
两点合约语义需要单独说明。其一,send_input 虽列名 P0,但当前不是实时对话投递:它以 simple_codex_tool! 宏注册(crates/octos-agent/src/tools/coding_tools.rs:1839-1842),写入的是受监督任务的输入通道而非交互式会话。其二,image_generation 的 typed unsupported 是有意的合约姿态:调用方得到的是结构化的"此环境不支持"信封,而不是 tool not found 错误,前端可以据此降级 UI 而不破坏协议。
工程决策侧栏:为什么别名必须进组(#1172) deny-wins 策略按"注册名或组名"匹配。
bash与delegate这类别名若不进group:runtime/group:sessions,一个禁用了执行或子代理的 profile 仍会从别名绕回原能力。修复方式是把别名写进组定义(crates/octos-agent/src/tools/policy.rs:201-205注释原文),并让delegate持有被绑定spawn_agent的 Arc 句柄这一事实也成为组注释的一部分:策略层覆盖的是"每一个入口",而不是"每一个实现文件"。
B.4 fleet worker 的两张子表
fleet worker(第 16 章)的工具面由 crates/octos-fleet/src/grant.rs 的常量表约束,master 能授予的工具以白名单封顶:
| 子表 | 成员 | 依据(全路径) |
|---|---|---|
| BASE_TOOLS(默认 7) | read_file、write_file、edit_file、glob、grep、list_dir、shell | crates/octos-fleet/src/grant.rs:27 |
| GRANTABLE_TOOLS(可授 9) | 上述 7 个加 web_fetch、web_search | crates/octos-fleet/src/grant.rs:41 |
| WEB_TOOLS(网络 2) | web_fetch、web_search(仅在 network grant 下可授) | crates/octos-fleet/src/grant.rs:56 |
复核命令:sed -n '27,60p' crates/octos-fleet/src/grant.rs。
lean coding profile 的工具面由 crates/octos-agent/src/assets/profiles/coding.json 的 allow_list 决定,共 12 条(4 个单名加三个组加 5 个单名),按 TOOL_GROUPS 展开后为 20 个注册名:
| allow_list 条目 | 展开后的注册名 |
|---|---|
read_file、write_file、edit_file、diff_edit | 同名 4 个 |
group:runtime | shell、exec_command、write_stdin、bash |
group:search | glob、grep、list_dir |
group:memory | recall_memory、save_memory、memory_note、record_memory_use |
spawn、ask_user_question、check、update_plan、tool_search | 同名 5 个 |
该 profile 的设计意图写在 json description 原文里:加入 check、update_plan、tool_search 三个此前缺失的循环工具,只丢弃 apply_patch(edit_file/diff_edit 覆盖其职责);被排除的工具重面时用 --profile coding-full 恢复,而不是运行时发现。run_pipeline 为 spawn_only 注册且不在 allow_list 内(crates/octos-agent/src/profile/mod.rs:874)。一处口径差需要指明:事实表标签写"展开后 19 名",但逐组展开实测为 20 名(上表合计 4+4+3+4+5),本表以逐组展开为准。
B.5 feature 门:编译期 2 + 运行期 2
80 个注册名中只有 4 个受门控,两类各两个:
| 门类型 | 注册名 | 开关 | 依据(全路径) |
|---|---|---|---|
| 编译期 feature | git | feature = "git" | crates/octos-agent/src/tools/mod.rs:861 |
| 编译期 feature | code_structure | feature = "ast" | crates/octos-agent/src/tools/mod.rs:864 |
| 运行期环境变量 | read_window(内部,非注册名) | OCTOS_READ_WINDOW=1,默认关 | crates/octos-agent/src/tools/read_window.rs:177-179 |
| 运行期环境变量 | read_paging_probe(内部,非注册名) | OCTOS_READ_PAGING_PROBE=1,不武装不记录 | crates/octos-agent/src/tools/read_paging_probe.rs:131 |
两个运行期门都作用于"内部件"而非独立注册名,这与第 6 章的结论一致:读路径的收紧先以探针取数,再决定是否成为默认行为。编译期门则在 crates/octos-agent/src/tools/registry.rs:1466-1469 以 #[cfg] 出现在 CWD 绑定列表里,未开启 feature 时连重绑列表都不含这两个名字。
B.6 延伸阅读与思考题
延伸阅读:第 6 章(58 个条目的能力域划分与注册分层)、第 7 章(group 策略判定、沙箱与 write_grant)、第 16 章(fleet 的 WorkerGrant 与本附录两张子表的关系)。
思考题:
group:delegated是一张 deny 表而非 allow 表(crates/octos-agent/src/tools/policy.rs:307-331),但它明确不限制命令执行。为什么"递归委派要断、命令执行不断"是自洽的边界选择?- coding.json 的 allow_list 只有 12 条却展开出 20 个名字。当你想给 lean profile 增删一个工具时,改 allow_list 条目和改
TOOL_GROUPS成员各会影响哪些下游? image_generation选择返回 typed unsupported 而不是从注册表移除。如果移除,前端合约校验和用户提示分别会损失什么?
版本演化说明 本章基线为 octos main @
9c157101(2026-09-03 实测)。数据源为assets/appendixB-facts.md(commitad387d1同批),行号均经本轮会话亲测复核。相对旧稿:旧版按固定数量罗列"核心内置工具"的口径已废弃,当前注册名总数 80(核心 60 + admin 20),能力域 10 个,P0 合约十项的 shim 归属、fleet 两张子表与 4 个 feature 门均为本轮新采集;bash/delegate入组(#1172)与image_generation的 typed unsupported(#1149)是旧稿成书后新发生的行为变化。
附录 C:配置参考
定位:这是
config.json与 profile 文件的完整字段参考——每个顶层配置节一张表,字段路径、类型、默认值、一句话作用、来源行号全部亲测自 octos9c157101(crates/octos-cli/src/config.rs共 3,790 行,crates/octos-cli/src/profiles.rs共 7,003 行)。功能语义(沙箱怎么选后端、MCP 怎么握手、goal 三档继承怎么运作)见对应章节,本附录只回答"这个字段是什么、默认多少、在哪定义"。与 Ch9(扩展机制)交叉的mcp_servers/sub_providers在这里只给字段表。
C.1 配置文件位置与加载顺序
顶层 config.json 的解析顺序(crates/octos-cli/src/config.rs:1715 resolve_config_file_path):显式 --config <FILE> 覆盖 →(默认安装下)项目本地 <cwd>/.octos/config.json → <config_home>/config.json →(默认安装下)legacy ~/.octos/config.json。无文件时写入位置回退到 config_home/config.json。
profile 文件位于 ~/.octos/profiles/<id>.json(crates/octos-cli/src/profiles.rs:5),数据目录默认 ~/.octos/profiles/{id}/data(crates/octos-cli/src/profiles.rs:164)。
C.2 顶层 Config(crates/octos-cli/src/config.rs:26)
| 字段路径 | 类型 | 默认值 | 作用 | 来源行号 |
|---|---|---|---|---|
version | u32? | null(常量当前为 1) | 配置迁移版本号 | crates/octos-cli/src/config.rs:29 |
provider | string? | null | LLM provider 名("anthropic" / "openai" / "gemini") | crates/octos-cli/src/config.rs:33 |
model | string? | null | 模型 ID | crates/octos-cli/src/config.rs:37 |
context_window | u32? | null | 主模型有效上下文窗口覆盖(tokens),最外层包裹,压过目录与探测 | crates/octos-cli/src/config.rs:46 |
model_temperature | f32? | null | 主模型 typed 采样温度投影(#2166) | crates/octos-cli/src/config.rs:56 |
model_top_p | f32? | null | 主模型 top_p 投影,运行时覆盖采样参数表同名键 | crates/octos-cli/src/config.rs:61 |
model_reasoning_effort | enum? | null | 主模型推理力度默认(low/medium/high) | crates/octos-cli/src/config.rs:65 |
base_url | string? | null | API endpoint 覆盖 | crates/octos-cli/src/config.rs:69 |
api_key_env | string? | provider 默认 | API key 环境变量名 | crates/octos-cli/src/config.rs:73 |
env_vars | map<string,string> | {} | profile 级环境变量(含 dashboard 持久化的密钥) | crates/octos-cli/src/config.rs:77 |
bypass_auth_store | bool | false | 内部字段(#[serde(skip)],不参与序列化):跳过全局 AuthStore 查密钥 | crates/octos-cli/src/config.rs:86 |
model_hints | object? | null | OpenAI 兼容代理自定义模型行为提示 | crates/octos-cli/src/config.rs:91 |
api_type | string? | provider 默认 | 协议覆盖("openai" / "anthropic") | crates/octos-cli/src/config.rs:97 |
auth_token | string? | null | dashboard/admin token;也可 --auth-token 或 OCTOS_AUTH_TOKEN | crates/octos-cli/src/config.rs:102 |
gateway | object? | null | gateway 模式配置(见 C.3) | crates/octos-cli/src/config.rs:106 |
mcp_servers | array | [] | MCP server 列表(见 C.9) | crates/octos-cli/src/config.rs:110 |
sandbox | object | SandboxConfig::default() | 工具沙箱(见 C.5) | crates/octos-cli/src/config.rs:114 |
snapshots | object? | null(=关) | 工作区快照-回滚(#1768,默认关) | crates/octos-cli/src/config.rs:123 |
tool_policy | object? | null | 全局工具 allow/deny/tag 策略(见 C.5) | crates/octos-cli/src/config.rs:127 |
tool_policy_by_provider | map<string,ToolPolicy> | {} | 按模型/provider 前缀的工具策略 | crates/octos-cli/src/config.rs:132 |
embedding | object? | null | hybrid memory embedding(见 C.6) | crates/octos-cli/src/config.rs:136 |
memory | object? | null | 记忆子系统(见 C.6) | crates/octos-cli/src/config.rs:140 |
fallback_models | array | [] | provider failover 链的 fallback 模型(见 C.4) | crates/octos-cli/src/config.rs:145 |
max_iterations | u32? | null | 单消息最大 agent 迭代数(--max-iterations 可覆盖) | crates/octos-cli/src/config.rs:149 |
format_after_edit | bool | false | 编辑成功后跑语言 formatter(rustfmt/prettier 等,5s 超时,#1774) | crates/octos-cli/src/config.rs:158 |
hooks | array | [] | 生命周期 hooks(见 C.7) | crates/octos-cli/src/config.rs:162 |
approval_policy | object? | null | 工具调用人工审批规则(见 C.7) | crates/octos-cli/src/config.rs:168 |
context_filter | string[] | [] | 工具 tag 过滤;仅匹配 tag 的工具对 LLM 可见 | crates/octos-cli/src/config.rs:173 |
sub_providers | array | [] | 子 agent 可选 provider 车道(见 C.10) | crates/octos-cli/src/config.rs:184 |
adaptive_routing | object? | null | 自适应路由(见 C.8) | crates/octos-cli/src/config.rs:189 |
email | object? | null | send_email 工具配置(见 C.6) | crates/octos-cli/src/config.rs:193 |
voice | object? | null | ASR/TTS(见 C.6) | crates/octos-cli/src/config.rs:198 |
mode | enum | "local" | 部署模式:local / tenant / cloud | crates/octos-cli/src/config.rs:206 |
tunnel_domain | string? | null | cloud/tenant tunnel 域名 | crates/octos-cli/src/config.rs:211 |
base_domain | string? | 兼容 crew.ominix.io | 公开 profile 域名基座(OCTOS_BASE_DOMAIN 优先) | crates/octos-cli/src/config.rs:222 |
frps_server | string? | null | frps server 地址(FRPS_SERVER env 可覆盖) | crates/octos-cli/src/config.rs:227 |
allow_admin_shell | bool | false | 是否开 /api/admin/shell;生产保持关闭 | crates/octos-cli/src/config.rs:233 |
dashboard_auth | object? | null | email OTP 登录(api feature,见 C.8) | crates/octos-cli/src/config.rs:239 |
monitor | object? | null | watchdog/alert(api feature,见 C.8) | crates/octos-cli/src/config.rs:244 |
credential_pool | object? | null | 密钥池(M6.5,见 C.8) | crates/octos-cli/src/config.rs:251 |
content_routing | object? | null | 内容分类 Cheap/Strong 路由(M6.6) | crates/octos-cli/src/config.rs:257 |
appui | object | AppUiConfig::default() | AppUI 会话默认(见 C.8) | crates/octos-cli/src/config.rs:269 |
plugins | object | PluginsConfig::default() | 插件签名校验开关(见 C.8) | crates/octos-cli/src/config.rs:277 |
cli | map<string,json> | {}(省略序列化) | 按子命令持久化 CLI flag 默认值,如 {"serve": {"port": 50080}} | crates/octos-cli/src/config.rs:292 |
mode 枚举定义在 crates/octos-cli/src/config.rs:14:Local(独立安装、无 tunnel、dashboard 在 /admin/)、Tenant(frpc tunnel 接入云)、Cloud(VPS 中继 + 租户管理 + 落地页)。
C.3 gateway 节(crates/octos-cli/src/config.rs:1524 GatewayConfig)
| 字段路径 | 类型 | 默认值 | 作用 | 来源行号 |
|---|---|---|---|---|
gateway.channels[] | array<ChannelEntry> | [{"type":"cli",…}] | 启用的频道列表 | crates/octos-cli/src/config.rs:1527 |
gateway.channels[].type | string | 必填 | 频道类型:"cli" / "telegram" / "discord" | crates/octos-cli/src/config.rs:1642 |
gateway.channels[].allowed_senders | string[] | [](= 允许所有人) | 允许的发送者 ID | crates/octos-cli/src/config.rs:1646 |
gateway.channels[].settings | json | {} | 频道特定设置 | crates/octos-cli/src/config.rs:1650 |
gateway.max_history | usize | 50 | 注入 LLM 的最大历史消息数 | crates/octos-cli/src/config.rs:1531 |
gateway.system_prompt | string? | null | gateway 模式自定义系统提示 | crates/octos-cli/src/config.rs:1535 |
gateway.queue_mode | enum | "collect" | 运行中收到的消息排队策略 | crates/octos-cli/src/config.rs:1541 |
gateway.max_sessions | usize | 1000 | 内存中最大会话数(LRU 淘汰) | crates/octos-cli/src/config.rs:1545 |
gateway.max_concurrent_sessions | usize | 10 | 最大并发会话处理数 | crates/octos-cli/src/config.rs:1549 |
gateway.browser_timeout_secs | u64? | null(代码内 300s) | 单个浏览器动作超时 | crates/octos-cli/src/config.rs:1554 |
gateway.llm_timeout_secs | u64? | null(默认 120) | LLM HTTP 请求总超时 | crates/octos-cli/src/config.rs:1558 |
gateway.llm_connect_timeout_secs | u64? | null(默认 30) | LLM HTTP 连接超时 | crates/octos-cli/src/config.rs:1562 |
gateway.tool_timeout_secs | u64? | null(默认 300) | 并行工具调用完成上限 | crates/octos-cli/src/config.rs:1566 |
gateway.session_timeout_secs | u64? | null(默认 600) | 单条会话消息处理上限 | crates/octos-cli/src/config.rs:1570 |
gateway.max_output_tokens | u32? | null | 每次调用默认最大输出 tokens(压过 model_limits.json) | crates/octos-cli/src/config.rs:1575 |
gateway.reasoning_effort | enum? | null | 思考模型推理力度(low/medium/high),非思考模型忽略 | crates/octos-cli/src/config.rs:1582 |
gateway.llm_temperature | f32? | null(= greedy 0.0) | 采样温度覆盖,主要救本地模型重复坍缩(#2172) | crates/octos-cli/src/config.rs:1592 |
gateway.llm_sampling_params | map<string,json>? | null | 透传采样参数表,如 {"repeat_penalty":1.1}(#2172) | crates/octos-cli/src/config.rs:1600 |
queue_mode 枚举(crates/octos-cli/src/config.rs:1499):followup(FIFO 逐条)、collect(默认,同会话拼接)、latest(只留最新,别名 steer)、interrupt(取消当前)、speculative(慢调用并发起新 agent)。
octos serve 的默认端口是 50080(crates/octos-cli/src/config.rs:283 处文档示例)。
C.4 fallback_models[](crates/octos-cli/src/config.rs:460 FallbackModel)
| 字段路径 | 类型 | 默认值 | 作用 | 来源行号 |
|---|---|---|---|---|
provider | string | 必填 | provider 名 | crates/octos-cli/src/config.rs:462 |
model | string? | null | 模型名 | crates/octos-cli/src/config.rs:465 |
base_url | string? | null | 该 fallback 的 base URL | crates/octos-cli/src/config.rs:468 |
api_key_env | string? | null | 该 fallback 的密钥环境变量 | crates/octos-cli/src/config.rs:471 |
model_hints | object? | null | 模型行为提示 | crates/octos-cli/src/config.rs:474 |
api_type | string? | null | 协议覆盖 | crates/octos-cli/src/config.rs:477 |
cost_per_m | f64? | null | 每百万 token 输出价格(成本感知路由) | crates/octos-cli/src/config.rs:480 |
strong | bool | true | 是否强模型(30+ 工具/大 payload 可靠) | crates/octos-cli/src/config.rs:485 |
context_window | u32? | null | 该 fallback 的上下文窗口覆盖(#2142) | crates/octos-cli/src/config.rs:492 |
C.5 sandbox / tool_policy / snapshots
sandbox(crates/octos-agent/src/sandbox/mod.rs:37 SandboxConfig)
| 字段路径 | 类型 | 默认值 | 作用 | 来源行号 |
|---|---|---|---|---|
sandbox.enabled | bool | true | 是否启用沙箱 | crates/octos-agent/src/sandbox/mod.rs:40 |
sandbox.mode | enum | "auto" | auto / bwrap / landlock / macos / docker / appcontainer / none | crates/octos-agent/src/sandbox/mod.rs:44、枚举 crates/octos-agent/src/sandbox/mod.rs:423 |
sandbox.fail_closed | bool | false | auto 无后端时拒绝执行而非降级裸跑 | crates/octos-agent/src/sandbox/mod.rs:58 |
sandbox.allow_network | bool | false | 沙箱内放行网络 | crates/octos-agent/src/sandbox/mod.rs:62 |
sandbox.workspace_write | bool | true | shell 是否可写 cwd(false = 工作区只读挂载) | crates/octos-agent/src/sandbox/mod.rs:75 |
sandbox.repo_git_write | path? | null | 额外授予 <repo>/.git 写权限 | crates/octos-agent/src/sandbox/mod.rs:92 |
sandbox.docker.image | string | "ubuntu:24.04" | docker 模式镜像 | crates/octos-agent/src/sandbox/mod.rs:361 |
sandbox.docker.cpu_limit | string? | null | CPU 限制如 "1.0" | crates/octos-agent/src/sandbox/mod.rs:365 |
sandbox.docker.memory_limit | string? | null | 内存限制如 "512m" | crates/octos-agent/src/sandbox/mod.rs:369 |
sandbox.docker.pids_limit | u32? | null | 最大进程数 | crates/octos-agent/src/sandbox/mod.rs:373 |
sandbox.docker.mount_mode | enum | "rw" | none / ro / rw 工作区挂载 | crates/octos-agent/src/sandbox/mod.rs:377、枚举 crates/octos-agent/src/sandbox/mod.rs:408 |
sandbox.docker.extra_binds | string[] | [] | 额外 bind 挂载(host:container[:ro]) | crates/octos-agent/src/sandbox/mod.rs:381 |
sandbox.read_allow_paths | string[] | [](= 全放行) | 限制可读路径(cwd 之外白名单) | crates/octos-agent/src/sandbox/mod.rs:102 |
sandbox.write_allow_globs | string[]? | null | #1976 shell 写围栏:工作区相对 glob | crates/octos-agent/src/sandbox/mod.rs:125 |
sandbox.profile_name | string? | null | 沙箱 profile 名(Windows AppContainer ID) | crates/octos-agent/src/sandbox/mod.rs:129 |
sandbox.allow_toolchains | bool | true | 授予语言工具链必需写路径(cargo 缓存等) | crates/octos-agent/src/sandbox/mod.rs:149 |
tool_policy(crates/octos-agent/src/tools/policy.rs:28 ToolPolicy)
| 字段路径 | 类型 | 默认值 | 作用 | 来源行号 |
|---|---|---|---|---|
tool_policy.allow | string[] | [](= 全允许) | 允许的工具/组/通配符 | crates/octos-agent/src/tools/policy.rs:31 |
tool_policy.deny | string[] | [] | 拒绝列表,永远压过 allow | crates/octos-agent/src/tools/policy.rs:34 |
tool_policy.require_tags | string[] | [] | 只暴露声明匹配 tag 的工具(fail closed) | crates/octos-agent/src/tools/policy.rs:40 |
tool_policy.bash_file_writes | enum | "allow" | allow / warn / deny:shell 写文件三档 | crates/octos-agent/src/tools/policy.rs:49 |
tool_policy_by_provider 为同结构 map(crates/octos-cli/src/config.rs:132),键为精确 model ID(优先)或 provider 前缀。
snapshots(crates/octos-agent/src/snapshot.rs:122 SnapshotConfig)
| 字段路径 | 类型 | 默认值 | 作用 | 来源行号 |
|---|---|---|---|---|
snapshots.enabled | bool | false | 快照总开关(git-backed,存 <data_dir>/snapshots/) | crates/octos-agent/src/snapshot.rs:124 |
snapshots.keep_last | usize | 20 | 每工作区保留份数(<1 钳到 1) | crates/octos-agent/src/snapshot.rs:127 |
C.6 embedding / memory / email / voice
embedding(crates/octos-cli/src/config.rs:655 EmbeddingConfig)
| 字段路径 | 类型 | 默认值 | 作用 | 来源行号 |
|---|---|---|---|---|
embedding.provider | string | "openai" | embedding provider(含本地 llamacpp) | crates/octos-cli/src/config.rs:658 |
embedding.api_key_env | string? | null | 密钥环境变量名 | crates/octos-cli/src/config.rs:662 |
embedding.base_url | string? | null | API base URL | crates/octos-cli/src/config.rs:666 |
embedding.model | string? | text-embedding-3-small | embedding 模型 ID | crates/octos-cli/src/config.rs:672 |
embedding.dimensions | u32? | null | 请求维度字段(episodic HNSW 固定 1536) | crates/octos-cli/src/config.rs:678 |
embedding.model_path | string? | null | 本地 .gguf 路径(feature embed-llama) | crates/octos-cli/src/config.rs:685 |
memory(crates/octos-cli/src/config.rs:694 MemoryConfig)
| 字段路径 | 类型 | 默认值 | 作用 | 来源行号 |
|---|---|---|---|---|
memory.max_inject_tokens | usize? | DEFAULT_MAX_INJECT_TOKENS | 注入系统提示的记忆块 token 预算 | crates/octos-cli/src/config.rs:701 |
memory.refresh.enabled | bool? | null(= 开) | 记忆自动刷新总开关(默认产品行为为开) | crates/octos-cli/src/config.rs:719 |
memory.refresh.extract_model | string? | null(= profile provider) | 抽取模型键 | crates/octos-cli/src/config.rs:723 |
memory.refresh.consolidate_model | string? | null(= profile provider) | 整合模型键 | crates/octos-cli/src/config.rs:726 |
memory.refresh.min_idle_minutes | u64? | 30 | 会话空闲多久才可被扫 | crates/octos-cli/src/config.rs:729 |
memory.refresh.max_session_age_days | u64? | 10 | 超龄会话不再扫 | crates/octos-cli/src/config.rs:732 |
memory.refresh.max_sessions_per_pass | usize? | 2 | 每轮扫的会话数 | crates/octos-cli/src/config.rs:735 |
memory.refresh.max_extractions_per_day | u32? | 20 | 每 profile 日抽取预算 | crates/octos-cli/src/config.rs:738 |
memory.refresh.max_consolidations_per_day | u32? | 12 | 每 profile 日整合预算 | crates/octos-cli/src/config.rs:742 |
memory.refresh.max_daily_tokens | u64? | 200000 | 日 token 预算(抽取+整合共享) | crates/octos-cli/src/config.rs:745 |
memory.refresh.consolidate_interval_minutes | u64? | 30 | 后台扫描间隔(分钟) | crates/octos-cli/src/config.rs:748 |
memory.refresh.debounce_seconds | u64? | 90 | 用户笔记后的快车道防抖 | crates/octos-cli/src/config.rs:751 |
memory.refresh.unused_days | u32? | null | 闲置多久成为归档候选 | crates/octos-cli/src/config.rs:755 |
memory.refresh.max_memory_file_tokens | usize? | null | MEMORY.md 大小上限(整合时执行) | crates/octos-cli/src/config.rs:758 |
memory.refresh.pending_confirm_days | u32? | null | 待确认遗忘请求过期天数 | crates/octos-cli/src/config.rs:762 |
memory.refresh.max_extract_input_tokens | usize? | 24000 | 单次抽取调用输入硬预算 | crates/octos-cli/src/config.rs:767 |
设计默认值来自 crates/octos-cli/src/config.rs:772 MemoryRefreshConfig::knobs。
email(crates/octos-cli/src/config.rs:856 EmailConfig)
| 字段路径 | 类型 | 默认值 | 作用 | 来源行号 |
|---|---|---|---|---|
email.provider | string | 必填 | "smtp" 或 "feishu"/"lark" | crates/octos-cli/src/config.rs:858 |
email.smtp_host | string? | null | SMTP 主机 | crates/octos-cli/src/config.rs:862 |
email.smtp_port | u16? | null | SMTP 端口 | crates/octos-cli/src/config.rs:864 |
email.username | string? | null | SMTP 用户名 | crates/octos-cli/src/config.rs:866 |
email.password_env | string? | null | SMTP 密码环境变量(legacy) | crates/octos-cli/src/config.rs:869 |
email.password | string? | null | SMTP 密码明文(优先于 password_env) | crates/octos-cli/src/config.rs:872 |
email.from_address | string? | null | 发件人地址 | crates/octos-cli/src/config.rs:874 |
email.feishu_app_id | string? | null | Feishu app ID | crates/octos-cli/src/config.rs:878 |
email.feishu_app_secret_env | string? | null | Feishu secret 环境变量(legacy) | crates/octos-cli/src/config.rs:881 |
email.feishu_app_secret | string? | null | Feishu secret 明文(优先) | crates/octos-cli/src/config.rs:884 |
email.feishu_from_address | string? | null | Feishu 发件人 | crates/octos-cli/src/config.rs:886 |
email.feishu_region | string? | "cn" | "cn" / "global" | crates/octos-cli/src/config.rs:889 |
voice(crates/octos-cli/src/config.rs:936 VoiceConfig)
| 字段路径 | 类型 | 默认值 | 作用 | 来源行号 |
|---|---|---|---|---|
voice.api_url | string? | —(legacy,忽略) | OminiX URL 已改为平台级 OMINIX_API_URL | crates/octos-cli/src/config.rs:939 |
voice.auto_asr | bool | true | 自动转写语音消息 | crates/octos-cli/src/config.rs:942 |
voice.auto_tts | bool | true | 语音会话自动合成回复 | crates/octos-cli/src/config.rs:945 |
voice.default_voice | string | "vivian" | 默认 TTS 音色 | crates/octos-cli/src/config.rs:948 |
voice.asr_language | string? | null(自动检测) | ASR 语言提示 | crates/octos-cli/src/config.rs:951 |
voice.tts_provider | string | "auto" | TTS 路由:auto / local / cloud | crates/octos-cli/src/config.rs:964 |
voice.cloud | object? | null | Volcano 云 TTS 非密钥设置 | crates/octos-cli/src/config.rs:969 |
voice.cloud.appid | string? | null | 云 TTS app ID | crates/octos-cli/src/config.rs:904 |
voice.cloud.voice | string? | null | 云 TTS 音色 | crates/octos-cli/src/config.rs:906 |
voice.cloud.cluster | string? | null | 云 TTS 集群 | crates/octos-cli/src/config.rs:908 |
voice.cloud.encoding | string? | null | 云 TTS 编码 | crates/octos-cli/src/config.rs:910 |
voice.cloud.endpoint | string? | null | 云 TTS endpoint | crates/octos-cli/src/config.rs:912 |
云 TTS 密钥永不落此节:token 走 env_vars["VOLC_TTS_TOKEN"](crates/octos-cli/src/config.rs:1040 with_cloud_token_from_env)。
C.7 hooks 与 approval_policy
hooks[](crates/octos-agent/src/hooks.rs:77 HookConfig)
| 字段路径 | 类型 | 默认值 | 作用 | 来源行号 |
|---|---|---|---|---|
hooks[].event | enum | 必填 | 触发事件(见下) | crates/octos-agent/src/hooks.rs:79 |
hooks[].command | string[] | 必填 | argv 数组直执行(无 shell 解释) | crates/octos-agent/src/hooks.rs:81 |
hooks[].timeout_ms | u64 | 5000 | 超时毫秒数 | crates/octos-agent/src/hooks.rs:84、默认 crates/octos-agent/src/hooks.rs:112 |
hooks[].tool_filter | string[] | [](= 全部工具) | 仅这些工具名触发(工具事件) | crates/octos-agent/src/hooks.rs:87 |
hooks[].path_filter | string[] | [](= 不过滤) | 工具参数 path 匹配 glob 才触发 | crates/octos-agent/src/hooks.rs:103 |
hooks[].requires_bin | string? | null | PATH 上须存在此二进制才触发 | crates/octos-agent/src/hooks.rs:109 |
event 可取(crates/octos-agent/src/hooks.rs:58 as_str):user_prompt_submit、before_tool_call、after_tool_call、before_llm_call、after_llm_call、on_resume、on_turn_end、before_spawn_verify、on_spawn_verify、on_spawn_complete、on_spawn_failure。
approval_policy(crates/octos-cli/src/config.rs:545 ApprovalPolicyConfig)
| 字段路径 | 类型 | 默认值 | 作用 | 来源行号 |
|---|---|---|---|---|
approval_policy.default | enum | "allow" | 未匹配规则的工具的处置(v1 仅 allow) | crates/octos-cli/src/config.rs:547 |
approval_policy.rules[] | array | [] | 人工审批规则列表 | crates/octos-cli/src/config.rs:549 |
approval_policy.rules[].tools | string[] | 必填非空 | 本规则拦截的工具名 | crates/octos-cli/src/config.rs:530 |
approval_policy.rules[].require_approval | bool | 必须为 true | 显式意图声明 | crates/octos-cli/src/config.rs:532 |
approval_policy.rules[].risk_level | enum | — | normal / critical | crates/octos-cli/src/config.rs:533 |
approval_policy.rules[].authorized_approvers | string[] | 必填非空 | 有权答复的频道用户 ID | crates/octos-cli/src/config.rs:535 |
approval_policy.rules[].expires_in_secs | u64 | 必填 >0 | 请求过期秒数 | crates/octos-cli/src/config.rs:537 |
approval_policy.rules[].on_timeout | enum | — | v1 仅 notify | crates/octos-cli/src/config.rs:538 |
校验逻辑在 crates/octos-cli/src/config.rs:587 validate:非空 tools、require_approval == true、非空 approvers、expires_in_secs > 0,否则 fail fast。
C.8 自适应路由与运维节
adaptive_routing(crates/octos-cli/src/config.rs:1085 AdaptiveRoutingConfig)
| 字段路径 | 类型 | 默认值 | 作用 | 来源行号 |
|---|---|---|---|---|
adaptive_routing.enabled | bool | false | 总开关 | crates/octos-cli/src/config.rs:1088 |
adaptive_routing.latency_threshold_ms | u64 | 10000 | 软惩罚延迟阈值 | crates/octos-cli/src/config.rs:1092、默认 crates/octos-cli/src/config.rs:1253 |
adaptive_routing.error_rate_threshold | f64 | 0.3 | 去优先级错误率阈值 | crates/octos-cli/src/config.rs:1096、默认 crates/octos-cli/src/config.rs:1256 |
adaptive_routing.probe_probability | f64 | 0.1 | 探测非主 provider 概率 | crates/octos-cli/src/config.rs:1100、默认 crates/octos-cli/src/config.rs:1259 |
adaptive_routing.probe_interval_secs | u64 | 60 | 同 provider 探测最小间隔 | crates/octos-cli/src/config.rs:1104、默认 crates/octos-cli/src/config.rs:1262 |
adaptive_routing.failure_threshold | u32 | 3 | 熔断开启的连续失败数 | crates/octos-cli/src/config.rs:1108、默认 crates/octos-cli/src/config.rs:1265 |
adaptive_routing.mode | enum | "off" | off / hedge / lane | crates/octos-cli/src/config.rs:1114、枚举 crates/octos-cli/src/config.rs:1053 |
adaptive_routing.qos_ranking | bool | false | 评分计入响应质量 | crates/octos-cli/src/config.rs:1120 |
adaptive_routing.weight_latency | f64 | 0.3 | 延迟评分权重 | crates/octos-cli/src/config.rs:1124、默认 crates/octos-cli/src/config.rs:1268 |
adaptive_routing.weight_error_rate | f64 | 0.3 | 错误率权重 | crates/octos-cli/src/config.rs:1127、默认 crates/octos-cli/src/config.rs:1271 |
adaptive_routing.weight_priority | f64 | 0.2 | 配置优先级权重 | crates/octos-cli/src/config.rs:1130、默认 crates/octos-cli/src/config.rs:1274 |
adaptive_routing.weight_cost | f64 | 0.2 | token 成本权重 | crates/octos-cli/src/config.rs:1133、默认 crates/octos-cli/src/config.rs:1277 |
adaptive_routing.auto_escalation.enabled | bool | true | 延迟劣化自动升 hedge | crates/octos-cli/src/config.rs:1169+、默认 crates/octos-cli/src/config.rs:1214 |
adaptive_routing.auto_escalation.window_size | usize | 5 | 观测窗口样本数 | crates/octos-cli/src/config.rs:1217 |
adaptive_routing.auto_escalation.baseline_samples | usize | 5 | 基线样本数 | crates/octos-cli/src/config.rs:1220 |
adaptive_routing.auto_escalation.degradation_threshold | f64 | 3.0 | 劣化判定倍数 | crates/octos-cli/src/config.rs:1223 |
adaptive_routing.auto_escalation.slow_trigger | u32 | 3 | 连续慢样本触发数 | crates/octos-cli/src/config.rs:1226 |
adaptive_routing.auto_escalation.latency_ceiling_ms | u64 | 8000 | 延迟上限 | crates/octos-cli/src/config.rs:1229 |
adaptive_routing.auto_escalation.recovery_factor | f64 | 0.6 | 恢复因子 | crates/octos-cli/src/config.rs:1232 |
monitor(crates/octos-cli/src/config.rs:1284 MonitorConfig,api feature)
| 字段路径 | 类型 | 默认值 | 作用 | 来源行号 |
|---|---|---|---|---|
monitor.alerts_enabled | bool | true | 主动告警开关 | crates/octos-cli/src/config.rs:1287 |
monitor.watchdog_enabled | bool | true | watchdog 自动重启 | crates/octos-cli/src/config.rs:1290 |
monitor.health_check_interval_secs | u64 | 60 | 健康检查间隔 | crates/octos-cli/src/config.rs:1293 |
monitor.max_restart_attempts | u32 | 3 | 放弃前最大重启次数 | crates/octos-cli/src/config.rs:1296 |
monitor.telegram_token_env | string? | null | Telegram bot token 环境变量名 | crates/octos-cli/src/config.rs:1299 |
monitor.telegram_alert_chat_ids | i64[] | [] | 告警 Telegram chat ID | crates/octos-cli/src/config.rs:1302 |
monitor.feishu_app_id_env | string? | null | Feishu app ID 环境变量 | crates/octos-cli/src/config.rs:1305 |
monitor.feishu_app_secret_env | string? | null | Feishu secret 环境变量 | crates/octos-cli/src/config.rs:1308 |
monitor.feishu_alert_user_ids | string[] | [] | 告警 Feishu 用户 ID | crates/octos-cli/src/config.rs:1311 |
dashboard_auth(crates/octos-cli/src/otp.rs:46 DashboardAuthConfig)
| 字段路径 | 类型 | 默认值 | 作用 | 来源行号 |
|---|---|---|---|---|
dashboard_auth.smtp.host | string | 必填 | SMTP 主机 | crates/octos-cli/src/otp.rs:23 |
dashboard_auth.smtp.port | u16 | 465 | SMTP 端口(465 隐式 TLS / 587 STARTTLS) | crates/octos-cli/src/otp.rs:26、默认 crates/octos-cli/src/otp.rs:40 |
dashboard_auth.smtp.username | string | 必填 | SMTP 用户名 | crates/octos-cli/src/otp.rs:28 |
dashboard_auth.smtp.password_env | string | 必填 | SMTP 密码环境变量(legacy,新装走 smtp_secret.json) | crates/octos-cli/src/otp.rs:35 |
dashboard_auth.smtp.from_address | string | 必填 | 发件人 | crates/octos-cli/src/otp.rs:37 |
dashboard_auth.session_expiry_hours | u64 | 24 | 会话过期小时数 | crates/octos-cli/src/otp.rs:59 |
dashboard_auth.allow_self_registration | bool | false | 未知邮箱自动建号 | crates/octos-cli/src/otp.rs:62 |
dashboard_auth.static_tokens | string[] | [] | 绕过 OTP 的静态 token(E2E 用) | crates/octos-cli/src/otp.rs:67 |
dashboard_auth.smtp 整体可省(crates/octos-cli/src/otp.rs:56),缺省时 OTP 走控制台输出。
credential_pool(crates/octos-cli/src/config.rs:415 CredentialPoolConfig)
| 字段路径 | 类型 | 默认值 | 作用 | 来源行号 |
|---|---|---|---|---|
credential_pool.state_path | string? | <data_dir>/credential_pool.redb | 持久状态文件覆盖 | crates/octos-cli/src/config.rs:419 |
credential_pool.name | string | "default" | 指标标签用池名 | crates/octos-cli/src/config.rs:423 |
credential_pool.strategy | string | "round_robin" | fill_first/round_robin/random/least_used | crates/octos-cli/src/config.rs:427 |
credential_pool.credential_ids | string[] | [] | 池内 credential id(运行时配 env_vars 密钥) | crates/octos-cli/src/config.rs:431 |
credential_pool.default_cooldown_ms | u64? | null | 429 无 reset 提示时的默认冷却 | crates/octos-cli/src/config.rs:435 |
appui(crates/octos-cli/src/config.rs:339 AppUiConfig)与 plugins(crates/octos-cli/src/config.rs:315 PluginsConfig)
| 字段路径 | 类型 | 默认值 | 作用 | 来源行号 |
|---|---|---|---|---|
appui.allowed_origins | string[] | [] | 追加允许的 REST/WS 浏览器 origin(精确 http(s)://) | crates/octos-cli/src/config.rs:348 |
appui.default_session_cwd | path? | null | AppUI 会话未声明 cwd capability 时的 Tier-2 回退目录(须绝对路径) | crates/octos-cli/src/config.rs:360 |
appui.sessions_in_cwd | bool | true | 会话存储按项目目录(<cwd>/.octos/sessions/) | crates/octos-cli/src/config.rs:388 |
plugins.require_signed | bool | false | 强制插件 manifest.sha256 签名(默认警告放行) | crates/octos-cli/src/config.rs:325 |
C.9 mcp_servers[] 专节(crates/octos-agent/src/mcp.rs:53 McpServerConfig)
字段表见下;协议行为(握手、DNS 防回环、OAuth 流程)详见 Ch9,此处不重复。
| 字段路径 | 类型 | 默认值 | 作用 | 来源行号 |
|---|---|---|---|---|
mcp_servers[].command | string? | null | stdio 传输:要 spawn 的命令 | crates/octos-agent/src/mcp.rs:56 |
mcp_servers[].args | string[] | [] | stdio 传输:命令参数 | crates/octos-agent/src/mcp.rs:58 |
mcp_servers[].env | map<string,string> | {} | stdio 传输:子进程环境变量 | crates/octos-agent/src/mcp.rs:60 |
mcp_servers[].url | string? | null | HTTP 传输:server endpoint URL | crates/octos-agent/src/mcp.rs:63 |
mcp_servers[].headers | map<string,string> | {} | HTTP 传输:附加头(如静态 bearer);设 oauth 时忽略 | crates/octos-agent/src/mcp.rs:67 |
mcp_servers[].oauth | bool | false | 对该 server 走 OAuth 2.1 授权码流(须 url) | crates/octos-agent/src/mcp.rs:72 |
mcp_servers[].scopes | string[] | [] | octos mcp login 时请求的 OAuth scope | crates/octos-agent/src/mcp.rs:75 |
mcp_servers[].concurrency_class | string? | null(= safe) | 该 server 全部工具的并发类:safe/exclusive;未知值保守取 exclusive | crates/octos-agent/src/mcp.rs:84 |
不可配置的硬限制(常量):握手超时 30s(crates/octos-agent/src/mcp.rs:43)、单次 tools/call 60s(crates/octos-agent/src/mcp.rs:45)、工具输入 schema 嵌套 ≤10 层(crates/octos-agent/src/mcp.rs:47)、schema 序列化 ≤64KB(crates/octos-agent/src/mcp.rs:49)。
C.10 sub_providers[] 专节(crates/octos-cli/src/config.rs:618 SubProviderConfig)
spawn 子 agent 可引用的模型车道。保留键:goal_verifier(#1935):存在时该 lane 成为独立的 goal 完成校验模型,否则回退到旧行为(用评分会话自己的 provider 校验)。与 Ch9 的分工:那边讲 spawn 工具如何消费这些车道,这里只给字段。
| 字段路径 | 类型 | 默认值 | 作用 | 来源行号 |
|---|---|---|---|---|
sub_providers[].key | string | 必填 | LLM 引用的短键(如 cheap / strong / goal_verifier) | crates/octos-cli/src/config.rs:620 |
sub_providers[].provider | string | 必填 | provider 名 | crates/octos-cli/src/config.rs:622 |
sub_providers[].model | string? | null | 模型名 | crates/octos-cli/src/config.rs:625 |
sub_providers[].api_key_env | string? | provider 默认 | 密钥环境变量名 | crates/octos-cli/src/config.rs:629 |
sub_providers[].base_url | string? | null | base URL | crates/octos-cli/src/config.rs:632 |
sub_providers[].description | string? | null | 何时用此模型的说明(进 spawn 工具 schema) | crates/octos-cli/src/config.rs:636 |
sub_providers[].default_context_window | u32? | null | 选中时子 agent 的默认上下文预算 | crates/octos-cli/src/config.rs:642 |
sub_providers[].max_output_tokens | u32? | 自动检测 | 每次调用最大输出 tokens 覆盖 | crates/octos-cli/src/config.rs:647 |
sub_providers[].api_type | string? | provider 默认 | 协议覆盖 | crates/octos-cli/src/config.rs:650 |
C.11 validators(声明式校验器,workspace policy 层)
validators 不在 config.json 里——它是工作区策略文件 .octos-workspace.toml(crates/octos-agent/src/workspace_policy.rs:13 WORKSPACE_POLICY_FILE)中 validation 节的子键。顶层策略结构 WorkspacePolicy 在 crates/octos-agent/src/workspace_policy.rs:22;validation 节(crates/octos-agent/src/workspace_policy.rs:115 ValidationPolicy):
| 字段路径 | 类型 | 默认值 | 作用 | 来源行号 |
|---|---|---|---|---|
validation.on_turn_end | string[] | [] | Tier 1:每轮末跑的廉价检查(<100ms) | crates/octos-agent/src/workspace_policy.rs:118 |
validation.on_source_change | string[] | [] | Tier 2:源码变更时跑的中档检查(1-5s) | crates/octos-agent/src/workspace_policy.rs:121 |
validation.on_completion | string[] | [] | Tier 3:完成/发布才跑的昂贵检查(10-30s) | crates/octos-agent/src/workspace_policy.rs:124 |
validation.validators[] | array<Validator> | [] | 类型化声明式校验器(M4.3) | crates/octos-agent/src/workspace_policy.rs:127 |
每个 Validator(crates/octos-agent/src/workspace_policy.rs:143):
| 字段路径 | 类型 | 默认值 | 作用 | 来源行号 |
|---|---|---|---|---|
validators[].id | string | 必填 | 稳定唯一标识 | crates/octos-agent/src/workspace_policy.rs:145 |
validators[].required | bool | true | 失败是否阻断终态成功 | crates/octos-agent/src/workspace_policy.rs:149 |
validators[].soft_fail | bool | false | true 时失败只告警不降级(Wave-3a 软档) | crates/octos-agent/src/workspace_policy.rs:157 |
validators[].timeout_ms | u64? | null | 单校验器超时(命令/工具类) | crates/octos-agent/src/workspace_policy.rs:161 |
validators[].phase | enum | "completion" | turn_end / completion | crates/octos-agent/src/workspace_policy.rs:164、枚举 crates/octos-agent/src/workspace_policy.rs:290 |
validators[].spec | enum(tagged kind) | 必填 | 校验体,见下 | crates/octos-agent/src/workspace_policy.rs:166 |
spec 变体(crates/octos-agent/src/workspace_policy.rs:301 ValidatorSpec,serde tag kind,snake_case):
command:cmd(必填)、args(默认[]):子进程命令,走 shell 安全层(crates/octos-agent/src/workspace_policy.rs:306)tool_call:tool(必填)、args(默认{}):调用注册的 agent 工具(crates/octos-agent/src/workspace_policy.rs:313)file_exists:path(必填)、min_bytes(默认null):断言文件存在/最小字节数(crates/octos-agent/src/workspace_policy.rs:318)http_probe:url_template(必填)、expected_status(默认200)、expected_contains(默认null):HTTP 探测,默认 5s 超时(crates/octos-agent/src/workspace_policy.rs:325)ominix_voice_exists:name_arg(必填):查 ominix-api 已注册自定义音色(crates/octos-agent/src/workspace_policy.rs:336)audio_non_silent:glob(默认"")、min_ratio(默认0.3)、source(默认 glob 模式)、extension(默认null):音频非静音比例(crates/octos-agent/src/workspace_policy.rs:344)per_file_non_silent:整文件逐一非静音校验,require_at_least计数(crates/octos-agent/src/workspace_policy.rs:368附近)- 魔数断言
MagicByteKind:mp3/wav/png/jpeg/pdf/mp4/webm/pptx(crates/octos-agent/src/workspace_policy.rs:469)
运行时常量:命令类默认超时 30s(crates/octos-agent/src/validators.rs:55 DEFAULT_COMMAND_TIMEOUT_MS)、HTTP 探测默认 5s(crates/octos-agent/src/validators.rs:60 DEFAULT_HTTP_PROBE_TIMEOUT_MS)、证据文件在 <workspace_root>/.octos/validator-evidence/(crates/octos-agent/src/validators.rs:53)、单条证据 ≤512KB(crates/octos-agent/src/validators.rs:62 MAX_EVIDENCE_BYTES)。
C.12 profile 体系简表
profile 文件 ~/.octos/profiles/<id>.json 的 config.llm 是一等结构化 LLM 选择契约,不是顶层 config.json 那种 provider/model 扁平键。三层结构:
config.llm(crates/octos-cli/src/profiles.rs:814 LlmProfileConfig)
| 字段 | 类型 | 默认 | 作用 |
|---|---|---|---|
primary | LlmModelSelectionConfig? | null | 主模型选择 |
fallbacks | array | [] | 备选模型链 |
config.llm.primary / 每个 fallback(crates/octos-cli/src/profiles.rs:824 LlmModelSelectionConfig,#[serde(deny_unknown_fields)])
| 字段 | 类型 | 默认 | 作用 |
|---|---|---|---|
family_id | string? | null | 模型家族(如 moonshot / deepseek) |
model_id | string? | null | 具体模型 ID(如 kimi-k2.5) |
route | LlmRouteConfig? | null | 该模型的 provider 路由 |
model_hints | object? | null | 自定义/代理模型行为提示 |
cost_per_m | f64? | null | 每百万 token 输出价格(路由用) |
strong | bool? | null | 大工具量任务是否可靠 |
temperature | f32? | null | typed 采样温度(#2166,AppUI 校验 0.0..=2.0) |
top_p | f32? | null | nucleus 采样(#2166,校验 0.0..=1.0) |
reasoning_effort | enum? | null | 思考模型默认推理力度(#2166) |
context_window | u32? | null | 上下文窗口覆盖(#2135/#2142,压过探测与目录) |
config.llm.primary.route(crates/octos-cli/src/profiles.rs:881 LlmRouteConfig)
| 字段 | 类型 | 默认 | 作用 |
|---|---|---|---|
route_id | string? | null | 目录中的稳定路由 ID(如 official / wisemodel) |
label | string? | null | 人读路由标签 |
base_url | string? | null | 该路由 endpoint |
api_key_env | string? | null | 该路由密钥环境变量 |
api_type | string? | null | 协议覆盖 |
三档继承(crates/octos-cli/src/profiles.rs:2403 resolve_effective_profile):子 profile 带 parent_id 时,父的 LLM 契约整体覆盖继承(ec.llm = pc.llm),review/search/deep_crawl/apps/email/tool_policy 等结构节则仅当子级为空时继承;无 parent 则原样返回。这是 goal 三档(operator 默认 → 队列 → worker 覆盖)中 profile 侧的机制,完整三档语义见 Ch15。
最小可用 profile JSON 示例(密钥只出现环境变量名,无任何密钥值):
{
"id": "writer",
"display_name": "Writer",
"config": {
"llm": {
"primary": {
"family_id": "deepseek",
"model_id": "deepseek-v4-pro",
"route": {
"route_id": "wisemodel",
"base_url": "https://api.wisemodel.cn/v1",
"api_key_env": "WISEMODEL_API_KEY"
},
"temperature": 0.3,
"context_window": 131072
},
"fallbacks": [
{
"family_id": "moonshot",
"model_id": "kimi-k2.5",
"route": { "api_key_env": "MOONSHOT_API_KEY" },
"strong": true
}
]
},
"memory": { "max_inject_tokens": 4000 }
}
}
C.13 完整 config.json 示例(JSONC 注释)
{
// 迁移版本,当前 1
"version": 1,
// 主 LLM(扁平键;serve 多租户请用 profile 的 config.llm)
"provider": "anthropic",
"model": "claude-sonnet-4",
"api_key_env": "ANTHROPIC_API_KEY",
"env_vars": {},
"context_window": 200000, // #2142 覆盖目录/探测
// failover 链
"fallback_models": [
{ "provider": "openai", "model": "gpt-4o", "strong": true, "cost_per_m": 2.5 }
],
// 会话与工具
"max_iterations": 50,
"format_after_edit": true, // #1774 编辑后自动格式化
"context_filter": [],
"tool_policy": { "deny": [], "allow": [] },
"sandbox": {
"enabled": true,
"mode": "auto",
"allow_network": false,
"workspace_write": true
},
"snapshots": { "enabled": false, "keep_last": 20 },
// MCP server(见 C.9;协议细节见 Ch9)
"mcp_servers": [
{
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
"env": {},
"concurrency_class": "safe"
}
],
// 子 agent 车道(见 C.10;goal_verifier 为保留键)
"sub_providers": [
{ "key": "cheap", "provider": "openai", "model": "gpt-4o-mini", "api_key_env": "OPENAI_API_KEY" }
],
// 生命周期 hooks(C.7)
"hooks": [
{ "event": "after_tool_call", "command": ["cargo", "check"], "timeout_ms": 5000, "tool_filter": ["edit_file"], "path_filter": [] }
],
// gateway(C.3)
"gateway": {
"channels": [{ "type": "cli", "allowed_senders": [], "settings": {} }],
"max_history": 50,
"queue_mode": "collect",
"llm_timeout_secs": 120
},
// 自适应路由(C.8)
"adaptive_routing": { "enabled": false, "mode": "off" },
// 记忆与 embedding(C.6)
"memory": { "max_inject_tokens": 4000, "refresh": { "enabled": true } },
"embedding": { "provider": "openai", "model": "text-embedding-3-small", "api_key_env": "OPENAI_API_KEY" },
// 运维(C.8)
"mode": "local",
"allow_admin_shell": false,
"appui": { "allowed_origins": [], "sessions_in_cwd": true },
"plugins": { "require_signed": false },
"cli": { "serve": { "port": 50080 } }
}
C.14 版本演化说明
本附录数据基线为 octos main 9c157101(docs(guide): document mcp_servers stdio fields, env sanitization, timeouts, #2211/#2212)。相对早期基线的主要演化:
- typed LLM schema(
3a567a4c/ #2166):LlmModelSelectionConfig新增temperature/top_p/reasoning_effort/context_window(#2142 拆自 #2127),顶层Config同步投影出model_temperature/model_top_p/model_reasoning_effort(crates/octos-cli/src/config.rs:56-65),与gateway.llm_temperature/gateway.llm_sampling_params(#2172)构成显式优先级链。 mcp_servers:profile 级[[mcp_servers]]在 OLP #29 S2b 前不会注册工具(crates/octos-cli/src/profiles.rs:198注释);stdio 字段、env 清洗与超时在 #2211/#2212 文档化。sub_providers保留键goal_verifier(#1935):缺省时 goal 完成校验回退评分会话自身 provider(旧行为)。- hooks schema:现行为
hooks[]+event+command(argv)+timeout_ms(默认 5000)+tool_filter: string[],另有较新的path_filter与requires_bin。 - queue_mode:
steer更名latest(crates/octos-cli/src/config.rs:1507-1512,serde 别名保留兼容)。 snapshots(#1768)、approval_policy、appui.sessions_in_cwd、plugins.require_signed、cli节均为后续新增;sandbox.write_allow_globs(#1976)与tool_policy.bash_file_writes(#28b)是近期收紧项。
旧版附录中的数字与字段表一律作废,以本表为准。
附录 D:Feature Flags 一览
定位:本附录是 octos workspace 全部 Cargo feature 的对照表:12 个 crate 的 79 条 feature 定义逐条列出(crate、拉入的依赖、一句作用、是否默认开启、来源行号),外加 14 个消息频道门的定义与镜像转发、default 链、feature 传播图。前置依赖:第 14 章 14.7 节(编译期运行面的概念)、附录 A(A.5 节的 feature-gated 依赖标注)。适用场景:部署前决定开哪些 feature;排查「这个功能为什么没编译进去」;为 octos 新增频道或工具前确定改动落点。
本附录与第 14 章、附录 A 同源同口径:统计基线为 octos main @ 9c157101,唯一数据源是仓库事实表 assets/appendixD-facts.md。workspace 38 个 member 中,12 个 crate 的 Cargo.toml 里有 [features] 段,共 79 条 feature 定义:70 条非默认 feature 加 9 条显式 default 行(octos-embed-llama、octos-store、octos-services 无显式 default 行)。全量口径的复现命令只有一条,在 octos 源码仓库根执行:
awk '/^\[features\]/{f=1;next} /^\[/{f=0} f&&/^[a-z]/' crates/*/Cargo.toml
分工边界:14.7 节回答「运行面选择与能力裁剪为什么分属两个时期」,本附录回答「每一个门具体是什么、拉什么、默认开不开」。两处不重复展开,交叉点在 D.6 说明。
D.1 主表:79 条 feature 全量
「默认」列的「是」表示该 feature 出现在所在 crate 的显式 default 行里。「来源」列指向该 crate Cargo.toml 中 [features] 段的行号范围。
| crate | feature | 拉入的依赖 / 下游 feature | 一句作用 | 默认 | 来源 |
|---|---|---|---|---|---|
| octos-cli | default | [] | 默认最小集,不含 API/频道/工具 | 是 | crates/octos-cli/Cargo.toml:142-177 |
| octos-cli | embed-llama | dep:octos-embed-llama、octos-embed-llama/embed-llama | 进程内 llama.cpp GGUF 嵌入 provider(接 embedding.provider = "llamacpp"),默认 CPU 后端 | 否 | crates/octos-cli/Cargo.toml:142-177 |
| octos-cli | embed-llama-metal | embed-llama、octos-embed-llama/metal | 在 embed-llama 上叠加 Apple Metal 加速 | 否 | crates/octos-cli/Cargo.toml:142-177 |
| octos-cli | embed-llama-cuda | embed-llama、octos-embed-llama/cuda | 在 embed-llama 上叠加 NVIDIA CUDA 加速 | 否 | crates/octos-cli/Cargo.toml:142-177 |
| octos-cli | api | dep:axum、dep:tower-http、dep:tokio-util、dep:futures、dep:tokio-tungstenite、dep:rustls、dep:rustls-native-certs、dep:rust-embed、dep:metrics-exporter-prometheus、dep:lettre、dep:rand、dep:sysinfo、dep:subtle、octos-bus/api、matrix | REST API/dashboard/SSE/WS 控制面;serve 子命令依赖它,漏掉则 octoscode 无法启动(Command::Serve 带 #[cfg(feature = "api")],crates/octos-cli/src/commands/mod.rs:398-399;octoscode 默认命令是 octos serve --stdio --solo,octoscode/src/cli.rs:118);matrix 无条件连带,因为 API handler 与管理台引用 octos_bus::MatrixInviteStore 等 matrix-only 类型 | 否 | crates/octos-cli/Cargo.toml:142-177 |
| octos-cli | admin-bot | dep:teloxide、dep:futures、api | Telegram 管理 Bot;显式依赖 api | 否 | crates/octos-cli/Cargo.toml:142-177 |
| octos-cli | telegram | octos-bus/telegram | Telegram 频道门 | 否 | crates/octos-cli/Cargo.toml:142-177 |
| octos-cli | discord | octos-bus/discord | Discord 频道门 | 否 | crates/octos-cli/Cargo.toml:142-177 |
| octos-cli | dingtalk | octos-bus/dingtalk | 钉钉回调频道门 | 否 | crates/octos-cli/Cargo.toml:142-177 |
| octos-cli | slack | octos-bus/slack | Slack WebSocket 频道门 | 否 | crates/octos-cli/Cargo.toml:142-177 |
| octos-cli | whatsapp | octos-bus/whatsapp | WhatsApp WebSocket 频道门 | 否 | crates/octos-cli/Cargo.toml:142-177 |
| octos-cli | email | octos-bus/email | Email(IMAP/SMTP)频道门 | 否 | crates/octos-cli/Cargo.toml:142-177 |
| octos-cli | feishu | octos-bus/feishu | 飞书频道门 | 否 | crates/octos-cli/Cargo.toml:142-177 |
| octos-cli | twilio | octos-bus/twilio | Twilio webhook 频道门 | 否 | crates/octos-cli/Cargo.toml:142-177 |
| octos-cli | wecom | octos-bus/wecom | 企业微信回调频道门 | 否 | crates/octos-cli/Cargo.toml:142-177 |
| octos-cli | line | octos-bus/line | LINE webhook 频道门 | 否 | crates/octos-cli/Cargo.toml:142-177 |
| octos-cli | matrix | octos-bus/matrix | Matrix 频道门 | 否 | crates/octos-cli/Cargo.toml:142-177 |
| octos-cli | wecom-bot | octos-bus/wecom-bot | 企业微信 Bot WebSocket 频道门 | 否 | crates/octos-cli/Cargo.toml:142-177 |
| octos-cli | qq-bot | octos-bus/qq-bot | QQ Bot WebSocket 频道门 | 否 | crates/octos-cli/Cargo.toml:142-177 |
| octos-cli | wechat | octos-bus/wechat | WeChat bridge WebSocket 频道门 | 否 | crates/octos-cli/Cargo.toml:142-177 |
| octos-cli | git | octos-agent/git | Git 工具能力门(gix+similar) | 否 | crates/octos-cli/Cargo.toml:142-177 |
| octos-cli | ast | octos-agent/ast | AST 解析工具能力门(tree-sitter 五语言) | 否 | crates/octos-cli/Cargo.toml:142-177 |
| octos-cli | audio_mp3 | octos-agent/audio_mp3 | AudioNonSilent 工作区契约校验器的 mp3 解码;不带则 .mp3 工件报错,提示启用 audio_mp3 或改用 .wav(crates/octos-agent/src/validators.rs:2441-2445) | 否 | crates/octos-cli/Cargo.toml:142-177 |
| octos-bus | default | [] | 纯 bus 核心(会话/调度/去重),零频道 | 是 | crates/octos-bus/Cargo.toml:9-26 |
| octos-bus | api | axum | API/SSE/WS 接入所需 bus 类型(ApiChannel 等) | 否 | crates/octos-bus/Cargo.toml:9-26 |
| octos-bus | telegram | teloxide | TelegramChannel 实现 | 否 | crates/octos-bus/Cargo.toml:9-26 |
| octos-bus | discord | serenity | DiscordChannel 实现 | 否 | crates/octos-bus/Cargo.toml:9-26 |
| octos-bus | dingtalk | axum | DingTalkChannel 回调实现 | 否 | crates/octos-bus/Cargo.toml:9-26 |
| octos-bus | slack | tokio-tungstenite | SlackChannel WS 实现 | 否 | crates/octos-bus/Cargo.toml:9-26 |
| octos-bus | whatsapp | tokio-tungstenite | WhatsAppChannel WS 实现 | 否 | crates/octos-bus/Cargo.toml:9-26 |
| octos-bus | feishu | tokio-tungstenite、axum、rustls、rustls-native-certs | FeishuChannel WS+回调实现 | 否 | crates/octos-bus/Cargo.toml:9-26 |
| octos-bus | line | axum | LineChannel webhook 实现 | 否 | crates/octos-bus/Cargo.toml:9-26 |
| octos-bus | twilio | axum | TwilioChannel webhook 实现 | 否 | crates/octos-bus/Cargo.toml:9-26 |
| octos-bus | wecom | axum | WeComChannel 回调实现 | 否 | crates/octos-bus/Cargo.toml:9-26 |
| octos-bus | matrix | axum | MatrixChannel + MatrixUserChannel(含 MatrixInviteStore)实现 | 否 | crates/octos-bus/Cargo.toml:9-26 |
| octos-bus | wecom-bot | tokio-tungstenite、rustls、rustls-native-certs | WeComBotChannel WS 实现 | 否 | crates/octos-bus/Cargo.toml:9-26 |
| octos-bus | qq-bot | tokio-tungstenite、rustls、rustls-native-certs | QQBotChannel WS 实现 | 否 | crates/octos-bus/Cargo.toml:9-26 |
| octos-bus | wechat | tokio-tungstenite | WeChatChannel WS bridge 实现 | 否 | crates/octos-bus/Cargo.toml:9-26 |
| octos-bus | email | async-imap、tokio-rustls、rustls、webpki-roots、lettre、mailparse | EmailChannel IMAP/SMTP 实现 | 否 | crates/octos-bus/Cargo.toml:9-26 |
| octos-agent | default | ["browser"] | 默认开 browser | 是 | crates/octos-agent/Cargo.toml:117-131 |
| octos-agent | browser | [](chromiumoxide 无条件编译) | 只切换 web_search 的无头 Chrome(CDP)兜底 provider;关掉则 HTTP-only 行为与旧版逐字节一致 | 是 | crates/octos-agent/Cargo.toml:117-131 |
| octos-agent | git | dep:gix、dep:similar | Git 操作与 diff 能力 | 否 | crates/octos-agent/Cargo.toml:117-131 |
| octos-agent | ast | dep:tree-sitter 加 rust/python/javascript/typescript 四个 grammar | AST 代码结构分析 | 否 | crates/octos-agent/Cargo.toml:117-131 |
| octos-agent | audio_mp3 | dep:symphonia | AudioNonSilent 校验器的 mp3 解码(WAV 走常开的 hound) | 否 | crates/octos-agent/Cargo.toml:117-131 |
| octos-server | default | [] | 最小 server 库 | 是 | crates/octos-server/Cargo.toml:46-81 |
| octos-server | api | 依赖清单与 octos-cli api 相同(13 个 optional 依赖加 octos-bus/api 与 matrix) | HTTP/WS API 层,镜像 octos-cli api;matrix 必选(handler 引用 matrix 类型) | 否 | crates/octos-server/Cargo.toml:46-81 |
| octos-server | telegram | octos-bus/telegram | 频道门转发 | 否 | crates/octos-server/Cargo.toml:46-81 |
| octos-server | discord | octos-bus/discord | 频道门转发 | 否 | crates/octos-server/Cargo.toml:46-81 |
| octos-server | dingtalk | octos-bus/dingtalk | 频道门转发 | 否 | crates/octos-server/Cargo.toml:46-81 |
| octos-server | slack | octos-bus/slack | 频道门转发 | 否 | crates/octos-server/Cargo.toml:46-81 |
| octos-server | whatsapp | octos-bus/whatsapp | 频道门转发 | 否 | crates/octos-server/Cargo.toml:46-81 |
| octos-server | email | octos-bus/email | 频道门转发 | 否 | crates/octos-server/Cargo.toml:46-81 |
| octos-server | feishu | octos-bus/feishu | 频道门转发 | 否 | crates/octos-server/Cargo.toml:46-81 |
| octos-server | twilio | octos-bus/twilio | 频道门转发 | 否 | crates/octos-server/Cargo.toml:46-81 |
| octos-server | wecom | octos-bus/wecom | 频道门转发 | 否 | crates/octos-server/Cargo.toml:46-81 |
| octos-server | line | octos-bus/line | 频道门转发 | 否 | crates/octos-server/Cargo.toml:46-81 |
| octos-server | matrix | octos-bus/matrix | 频道门转发 | 否 | crates/octos-server/Cargo.toml:46-81 |
| octos-server | wecom-bot | octos-bus/wecom-bot | 频道门转发 | 否 | crates/octos-server/Cargo.toml:46-81 |
| octos-server | qq-bot | octos-bus/qq-bot | 频道门转发 | 否 | crates/octos-server/Cargo.toml:46-81 |
| octos-server | wechat | octos-bus/wechat | 频道门转发 | 否 | crates/octos-server/Cargo.toml:46-81 |
| octos-embed-llama | embed-llama | dep:llama-cpp-2、dep:self_cell | 从源码编译 llama.cpp(CMake 加 C++ 工具链),进程内 GGUF 嵌入;不开则 crate 近空 | 否 | crates/octos-embed-llama/Cargo.toml:14-20 |
| octos-embed-llama | metal | llama-cpp-2/metal | Apple Metal 加速 | 否 | crates/octos-embed-llama/Cargo.toml:14-20 |
| octos-embed-llama | cuda | llama-cpp-2/cuda | NVIDIA CUDA 加速 | 否 | crates/octos-embed-llama/Cargo.toml:14-20 |
| octos-ffi | default | [] | FFI 面保持纯 Rust | 是 | crates/octos-ffi/Cargo.toml:37-47 |
| octos-ffi | embed-llama | dep:octos-embed-llama、octos-embed-llama/embed-llama | 把 GGUF 后端编进 octos_embed,否则报 embedding support not compiled in | 否 | crates/octos-ffi/Cargo.toml:37-47 |
| octos-ffi | embed-llama-metal | embed-llama、octos-embed-llama/metal | FFI 侧 Metal 加速 | 否 | crates/octos-ffi/Cargo.toml:37-47 |
| octos-ffi | embed-llama-cuda | embed-llama、octos-embed-llama/cuda | FFI 侧 CUDA 加速 | 否 | crates/octos-ffi/Cargo.toml:37-47 |
| octos-uniffi | default | [] | uniffi 绑定保持纯 Rust | 是 | crates/octos-uniffi/Cargo.toml:36-42 |
| octos-uniffi | embed-llama | octos-ffi/embed-llama | 经 ffi 转发 Runtime::embed,否则返回 NoEmbedder | 否 | crates/octos-uniffi/Cargo.toml:36-42 |
| octos-pyo3 | default | [] | Python-less CI 上 cargo build --workspace 不破 | 是 | crates/octos-pyo3/Cargo.toml:42-52 |
| octos-pyo3 | python | dep:pyo3 | 编译 pyo3 面(拉 libpython) | 否 | crates/octos-pyo3/Cargo.toml:42-52 |
| octos-pyo3 | extension-module | python、pyo3/extension-module | wheel 构建:python 加不链 libpython(宿主解释器解析符号) | 否 | crates/octos-pyo3/Cargo.toml:42-52 |
| octos-pyo3 | embed-llama | octos-ffi/embed-llama | Runtime.embed 返回真实向量而非 NoEmbedder | 否 | crates/octos-pyo3/Cargo.toml:42-52 |
| octos-diagnostics | default | [] | Stage 1 零网络依赖 | 是 | crates/octos-diagnostics/Cargo.toml:22-26 |
| octos-diagnostics | github | dep:reqwest(workspace 同 pin,rustls-tls) | update --check 的 GitHub Releases 客户端(Stage 2;self-update 是 Stage 3,不在此) | 否 | crates/octos-diagnostics/Cargo.toml:22-26 |
| octos-llm | default | [] | 生产构建不暴露测试面 | 是 | crates/octos-llm/Cargo.toml:9-15 |
| octos-llm | test-utils | [] | 暴露 AdaptiveRouter::publish_failover_for_subscribers 等测试助手;生产禁开 | 否 | crates/octos-llm/Cargo.toml:9-15 |
| octos-store | test-util | [] | 暴露 approvals_audit::read_audit_lines 等测试助手;生产禁开 | 否(无显式 default 行) | crates/octos-store/Cargo.toml:26-30 |
| octos-services | test-util | [] | 暴露 crate 级 env-test 锁 config_context::TEST_ENV_LOCK 供下游测试串行化;生产禁开 | 否(无显式 default 行) | crates/octos-services/Cargo.toml:26-31 |
主表逐 crate 计数:octos-cli 23、octos-bus 16、octos-server 16、octos-agent 5、octos-embed-llama 3、octos-ffi 4、octos-uniffi 2、octos-pyo3 4、octos-diagnostics 2、octos-llm 2、octos-store 1、octos-services 1,合计 79。octos-cli 的 spec 事实边界旧列 22 个(未含 audio_mp3);当前实取 23 条,audio_mp3(crates/octos-cli/Cargo.toml:177)为边界后新增。
D.2 十二个 crate 的导览
把 79 条 feature 按 crate 归组,能看到一条清晰的分层纪律。第一组是两个入口。octos-cli 以 23 条 feature 居全库之首,但它自己几乎不实现任何频道与工具:14 个频道门逐个转发到 octos-bus,git、ast、audio_mp3 三个工具门转发到 octos-agent,embed-llama 三连转发到 octos-embed-llama;唯一在本 crate 内展开的重量级门是 api,它拉入 13 个 optional 依赖,并连带 octos-bus/api 与 matrix 两个下游门。octos-server 是 cli 的库级镜像,16 条 feature 与 cli 同名同义,api 与 14 个频道门各自转发一份,让不走 CLI 的宿主程序复用同一套装配。
第二组是运行时与总线。octos-bus 的 16 条 feature 里,除 default 与 api 外全是频道实现本体:teloxide、serenity、tokio-tungstenite、async-imap 这些网络栈只在对应频道门打开时才进入编译,默认构建不含任何一个聊天协议栈。octos-agent 只有 5 条,却是唯一 default 非空的业务 crate:default = ["browser"] 让无头 Chrome 兜底成为默认行为,而 browser 门本身不增删任何依赖,chromiumoxide 是无条件编译的,门只决定 web_search 要不要用它。
第三组是嵌入链与绑定链,共五个 crate。octos-embed-llama 的 3 条 feature 全部围绕 llama.cpp:本体门从源码编译 C++,metal 与 cuda 只是后端叠加开关。octos-ffi 的 4 条决定 FFI 面里有没有 GGUF 嵌入;octos-uniffi 与 octos-pyo3 各持一条 embed-llama,层层代理到 octos-ffi,不开就返回 NoEmbedder。最后一组是四个小 crate:octos-diagnostics 用 github 门把 Releases 检查所需的 reqwest 挡在默认构建之外;octos-llm 的 test-utils 与 octos-store、octos-services 的 test-util 是纯测试面,生产构建禁开;embed-llama、store、services 三个 crate 没有显式 default 行,零 feature 是它们的常态。
D.3 频道门:一处定义、两处转发
octos-bus 是 14 个频道门的唯一定义点(crates/octos-bus/Cargo.toml:9-26),运行期导出与门一一对应:crates/octos-bus/src/lib.rs:17-49 逐 feature #[cfg] 门控类型导出,crates/octos-bus/src/lib.rs:71-107 门控 mod 声明,14 个频道一一对齐(该文件 cfg(feature 共命中 33 处:17 个类型导出行加 16 个 mod 行,api 门在非 mod 行少一条)。octos-cli(crates/octos-cli/Cargo.toml:142-177)与 octos-server(crates/octos-server/Cargo.toml:46-81)把 14 个门各转发一份:CLI 面向二进制构建,server 面向库级复用,网关运行期不走 HTTP api 层也能派发频道。
| 频道门 | octos-bus 拉入的依赖 | 接入形态 |
|---|---|---|
| telegram | teloxide | Bot API |
| discord | serenity | Bot API |
| dingtalk | axum | 回调 |
| slack | tokio-tungstenite | WebSocket |
| tokio-tungstenite | WebSocket | |
| async-imap、tokio-rustls、rustls、webpki-roots、lettre、mailparse | IMAP/SMTP | |
| feishu | tokio-tungstenite、axum、rustls、rustls-native-certs | WebSocket 加回调 |
| twilio | axum | webhook |
| wecom | axum | 回调 |
| line | axum | webhook |
| matrix | axum | webhook 加 MatrixInviteStore |
| wecom-bot | tokio-tungstenite、rustls、rustls-native-certs | WebSocket |
| qq-bot | tokio-tungstenite、rustls、rustls-native-certs | WebSocket |
| tokio-tungstenite | WebSocket bridge |
三个名字相邻的门容易混淆:wecom 与 wecom-bot 是两套实现(回调对 WebSocket),qq-bot 是 QQ 的 WebSocket 实现,wechat 是微信的 WebSocket bridge。它们在 cli 与 server 中各自有同名转发门,开关必须三处一致才算打开一条频道。
D.4 default 链:一个反直觉默认与两条代理链
workspace 的 9 条显式 default 行里,8 条是 default = [](最小集),唯一非空的是 octos-agent 的 default = ["browser"](crates/octos-agent/Cargo.toml:117-131)。这个默认与直觉相反之处在于:chromiumoxide 不受门控、始终编译,browser 门只决定 web_search 在 HTTP 检索失败后是否落到无头 Chrome。想要纯 HTTP 行为,要在依赖 octos-agent 时用 default-features = false 显式关掉,而不是以为「不开 feature 就没有浏览器代码」。
嵌入链从外到内共四跳。第一跳在 octos-cli 与 octos-ffi:两者的 embed-llama 都指向 octos-embed-llama/embed-llama,后者才真正拉入 llama-cpp-2 与 self_cell,并要求构建机具备 CMake 与 C++ 工具链;metal、cuda 是在此之上的后端叠加,所以 embed-llama-metal 与 embed-llama-cuda 都隐含 embed-llama。第二跳是 octos-ffi 的镜像三连,把同样的开关复制到 FFI 面。第三、四跳是 octos-uniffi 与 octos-pyo3 各自唯一的 embed-llama,它们不直接接触 llama.cpp,只把开关转发给 octos-ffi;任一层没开,Runtime.embed 就返回 NoEmbedder 而不是向量。
D.5 feature 传播关系
flowchart LR
subgraph CLI["octos-cli"]
API["api"]
CH["14 个频道门<br/>telegram discord dingtalk slack whatsapp<br/>email feishu twilio wecom line matrix<br/>wecom-bot qq-bot wechat"]
TOOL["git / ast / audio_mp3"]
EMB["embed-llama / -metal / -cuda"]
AB["admin-bot"]
end
subgraph BUS["octos-bus"]
BAPI["api = axum"]
BCH["同名频道门 = 各频道依赖"]
end
subgraph AGENT["octos-agent"]
AG["git = gix + similar<br/>ast = tree-sitter x5<br/>audio_mp3 = symphonia<br/>browser 默认开"]
end
EL["octos-embed-llama<br/>embed-llama / metal / cuda"]
API -->|octos-bus/api 加 matrix| BAPI
CH -->|octos-bus 同名门| BCH
API -->|连带 matrix 门| BCH
TOOL --> AG
EMB --> EL
AB -->|dep:teloxide 加 api| API
三层结构:octos-cli 是唯一二进制入口,频道与工具 feature 全部转发到 octos-bus 与 octos-agent(与 14.7 节「CLI 只做 feature 转发」一致);octos-server 的 api 与 14 频道门镜像同一套转发,供库级复用。图中有三条隐式依赖边需要记住:api 连带 octos-bus/api 与 matrix;admin-bot 显式依赖 api;embed-llama-metal 与 embed-llama-cuda 都隐含 embed-llama。漏掉隐式边是 feature 裁剪出错的常见来源:只关 matrix 不关 api 是关不掉的,因为 API 层的 handler 引用了 matrix-only 类型。
D.6 与第 14 章 14.7 节的分工
14.7 节讲双层关系:运行面选择发生在运行期(子命令分派),能力裁剪发生在编译期(feature 门),并给了 cli 侧三个例子(api、频道门、embed-llama)与 serve 子命令的编译门。本附录不重复这些论证,只补全量数据:79 条定义、14 个门的依赖明细、default 链与传播图。查「某种运行面该开哪些门」翻 14.7 节与第 13 章;查「某个门拉了什么、默认开不开」翻本附录 D.1;查「某条 gated 依赖的版本与归属 crate」翻附录 A 的 A.5。
D.7 没有 [features] 的 crate:26 个
其余 26 个顶层条目的 Cargo.toml 没有 [features] 段,编译面固定、无门可开:
- 核心库(11):octos-core、octos-memory、octos-workflows、octos-pipeline、octos-plugin、octos-sandbox、octos-swarm、octos-fleet、octos-fleet-worker、octos-dora-mcp、octos-wasm(crates/ 下同名目录的 Cargo.toml)。
- App skills(14):news、deep-search、deep-crawl、send-email、account-manager、time、weather、smart-home、wechat-bridge、skill-evolve、harness-starter-generic、harness-starter-report、harness-starter-audio、harness-starter-coding(crates/app-skills/ 下同名目录的 Cargo.toml)。
- Platform skills(1):voice(crates/platform-skills/voice/Cargo.toml)。
能力二进制不走 Cargo feature 裁剪,它们用 workspace member 与 skill manifest 管理分发边界(第 9 章)。给这份清单排过错的读者请注意:旧稿把它列成 19 个且含已不存在的 pipeline-guard,本版按 9c157101 实测重列。
D.8 编译示例
# 最小 CLI:不启用 API、频道集成、Git/AST 工具
cargo build -p octos-cli --release
# CLI + Web API / dashboard(serve 子命令随之可用)
cargo build -p octos-cli --release --features api
# API + 管理 Bot
cargo build -p octos-cli --release --features admin-bot
# Gateway 常见多频道组合
cargo build -p octos-cli --release --features "telegram,slack,email,feishu,wecom-bot,qq-bot,wechat"
# 让 AudioNonSilent 校验器支持 .mp3(经 octos-agent/audio_mp3 生效)
cargo build -p octos-cli --release --features audio_mp3
延伸阅读
- Cargo Features 官方文档:https://doc.rust-lang.org/cargo/reference/features.html ,optional 依赖与 feature 统一推导规则,对照 D.1 的依赖列。
- 本书第 14 章 14.7 节:编译期运行面与运行期运行面的双层关系。
- 本书附录 A A.5 节:feature-gated 外部依赖的全量标注(50 项)与三个 crate 的 gate 设计。
- 本书附录 C:配置字段参考,feature 开门之后的运行期配置。
思考题
- octos-agent 的
default = ["browser"]让 chromiumoxide 无条件编译、browser 门只切行为。如果把 chromiumoxide 改成真正的 gated 依赖,默认构建的依赖树和语义分别会怎么变?哪种做法更符合「门即依赖边界」? - api 连带 matrix 是硬编码在 Cargo.toml 里的隐式边。如果要让 API 层在无 matrix 时也能编译,代码层要付出什么代价(handler 拆分、类型抽象),值得吗?
- octos-server 把 14 个频道门镜像转发了一份。设想删掉 server 的频道门、要求宿主程序直接依赖 octos-bus 开门,装配代码会发生什么?这解释了「镜像转发」换来了什么。
- 三个测试面门(octos-llm 的 test-utils、octos-store 与 octos-services 的 test-util)都靠纪律禁开。能不能用 Cargo 机制(如 doc(sqrt) 或 feature 命名约定)让生产构建更难误开?
版本演化说明
本附录分析基于 octos main @
9c157101(完整 hash9c1571016e5ea86955b4b3486c04f0359dfff339,2026-09-02 提交,2026-09-03 统计),全部数字(79 条 feature、12 个 crate、14 个频道门、26 个无 feature 条目)出自assets/appendixD-facts.md,复现命令随数据收录。相对 v1 旧稿,本附录做了三类更新。其一,覆盖面从 3 个 crate 扩到 12 个:补入 octos-diagnostics、octos-llm、octos-store、octos-services、octos-ffi、octos-uniffi、octos-pyo3、octos-server、octos-embed-llama 九个有 feature 的 crate,旧稿的 feature 清单不含它们。其二,事实纠正:octos-cli 补入 embed-llama、embed-llama-metal、embed-llama-cuda、dingtalk、line、audio_mp3 六个缺失条目(23 条对旧稿 17 条);补入 octos-agent 的 browser 与
default = ["browser"];「没有 [features] 的 crate」清单移除已不存在的 pipeline-guard,补列 octos-fleet、octos-fleet-worker、octos-workflows,并把 octos-diagnostics 移回有 feature 一侧。其三,结构补齐:新增 14 频道门专节、default 链说明与传播关系图,api 行写明 serve 子命令依赖(crates/octos-cli/src/commands/mod.rs:398-399)。
附录 E:从源码构建与贡献指南
本附录面向希望直接修改 octos 源码的贡献者。内容以当前 ../octos main 分支为准,主要对齐根 Cargo.toml、CLAUDE.md 的 Build & Test Commands、README 的 source build 说明,以及 .github/workflows/ci.yml 的 CI 分片策略。
环境要求
| 工具 | 版本要求 | 说明 |
|---|---|---|
| Rust | ≥ 1.85.0 | 根 Cargo.toml 的 rust-version |
| Edition | 2024 | 根 Cargo.toml 的 workspace edition |
| Cargo resolver | 2 | workspace 使用 resolver v2 |
| Git | 2.x+ | clone、branch、commit |
| Node.js / npm | 仅 dashboard 改动需要 | scripts/build-dashboard.sh 会构建嵌入式 admin SPA |
| Chrome / Chromium | 仅 browser/deep-crawl 相关开发需要 | CDP/browser 工具与 deep-crawl skill 使用 |
当前仓库没有 rust-toolchain.toml,因此不要假设会自动 pin 工具链;本地工具链至少要满足 rust-version = "1.85.0"。
最小构建路径
# 1. 克隆仓库
git clone https://github.com/octos-org/octos.git
cd octos
# 2. 检查工具链
rustc --version
cargo --version
# 3. 构建整个 workspace
cargo build --workspace
# 4. 运行 workspace 测试
cargo test --workspace
# 5. 格式化与 lint
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
这组命令与 CLAUDE.md 和 CI 的基础检查保持一致。注意:cargo build --workspace 会构建 workspace 成员,但不会自动启用 octos-cli/api;如果你要运行 octos serve,必须用带 api 的 feature 组合构建或安装 CLI。
本地安装 CLI
README 和 CLAUDE.md 当前推荐的本地安装命令是:
cargo install --path crates/octos-cli \
--features "api,telegram,discord,whatsapp,feishu,twilio,wecom,wecom-bot"
这个组合是 repo 内 canonical 默认安装集,原因是:
api是octos serve的前提;不带api的二进制会缺少 serve 相关能力。- 频道 feature 只编译对应 transport,不需要的频道可以删掉。
- 这个命令只安装 CLI 到
~/.cargo/bin,不会重建 dashboard,也不会替换install.sh安装的系统服务。
只需要本地 chat/serve 时,可以收缩为:
cargo install --path crates/octos-cli --features api
如果你修改了 dashboard 或想验证完整安装包流程,使用 README 中的本地 bundle 脚本:
./scripts/build-local-bundle.sh --install
./scripts/build-local-bundle.sh --install --tunnel
./scripts/build-local-bundle.sh --skip-dashboard
build-local-bundle.sh 会调用 scripts/build-dashboard.sh,再通过 scripts/milestone-ci.sh release-bundle 复用发布构建的 FEATURES / SKILL_CRATES 组合,最后生成可被 install.sh 识别的本地 tarball。
CI 等价测试矩阵
当前 CI 没有只依赖一个巨大的 cargo test --workspace --all-features。为控制内存和链接压力,它把重 crate 拆成独立 job。贡献者本地排查时可以按同样方式缩小范围:
| 场景 | 命令 |
|---|---|
| 格式检查 | cargo fmt --all -- --check |
| Clippy | cargo clippy --workspace --all-targets -- -D warnings |
| 全 workspace 构建 | cargo build --workspace |
| 只编译测试二进制 | cargo test --workspace --no-run |
| 核心类型 | cargo test -p octos-core |
| 记忆系统 | cargo test -p octos-memory |
| LLM lib 测试 | cargo test -p octos-llm --lib |
| LLM integration 测试 | cargo test -p octos-llm --tests |
| 消息总线 | cargo test -p octos-bus |
| Pipeline | cargo test -p octos-pipeline |
| Plugin SDK | cargo test -p octos-plugin |
| Swarm | cargo test -p octos-swarm |
| Agent lib | cargo test -p octos-agent --lib |
| Agent integration | cargo test -p octos-agent --tests |
| CLI lib | cargo test -p octos-cli --lib |
| CLI integration | cargo test -p octos-cli --tests |
| CLI API 认证 | cargo test -p octos-cli --features api api::auth_handlers |
| Gateway runtime | cargo test -p octos-cli gateway_runtime::tests --features api -- --nocapture |
| Harness starter skills | cargo test -p harness-starter-generic 等四个 starter crate |
| 文档测试 | cargo test --workspace --doc |
针对单个失败测试,优先使用:
cargo test -p octos-agent test_name -- --nocapture
cargo test -p octos-cli gateway_runtime::tests --features api -- --nocapture
代码规范
Rust 与 lint
- Workspace edition:
2024 - Minimum Rust:
1.85.0 - Workspace lint:
unsafe_code = "deny" - 格式化:
cargo fmt --all - lint gate:
cargo clippy --workspace --all-targets -- -D warnings
错误处理
- 项目约定使用
eyre/color-eyre,不要引入anyhow。 - 库层函数通常返回
eyre::Result<T>;CLI binary 初始化color_eyre::install()。 - 避免无解释的
.unwrap();如果是不变量,用.expect("...")写清楚原因。
并发与平台边界
- 异步代码使用 Tokio;共享 trait object 常见形式是
Arc<dyn Trait>。 - shutdown signaling 使用
AtomicBool,store/load 语义需要保持一致。 - 跨平台命令执行要考虑 Unix
sh -c与 Windowscmd /C的差异。 - 安全相关路径要遵守现有 sandbox、SSRF、symlink-safe I/O 和 env sanitization 约束,不要绕开公共实现。
测试放置
- 单元测试优先放在同文件
#[cfg(test)] mod tests。 - integration tests 放在 crate 的
tests/目录。 - 外部服务依赖测试应使用
#[ignore]或清晰的环境变量 gate。 - 临时目录使用
tempfile,不要写入用户真实 home 或固定系统路径。
TDD 工作流
CLAUDE.md 明确要求代码变更遵循 RED → GREEN → REFACTOR:
- RED:先写能暴露问题的失败测试,测试名使用
should_<expected>_when_<condition>风格。 - GREEN:写最小实现让目标测试通过。
- REFACTOR:在测试保护下清理结构,再跑相关 crate 测试和必要的 CI 子集。
实践上不要一开始就跑全量 CI。先用最小 selector 锁定问题,再逐步扩大:
cargo test -p octos-core should_do_x_when_y
cargo test -p octos-agent --lib
cargo test -p octos-agent --tests
cargo clippy --workspace --all-targets -- -D warnings
PR 提交指南
分支命名
feature/add-wechat-channel
fix/ssrf-ipv6-bypass
refactor/tool-registry-lru
docs/update-feature-flags
test/add-gateway-regression
Commit Message
推荐使用 conventional commit 风格:
feat(bus): add WeChat channel integration
- Implement WeChatChannel with encryption support
- Add message coalescing for channel limits
- Include session fork handling
常用类型包括 feat、fix、refactor、docs、test、chore。提交前至少确认:
- 只包含当前任务相关改动。
- 已运行与改动范围匹配的测试。
- 文档、spec、示例配置与代码行为一致。
- 安全相关变更有专门测试或清晰审查说明。
项目结构速查
octos/
├── Cargo.toml # Workspace: 26 members, Rust 2024, rust-version 1.85.0
├── crates/
│ ├── octos-core/ # 核心类型与 UTF-8 工具
│ ├── octos-llm/ # LLM providers、failover、adaptive routing、credential pool
│ ├── octos-memory/ # episodic memory、MEMORY.md、hybrid search
│ ├── octos-agent/ # agent loop、tools、sandbox、MCP、plugins、hooks
│ ├── octos-bus/ # sessions、channels、coalescing、cron、heartbeat
│ ├── octos-cli/ # CLI、config、serve/gateway、profiles、auth、monitor
│ ├── octos-pipeline/ # DOT workflow engine
│ ├── octos-plugin/ # plugin/skill manifest 与 gating
│ ├── octos-swarm/ # swarm orchestration primitive
│ ├── octos-dora-mcp/ # Dora-RS 到 MCP tool bridge
│ ├── octos-sandbox/ # Windows AppContainer helper
│ ├── app-skills/ # news、deep-search、deep-crawl、send-email、harness starters 等
│ └── platform-skills/voice/ # voice platform skill
├── dashboard/ # admin dashboard 前端
├── scripts/ # install、bundle、release、dashboard build 脚本
├── docs/ # 用户文档
└── .github/workflows/ # CI、release、platform build workflow
附录 F:OLP v2 协议速查
定位:本附录是 OLP(octos 外环协议 OctoLoop,当前版本
olp/v2)的一页纸速查:R 系列条款、ACK 定式、result.md frontmatter、黑板条目格式、外环上岗与重启清单、第五信道参数与车道模板。协议为什么这样设计、载体全景与实证分析见第 20 章,此处不重复;本附录只做一件事,把散在协议文档、上岗卡与源码里的可执行规则压成可直接对照的表,每张表附来源路径与行号,便于逐条复核。
术语约定(首次出现处统一解释,后文不再重复):外环(outer agent)指以计划、监控、审查、指导身份接入的外部强模型;内环(runtime)指 octos serve 驱动的 master/peers 执行体;operator 指人类操作者;黑板指各仓库 .octos/OUTER_LOOP_REVIEW.md 的 Active 区,是任务级指导的权威载体;第五信道指内环在 turn 内主动向外环发问的 MCP 问询信道。数据基线:octoscode 仓库 main @ 1129fa33。
导览:F.1 是 R 系列条款速查,按 R1 至 R7 顺序排列(R4b 为 R4 子条款一并收录);F.2 给出 ACK 定式语法与黑板条目的写入规范;F.3 是 result.md frontmatter v1 的六字段表;F.4 是外环上岗流程与内环重启后的巡检硬清单;F.5 是第五信道的参数、防滥用常量与 sub_providers 车道模板。使用方式建议:新外环上岗前按 F.4 顺序走一遍;写指导意见前对照 F.2 的条目要素;审查交付时用 F.3 校验 frontmatter、用 F.1 的 R2 复核验证声明;为任务选模型档位时查 F.5 的配对矩阵。
F.1 R 系列条款速查
| 条款 | 关键语义 | 来源行号 |
|---|---|---|
| R1 ACK 义务 | 黑板 Active 区每条意见,内环执行后必须在条目下补一行 ACK;无 ACK 视为未读,外环有权打回交付。v1 起定式语法由契约测试 olp_ack_lines_match_v1_grammar 钉住 | octoscode/docs/OUTER_LOOP_PROTOCOL.md:52-58 |
| R2 诚实验证声明 | 每个交付必须声明三档验证级别之一:verified(跑过 cargo test --all-targets + clippy + fmt)、partially-verified(列出跑了什么)、unverified(说明原因);声称 verified 但复验不符视为协议违例,打回并记黑板 | 同上 :67-70 |
| R3 升级分级 | escalation 三级:内环自决(重试/换法)→ 外环裁决(技术取舍、方案批准)→ operator 裁决(权限审批、范围变更、对外动作);外环不得代按审批,operator 缺席时 escalation 保持 park | 同上 :71-73 |
| R4 工作区共存 | 多写者各自只 git add 自己改的文件,禁止 git add -A;改动即原子 commit;来源不明的 dirty 文件必须保留并报告,不得自动清理或提交 | 同上 :74-76 |
| R4b 树主权与自动围栏 | 多 goal 并行时主工作树只属一个 goal:撞车谓词(active goal 多于一个 / peer 目标分支与主树不符 / 主树有未围栏在途 peer)命中即自动开 worktree 围栏;主树 owner 持久化进 goal-ledger,非 owner 会话在主树跨分支 checkout 一律拒绝;防撞成为系统默认,外环 steer 只做边界补位。作为 R4 子条款,不升协议版本 | 同上 :77-88 |
| R5 指导幂等 | 外环意见带日期与唯一编号,只在 Active 区可执行;ACK 后移入历史区永不重放,重复投递以 ACK 为去重依据 | 同上 :89-90 |
| R6 版本协商 | 协议文档头部 protocol: olp/vN,AGENTS.md 引用同版本;信道语义变更必须升版本 | 同上 :103-104 |
| R7 主审权 OS 独占锁 | 多外环的主审权以 per-project 会话寿命 OS 锁为准:上岗必须经 octoscode outer-duty hold --project P --signature S --duties D -- <agent> 包裹启动,锁即 authority;check 仅观察绝不夺取;活锁接管只归 operator(终止旧 holder 后再 acquire),无 agent 自助强夺;metadata sidecar 与一切 TTL 仅诊断、绝不参与裁定;Linux-only(非 Linux 平台显式 unsupported 退出 2,Windows LockFileEx 另立条目),NFS 不适用。v2 新增(#38-r1) | 同上 :91-102 |
原文条目顺序为 R1、R2、R3、R4、R4b、R5、R7、R6(R7 插在 R6 之前),本表按编号重排,行号可径直回查。
F.2 ACK 定式与黑板条目格式
ACK 定式语法(v1 起,ACK 行写在内环刚执行完的黑板条目之下):
ACK(done|wontdo|blocked): <说明>
来源:octoscode/docs/OUTER_LOOP_PROTOCOL.md:56-58。
| 定式 | 语义 | 说明字段要求 | 来源行号 |
|---|---|---|---|
ACK(done): … | 已执行 | 写做了什么与证据(commit hash / 测试结果) | 同上 :60 |
ACK(wontdo): … | 带证据的异议,不执行 | 写为何不做;分歧规则:外环对 wontdo 只能接受或升级 operator 裁决,不得对同一条目再次打回 | 同上 :61-62 |
ACK(blocked): … | 被阻塞无法执行 | 写阻塞原因与解除条件 | 同上 :63 |
生效边界:v1 语法只约束 2026-08-24 起新增的 ACK 行,历史行不重写,由契约测试的生效日期分界豁免;说明部分自由文本,非空即可(同上 :65-66)。
黑板条目写入规范(来源:octoscode/docs/OLP_OUTER_BOOT.md §1):
| 要素 | 规范 | 来源行号 |
|---|---|---|
| 板位置 | 每仓库一块 <repo>/.octos/OUTER_LOOP_REVIEW.md;docs/ 下同名文件是冻结快照,严禁写入 | octoscode/docs/OLP_OUTER_BOOT.md:36-37 |
| 写入方式 | 必须走原子追加助手 scripts/olp-board-append.sh <板路径>(正文从 stdin 喂),flock 互斥加自写登记 | 同上 :38-41 |
| 编号 | 先 grep -oE '^### [0-9]+' <板> | tail -1 取当前最大号,加一使用 | 同上 :42 |
| 条目内容 | 自包含:背景、精确文件/行号、修法方向、验收标准、分支名(基于 main) | 同上 :43-44 |
| 预算档 | 修订 5-10M / 切片 10-20M / 战役 30-50M,条目中写明 | 同上 :44 |
| 推送纪律 | 条目写明「只 commit 不 push,主审复验后代推」 | 同上 :45 |
F.3 result.md frontmatter v1 六字段
peers/<slug>/result.md 是内环到外环的每轮交付物,v1 起其 YAML frontmatter 必须包含恰好六个字段,契约测试 olp_result_schema_fields_documented 钉住该清单(来源:octoscode/docs/OUTER_LOOP_PROTOCOL.md:354-358)。
| 字段 | 类型 | 含义 | 来源行号 |
|---|---|---|---|
slug | string | peer 的唯一标识(目录名) | 同上 :362 |
outcome | string | 交付结论,取值 complete / partial / blocked / failed | 同上 :363 |
updated_unix | integer | 最近更新的 Unix 时间戳(秒) | 同上 :364 |
turn | integer | 该 peer 已运行的 turn 数 | 同上 :365 |
verified | string | R2 验证级别:verified / partially-verified / unverified | 同上 :366 |
protocol | string | 写入时遵循的协议版本,当前 olp/v2 | 同上 :367 |
消费侧约定:未知字段必须忽略(forward compatibility),消费方按上述六字段取数,对其他字段不解释、不报错;verified 与 protocol 由 octos 侧写入(同上 :369-373)。
F.4 外环上岗与重启硬清单
上岗流程(数据源:octoscode/docs/OLP_OUTER_BOOT.md 与 OUTER_LOOP_PROTOCOL.md 接入清单):
| 步骤 | 内容 | 来源行号 |
|---|---|---|
| 署名 | 选定署名 外环(<名字>),所有黑板写入必须署名;多外环并存时每条目单一主审,他人条目只可署名批注,分歧升级 operator | octoscode/docs/OLP_OUTER_BOOT.md:10-14 |
| 取主审锁 | 以 octoscode outer-duty hold --project <项目> --signature <署名> --duties <职责> -- <agent 启动命令> 包裹启动;check 仅观察,stdout 恰一态 VACANT/Held/ERROR | 同上 :74-81 |
| 定数据根与监听 | 数据根 ~/.octos/instances/<cwd-hash>/profiles/<profile>/data;tail serve 日志过滤 peer-goal:|escalation|transitioned goal|ERROR;观测分投递、消费、执行三层,只看一层必误判 | octoscode/docs/OUTER_LOOP_PROTOCOL.md:126-134 |
| 读协议与黑板 | 读协议全文与黑板 Active 区了解当前指导,历史区仅用于审计 | 同上 :135-136 |
内环重启后的外环巡检硬清单,四步逐项核对,禁止「记一笔稍后补」(来源:octoscode/docs/OLP_OUTER_BOOT.md:16-19):
| 步骤 | 内容 | 来源行号 |
|---|---|---|
| 1. serve 起 | operator 亲手执行;免沙箱启动属信任决策,永不由 agent 代劳 | 同上 :20-21 |
2. /loop resume 外环必代 | 先 /loop list 取 id,再 /loop resume <id>;裸 resume 缺 id 会被拒。maintenance 心跳暂停时主机制仍健康,兜底瘫痪不可见,只能靠巡检发现 | 同上 :22-27 |
| 3. 双哨挂载 | 正信号哨(ACK 落板)加负信号哨(events.jsonl 的 goal_transition blocked 与 escalation);只盯正信号时,goal 熔断的沉默与「还在干活」不可区分 | 同上 :28-30 |
| 4. fallbacks 与会话快照核对 | fallbacks 已配置且新会话已完成快照;改配置不重启等于纸面保险,工具表在会话建立时快照、不回补 | 同上 :31-32 |
F.5 第五信道参数与车道模板
第五信道由 octoscode olp-mcp-serve 子命令提供(#31 起纯 Rust 标准库实现,源文件 octoscode/src/olp_mcp.rs,共 406 行),暴露恰好两个 MCP 工具,供内环在 turn 内同步向外环发问或直通落板。工具注册表见 octoscode/src/olp_mcp.rs:328-352。
ask_outer 参数(三参全必填,required 声明在 octoscode/src/olp_mcp.rs:340):
| 参数 | 语义 | 校验 | 来源行号 |
|---|---|---|---|
question | 问外环的问题 | 为空直接拒绝 | 同上 :197-198 |
context | 卡在何处、相关状态 | 无额外校验 | 同上 :183、:336 |
tried | 已自行尝试过什么 | 为空拒绝(防思考外包:先试再问) | 同上 :186-188、:32 |
防滥用常量与行为:
| 项 | 值 | 行为 | 来源行号 |
|---|---|---|---|
| 超时 | 90 秒(ASK_TIMEOUT_SECS) | 超时返回降级指引:按黑板既有指导推进,无法推进则以 ACK(blocked) 收场并注明问询 id | 同上 :25、:30、:248-251 |
| 配额 | 每片 3 次(ASK_QUOTA_PER_SLICE) | 超出返回拒绝话术,提示自行推进或改用 report_blocked | 同上 :27、:31、:193-195 |
| 轮询间隔 | 0.5 秒(ASK_POLL_INTERVAL_SECS) | 答案文件轮询节奏 | 同上 :26 |
| 信箱 | ~/.octos/outer/mcp/ 下 questions、answers、consumed 三级流转 | 取答后问答对归档 consumed 并删除原文件 | 同上 :109-111、:231-241 |
| 审计 | 落 OUTER_LOOP_MCP.md,署名固定 MCP(ask_outer) | 全程留痕 | 同上 :24、:28 |
report_blocked 参数:reason(为何阻塞)与 needs(需要什么解除),二者必填,空 reason 拒绝;直通落板,无信箱往返(同上 :255-272、:344-352)。
sub_providers 车道模板(v1 附开箱模板,契约测试 olp_lane_template_parses 钉住;来源:octoscode/docs/OUTER_LOOP_PROTOCOL.md:375-391):
| 车道 | model | description 要点 | 来源行号 |
|---|---|---|---|
| cheap | kimi/kimi-k2-turbo | 低成本高吞吐:机械、低风险、强可回滚的任务(文档、测试诊断、日志分类、格式化),做错代价是一次重跑 | 同上 :384-386 |
| strong | anthropic/claude-opus | 长链推理与跨文件架构判断(审查定级、wontdo 复核、多步调试),做错会污染主线判断 | 同上 :388-390 |
双环搭配矩阵(同上 :393-400):
| 工作性质 | 车道 |
|---|---|
| 分析(读代码、写摘要、分类盘点) | cheap |
| 验证(跑测试、复验 R2 声明、机械断言) | cheap |
| 实施(写生产代码、契约测试、schema 改动) | primary(主档,不走路由) |
| keeper(goal 推进、ledger 记账、状态判断) | primary |
矩阵理由:分析与验证的产出被外层审查兜底,错了可重跑;实施与 keeper 的产出直接进主线与账本,错误成本高,留在主档由 R2 与外层审查控质(同上 :402-403)。
F.6 端到端追踪(全书贯穿)
前五节是协议侧的静态速查,本节换一个维度:从全书 21 章里挑两条横切链路,把一个用户可见的动作从入口追到出口,验证各章分别讲过的机制在真实执行顺序里如何咬合。写法统一:先给时序图,再按阶段展开,每阶段标注所属章节与源码路径;行号全部沿用各章正文已有的引用,不新造行号(基线见本节末尾版本演化说明)。协议机制(R1 ACK、frontmatter、车道矩阵)F.1 至 F.5 已有速查表,此处只引用、不重复展开。
Trace 1:一条 Matrix 用户消息如何变成频道里的回复
贯穿章节:第 11 章(消息总线,入站与会话)→ 第 5 章(Agent Loop 六阶段)→ 第 6 章(工具系统执行)→ 第 8 章(上下文压缩),回程再经第 11 章的出口切块机制。场景:用户在 Matrix 房间里发一句「把 ch07 的引用核对一遍」,期待 agent 读完文件、跑检查、把结论回到同一个房间。
sequenceDiagram
participant U as 用户 Matrix
participant B as octos-bus 总线
participant L as agent-loop 循环
participant T as 工具层
U->>B: 发送一条消息
B->>B: 长轮询 sync 收事件并鉴权
B->>B: SessionActor 追加写 JSONL
B->>L: AgentHandle 交付 InboundMessage
loop 六阶段循环, stop_reason 为 ToolUse
L->>L: 消息准备与预算检查
L->>L: 调用 LLM 流式消费
L->>T: execute_tools 派发工具批
T->>T: execute_with_context 执行
T-->>L: ToolResult 消息回填
L->>L: turn 状态更新
end
L->>L: 窗口逼近上限, 压缩历史
L-->>B: OutboundMessage 最终回复
B->>B: split_message 按平台限额切块
B-->>U: 分块回复送达 Matrix 房间
阶段一,入站与落账(第 11 章)。 用户账号模式下 Matrix 频道对 homeserver 的 Client-Server /sync API 做长轮询,超时 30 秒(SYNC_TIMEOUT_MS,crates/octos-bus/src/matrix_user_channel.rs:44):请求挂着,有事件才返回,返回后立刻发下一个,没有推送权限的自建服务器也能近实时收消息。收到的消息先过频道层的两道门:发送者鉴权 is_allowed() 在路由给 Agent 之前执行,默认放行、各频道按需覆盖(crates/octos-bus/src/channel.rs:27-30);入站去重针对的是 webhook 类平台的超时重发,MessageDedup 用容量 1,000、TTL 60 秒的 LRU 缓存已见消息 ID(crates/octos-bus/src/dedup.rs:12-25),Discord 网关重连重放挂的是同一个实例(crates/octos-bus/src/discord_channel.rs:32)。所有频道实现同一个 Channel trait,26 个方法里只有 name()、start()、send() 没有默认实现(crates/octos-bus/src/channel.rs:17-265),所以新频道可以只写三个方法先跑起来。随后消息进入会话层:SessionActor 每会话持独立 SessionHandle,优先 per-user 布局并在打开时自动迁移旧文件(crates/octos-bus/src/session.rs:1611-1819);同 key 写入经 persist_message_through_canonical_path() 以 per-key Tokio mutex 串行化,避免多个写入口重复计数(crates/octos-bus/src/session.rs:2332-2420)。总线与处理层的解耦靠对称通道 AgentHandle / BusPublisher(crates/octos-bus/src/bus.rs:8-77):所有频道的 inbound 发送端 drop 后,接收端 recv() 返回 None,处理层自动感知可以优雅退出,不需要额外的 shutdown 信号。
随后消息进入会话层:SessionActor 每会话持独立 SessionHandle,优先 per-user 布局并在打开时自动迁移旧文件(crates/octos-bus/src/session.rs:1611-1819);JSONL 文件头一行是 SessionMeta 元数据而非消息,后续每行才是消息(crates/octos-bus/src/session.rs:560),追加走 append_to_disk()、整体重写走 write-then-rename 的 rewrite(),单个会话文件 10MB 封顶防止失控历史耗尽磁盘(crates/octos-bus/src/session.rs:792);落盘后的 durable commit observer 只是 best-effort fan-out,失败不回滚(crates/octos-bus/src/session.rs:71)。同 key 写入经 persist_message_through_canonical_path() 以 per-key Tokio mutex 串行化,避免多个写入口重复计数(crates/octos-bus/src/session.rs:2332)。总线与处理层的解耦靠对称通道 AgentHandle / BusPublisher(crates/octos-bus/src/bus.rs:8-77):所有频道的 inbound 发送端 drop 后,接收端 recv() 返回 None,处理层自动感知可以优雅退出,不需要额外的 shutdown 信号。
阶段二,进入循环(第 5 章)。 处理层拿到消息后走 process_message 家族,一次 turn 按 ①消息准备、②预算检查、③LLM 调用、⑤工具派发、⑥状态更新推进。①之前历史先送修复管线,入口是 prepare_conversation_messages(crates/octos-agent/src/agent/loop_compaction.rs:27),统一 tool_call_id、修复消息顺序与工具配对,避免破损历史触发 provider 校验失败。②每次迭代开头跑 check_budget(crates/octos-agent/src/agent/budget.rs:100),五道闸按固定顺序,任何一道命中即返回 BudgetStop(crates/octos-agent/src/agent/budget.rs:13),顺序本身就是设计:shutdown 原子加载最先响应,迭代上限次之,两种超时依赖活动跟踪(crates/octos-agent/src/agent/activity.rs:16),token 预算最后。③整个调用编排收敛在单一主函数 call_llm_with_hooks(crates/octos-agent/src/agent/llm_call.rs:22),重试尝试的 token 并入最终 usage 记账;流式消费由 pub(super) 的 consume_stream_with_input_estimate(crates/octos-agent/src/agent/streaming.rs:73)完成。错误路径不是散落的 unwrap,而是先经 classify_loop_error(crates/octos-agent/src/agent/loop_runner.rs:313)归类进 typed retry-bucket 状态机,调用侧拿到的只有 Retry 或 Bail 两个粗粒度动作。响应的 stop_reason 决定下一跳:EndTurn 返回、MaxTokens 走续跑与自愈(对话循环的续跑分支在 crates/octos-agent/src/agent/loop_runner.rs:2171 附近,nudge 提示续跑上限 2 次)、ToolUse 回到工具派发。
阶段三,意图变成副作用(第 6 章)。 工具派发主入口 execute_tools 是 pub(super)(crates/octos-agent/src/agent/execution.rs:2483),它决定串行还是并行、计算批超时、执行前后 hook;取消与 panic 都要投影成 LLM 能读的工具结果消息而不是让循环崩掉。工具本体实现 Tool trait(crates/octos-agent/src/tools/mod.rs:609),执行层走 M8.1 引入的类型化入口 execute_with_context(crates/octos-agent/src/tools/mod.rs:11-28),遗留的 execute 与类型化入口互相委托、一个工具至多覆写其一。注册与查找在 ToolRegistry(crates/octos-agent/src/tools/registry.rs:536 的 register、:558 的 register_arc),派发边界叠四层防线:provider 策略拒绝、参数体积上限、catch_unwind 的 panic 隔离(一个工具 panic 只降级为失败的 ToolResult,不再连带杀死 session actor)、全局执行超时。被标记 spawn_only 的工具在派发点被拦截转后台任务(crates/octos-agent/src/agent/execution.rs:775 附近),LLM 拿到的是小型 task_handle 信封,五种读法按需检查中间产物而不撑大上下文。本场景里 read_file 与 bash 的结果作为 ToolResult 消息回填,循环回到 ⑥:LoopTurnState(crates/octos-agent/src/agent/turn_state.rs:59)累积 usage 与终止原因,下一轮迭代继续。
阶段四,窗口回收(第 8 章)。 十几次工具迭代后窗口逼近上限,循环在错误分类处触发 CompactAndRetry,带内完成、调用方无须穿透压缩状态(crates/octos-agent/src/agent/loop_runner.rs:313)。压缩分层削减:本地占位符、服务端清理、完整摘要三档;最近消息无条件保留满六条且切割点不落在 Tool 消息上,放不下时退回尾部截断 fallback_truncate()(crates/octos-agent/src/agent/compaction.rs:319)。压缩掉的输出并非丢失:字节可凭 id 用 recall 取回,语义摘要作为可检索 episode 存档,入口压缩与轮内压缩各存一次(crates/octos-agent/src/agent/loop_runner.rs:1215)。压缩完成后循环继续,直到 stop_reason 为 EndTurn。本场景里 ch07 的引用核对要读多个事实表文件,若没有 recall 机制,同一个源文件会进窗口、被截断、被压缩、再被读取地循环;三层回收把这变成一次读加多次凭 id 取回。
阶段五,回程(第 11 章出口)。 最终回复作为 OutboundMessage 回到频道层,超过平台字符限制时长消息交给 coalescing:split_message() 按段落、句号、空格、硬切五级优先级找断点,先构造 UTF-8 安全窗口再在窗口内 rfind(crates/octos-bus/src/coalesce.rs:68 起的两步切割),MAX_CHUNKS = 50 防止极长消息碎片化成小消息洪水(crates/octos-bus/src/coalesce.rs:47);断点若等于 0 会被跳过,保证向前推进,不会在同一位置切出空块。分块经频道渲染送达 Matrix 房间;频道健康由 health_check() 统一暴露给管理面板(crates/octos-bus/src/channel.rs:245),而不是散落在各频道自己的管理接口。至此一条消息完成了从平台事件到分块回复的完整往返:入口去重、会话落账、六阶段循环、工具执行、上下文回收、出口切块,每一站都有自己的失败投影。
这条追踪揭示的系统性事实:入口与出口各有一道独立防线(入站去重、出站切块),坏消息进不来也出不去;循环的器官全部 pub(super),crate 边界只暴露循环本身;上下文被当作流体管理,压缩、recall、episode 三层保证信息只降密度不丢失;每一层的失败(重复投递、超限消息、工具 panic)都有降级投影,而不是中断整条链路。
Trace 2:一个 goal 从创建到双环收口
贯穿章节:第 18 章(goal/peer 双线)→ 第 12 章(并发三层与租约)→ 第 18 章(三条回流通道)→ 第 20 章(外环 OLP 观测与判词)。场景:operator 给 master 一句宏观指令「把附录 F 补两条端到端追踪」,从建目标到 goal_update complete 加外环 ACK 收口。
sequenceDiagram
participant O as operator
participant M as master keeper
participant G as GoalLedger 账本
participant P as peer worker
participant W as 外环 outer
O->>M: 宏观指令
M->>G: goal_create 建目标 status 为 active
M->>G: goal_plan 分解, goal_dispatch 派任务
M->>P: peer_handoff 落盘 brief 与 goal
P->>P: boot 读回, turn 循环, 租约保活
P->>G: append_finding 落权威历史
P-->>W: result.md 与事件流上行
W->>W: R1 ACK 与 R2 复验
W-->>M: 黑板指导下行
M->>G: goal_update complete 过 verifier
G-->>O: 账本即审计事实, 双环收口
阶段一,建账(第 18 章)。 keeper 侧的 goal_create(GoalCreateTool,crates/octos-cli/src/goal_tool.rs 的 :1495/:1509,准入检查跨两次调用串行化)接线在 crates/octos-cli/src/runtime/profile.rs:1326。落账走 GoalLedger(crates/octos-fleet/src/sqlite_ledger.rs:13):WAL 模式 SQLite,master、进程管理器、peers 是独立进程,共享同一个 goal-ledgers/<goal_id>.db;impl GoalLedger 的 39 个 pub fn 按用途分五组,目标的每次状态转移、每条发现、每个升级请求、每条决策都留审计痕迹,一个账本文件 6,360 行的体量由此而来。账本状态集是 active、complete、blocked、budget_limited、paused、cleared 六个字符串状态(crates/octos-fleet/src/sqlite_ledger.rs:39 注释,终态保护为 complete 与 cleared 两种);archived 不在账本状态集里,它是 supervisor 事件流侧的终态标记,两本账的状态集不混写。随后 goal_plan 把目标分解到持久 fleet、goal_dispatch 把就绪任务发到活的 worker 池(接线 crates/octos-cli/src/runtime/profile.rs:1341 与 :1342)。计划与执行的机器状态在 fleet 内核(redb),目标与审计在 GoalLedger(SQLite),两本账互不替代;keeper 把目标放进账本而不是对话上下文,正是对「长程目标放在上下文里会腐烂」这一章首问题的回答。
阶段二,派 peer(第 18 章入口,第 12 章的并发底座)。 模型侧入口 peer_handoff 只做参数校验(crates/octos-agent/src/tools/peer_handoff.rs:133),staging 由 stage_peer(crates/octos-cli/src/peers/mod.rs:1563)按固定顺序落盘 worktree、originator、goal、brief、name;peer boot 时 read_peer_boot(crates/octos-cli/src/peers/host.rs:96)读回执行上下文,originator 只读一次防止运行中被重绑定。存活语义由第 12 章的三层并发模型托底:peer/lease 层的 PeerTaskBinding(crates/octos-cli/src/peers/mod.rs:166)把 peer 绑到 supervisor 的 TaskLivenessLease 上;fleet 侧的 Lease(crates/octos-fleet/src/records.rs:250)只有 owner_epoch 与 expires_at_ms 两个字段,daemon 重启拿新 epoch,旧主人自动失权;supervisor 事件账本 SupervisorStore(crates/octos-cli/src/autonomy/supervisor_store.rs 的 :697)持久化受监管 agent 组状态,load_state(:780)在重启时读回。peer 会话的工具面被刻意收窄:goal 绑定的 peer 只接线 goal_get 与 goal_update(crates/octos-cli/src/commands/chat.rs:859),看得到目标、能把发现记回 master 的账本,拿不到 plan 与 dispatch,无权改写计划。
阶段三,执行与回流(第 18 章)。 peer 有自己的 turn 循环与 token 预算,Trace 1 的六阶段循环就是它的基准变体,区别只在工具面与预算上限。终局产物由 write_peer_result_if_peer_session(crates/octos-cli/src/api/ui_protocol_transport.rs:14279)写回黑板,运行时落 frontmatter 四字段 slug/outcome/updated_unix/turn(:14334),turn 号由 result-<n>.md 计数加一推出;预算耗尽的 peer 另写五字段检查点副本(crates/octos-agent/src/agent/budget.rs:584),status/completed/iteration_budget/iterations_used/checkpoint_commit 五个字段把「跑到哪、差多少」留给下一次派发。回流通道有三条,在 goal_get 汇聚:live 通道读 peers/<slug>/result.md,快但会被覆盖;durable 通道走 GoalLedger::append_finding(crates/octos-fleet/src/sqlite_ledger.rs 的 :1623),goal-scoped peer 完成 turn 即落盘,重启仍在,是权威历史;escalation 通道在 peer park 时写 append_escalation(:1635),master 错过实时通知也能在 goal_get 的 open_escalations 里看到谁在等。外部事件经 fleet_wake(crates/octos-cli/src/autonomy/fleet_wake.rs)消费 fleet outbox 转成续跑请求,只有持久化成功(WakeCommit::Durable)才 ack,未持久化的事件租约过期后重投;请求入队点在 crates/octos-cli/src/autonomy/agent_orchestrator.rs:12963,GoalContinue 与 GoalWrapUp 两个变体(crates/octos-cli/src/autonomy/master_continuation_scheduler.rs 的 :141/:147)驱动 keeper 下一 tick,后者是预算耗尽后的收尾 turn。peer 终局以 closed 墓碑标记(peer_is_closed,:1317),master 用 peer_gather 拉取 result.md 收账;命令行观察面 octos peer list 同样直读黑板目录,零 serve 依赖。
阶段四,外环判词(第 20 章)。 双环不共享内存,一切协作走可审计的持久信道:上行有事件流、result.md、goal-ledgers、escalation、git diff 与主动问询,下行有 AGENTS.md、黑板、原子 commit、inbox 门铃与 TUI 注入。外环上岗前先取主审权锁,以 octoscode outer-duty hold 包裹启动,锁即 authority。观测分三层:投递、消费、执行,只看一层必误判,这也是外环最容易犯的错。判词的载体是黑板 Active 区每条意见的 ACK 行:执行后必须补一行,无 ACK 视为未读,外环有权打回交付(octoscode/docs/OUTER_LOOP_PROTOCOL.md:52 起,v1 定式语法由 octoscode/tests/olp_contract.rs:96 钉住);交付审查按 R2 三档验证级别复核 result.md 的 verified 声明,声称 verified 但复验不符视为协议违例,打回并记黑板。frontmatter 的协议 schema 为六字段(octoscode/docs/OUTER_LOOP_PROTOCOL.md:354,字段清单 :362 至 :367),消费侧忽略未知字段以保前向兼容,verified 与 protocol 两字段由 octos 侧写入。内环在 turn 内主动问询走第五信道 ask_outer(octoscode/src/olp_mcp.rs:174),超时与配额行为见 F.5 表:超时降级按既有黑板指导推进,无法推进则以 ACK(blocked) 收场并注明问询 id;配额超出直接拒绝,提示改用 report_blocked。本场景里外环对 F.6 两节初稿的评审意见正是经黑板下行、经 ACK 上行,与 goal 账本的状态机并行运转、互不阻塞。
阶段五,收口(第 18 章)。 master 调 goal_update 主张 complete(GoalUpdateTool,crates/octos-cli/src/goal_tool.rs 的 :1163/:1266,接线 crates/octos-cli/src/runtime/profile.rs:1329,若配置 verifier 车道则挂独立校验),完成主张要过 verifier 才生效。状态转移的唯一入口 cas_goal_status(crates/octos-fleet/src/sqlite_ledger.rs 的 :899)在 UPDATE 语句里内嵌预算规则:目标 active 且 tokens_used >= token_budget 时直接写 budget_limited 而不是调用者要写的终态;update_goal_status(:1498)走另一条非 CAS 的转移路径,complete 与 blocked 只有模型可到,reopen 只认 blocked、paused、budget_limited 三种入口,账本侧的终态保护是 complete 与 cleared 两种。外环在这一步之后做什么:黑板 Active 区的评审项逐条核对 ACK 是否落地、R2 声明的验证档位是否与实际跑过的命令相符,复核通过才同意 operator 代推分支;若有 wontdo 分歧,按 F.1 的分歧规则只能接受或升级 operator,不得对同一条目再次打回。goal_update complete 之后,账本是审计事实,外环的 ACK 与黑板历史区是人可读的对照面,两圈各自留痕、互不覆写;若日后 operator 重开此目标,reopen 的三种入口也都能在账本的状态转移记录里找到出处。
这条追踪揭示的系统性事实:目标的状态只活在一本 SQLite 账里,人读黑板、模型读工具、外环读文件,三种读者看同一个事实的三种投影;所有权用租约与 epoch 表达,不依赖进程内锁,重启即自动纠偏;「完成」是一个要过独立验证的主张,不是一次写操作;错过通知被从异常路径变成普通读路径,goal_get 一次调用把三通道的账收齐。
版本演化说明
- v0 至 v1(2026-08-24 生效):R1 ACK 定式语法化(done/wontdo/blocked 与 wontdo 分歧规则);result.md frontmatter v1 schema 固化;sub_providers 车道模板(附录 A、B 随之定稿)。v1 语法只约束生效日起新增的 ACK 行(octoscode/docs/OUTER_LOOP_PROTOCOL.md:9-12)。
- v1 至 v2(2026-08-30 生效,#38-r1):新增 R7 主审权 OS 独占锁(outer-duty hold/check;锁即 authority、check 仅观察、活锁接管归 operator、metadata/TTL 仅诊断;Linux-only 单机 flock 加 PDEATHSIG,Windows 另立条目,NFS 不适用);
AGENTS.md引用同步 v2(同上 :14-17)。 - R4b 树主权与自动围栏系 octos #20-20c 移植,作为 R4 子条款收录,不升协议版本(同上 :88)。
- 后续:L3 平台扩展(R7 主审锁 macOS 支持)已于 2026-09-02 立项,守护 reaper 进程加 kqueue 复刻死亡耦合,信道语义不变、不升协议版本(同上 :167-172)。
- 本附录全部行号以 octoscode 仓库 main @ 1129fa33 为基线,后续协议演进请以仓库内文档为准。F.6 追踪基线同全书 octos main @ 9c157101。