前言

为什么写这本书

AI Agent 正在从实验室走向生产。但当你翻开大多数 Agent 框架的源码,看到的是 Python 脚本、字符串拼接的 prompt、和 try: ... except: pass 式的错误处理。这在原型阶段没有问题,但当你需要让 Agent 在生产环境中 7x24 运行、服务多个租户、执行 Shell 命令和文件操作时,你需要的不是一个框架,而是一个操作系统级别的基础设施。

octos 是一个用 Rust 构建的 AI Agent 操作系统。当前主分支约 26 万行 Rust 源文件,11 个 octos-* 核心 crate,workspace 级别 deny(unsafe_code)。它不是"Rust 写的 LangChain"——它是从第一行代码就为多租户、安全隔离、生产可靠性设计的系统。

这本书不是 octos 的用户手册。它是一本工程决策解析——每一章深入一个子系统的源码,展示"为什么这样做"、"考虑过什么替代方案"、"付出了什么代价"。如果你想理解如何用 Rust 的类型系统消除运行时错误、如何用三层容错链实现 LLM 调用的生产级可靠性、如何在不引入外部向量数据库的情况下构建混合搜索——这本书为你而写。

阅读准备

前置知识

  • Rust 基础:理解所有权、借用、生命周期、trait、枚举。不需要精通,但需要能读懂 Rust 代码
  • 异步编程概念:理解 async/await、Future、事件循环。不需要 Tokio 经验
  • AI/LLM 概念:理解什么是 LLM、token、上下文窗口、工具调用。不需要 prompt engineering 经验
  • 不需要:编译器原理、操作系统内核开发、机器学习数学

推荐阅读路径

本书 14 章 + 5 附录,根据你的背景选择最适合的路径:

路径 A:Rust 学习者(通过实战项目学 Rust)

Ch1 → Ch2 → Ch4 → Ch5 → Ch6 重点关注类型系统设计(Ch2)、枚举状态机、错误处理模式

路径 B:资深 Rust 开发者(学习大型 AI 系统架构)

Ch1 → Ch3 → Ch5 → Ch7 → Ch11 → Ch13 重点关注 trait object 选型(Ch3)、并发模型(Ch11)、安全纵深(Ch7)

路径 C:AI/LLM 应用开发者(理解 Agent OS 设计)

Ch1 → Ch3 → Ch5 → Ch8 → Ch9 重点关注 Provider 容错(Ch3)、Agent Loop(Ch5)、上下文管理(Ch8)

路径 D:octos 贡献者(深入内部实现)

全部章节按序阅读 + 附录 E(贡献指南)

全书知识地图

graph LR
    subgraph "Part 1: 地基"
        C1["Ch1<br/>为什么 Rust"]
        C2["Ch2<br/>Core Types"]
        C3["Ch3<br/>LLM Providers"]
        C4["Ch4<br/>Memory"]
    end

    subgraph "Part 2: 引擎"
        C5["Ch5<br/>Agent Loop ★"]
        C6["Ch6<br/>工具系统"]
        C7["Ch7<br/>安全"]
        C8["Ch8<br/>上下文"]
        C9["Ch9<br/>扩展"]
    end

    subgraph "Part 3: 平台"
        C10["Ch10<br/>消息总线"]
        C11["Ch11<br/>并发"]
        C12["Ch12<br/>Pipeline"]
        C13["Ch13<br/>运行模式"]
        C14["Ch14<br/>生产化"]
    end

    C1 --> C2 --> C3
    C2 --> C4
    C3 --> C5
    C4 --> C5
    C5 --> C6 --> C7
    C5 --> C8
    C6 --> C9
    C5 --> C10 --> C11
    C5 --> C12
    C10 --> C13 --> C14

★ Ch5(Agent Loop)是全书枢纽——理解了它,前四章是它的基础,后九章是它的延伸。

阅读标记说明

  • 源码引用crates/octos-core/src/task.rs:63-77 格式,可直接在源码仓库中定位
  • 工程决策侧栏:每章一个,用 > 引用格式高亮,分析 2-3 种替代方案的利弊
  • Mermaid 图表:架构图、状态机图、流程图,可用 Mermaid Live Editor 渲染
  • 思考题:每章结尾 3-4 道开放式问题,适合团队讨论或面试准备

第 1 章:为什么是 Rust?为什么是 Agent OS?

定位:本章是全书开篇,回答一个根本问题——为什么要用 Rust 构建多租户 AI Agent 平台?前置依赖:无。适用场景:任何想理解 octos 项目存在理由的读者,无论你是 Rust 初学者(读者 A)、资深 Rust 开发者(读者 B)、还是来自 Python/Go 生态的 AI 应用开发者(读者 C)。

当你第一次打开 octos 的代码仓库,看到 26 万多行 Rust 源文件、400+ 个 Rust 文件,以及一个由 11 个 octos-* 核心 crate、14 个 app skill、1 个 platform skill 组成的 Cargo workspace,心中难免浮现一个问题:为什么不用 Python?LangChain 和 AutoGen 不是已经很成熟了吗?为什么不用 Go?它的并发模型不是更简单吗?

这不是一个关于语言偏好的问题。当你把「AI Agent」从单用户玩具推向多租户生产平台时,你面对的是一组相互纠缠的工程约束:安全隔离、并发控制、性能预算。这三个约束中的任何一个都不难单独解决,但当它们同时出现在一个系统中时,语言选型就不再是品味问题,而是架构决策。

本章将从问题空间出发,解释这三大挑战为什么如此棘手,然后论证 Rust 为什么是目前最适合应对这组约束的语言,最后展开 octos 的 workspace 拓扑,为后续 13 章建立全局地图。


1.1 问题空间:多租户 AI Agent 平台的三大挑战

要理解 octos 的设计决策,首先要理解它试图解决的问题。octos 不是一个 chatbot 框架——它是一个多租户 AI Agent 操作系统,需要同时为多个用户、多个 Agent 实例提供服务,每个 Agent 都可以调用文件系统操作、Shell 命令、网络请求等具有副作用的工具。

1.1.1 挑战一:安全隔离

想象一个场景:租户 A 的 Agent 被 prompt 注入攻击,恶意指令试图读取租户 B 的会话历史,或者执行 rm -rf / 来破坏宿主机。在多租户环境中,这不是理论风险,而是日常威胁。

AI Agent 的安全隔离比传统 Web 服务更复杂,原因有三:

  1. 工具调用是 Agent 的核心能力。Agent 不只是生成文本——它执行 Shell 命令、读写文件、发起网络请求。每一次工具调用都是一个潜在的攻击面。octos 的默认工具注册表至少包含 Shell/File/Web/Browser 等内置工具;启用 git / ast feature,或进入 Gateway/Serve 运行时后,还会再注册记忆、模型切换、研究与管理类工具(../octos/crates/octos-agent/src/tools/registry.rs; ../octos/crates/octos-cli/src/commands/gateway/gateway_runtime.rs)。每一类工具都需要独立的安全策略。

  2. Prompt 注入是新型攻击向量。与传统 SQL 注入不同,prompt 注入发生在自然语言层面,更难用正则表达式或 WAF 规则拦截。攻击者可以在看似无害的文档中嵌入指令,诱导 Agent 执行越权操作。

  3. 隔离粒度需要精细控制。不同租户需要不同的权限边界:有的允许访问 Git 仓库,有的只允许只读文件操作,有的需要完全的沙箱隔离。一刀切的隔离策略要么太松(安全风险),要么太紧(功能受限)。

octos 的应对策略是纵深防御——从 Rust 语言层面消除内存安全漏洞,到 Linux bwrap / macOS sandbox-exec / Docker 三后端沙箱提供进程级隔离,再到工具级别的 deny-wins 策略引擎实现细粒度权限控制,构建了多层安全屏障(详见第 7 章)。

举一个具体例子:当 Agent 执行 Shell 命令时,octos 的 ShellTool 会先通过 SafePolicy 检查命令是否在危险命令黑名单中(如 rm -rf /ddmkfs、fork bomb 等),然后将命令提交到沙箱环境中执行。即使 prompt 注入成功诱导 LLM 生成了恶意命令,这两道防线仍然可以拦截。而沙箱本身的实现依赖 Rust 的类型系统确保资源句柄不会泄漏——文件描述符在 Drop 时自动关闭,不会出现 C/C++ 中常见的资源泄漏问题。

1.1.2 挑战二:并发控制

一个生产级 Agent 平台需要同时处理大量并发请求。考虑以下场景:

  • 10 个用户同时与各自的 Agent 对话
  • 每个 Agent 在一次迭代中可能并行调用 3-5 个工具
  • 每个工具调用可能涉及异步 HTTP 请求、文件 I/O、子进程管理
  • 后台还有 Cron 任务和 Heartbeat 定时触发新的 Agent 会话

这意味着系统中可能同时存在数百个异步任务。并发本身不是问题——问题是并发中的正确性:

  • 会话级串行化:同一个用户的消息必须按序处理,不能出现两条消息同时修改同一个会话状态的情况。Serve/API 路径把会话状态收敛到受 Arc<tokio::sync::Mutex<_>> 保护的共享管理器中,确保读写不会并发踩踏(../octos/crates/octos-cli/src/commands/serve.rs)。
  • 工具级并行:在单次 Agent 迭代内,多个不相关的工具调用应该并行执行以减少延迟。当前实现把工具任务句柄交给 futures::future::join_all 聚合,并在超时层外包一层 tokio::time::timeout../octos/crates/octos-agent/src/agent/execution.rs)。
  • 资源限流:无限制的并发会耗尽系统资源。octos 通过 tokio::sync::Semaphore 限制最大并发会话数;默认值定义在配置层,Gateway 启动时再把它实例化成并发信号量(../octos/crates/octos-cli/src/config.rs; ../octos/crates/octos-cli/src/commands/gateway/gateway_runtime.rs)。
  • 优雅关停:当收到 SIGTERM/CTRL-C 时,不能粗暴地杀死正在进行的 Agent 对话。octos 使用 AtomicBool 标志位实现优雅关停:Gateway 在信号处理路径上写入 shutdown flag,Agent Loop 在预算检查和流式消费路径上读取这个标志,让进行中的对话自然结束(../octos/crates/octos-cli/src/commands/gateway/gateway_runtime.rs; ../octos/crates/octos-agent/src/agent/budget.rs; ../octos/crates/octos-agent/src/agent/streaming.rs)。

Python 虽然可以通过 multiprocessingconcurrent.futures.ProcessPoolExecutor 实现 CPU 并行,但进程间通信的序列化开销使其不适合上述细粒度共享状态的并发模式。Go 的 goroutine 模型可以实现这些模式,但数据竞争只能通过 -race 运行时检测发现——尽管 Go 的 race detector 基于 happens-before 算法,在实际测试中相当有效,但它本质上依赖测试覆盖率,无法提供编译期的完备性保证。Rust 的 Send/Sync trait 则在编译期消除了整类数据竞争(详见第 11 章)。

1.1.3 挑战三:性能预算

AI Agent 的主要延迟瓶颈是 LLM API 调用(通常 1-10 秒),这让很多人认为 Agent 框架的性能无关紧要。这是一个危险的误解。

首先,延迟是累积的。 一次 Agent 执行可能包含多达 50 次迭代(octos 的默认上限),每次迭代涉及消息构建、工具调用、上下文压缩。如果框架层面每次迭代增加 50ms 开销,50 次迭代就是 2.5 秒——对于流式交互场景,这是用户可感知的延迟。

其次,内存是多租户的硬约束。 每个 Agent 会话需要维护对话历史、工具状态、上下文窗口。如果每个会话占用 100MB 内存(Python 应用中并不罕见),10 个并发会话就是 1GB,100 个就是 10GB。octos 的核心数据结构设计注重零拷贝和最小分配——例如 truncate_utf8 函数(../octos/crates/octos-core/src/utils.rs)通过 UTF-8 字符边界检测实现安全截断,避免不必要的字符串复制。

最后,上游 SSE 流式解析需要持续的 CPU 效率。 LLM Provider 的流式响应以 Server-Sent Events(SSE)格式传输,框架需要在 token 到达的毫秒级时间内完成解析,并把它投递成 Agent 内部进度事件。在多租户场景下,平台可能同时维护数十条上游 Provider 流,每条流持续数十秒。如果解析器每次事件都触发堆分配,高并发下的分配压力会导致延迟尖刺。

octos-llm 的有状态 SSE 解析器(../octos/crates/octos-llm/src/sse.rs:5-72)设置了 1MB 缓冲上限,采用增量解析策略——数据追加到字节缓冲区中,在完整事件边界处再做 UTF-8 转换。这种设计避免了 GC 语言中常见的"解析触发 GC、GC 阻塞所有连接"的级联效应,也避免中文等多字节字符被 chunk 边界切坏。

一个容易被忽视的成本:上下文压缩。 当对话历史接近 LLM 的上下文窗口限制时(通常 128K-200K tokens),octos 需要执行上下文压缩(Context Compaction)——将旧消息摘要化以腾出空间(详见第 8 章)。这个操作涉及大量字符串处理和 token 计数,在 GC 语言中容易产生大量临时对象和 GC 压力。octos 通过 truncate_utf8../octos/crates/octos-core/src/utils.rs)等 UTF-8 安全工具函数,以及工具注册表里的 JSON size 估算路径(../octos/crates/octos-agent/src/tools/registry.rs),将这些热路径的内存开销降到最低。


1.2 语言选型:为什么是 Rust

理解了问题空间之后,我们可以在三个维度上比较候选语言:安全性、并发模型、运行时性能。

1.2.1 安全性维度

特性PythonGoRust
内存安全GC 保证,但 C 扩展不受保护GC 保证所有权系统编译期保证
类型安全动态类型,运行时错误静态类型,但 any 绕过编译检查强静态类型 + 枚举穷举匹配
unsafe 控制无此概念unsafe 包,但无编译器约束unsafe 块 + workspace 级 deny(unsafe_code)
依赖安全PyPI 无签名验证go.sum 校验Cargo 校验 + cargo-audit

octos 在 workspace 根 Cargo.toml 中设置了 unsafe_code = "deny"../octos/Cargo.toml[workspace.lints.rust]),这意味着整个 workspace 的核心 crate 和 skill 程序都继承同一个安全基线。这不是一个 lint 建议,而是一个编译期硬约束。任何包含 unsafe 块的代码都无法通过 cargo build

对于一个需要执行 Shell 命令、读写文件系统的 Agent 平台,这个约束的意义在于:所有与操作系统的交互都通过标准库的安全抽象完成,消除了缓冲区溢出、use-after-free 等内存安全漏洞的可能性。

相比之下,Python 的 AI 框架大量使用 C 扩展(numpy、tokenizers 等),这些 C 代码不受 Python GC 保护。Go 虽然有内存安全保证,但 unsafe 包的使用没有编译器级别的全局禁止机制。

1.2.2 并发模型维度

特性PythonGoRust (Tokio)
并发原语asyncio(单线程事件循环)goroutine + channelasync/await + Tokio 多线程运行时
CPU 并行GIL 限制,需多进程原生支持原生支持
数据竞争检测-race 运行时检测Send/Sync 编译期保证
结构化并发有限(TaskGroup无内置支持tokio::select! + JoinSet

Rust 的核心优势在于 SendSync trait 提供的编译期线程安全保证。考虑 octos 中的一个典型场景:Agent 配置(AgentConfig)需要在多个异步任务间共享。在 Go 中,你可能会用一个普通指针传递配置,直到某天在高并发下触发数据竞争。Go 的 race detector 虽然基于成熟的 happens-before 算法,能有效检测实际执行路径上的竞争,但它本质上是运行时工具——只有被测试覆盖到的代码路径才能被检测。

在 Rust 中,如果你试图在线程间共享一个非 Send 类型,编译器会直接拒绝:

#![allow(unused)]
fn main() {
// 示意代码——Rc 不是 Send,这段无法编译
let config = Rc::new(AgentConfig::default());
tokio::spawn(async move {
    let _ = config.max_iterations; // 编译错误:Rc<AgentConfig> cannot be sent between threads safely
});

// octos 的实际做法:使用 Arc 实现线程安全共享
let config = Arc::new(AgentConfig::default());
tokio::spawn(async move {
    let _ = config.max_iterations; // 编译通过:Arc<AgentConfig> 是 Send + Sync
});
}

这意味着整类并发 bug(数据竞争、use-after-free across threads)在 octos 中被编译器彻底消除,而不是依赖测试覆盖率和运行时检测。

1.2.3 性能维度

指标PythonGoRust
启动时间200-500ms(导入开销)10-50ms5-20ms
内存占用(典型 Agent 进程)50-150MB15-30MB5-15MB
GC 停顿可预测但频繁亚毫秒级(Go 1.19+)无 GC

以上数据为典型 AI Agent 场景下的量级估计,具体数值因实现、负载和硬件而异。Python 内存占用包含常见依赖(requests、json 等)的开销。

对于 AI Agent 平台,最关键的性能指标不是峰值吞吐量,而是尾延迟(P99 latency)。Go 自 1.19 版本以来,GC 停顿已优化到亚毫秒级(通常 < 100 微秒),对大多数场景已经足够好。但在多租户高并发场景下——数十个 Agent 同时解析上游 Provider SSE、投递 token 和工具进度事件——即使亚毫秒级的 GC 停顿也会在 P99 尾延迟中累积放大。Rust 没有 GC,内存分配和释放完全确定性,这让 octos 在极端场景下的尾延迟保持稳定和可预测。

从内存效率的角度看,无 GC 意味着没有堆碎片化问题,也不需要预留 2-3 倍的堆空间给 GC 使用。对于需要同时维护大量会话状态的多租户系统,这直接影响单机可承载的并发会话数。

1.2.4 选型的代价

公平地说,选择 Rust 也有明确的代价:

  • 学习曲线:所有权和生命周期是 Rust 独有的概念,新开发者需要 2-4 周适应期。这不仅是语法问题——理解何时使用 &&mutBoxRcArc 需要建立新的心智模型。
  • 异步编程复杂度:Rust 的 async/await 与所有权系统的交互产生了独特的复杂度。Pin<Box<dyn Future>>、async trait 中的生命周期标注、跨 .await 点持有引用的限制,这些在 Python 和 Go 的异步模型中不存在。octos 大量使用 async-trait crate 和 Arc 共享来绕过这些限制。
  • 编译时间:octos 的完整编译(clean build)需要数分钟,增量编译通常在 10-30 秒。对比 Go 的亚秒级编译,这在快速迭代阶段是明显的效率损失。
  • 生态成熟度:AI/ML 生态远不如 Python 丰富,octos 需要自行实现 BM25 搜索(crates/octos-memory/)和集成 HNSW 向量索引(hnsw_rs crate),而不是直接调用 scikit-learn 或 FAISS。
  • 开发速度:同样功能的 Rust 代码通常比 Python 多 30-50% 的行数,主要增加在错误处理(Result/? 链)和类型标注上。

octos 团队认为这些代价是值得的:对于一个需要长期运行的多租户生产平台,运行时的正确性和性能比开发时的便利性更重要。编译器在开发阶段多花的 30 秒,换来的是生产环境中不会出现的内存泄漏、数据竞争和未定义行为。而异步编程的复杂度虽然提高了入门门槛,但一旦代码通过编译,其并发正确性就有了编译期保证——这对一个 7×24 运行的 Agent 平台至关重要。


1.3 Workspace 拓扑:11 个核心 crate 的分层架构

octos 采用 Cargo workspace 组织代码。按当前主分支的主架构口径计算,可以把它拆成 11 个 octos-* 核心 crate;另外还有 14 个 app skill 和 1 个 platform skill,负责补充具体能力。workspace 根 Cargo.toml 是这个拓扑的事实来源。

1.3.1 四层架构

第零层:独立基础设施

这一层的 crate 没有内部依赖,提供独立的基础能力:

  • octos-core(8,757 行):核心类型定义——TaskMessageMessageRoleAgentIdSessionKey 等。这是整个系统的"领域语言",所有其他 crate 共享这些类型定义。零内部依赖的设计确保了类型定义的稳定性。
  • octos-plugin(2,882 行):插件 SDK——manifest.json 解析、插件发现(目录扫描 + 优先级规则)、三重门控检查(binary/env/OS)。当前由 octos-agent 依赖,用于把插件发现与门控从主循环里拆出来。
  • octos-sandbox(146 行):Windows 平台的 AppContainer 沙箱辅助。极简实现,平台特定。

第一层:领域服务

依赖 octos-core,提供特定领域的能力:

  • octos-llm(17,604 行):LLM Provider 抽象层。统一了 Anthropic(Claude)、OpenAI(GPT-4)、Google Gemini、Ollama 等多种 Provider 的调用接口。包含三层容错链(RetryProvider → ProviderChain → AdaptiveRouter)、credential pool、content classifier、SSE 流式解析器、模型目录和定价计算。
  • octos-memory(1,635 行):混合搜索记忆系统。基于 redb 嵌入式数据库实现 BM25 全文搜索和 HNSW 向量索引,支持 Episode Store(任务完成摘要与 7 天窗口记忆)。
  • octos-bus(30,270 行):消息总线与频道集成。支持 Telegram、Discord、Slack、WhatsApp、飞书、邮件等消息频道,提供会话管理、多租户账号绑定、管理令牌和消息分片策略。

第二层:运行时引擎

依赖第零层和第一层,实现核心运行时逻辑:

  • octos-agent(84,422 行):Agent 运行时——这是整个系统的心脏。包含 Agent 主循环、工具注册与执行、命令审批策略、沙箱集成、MCP 客户端、Hook 系统、循环检测、上下文压缩等。依赖 octos-core、octos-llm、octos-memory、octos-bus、octos-plugin。
  • octos-pipeline(13,497 行):工作流引擎。基于 Graphviz DOT 语法定义工作流拓扑,当前主路径支持 CodergenShellGateNoopParallelDynamicParallel 等 handler。依赖 octos-core、octos-agent、octos-llm、octos-memory。
  • octos-swarm(3,738 行):多子 Agent 编排原语。它把 fan-out、sequence、pipeline 等模式封装为可持久化的 swarm plan,底层依赖 octos-agent 执行 MCP-backed sub-agent。
  • octos-dora-mcp(372 行):Dora-RS 到 MCP tool 的桥接层。它依赖 octos-agent,把外部数据流/节点能力包装成 Agent 可调用的 MCP 工具。

第三层:用户入口

  • octos-cli(89,237 行):CLI、Web 与 MCP Server 入口——整个系统的"前门"。提供 chatgatewayservemcp-serve 等运行模式,并承载 Web Dashboard、REST/API/AppUI、生产控制面和 swarm 命令入口。通过 feature flags 控制各频道集成(telegram、discord、slack 等)的编译。依赖 octos-core、octos-agent、octos-llm、octos-memory、octos-pipeline、octos-bus、octos-swarm。

1.3.2 依赖拓扑图

graph BT
    subgraph "第零层:独立基础设施"
        core["octos-core<br/><i>8,757 行 · 核心类型</i>"]
        plugin["octos-plugin<br/><i>2,882 行 · 插件 SDK</i>"]
        sandbox["octos-sandbox<br/><i>146 行 · Windows 沙箱</i>"]
    end

    subgraph "第一层:领域服务"
        llm["octos-llm<br/><i>17,604 行 · LLM 抽象</i>"]
        memory["octos-memory<br/><i>1,635 行 · 混合搜索</i>"]
        bus["octos-bus<br/><i>30,270 行 · 消息总线</i>"]
    end

    subgraph "第二层:运行时引擎"
        agent["octos-agent<br/><i>84,422 行 · Agent 运行时</i>"]
        pipeline["octos-pipeline<br/><i>13,497 行 · 工作流引擎</i>"]
        swarm["octos-swarm<br/><i>3,738 行 · 子 Agent 编排</i>"]
        dora["octos-dora-mcp<br/><i>372 行 · Dora/MCP 桥接</i>"]
    end

    subgraph "第三层:用户入口"
        cli["octos-cli<br/><i>89,237 行 · CLI / Web / Gateway</i>"]
    end

    llm --> core
    memory --> core
    bus --> core

    agent --> core
    agent --> bus
    agent --> llm
    agent --> memory
    agent --> plugin

    pipeline --> core
    pipeline --> agent
    pipeline --> llm
    pipeline --> memory

    swarm --> agent
    dora --> agent

    cli --> core
    cli --> agent
    cli --> llm
    cli --> memory
    cli --> pipeline
    cli --> bus
    cli --> swarm

图 1-1:octos workspace 依赖拓扑。 箭头方向为"依赖于",即上层依赖下层。octos-cli 对 octos-bus 和 octos-swarm 是硬依赖,但 bus 内部的各频道集成(Telegram、Discord 等)通过 feature flags 按需启用。octos-plugin 已经进入 Agent 运行时依赖链;octos-sandbox 仍是平台辅助 crate,不被其他核心 crate 直接依赖。

1.3.3 代码规模一览

Crate代码行数占比核心职责
octos-cli89,23733.7%运行模式 + Web UI + REST API + MCP Serve + 控制面
octos-agent84,42231.9%Agent 主循环 + 工具系统 + 安全策略
octos-bus30,27011.4%多频道集成 + 会话/账号管理
octos-llm17,6046.6%多 Provider 抽象 + 容错 + 流式
octos-pipeline13,4975.1%DOT 工作流引擎
octos-core8,7573.3%核心类型定义
octos-swarm3,7381.4%子 Agent 编排原语
octos-plugin2,8821.1%插件发现与门控
octos-memory1,6350.6%嵌入式混合搜索
octos-dora-mcp3720.1%Dora-RS / MCP 桥接
octos-sandbox1460.1%Windows 沙箱
app-skills + platform-skills12,3264.7%15 个 skill 二进制程序
合计264,886100%11 个 octos-* crate + 15 个 skill 程序

表 1-1:octos 代码规模分布。 基于 tokei ../octos/crates --type Rust,当前主分支共有 264,886 行 Rust 源文件、444 个 Rust 文件。行数用于建立规模感,不作为质量指标;随着 Web/API 控制面、swarm、harness starter skills 的加入,当前主分支已经明显大于 v0.1.0 时的规模。

除 11 个核心 crate 外,octos 还包含两类 skill 二进制程序:

  • app-skills(14 个):应用级能力——新闻聚合、深度搜索、深度爬虫、邮件发送、账号管理、时间查询、天气查询、微信桥接、Pipeline 审批、skill evolution,以及 generic/report/audio/coding harness starter。每个 skill 是一个独立的二进制程序,通过 stdin/stdout JSON 协议与 Agent 交互。
  • platform-skills(1 个):平台级能力——voice skill,提供 Apple Silicon 上的 ASR/TTS 模型管理。

工程决策侧栏:Mono-repo vs Multi-repo

octos 选择了 Cargo workspace(mono-repo)而非将每个 crate 发布为独立仓库。这个决策值得展开分析。

方案一:Multi-repo(每个 crate 独立仓库)

优势:

  • 每个 crate 可以独立发布到 crates.io,其他项目可以按需引用
  • 各 crate 有独立的 issue tracker 和 CI pipeline
  • 权限可以按仓库粒度控制

劣势:

  • 跨 crate 的重构变成多仓库协调,一个类型改名需要按依赖顺序发布 5+ 个 crate
  • 版本兼容性噩梦:octos-agent v0.3 依赖 octos-core v0.2,但 octos-cli v0.4 依赖 octos-core v0.3,导致菱形依赖
  • CI 测试无法原子性地验证跨 crate 变更

方案二:Mono-repo + Cargo workspace

优势:

  • 跨 crate 重构是一个 commit、一个 PR,原子性保证
  • 所有 crate 共享统一的依赖版本([workspace.dependencies]),消除版本碎片化
  • 一次 cargo test --workspace 验证全部 workspace 成员的兼容性
  • workspace 级别的 lint 配置(如 deny(unsafe_code))自动应用到所有 crate

劣势:

  • 仓库体积随时间增长
  • 不适合外部用户单独引用某个 crate

octos 的选择:workspace,原因有三。

第一,octos 的核心 crate 高度耦合——octos-agent 同时依赖 octos-core、octos-bus、octos-llm、octos-memory、octos-plugin,任何一个核心类型或事件协议的变更都会波及多个 crate。multi-repo 模式下,一个 Message 类型的字段变更可能需要按 core → bus/llm/memory/plugin → agent → pipeline/swarm/dora → cli 的顺序发布多轮版本,每个版本都需要等上游发布后才能开始。在 workspace 中,这是一个 commit。

第二,[workspace.dependencies] 确保所有 crate 使用完全相同版本的 tokio、serde、reqwest 等关键依赖,避免了同一个程序中链接多个版本的运行时。

第三,workspace 级别的 [workspace.lints.rust]deny(unsafe_code) 策略自动覆盖所有继承 workspace lint 的 crate,无需在每个 crate 的 lib.rs 中重复声明。这确保了安全策略的一致性——不会有某个 crate 遗漏了这个约束。


1.4 本章回顾

本章从三个维度阐述了 octos 的设计基础:

  1. 问题空间:多租户 AI Agent 平台面临安全隔离、并发控制、性能预算三大相互纠缠的挑战。这三个约束的同时存在决定了语言选型不是品味问题。

  2. 语言选型:Rust 在安全性(deny(unsafe_code) + 所有权系统)、并发模型(Send/Sync 编译期保证)、性能(无 GC、确定性延迟)三个维度上最适合这组约束。代价是更陡峭的学习曲线和更长的编译时间。

  3. Workspace 拓扑:11 个 octos-* 核心 crate 分为四层——独立基础设施(core/plugin/sandbox)→ 领域服务(llm/memory/bus)→ 运行时引擎(agent/pipeline/swarm/dora-mcp)→ 用户入口(cli)。依赖方向总体从上到下,app/platform skills 作为独立二进制程序补充具体能力,各频道集成通过 feature flags 按需启用。

从下一章开始,我们将自底向上,从 octos-core 的类型系统出发,逐层深入每个 crate 的设计与实现。


延伸阅读

  • Rust 所有权系统The Rust Programming Language 第 4 章 "Understanding Ownership",https://doc.rust-lang.org/book/ch04-00-understanding-ownership.html
  • Cargo Workspace:Cargo 官方文档 "Workspaces" 章节,https://doc.rust-lang.org/cargo/reference/workspaces.html
  • Tokio 异步运行时:Tokio 官方教程,https://tokio.rs/tokio/tutorial
  • DDIA 设计哲学:Martin Kleppmann, Designing Data-Intensive Applications(O'Reilly, 2017)——本书的写作风格参考了 DDIA 的"先讲问题,再讲方案"叙事结构
  • AI Agent 安全:OWASP Top 10 for LLM Applications,https://owasp.org/www-project-top-10-for-large-language-model-applications/

思考题

  1. 安全隔离的边界:如果你正在设计一个多租户 Agent 平台,你会选择进程级隔离还是容器级隔离?各自的性能和安全 trade-off 是什么?

  2. GC vs 无 GC 的真实影响:本章提到 Rust 无 GC 带来确定性延迟。但在 AI Agent 场景中,LLM API 调用延迟(1-10 秒)远大于 GC 停顿(毫秒级)。在什么情况下 GC 停顿会成为真正的问题?(提示:考虑多租户、高并发、上游 SSE 解析与进度事件投递场景。)

  3. Workspace 设计练习:假设你要为 octos 添加一个新的存储后端(比如 PostgreSQL 替代 redb),你会把它放在哪个现有 crate 中,还是创建一个新的 crate?为什么?

  4. 语言选型反思:如果 octos 不需要多租户支持(只服务单个用户),语言选型的结论会改变吗?哪些约束会松弛,哪些仍然重要?


版本演化说明 本章分析基于当前 ../octos main 分支(workspace 定义见 Cargo.toml,edition = "2024",rust-version = "1.85.0")。相较 v0.1.0,核心拓扑已经加入 octos-swarmoctos-dora-mcp,skill 程序也扩展到 15 个;本章以当前 workspace 为准。

第 2 章:octos-core:用类型系统定义领域语言

定位:本章深入 octos 最底层的 crate——octos-core(当前约 8.8k 行 Rust 源文件,主要增长来自 UI Protocol wire 类型),展示如何用 Rust 类型系统构建 AI Agent 平台的领域语言。前置依赖:第 1 章。适用场景:想理解 octos 类型基础的所有读者,尤其是希望通过实战项目学习 Rust 枚举和错误处理设计的读者(读者 A),以及想了解"零依赖 core crate"设计哲学的资深开发者(读者 B)。

如果把 octos 比作一座城市,octos-core 就是它的语言——不是建筑、不是道路,而是居民用来交流的词汇和语法。TaskMessageMessageRoleError、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 而非 v4crates/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_memoryepisodic_refs 的区别值得关注:working_memory 是当前会话的短期记忆(最近几轮对话),而 episodic_refs 是从长期记忆中检索出的相关片段(详见第 4 章)。这种双记忆架构模仿了人类的工作记忆(working memory)和情景记忆(episodic memory)的区分。

TaskResult(crates/octos-core/src/task.rs:125-157)记录任务的产出:

#![allow(unused)]
fn main() {
pub struct TaskResult {
    pub schema_version: u32,
    pub success: bool,
    pub output: String,
    pub files_modified: Vec<PathBuf>,
    pub files_to_send: Vec<PathBuf>,
    pub subtasks: Vec<TaskId>,
    pub token_usage: TokenUsage,
}
}

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 定义了统一的 MessageMessageRole 类型,作为所有 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 字段(types.rs:232-234)支持多模态:当用户发送图片或语音时,文件路径存储在这里,由 octos-llm 在构建 API 请求时转换为对应 Provider 的格式(base64 编码或 URL 引用)。序列化时,空的 media 向量会被跳过。

reasoning_contenttypes.rs:239-241)是为推理模型(如 OpenAI o1、Kimi k2.5)设计的——这些模型会先输出一段内部推理过程,然后才给出最终回答。将推理内容与正式回答分离存储,让上层可以选择是否展示思维链。

client_message_idthread_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 拆成三个不同类型:

类型定义位置职责典型来源
ClientMessageIdtypes.rs:12-64客户端提交 user message 时生成的乐观 UI / 幂等 tokenWeb/AppUI client
ThreadIdtypes.rs:79-177渲染分组 key,让 assistant/tool 回复落回 originating user bubble通常 root at ClientMessageId
TurnIdui_protocol.rs:271-288UI Protocol 的服务器端 turn identitybackend 创建
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 链的类型化修复。ClientMessageIdThreadId 都包的是 String,但语义完全不同;TurnId 则是 UUID 形态的协议 identity。把它们做成 newtype,可以让编译器阻止“把 client message id 当 turn id 用”这类错误(crates/octos-core/src/types.rs:12-177crates/octos-core/src/ui_protocol.rs:271-288)。

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() 要求调用方显式传入 ThreadIdcrates/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()types.rs:453-463)返回 &'static str

#![allow(unused)]
fn main() {
impl MessageRole {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::System => "system",
            Self::User => "user",
            Self::Assistant => "assistant",
            Self::Tool => "tool",
        }
    }
}
}

Display trait 实现(types.rs:465-469)直接委托给 as_str()

#![allow(unused)]
fn main() {
impl fmt::Display for MessageRole {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}
}

为什么要同时实现这两个?因为它们服务于不同场景:

  • as_str() 接受 self(按值传递),返回 &'static str。这是可行的因为 MessageRole 实现了 Copy trait——枚举只有四个无数据变体,拷贝成本等同于拷贝一个字节。按值传递用于需要零分配的场景——比如构建 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 字段(types.rs:476-478)是为 Provider 特定数据预留的扩展点。例如,Google Gemini 的工具调用会携带 thought_signature 字段,用于验证工具调用是否来自模型的推理过程。通过 Option<Value> 存储这些异构数据,核心类型无需为每个 Provider 添加特定字段。

2.2.5 便捷构造函数

Message 仍保留了三个 legacy 便捷构造函数(types.rs:355-417),用于测试、反序列化 round-trip 和旧调用点:

#![allow(unused)]
fn main() {
impl Message {
    pub fn user(content: impl Into<String>) -> Self { /* ... */ }
    pub fn assistant(content: impl Into<String>) -> Self { /* ... */ }
    pub fn system(content: impl Into<String>) -> Self { /* ... */ }
}
}

注意参数类型是 impl Into<String> 而非 String&str。这让调用者可以传入 String&str、甚至 Cow<str>,编译器会自动选择最高效的转换路径。这是 Rust 中常见的 API 设计模式——通过泛型减少调用者的类型转换负担。


2.3 Durable ABI:TaskResult 与 SessionSummary 的 schema_version

octos-core 现在还承担一类更隐蔽的职责:给跨 crate 持久化 payload 定义 stable ABI。两个例子最典型。

TaskResult 带有 schema_versioncrates/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,保证历史数据可读。

第二个例子是 SessionSummarycrates/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 生态中有两个主流的错误处理库:anyhoweyre。它们都提供类型擦除的错误报告(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 变体的枚举(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:选型理由

anyhoweyre 的核心 API 几乎相同,但有两个关键差异:

差异一:自定义错误报告器。 eyre 支持通过 eyre::set_hook() 安装自定义的错误报告器。color-eyre 就是这样一个报告器——它在错误发生时自动捕获 std::backtrace::Backtracetracing_error::SpanTrace,并以彩色格式输出。对于一个 CLI 工具来说,当 Agent 执行失败时,开发者能立即看到彩色高亮的错误链和调用栈,这比 anyhow 的纯文本输出提供了更好的调试体验。

差异二:生态对齐。 octos 的 workspace 依赖声明中同时使用了 eyrecolor-eyreCargo.toml:71-72)。这是因为 color-eyre 在 CLI 入口初始化(main() 中调用 color_eyre::install()),而 eyre::Report 作为通用错误类型在整个代码库中使用。如果混用 anyhow::Erroreyre::Report,需要在边界处做转换,增加不必要的复杂度。

2.4.3 可操作的错误消息

octos 的错误设计最值得学习的不是库的选择,而是"让错误消息可操作"的理念。看几个便捷构造函数的实现(error.rs:80-173):

#![allow(unused)]
fn main() {
pub fn api_key_not_set(provider: impl Into<String>, env_var: impl Into<String>) -> Self {
    Self {
        kind: ErrorKind::ApiKeyNotSet {
            provider: provider.to_string(),
            env_var: env_var.to_string(),
        },
        context: None,
        suggestion: Some(format!(
            "Set the {} environment variable or configure it in config.json",
            env_var
        )),
    }
}
}

当用户忘记设置 API Key 时,错误消息不只告诉你"key 没设",还告诉你"设置 ANTHROPIC_API_KEY 环境变量,或在 config.json 中配置"。同样,api_error() 会根据 HTTP 状态码给出不同的建议——401 提示检查 key,429 提示被限流,504 提示超时。

Display 实现(error.rs:175-228)将这三层信息格式化为用户友好的输出,并使用 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 个字节时,截断点可能正好落在一个多字节字符的中间。

"你好世界" 的 UTF-8 编码:
你 = [E4 BD A0]  (3 bytes)
好 = [E5 A5 BD]  (3 bytes)
世 = [E4 B8 96]  (3 bytes)
界 = [E7 95 8C]  (3 bytes)
总计 12 bytes

如果截断到 7 bytes:
[E4 BD A0] [E5 A5 BD] [E4]  ← 最后一个字节是 "世" 的第一个字节
                              这不是一个合法的 UTF-8 序列!

在 C/C++ 中,这种截断会产生无效的 UTF-8 字符串,可能导致下游解析崩溃。Python 的 str[:7] 按字符而非字节截断,避免了这个问题但无法精确控制字节预算。

2.5.2 两个变体:in-place 与 copying

octos 提供了两个截断函数:

truncate_utf8utils.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_utf8utils.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:保留首尾的智能截断

对于工具输出和错误消息,仅保留开头往往不够——尾部的错误信息或结论同样重要。truncate_head_tailutils.rs:37-70)解决了这个问题:

#![allow(unused)]
fn main() {
pub fn truncate_head_tail(s: &str, max_len: usize, head_ratio: f32) -> String
}

它按 head_ratio(默认 0.5,钳位到 [0.1, 0.9])分配头部和尾部的字节预算,中间用 \n\n... [N bytes omitted] ...\n\n 连接。两端的截断点都通过 is_char_boundary() 保证 UTF-8 安全。

这个函数在 octos 的多个场景中使用:

  • 工具输出截断(Shell 命令的 stdout/stderr)
  • 错误消息中的 API 响应体截断(error.rs
  • 上下文压缩时的消息摘要(详见第 8 章)

2.5.4 tool_output_limit:按工具类型定制的截断策略

tool_output_limitutils.rs:73-85)为不同工具设置了不同的字符上限:

工具上限理由
read_file50,000源码文件可能很大
shell, grep30,000命令输出通常更精简
web_fetch40,000网页内容适中
web_search20,000搜索结果是摘要
deep_search, deep_research, spawn50,000深度任务需要更多上下文
默认50,000安全上限

这些限制不是任意的——它们反映了不同工具输出的信息密度差异。搜索结果的信息密度高(每条结果都是有用的摘要),所以 20,000 字符足够;而源码文件的信息分布不均(可能需要看到完整的函数体),所以给 50,000 字符。这些限制与 LLM 的上下文窗口预算配合使用(详见第 8 章上下文管理)。


2.6 SessionKey:多租户会话标识的设计

SessionKey(crates/octos-core/src/types.rs:489-567)是多租户系统中会话路由的关键。它的设计演进反映了从单频道到多频道、从单 Profile 到多 Profile,再到 topic 分支会话的需求扩展。

2.6.1 格式演变

SessionKey 支持四种格式,向后兼容:

格式示例场景
channel:chat_idtelegram:12345基础:单 Profile
profile:channel:chat_idwork:telegram:12345多 Profile 隔离
channel:chat_id#topictelegram:12345#research同一会话的多主题
profile:channel:chat_id#topicwork:telegram:12345#research完整形式

base_key() 方法(types.rs:525-528)返回去掉 #topic 后缀的部分,用于会话持久化(同一个 base_key 的不同 topic 共享持久化文件)。topic() 方法(types.rs:530-533)提取主题后缀。

2.6.2 Channel 推断,而不是构造期验证

当前源码没有在 SessionKey::new() 构造时拒绝未知 channel;构造函数只是格式化字符串。is_channel_name()types.rs:569-588)的职责是帮助 split_base_key() 判断三段式 key 里的第一段究竟是 profile_id 还是 channel。白名单包含 15 个已知 channel:

api, cli, discord, email, feishu, matrix, qq-bot, slack,
system, telegram, test, twilio, 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() 方法(message.rs:31-42)返回 Option<&TaskId>,为所有变体提供统一的任务 ID 访问接口。调用者无需对每个变体做模式匹配就能获取关联的任务 ID——只需处理 Option 即可。


2.8 abort:多语言中断检测

一个有趣的小模块:abort.rs 实现了多语言的 Agent 中断检测。当用户在 Agent 执行过程中发送"停"、"stop"、"やめて"、"стоп"等中断信号时,系统需要立即识别并终止当前操作。

ABORT_TRIGGERS 数组(abort.rs:32-71)包含 9 种语言、30 个触发词。is_abort_trigger()abort.rs:6-13)对输入进行 trim + lowercase 后精确匹配。abort_response()abort.rs:15-30)返回与触发语言匹配的本地化取消确认。

值得注意的是故意排除的词:代码注释中记录了 "wait"、"exit"、"para" 等被排除的词——它们在正常对话中出现频率太高,会导致误判。这是一个务实的设计选择:宁可漏掉一些中断信号(用户可以再说一次),也不要在正常对话中误触发中断。


工程决策侧栏:为什么 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 当前约 8.8k 行 Rust 源文件,其中大头是 UI Protocol wire 类型)
  • 依赖图清晰:所有 crate 依赖 core 的类型,但 core 不依赖任何人

劣势:

  • 跨 crate 共享的逻辑需要放在其他地方(比如 octos-agent 中的工具函数)
  • 可能出现"本应在 core 中"的类型被定义在上层 crate 的情况

octos 的选择:瘦 core,理由如下。

octos-core 的外部依赖仅限于 serdeserde_jsonchronouuideyre 这几个基础库——都是序列化、时间和错误处理的行业标准。这意味着 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.9 本章回顾

octos-core 用约 8.8k 行 Rust 源文件定义了整个系统的领域语言:

  1. Task 状态机:用 Rust 枚举编码合法状态和转换,在类型层面消除非法状态组合。UUID v7 提供时间排序,五维 TokenUsage 支持精细的成本追踪。

  2. Message 抽象:四角色统一模型(System/User/Assistant/Tool)+ as_str()/Display 双重实现,确保跨 Provider 的序列化一致性。ClientMessageIdThreadIdTurnId 则把 UI 乐观消息、渲染分组和协议 turn identity 分开,避免并发 turn 下的错误归属。

  3. Durable ABITaskResult.schema_versionSessionSummary.schema_version 让 task result 与 typed compaction summary 成为可升级的 wire-stable payload;未来版本通过 typed error fail closed。

  4. Error 设计:选择 eyre/color-eyre 获取彩色错误报告和 SpanTrace 支持。三层结构(kind + context + suggestion)让错误消息可操作。

  5. UTF-8 安全工具truncate_utf8 的两个变体(in-place 和 copying)通过 is_char_boundary() 保证截断安全。truncate_head_tail 保留首尾信息。

  6. 零依赖设计:瘦 core 策略确保类型基础稳定、编译快速,支撑上层 crate 的独立演进。

下一章,我们将进入 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 变长编码对安全截断至关重要

思考题

  1. 状态机扩展:如果要为 Task 添加一个 Cancelled 状态(用户主动取消),它应该从哪些状态可达?添加这个状态会对现有的 match 表达式产生什么影响?

  2. 胖 core vs 瘦 core:假设 octos-core 把 LlmProvider trait 也放进来(因为所有上层 crate 都需要它),会带来什么问题?提示:考虑 async-traitreqwest 等依赖的传递效应。

  3. 错误设计权衡:octos 的 ErrorKind 有 15 个变体。如果系统继续增长到 50 个变体,这种设计会遇到什么问题?你会如何重构?

  4. 截断策略的替代方案truncate_utf8 按字节截断并回退到字符边界。另一种方案是按 Unicode 字素簇(grapheme cluster)截断。两种方案在处理 emoji 组合序列(如 👨‍👩‍👧‍👦)时有什么区别?哪种更适合 LLM 上下文管理场景?


版本演化说明 本章按当前 ../octos/crates/octos-core/src/ 源码撰写。阅读后续版本时,除了 Task / Message / Error,还应核对 identity newtypes、SessionSummary ABI、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 注册表实现模型名自动检测和工厂创建,顶层的三级容错链(RetryProvider → ProviderChain → AdaptiveRouter)提供生产级可靠性。当前主分支还把 provider metadata、HTTP timeout knobs、credential pool、content classifier 和 routing.decision 事件纳入这条链路。本章将自底向上逐层展开。


3.1 LlmProvider trait:最小化的统一接口

3.1.1 trait 签名

LlmProvider 的定义位于 ../octos/crates/octos-llm/src/provider.rs:11-92

#![allow(unused)]
fn main() {
#[async_trait]
pub trait LlmProvider: Send + Sync {
    // 核心方法:非流式对话
    async fn chat(
        &self,
        messages: &[Message],
        tools: &[ToolSpec],
        config: &ChatConfig,
    ) -> Result<ChatResponse>;

    // 流式对话(有默认实现)
    async fn chat_stream(
        &self,
        messages: &[Message],
        tools: &[ToolSpec],
        config: &ChatConfig,
    ) -> Result<ChatStream>;

    // 元数据查询
    fn context_window(&self) -> u32;
    fn max_output_tokens(&self) -> u32;
    fn model_id(&self) -> &str;
    fn provider_name(&self) -> &str;
    fn provider_metadata(&self) -> ProviderMetadata;
    fn provider_metadata_for_index(&self, _index: 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 的设计遵循了"最小必要接口"原则(provider.rs:13 的注释明确说明了这一点):只定义所有 Provider 共同的能力,差异在各实现中处理。

几个值得关注的设计选择:

Send + Sync 约束。 trait 要求实现者是线程安全的,因为 Provider 实例会被多个异步任务通过 Arc 共享。这个约束在编译期保证了不会出现单线程 Provider 实现被意外用在多线程场景的错误。

chat_stream() 的默认实现。 不是所有 Provider 都原生支持流式响应。默认实现(provider.rs:25-49)调用非流式的 chat() 方法,然后将完整响应包装为一个合成流:按内容、工具调用、usage、done 事件依次输出。这让新 Provider 只需实现 chat() 就能基本工作,流式支持可以后续优化。

Provider metadata。 provider_metadata()provider_metadata_for_index() 是当前主分支新增的重要接口(provider.rs:67-76)。它们把实际命中的 provider slot、模型 ID、provider name 等信息交给上层,用于观测、成本归因和多 provider chain 的精确指标记录。provider_metadata_for_index() 解决的是组合 Provider 的问题:当一个 ProviderChain 内部选中了第 N 个 slot,上层不能只看到 chain 本身的名字,而必须知道真正被调用的是哪一个后端。

指标上报方法。 export_metrics()report_late_failure()report_stream_metrics() 三个方法都有空的默认实现。它们为 AdaptiveRouter 的 EMA 评分系统提供数据源(见 3.4 节),但不强制所有 Provider 实现。这种"可选钩子"模式避免了 trait 膨胀。

3.1.2 核心数据类型

ChatConfig../octos/crates/octos-llm/src/config.rs)封装了所有可调参数:

  • model: 模型 ID
  • temperature: 采样温度
  • max_tokens: 最大输出 token 数
  • system_prompt: 系统提示
  • response_format: 响应格式约束(文本/JSON/结构化输出)
  • tool_choice: 工具选择策略(auto/required/none/指定工具)

ChatResponse 包含 LLM 返回的完整信息:内容、stop reason、工具调用请求、token 使用量。ChatStream 是一个异步流(Pin<Box<dyn Stream<Item = Result<StreamEvent>>>>),逐事件产出流式响应。

Provider 工厂还接收 LLM HTTP timeout knobs。CreateParams 中的 llm_timeout_secsllm_connect_timeout_secs 会转换为 HttpTimeoutConfig../octos/crates/octos-llm/src/registry/mod.rs:33-58),默认值则在 provider.rs:117-139 中定义:LLM 总超时 300 秒、连接超时 10 秒,embedding 总超时 60 秒、连接超时 15 秒。这把"模型推理慢"和"网络连接失败"分开处理,避免所有超时都挤在一个不可解释的配置项里。


3.2 Provider 注册表:模型名自动检测

当用户配置 model: "claude-sonnet-4" 时,octos 需要自动确定使用 Anthropic Provider。这个映射由 Provider 注册表实现(../octos/crates/octos-llm/src/registry/mod.rs)。

3.2.1 检测机制

每个 Provider 注册时声明自己的名称、别名、默认模型、凭据要求、base URL 要求、模型要求、检测模式和工厂函数(registry/mod.rs:61-82):

#![allow(unused)]
fn main() {
struct ProviderEntry {
    name: &'static str,
    aliases: &'static [&'static str],
    default_model: Option<&'static str>,
    api_key_env: Option<&'static str>,
    default_base_url: Option<&'static str>,
    requires_api_key: bool,
    requires_base_url: bool,
    requires_model: bool,
    detect_patterns: &'static [&'static str],
    create: CreateFn,
}
}

detect_provider() 方法(registry/mod.rs:131-150)按优先级顺序遍历所有 Provider,检查模型名是否包含检测模式:

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 系列需要前缀匹配而非子串匹配(registry/mod.rs:137-140),因为 "o1" 作为子串可能匹配到其他 Provider 的模型名中(如 ro1and 假设模型名)。

另一个容易误读的点是:有些 Provider 的 detect_patterns 为空,这不是遗漏,而是明确要求用户通过显式 provider 名或 alias 命中。典型例子包括 R9s、OpenRouter、Z.AI、NVIDIA、Ollama 和 vLLM。它们的模型名往往是跨平台转发名、OpenAI 兼容名或用户本地自定义名,盲目做子串检测会产生错误路由。

3.2.2 完整 Provider 注册表

octos 当前注册 15 个 Provider,按优先级排序检测(registry/mod.rs:87-105):

优先级Provider协议别名检测模式默认/示例模型
1Anthropic原生-claudeclaude-sonnet-4-20250514
2OpenAI原生-gpt, o1/o3/o4 前缀gpt-4o
3Gemini原生googlegeminigemini-2.5-flash
4R9sOpenAI/Anthropic 兼容r9s.ai-claude-sonnet-4-6
5OpenRouterOpenAI 兼容元路由--anthropic/claude-sonnet-4-20250514
6DeepSeekOpenAI 兼容-deepseekdeepseek-chat
7GroqOpenAI 兼容-llama, mixtralllama-3.3-70b-versatile
8MoonshotOpenAI 兼容kimikimi, moonshotkimi-k2.5
9DashscopeOpenAI 兼容qwenqwenqwen-max
10MinimaxOpenAI 兼容-minimaxMiniMax-Text-01
11ZhipuOpenAI 兼容glmglmglm-4-plus
12ZaiAnthropic 兼容z.ai-glm-5-turbo
13NVIDIAOpenAI 兼容nim-meta/llama-3.3-70b-instruct
14OllamaOpenAI 兼容--llama3.2
15vLLMOpenAI 兼容--用户必须显式提供 model

当前注册表共有 15 个 Provider。Anthropic、OpenAI、Gemini 使用专用实现;其余多数条目通过兼容层接入,其中 Ollama、vLLM、OpenRouter、DeepSeek 等复用 OpenAIProvider,Z.AI 复用 Anthropic Messages API,R9s 则按模型族在两种协议间自动切换。这种"少数专用实现 + 多数兼容适配"架构让新 Provider 的接入成本极低——很多情况下只需在注册表中添加一个条目。

3.2.3 Provider 工厂

检测到 Provider 后,注册表通过工厂函数创建具体实例。每个工厂函数读取对应的环境变量(ANTHROPIC_API_KEYOPENAI_API_KEY 等)或配置文件中的凭据,构造带有正确 base URL 和认证头的 HTTP 客户端。

工厂返回的类型是 Arc<dyn LlmProvider>——这是动态分发的关键点。注册表不知道(也不需要知道)具体的 Provider 类型,只知道它实现了 LlmProvider trait。这让上层代码可以用统一的方式处理所有 Provider,包括将它们放入容错链中。


3.3 三层容错链

生产环境中,LLM API 调用可能因为多种原因失败:速率限制(429)、服务器过载(503/529)、认证失效(401)、网络超时。octos-llm 用三层容错链处理这些故障,每一层解决不同级别的问题。

flowchart TD
    Request["用户请求"] --> AR["AdaptiveRouter<br/>EMA 评分选择最优 Provider"]
    AR --> PC1["ProviderChain #1<br/>带 Circuit Breaker"]
    AR --> PC2["ProviderChain #2<br/>带 Circuit Breaker"]
    AR -.->|"hedge racing"| PC2

    PC1 --> RP1a["RetryProvider (Provider A)<br/>指数退避 429/5xx"]
    PC1 -->|"failover"| RP1b["RetryProvider (Provider B)<br/>指数退避 429/5xx"]

    PC2 --> RP2a["RetryProvider (Provider C)<br/>指数退避 429/5xx"]

    RP1a --> LLM_A["Anthropic API"]
    RP1b --> LLM_B["OpenAI API"]
    RP2a --> LLM_C["Gemini API"]

    style AR fill:#f9f,stroke:#333
    style PC1 fill:#bbf,stroke:#333
    style PC2 fill:#bbf,stroke:#333
    style RP1a fill:#bfb,stroke:#333
    style RP1b fill:#bfb,stroke:#333
    style RP2a fill:#bfb,stroke:#333

图 3-1:三层容错链架构。 请求从 AdaptiveRouter 进入,经 ProviderChain 路由到具体 Provider,每个 Provider 包裹在 RetryProvider 中处理瞬时故障。

3.3.1 第一层:RetryProvider — 指数退避

RetryProvider(../octos/crates/octos-llm/src/retry.rs:40-226)处理单个 Provider 的瞬时故障。

退避算法retry.rs:149-154):

#![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)
}
}

默认配置(retry.rs:28-37):最多重试 3 次,初始延迟 1 秒,退避乘数 2.0,最大延迟 60 秒。实际退避序列为 1s → 2s → 4s → 8s(但被 60s 上限钳位)。

哪些错误可重试?retry.rs:107-147

HTTP 状态码含义是否重试是否触发 failover
429速率限制是(解析 retry-after)
500, 502, 503服务器错误
529过载
401, 403认证错误是(立即 failover)
504Gateway 超时是(服务器可能恢复)
408请求超时看具体情况
reqwest timeout网络超时否(本地不重试)是(立即 failover)
400请求错误看具体消息部分情况

注意 reqwest 级别的网络超时(连接超时、读超时)的特殊处理:不在本地重试(因为同一个 Provider 大概率还是超时),而是立即向上层触发 failover,让 ProviderChain 切换到另一个 Provider。HTTP 504(Gateway Timeout)则被视为可重试——服务器可能在短暂过载后恢复。

速率限制解析retry.rs:159-185):当收到 429 响应时,RetryProvider 会尝试从错误消息中解析推荐的等待时间(如 "Please try again in 29.159s"),加上 1 秒缓冲后等待。如果无法解析,回退到 30 秒固定等待。

3.3.2 第二层:ProviderChain — 有序故障转移

ProviderChain(../octos/crates/octos-llm/src/failover.rs:36-249)管理一组 Provider 的故障转移顺序。

Circuit Breaker 设计failover.rs:23-26):

#![allow(unused)]
fn main() {
struct ProviderSlot {
    provider: Arc<dyn LlmProvider>,
    failures: AtomicU32,  // 连续失败计数器
}
}

每个 Provider 维护一个原子计数器记录连续失败次数。当失败次数达到阈值(默认 3),该 Provider 被标记为"降级"(degraded)。成功调用后计数器重置为 0(failover.rs:104)。

故障转移逻辑failover.rs:85-99):

  1. 首先尝试第一个未降级的 Provider
  2. 如果所有 Provider 都降级了,选择失败次数最少的那个
  3. 跳过已降级的 Provider,除非它是最后的选择

延迟故障上报failover.rs:245-248):report_late_failure() 处理一种微妙的场景——Provider 返回了 200 响应,但流式解析后发现内容为空或格式错误。这时需要回溯性地惩罚该 Provider,增加其失败计数,让后续请求优先选择其他 Provider。

3.3.3 第三层:AdaptiveRouter — EMA 评分与对冲竞赛

AdaptiveRouter(../octos/crates/octos-llm/src/adaptive.rs:486-1490)是容错链的最高层,实现了智能路由。

三种模式adaptive.rs:486-499):

  • Off (0):静态优先级排序 + circuit breaker,最简单可靠
  • Hedge (1):基于评分选择 + 对冲竞赛(hedge racing)
  • Lane (2):基于评分的车道切换,比 hedge 更节省成本

EMA 评分系统

AdaptiveRouter 为每个 Provider 维护一个实时评分。默认配置下,评分由四个因子的加权组合决定(adaptive.rs:1126-1202);其中配置字段仍保留 weight_latency 这个名字,但第二项实际是"质量 + 吞吐"的复合因子,源码并不直接使用原始 latency:

因子权重含义数据来源
稳定性 (error_rate)30%错误率实时统计 + 目录基线混合
质量/吞吐 (weight_latency)30%输出质量 + 运行时吞吐60% 深度搜索 token 数 + 40% 吞吐量
优先级 (priority)20%配置顺序用户配置
成本 (cost)20%价格模型目录

混合权重设计adaptive.rs:933-955):稳定性因子使用"目录基线 + 实时数据"的混合计算。混合权重按调用次数递增:min(total_calls / 20.0, 0.5),这意味着目录基线始终至少占 50% 的影响力。这个设计防止了"冷启动"问题——新 Provider 只有少量调用时,不会因为一两次偶然失败就被判为不可靠。

这里要特别注意:score() 明确用 throughput 而不是原始 latency 做运行时速度信号,因为单次请求延迟更容易受任务复杂度影响,不适合作为跨 Provider 的直接质量指标。

对冲竞赛(Hedge Racing)

在 Hedge 模式下,AdaptiveRouter 会同时向两个 Provider 发起请求,取先返回的结果(adaptive.rs:1310-1409):

#![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 发送"探针"请求(adaptive.rs:1297-1308),刷新其性能指标。探针间隔默认 60 秒,避免频繁探测带来的成本。

3.3.4 Credential Pool 与 Content Classifier:从 Provider failover 到 key / tier 路由

当前主分支的容错链不只是在 Provider 之间切换,还会在同一 Provider 的多组凭据之间轮换,并根据内容复杂度选择模型 tier。

Credential pool。 credential_pool.rs 的模块注释明确把目标定义为"持久化 cooldown / rotation state"(../octos/crates/octos-llm/src/credential_pool.rs:1-29)。每个凭据都有 cooldown、rate-limit 计数、reset 时间、last used、usage count、reservation 等状态;轮换策略包括 FillFirstRoundRobinRandomLeastUsedcredential_pool.rs:166-189)。当 AdaptiveRouter 发现 401/403 或认证文本时,会把失败分类为 auth failure;发现 429 或 rate limit 文本时,会分类为 rate-limit failure,并通知 credential pool 进入 cooldown 或刷新流程(adaptive.rs:674-737, adaptive.rs:1473-1490)。轮换结果还会发出稳定的 harness event,schema 为 octos.harness.event.v1,kind 为 credential_rotationcredential_pool.rs:213-245)。

Content classifier。 content_classifier.rs 是一个无 I/O 的启发式分类器,默认关闭;关闭时返回 Strong,避免因为未启用策略而误把复杂任务路由到便宜模型(../octos/crates/octos-llm/src/content_classifier.rs:1-18, content_classifier.rs:61-80)。启用后,它根据代码块、消息长度、强模型关键词和 URL 信号产生 CheapStrong tier:代码块、长度超过阈值、命中 debug/refactor/architecture/prove/proof/analyze/design 等关键词会升到 Strong;URL 只作为 reason 记录,不单独触发 Strong(content_classifier.rs:158-209)。AdaptiveRouter 可挂载 classifier,并在选择 lane 之前通过 callback 发出 routing.decision harness event(adaptive.rs:783-812)。这让上层 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 解析器(../octos/crates/octos-llm/src/sse.rs:16-72)采用字节级缓冲策略:

  1. 原始字节累积:将每个 chunk 的原始字节追加到 Vec<u8> 缓冲区,不做 UTF-8 转换
  2. 事件边界检测:在原始字节中搜索 \n\n\r\n\r\n 分隔符
  3. 按事件转换:找到完整事件后,才将该事件的字节块转换为 UTF-8 字符串
  4. 剩余字节保留:未形成完整事件的尾部字节保留在缓冲区中

这种设计保证了 UTF-8 转换只发生在完整事件上——SSE 协议保证事件边界不会落在 UTF-8 字符中间(因为 \n 是 ASCII 单字节字符)。

解析器使用 stream::unfold() 构建为一个异步流,保持状态(字节流 + 缓冲区)在 yield 事件之间传递。

3.4.3 1MB 缓冲上限

安全考量:如果恶意或异常的 LLM Provider 发送一个永远不包含 \n\n 的超长响应,缓冲区会无限增长。MAX_BUFFER_SIZEsse.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 分割测试:为什么字节级缓冲不可省略

sse.rs 的测试(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(../octos/crates/octos-llm/src/catalog.rs:48-275)为每个已知模型维护元数据:

#![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),目录还支持别名查找(如 sonnetclaude-sonnet-4-20250514)。查找顺序(catalog.rs:72-74):精确 ID 匹配 → 别名匹配 → None。

成本追踪ModelCost 记录输入、输出、缓存读取三种 token 类型的百万 token 价格。AdaptiveRouter 的评分系统使用这些数据计算成本因子(见 3.3.3 节),在延迟和成本之间做权衡。


工程决策侧栏: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 表达式
  • 对于 15+ 个 Provider,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.6 本章回顾

octos-llm 解决了 LLM Provider 集成的核心挑战:

  1. LlmProvider trait:最小化的统一接口,chat() + chat_stream() 双方法设计,Send + Sync 约束保证线程安全。Provider metadata 让上层能看到实际命中的 provider slot,而不是只看到组合包装器。

  2. Provider 注册表:模型名子串匹配自动检测 Provider,工厂模式动态创建 Arc<dyn LlmProvider> 实例。特殊处理 O 系列模型的前缀匹配,并明确把 R9s、OpenRouter、Z.AI、NVIDIA、Ollama、vLLM 这类 detect_patterns 为空的 Provider 留给显式 provider / alias 选择。

  3. 三层容错链

    • RetryProvider:指数退避(1s→2s→4s),智能解析 429 响应的 retry-after 头
    • ProviderChain:有序故障转移 + circuit breaker(3 次连续失败触发降级)
    • AdaptiveRouter:四因子 EMA 评分(稳定性 30% + 质量/吞吐 30% + 优先级 20% + 成本 20%)+ 对冲竞赛 + 探针策略
  4. Credential pool 与 content classifier:429/auth failure 会进入凭据 cooldown / refresh 路径;content classifier 发出 routing.decision 事件,把 Cheap/Strong tier 的选择暴露给 harness。

  5. SSE 流式解析:字节级缓冲避免 UTF-8 分割问题,1MB 上限防止内存耗尽,stream::unfold() 构建有状态异步流。

  6. 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"

思考题

  1. 容错层次设计:octos 的三层容错链中,如果把 RetryProvider 和 ProviderChain 合并为一层会怎样?分离的好处是什么?

  2. 对冲竞赛的成本模型:假设你有两个 Provider:Provider A 价格 $10/M tokens、平均延迟 500ms;Provider B 价格 $3/M tokens、平均延迟 1500ms。在什么条件下开启 hedge racing 是划算的?

  3. SSE 解析器的替代方案:如果不用字节级缓冲,而是用 String::from_utf8_lossy() 处理每个 chunk,会产生什么问题?在什么场景下这些问题会变得可观测?

  4. 泛型 vs trait object 的边界:如果 octos 只需要支持 3 个 Provider(Anthropic、OpenAI、Gemini),枚举分发是否是更好的选择?支持多少个 Provider 时动态分发才开始胜出?


版本演化说明 本章分析基于当前 ../octos main 分支,octos-llm crate 位于 crates/octos-llm/src/。Provider 注册表、credential pool 与 AdaptiveRouter 的评分权重可能随新 Provider 和运行策略继续调整,但三层容错架构本身仍是理解 octos-llm 的主线。

第 4 章:octos-memory:混合搜索的工程实现

定位:本章深入 octos-memory crate(约 1,750 行),展示如何用纯 Rust 构建嵌入式 BM25 + HNSW 混合搜索引擎,为 Agent 提供长期记忆能力。前置依赖:第 2 章。适用场景:想理解 RAG(检索增强生成)底层实现的 AI 应用开发者(读者 C),以及对嵌入式数据库和搜索算法感兴趣的 Rust 开发者(读者 B)。

AI Agent 和 chatbot 的根本区别之一在于记忆。chatbot 的每次对话都是独立的——上一次帮你写的代码、做过的决策、踩过的坑,下一次全部忘记。Agent 需要记忆来积累经验:上次用户偏好什么代码风格?这个仓库最近修改了哪些文件?三天前解决一个类似 bug 时采取了什么策略?

octos-memory 用 1,750 行代码实现了这个记忆系统。它不依赖任何外部服务——没有 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 中定义了三张表(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:21-43):

#![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 key_decisions: Vec<String>,  // 关键决策记录
    pub files_modified: Vec<PathBuf>,
    pub created_at: DateTime<Utc>,
}
}

EpisodeOutcomeepisode.rs:72-81)定义了内存层可表达的四种结果:SuccessFailureBlockedCancelled。它和 Task 的几类终态语义相近,但不能反推"系统一定会把所有终态都写成 Episode";是否落库取决于上层调用点。当前主 Agent loop 实际只会写入 Success episode。

schema_version 字段(episode.rs:24)是前向兼容的关键。当 Episode 的格式需要升级时(比如新增字段),旧版本的数据仍然可以通过版本号正确解析。默认值为 1episode.rs:16-17),反序列化时如果 JSON 中没有这个字段,自动填充默认值。

4.2.2 写入时机

在当前主 Agent 路径里,Episode 只会在 LLM 响应以 StopReason::EndTurnStopSequence 正常结束,且 save_episodes 打开时创建并存储(crates/octos-agent/src/agent/loop_runner.rs:368-395,落库逻辑位于 crates/octos-memory/src/store.rs:87-151)。写入过程:

  1. 将 Episode 序列化为 JSON,存入 EPISODES_TABLE
  2. 更新 CWD_INDEX_TABLE,将 Episode ID 追加到对应工作目录的列表中
  3. 更新内存中的混合搜索索引(文本部分)

向量并不是在 store() 中同步写入的。当前实现把 episode 文本和 embedding 分成两个阶段:store() 先写 JSON 和 BM25 索引;store_embedding() 再把 Vec<f32> 用 bincode 写入 EMBEDDINGS_TABLE,并调用 HybridIndex::add_embedding() 给已有文档补上 HNSW 向量。这让 episode 落库不依赖 embedding provider 的可用性。

启动时,EpisodeStore::open() 会扫描 EPISODES_TABLEEMBEDDINGS_TABLE,重建内存中的 HybridIndex。这意味着 redb 是唯一持久源,HNSW 和倒排索引都是可重建缓存。

腐败恢复store.rs:109-127):CWD_INDEX_TABLE 中的值是 JSON 数组(["id1", "id2", ...])。如果之前的写入因为崩溃被中断,JSON 可能是损坏的。代码会尝试从损坏的 JSON 中按引号分割抢救 Episode ID,而不是丢弃整个索引。这种防御性编程确保了即使在非正常关闭后也不会丢失索引数据。

删除路径store.rs:291-370):delete_by_id() 会从 EPISODES_TABLECWD_INDEX_TABLEEMBEDDINGS_TABLE 中删除对应数据;内存索引侧调用 HybridIndex::remove(),但它不会重排 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() {
// hybrid_search.rs:8-28(简化)
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 相关字段
}
}

inverted 是倒排索引的核心:给定一个词项(如"重构"),可以快速找到包含该词项的所有文档及其出现频率。

分词策略hybrid_search.rs:288-295):

#![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.2 BM25 评分公式

BM25 的核心公式(hybrid_search.rs:251-285):

score(q, d) = Σ IDF(qi) × (tf(qi, d) × (K1 + 1)) / (tf(qi, d) + K1 × (1 - B + B × |d| / avgdl))

octos 使用的参数(hybrid_search.rs:31-32):

参数含义
K11.2词频饱和度控制。值越大,高频词的权重越高
B0.75文档长度归一化。B=0 时忽略长度差异,B=1 时完全按长度归一化

这两个参数值是信息检索领域经过数十年实践验证的经典默认值(源自 TREC 评测实验),octos 直接采用而非自行调优。

IDF 计算hybrid_search.rs:259):

#![allow(unused)]
fn main() {
let idf = ((n as f64 - df as f64 + 0.5) / (df as f64 + 0.5) + 1.0).ln();
}

IDF(逆文档频率)衡量一个词项的区分度:出现在越多文档中的词(如"代码"、"修改")IDF 越低,出现在越少文档中的词(如"deadlock"、"HNSW")IDF 越高。

4.3.3 epsilon 防 NaN

BM25 的评分归一化步骤(hybrid_search.rs:271-284)中有一个微妙的工程细节:

#![allow(unused)]
fn main() {
let max_score = bm25_scores.values().cloned().fold(f64::NEG_INFINITY, 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.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 构建向量索引(hybrid_search.rs:41-47):

参数含义
max_nb_connection16每个节点的最大边数(M 参数)
capacity10,000预分配的槽位数
ef_construction200构建时的搜索宽度(越大越准确但越慢)
max_layer16最大层数

10,000 的容量对于 Agent 的经验存储来说绰绰有余——即使每天执行 10 个任务,也需要近 3 年才会达到上限。索引在容量达到 80% 和 100% 时会打印警告(hybrid_search.rs:86-98)。

4.4.3 L2 归一化与 cosine similarity

向量搜索的距离度量使用 cosine similarity(余弦相似度),但 HNSW 内部使用的是 DistCosine 距离(hybrid_search.rs:137)。两者的关系是:

similarity = 1.0 - distance

为了确保 cosine similarity 的正确性,所有嵌入向量在插入索引前都经过 L2 归一化(hybrid_search.rs:297-305):

#![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 检查(hybrid_search.rs:301)防止除以零。当 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 权重配置

默认权重(hybrid_search.rs:35-37):

#![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() 方法配置(hybrid_search.rs:72-76),适应不同场景需求。

4.5.3 融合算法

融合逻辑(hybrid_search.rs:221-237):

#![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 搜索:

  1. 插入时insert() 接受 embedding: Option<&[f32]>,为 None 时只更新倒排索引
  2. 搜索时query_embedding 为 None 时,向量分数全部为 0,最终分数完全由 BM25 决定
  3. 索引为空时:如果混合索引中没有任何文档,退回到直接扫描 redb 数据库(store.rs:171-187),通过 CWD 索引和词项匹配提供基础检索

这种三级降级(混合搜索 → BM25 only → DB 扫描)确保了记忆系统在任何条件下都能提供结果,只是精度逐级降低。


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()memory_store.rs:102-147)构建 Agent 的记忆上下文时,读取最近 7 天的笔记(memory_store.rs:110):

#![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(memory_store.rs:153-241)。它把稳定事实按实体拆成 Markdown 文件,路径为 memory/bank/entities/<slug>.md。这套机制不是全文搜索,而是两级提示注入:

  1. Level 1:摘要索引。 get_bank_summary() 遍历所有 entity 文件,跳过 YAML frontmatter,提取第一个非空、非标题行作为最多 100 字符的摘要,然后把 - **name**: abstract 注入系统提示。chatgateway 都会在长期记忆/每日笔记之后追加这段 Memory Bank 摘要。
  2. Level 2:按需全文。 当摘要不够时,Agent 调用 recall_memory 工具读取完整 entity 页面。工具会把用户传入的名字 trim、转小写、空格替换成 -,再从 read_entity() 读取对应 Markdown。

写入由 save_memory 工具完成。它要求内容以标题和一行摘要开头;更新已有 entity 时会先读出旧内容,在工具结果中返回旧版本,提醒调用方不要覆盖掉已有事实。save_memory 的并发等级是 Exclusive,避免同一批工具调用中读写 Memory Bank 产生半写状态。


工程决策侧栏:为什么不用 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 用 1,635 行 Rust 源文件构建了一个自包含的 Agent 记忆系统:

  1. redb 嵌入式存储:三张表(Episodes/CWD 索引/向量嵌入),纯 Rust 实现,ACID 事务保证,单文件部署。

  2. BM25 全文搜索:经典 K1=1.2/B=0.75 参数,倒排索引 + IDF 加权,epsilon 防 NaN 归一化。

  3. HNSW 向量索引hnsw_rs crate 提供分层图搜索,L2 归一化保证 cosine similarity 正确性,零向量保护防止索引污染。

  4. 混合排名融合:0.7 向量 + 0.3 BM25 加权求和,候选集取并集,三级降级(混合→BM25→DB 扫描)确保任何条件下都能返回结果。

  5. Markdown + Memory BankMEMORY.md、每日笔记和 entity bank 共同组成提示侧记忆;entity 摘要自动注入,全文通过 recall_memory 按需加载。

下一章将进入 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 + 向量搜索的互补价值

思考题

  1. BM25 参数调优:如果 Agent 主要处理中文任务,K1 和 B 参数需要调整吗?中文的分词粒度(单字 vs 词组)如何影响 BM25 的检索效果?

  2. 向量维度选择:octos 默认使用 1536 维向量(OpenAI text-embedding-3-small)。如果切换到 384 维的轻量模型,对检索质量和内存占用的影响分别是什么?

  3. 混合权重的动态调整:固定的 0.7/0.3 权重是否最优?设想一种根据查询类型动态调整权重的策略——关键词精确查询偏向 BM25,开放式语义查询偏向向量搜索。这需要在架构上做什么修改?

  4. 规模瓶颈:如果 octos 需要支持企业级部署(10 万个 episode),当前的嵌入式方案需要做哪些改造?有没有介于"嵌入式"和"外部数据库"之间的中间方案?


版本演化说明 本章分析基于当前 ../octos main 分支,octos-memory crate 位于 crates/octos-memory/src/,按 tokei 统计共 1,635 行 Rust 源文件。相比早期版本,MemoryStore 的 entity bank 已经接入 save_memory / recall_memory 工具和系统提示摘要注入;EpisodeStore 也支持 embedding 后写入与 tombstone 删除。

第 5 章:Agent Loop:一次对话的完整生命周期

定位:本章是全书最核心的一章——深入 octos-agent 的配置与主循环(crates/octos-agent/src/agent/mod.rs + crates/octos-agent/src/agent/loop_runner.rs),逐段走读从消息构建到工具调用、上下文压缩、错误恢复再到返回结果的完整流程。前置依赖:第 3 章(LLM Provider)、第 4 章(记忆系统)。适用场景:任何想理解 AI Agent 运行时机制的读者,尤其是 AI 应用开发者(读者 C)和想贡献 octos 核心代码的开发者(读者 D)。

理解了 octos-core 的类型系统(第 2 章)、octos-llm 的 Provider 抽象(第 3 章)和 octos-memory 的记忆系统(第 4 章)之后,我们终于来到了整个系统的心脏——Agent Loop。

一个 AI Agent 的"智能"本质上是一个循环:接收用户消息 → 调用 LLM → 解析 LLM 的意图 → 如果 LLM 想用工具就执行工具 → 把工具结果反馈给 LLM → 重复,直到 LLM 认为任务完成。这个循环看似简单,但生产级实现需要处理大量边界情况:迭代上限、token 预算、idle progress timeout、上下文窗口溢出、消息格式修复、循环检测、优雅关停、provider/harness 错误恢复。

本章将走读 crates/octos-agent/src/agent/ 目录下的核心代码,用约 200 行关键代码展示 Agent Loop 的完整生命周期。


5.1 Agent 结构体与配置

5.1.1 Agent 的组成

Agent 结构体(crates/octos-agent/src/agent/mod.rs:143-230)持有执行一次对话所需的全部资源:

#![allow(unused)]
fn main() {
pub struct Agent {
    pub id: AgentId,                              // Agent 唯一标识
    pub llm: Arc<dyn LlmProvider>,                // LLM Provider(详见第 3 章)
    pub tools: Arc<ToolRegistry>,                 // 工具注册表(详见第 6 章)
    pub memory: Arc<EpisodeStore>,                // 长期记忆(详见第 4 章)
    pub embedder: Option<Arc<dyn EmbeddingProvider>>,
    pub system_prompt: RwLock<String>,            // 系统提示(支持热加载)
    pub config: AgentConfig,                      // 执行配置
    pub reporter: RwLock<Arc<dyn ProgressReporter>>, // 进度上报
    pub hooks: Option<Arc<HookExecutor>>,         // 钩子系统(详见第 14 章)
    pub harness_event_sink: Option<String>,
    pub shutdown: Arc<AtomicBool>,                // 优雅关停标志
    pub persistent_retry_state: Option<Arc<Mutex<LoopRetryState>>>,
    pub tiered_compaction: Option<Arc<TieredCompactionRunner>>,
    pub file_state_cache: Option<Arc<FileStateCache>>,
    pub profile: Option<Arc<ProfileDefinition>>,
    // ...
}
}

几个设计要点值得注意:llmtools 使用 Arc 包装,因为 Agent 可能在多个异步任务间共享(工具并行执行时)。system_prompt 使用 RwLock<String> 而非普通 String,支持配置热加载(详见第 13 章)——运行中的 Agent 可以在不重启的情况下更新系统提示。shutdown: Arc<AtomicBool> 是一个跨线程共享的原子布尔标志。当收到 SIGTERM 信号时,主线程将其设为 true,Agent Loop 在每次迭代开始时检查这个标志,如果为 true 就优雅退出而非粗暴终止(详见第 11 章)。

当前主分支的 Agent 还显式携带几类运行时状态:persistent_retry_state 保存跨 turn 的 retry bucket,tiered_compaction 驱动三层上下文压缩,file_state_cache 让文件工具读写可以共享缓存,profile 记录启动时应用的 profile envelope。这些字段说明 Agent 已经不是“LLM + tools”的薄包装,而是一个把 prompt-visible state、runtime control state 和 durable evidence state 连接起来的运行时对象。

5.1.2 AgentConfig

AgentConfig(crates/octos-agent/src/agent/mod.rs:45-94)控制 Agent 的执行边界:

字段默认值含义
max_iterations50最大迭代次数
max_tokensNone(无限制)token 预算上限
max_timeout1800 秒(30 分钟)activity timeout:只有在没有近期进展时才触发
tool_timeout_secs1800单个工具调用默认超时,上限同为 1800 秒
save_episodestrue是否保存经验到记忆
chat_max_tokensNone单次 LLM 输出 token override
suppress_auto_send_filesfalse背景 worker 是否跳过通用 files_to_send 自动发送

50 次迭代上限(crates/octos-agent/src/agent/mod.rs:82-94)是一个安全阀。一个典型的代码修改任务通常在 5-15 次迭代内完成(读文件 → 分析 → 修改 → 测试)。如果 Agent 在 50 次迭代后仍未完成,几乎可以确定它陷入了某种低效循环。


5.2 主循环:逐段走读

主循环位于 crates/octos-agent/src/agent/loop_runner.rs:578-1057(对话模式)和 loop_runner.rs:1060-1325(任务模式)。让我们逐段走读。

5.2.1 入口点

Agent 有两个入口点(crates/octos-agent/src/agent/loop_runner.rs:33-41,293-474):

  • process_message():对话模式——接收用户消息和历史,返回 ConversationResponse
  • run_task():任务模式——接收 Task 定义,返回 TaskResult

两者最终都调用同一个内部循环 process_message_inner()

5.2.2 迭代流程

每次迭代的完整流程如下:

flowchart TD
    Start["迭代开始"] --> Budget["预算检查<br/>iterations/tokens/activity/idle/shutdown"]
    Budget -->|"超出预算"| Return["返回当前结果"]
    Budget -->|"通过"| Iter["iteration++<br/>上报 thinking 状态"]
    Iter --> LRU["工具 LRU 管理<br/>tick + 自动淘汰"]
    LRU --> Compact["Tiered compaction<br/>preflight + tier1 + tier2"]
    Compact --> Repair["消息修复管线<br/>7 类 repair reason"]
    Repair --> LLM["调用 LLM<br/>(带 hooks + context_management)"]
    LLM --> Stream["流式消费<br/>累积 tokens"]
    Stream --> Stop{"stop_reason?"}
    Stop -->|"EndTurn"| EndReturn["返回响应"]
    Stop -->|"ToolUse"| LoopCheck["循环检测"]
    Stop -->|"MaxTokens"| MaxReturn["返回部分响应"]
    Stop -->|"ContentFiltered"| FilterReturn["返回安全提示"]
    LoopCheck --> ToolExec["并行执行工具"]
    ToolExec --> Start

图 5-1:Agent Loop 单次迭代流程。 关键路径是 ToolUse 分支——它是唯一导致循环继续的 stop_reason。

5.2.3 预算检查

每次迭代最先执行的是预算检查(crates/octos-agent/src/agent/budget.rs:42-80):

#![allow(unused)]
fn main() {
pub(super) fn check_budget(
    &self,
    iteration: u32,
    start: Instant,
    total_usage: &TokenUsage,
    activity: &LoopActivityState,
) -> Option<BudgetStop> {
    // 1. 优雅关停——原子读取,O(1)
    if self.shutdown.load(Ordering::Acquire) {
        return Some(BudgetStop::Shutdown);
    }
    // 2. 迭代次数——简单比较
    if iteration >= self.config.max_iterations {
        return Some(BudgetStop::MaxIterations);
    }
    // 3. idle progress timeout——无可观察进展
    if activity.has_timed_out(idle_timeout) {
        return Some(BudgetStop::IdleProgressTimeout { limit: idle_timeout });
    }
    // 4. activity timeout——只有没有近期进展时才触发
    if let Some(timeout) = self.config.max_timeout {
        if start.elapsed() > timeout && !activity.recently_active_within(timeout) {
            return Some(BudgetStop::ActivityTimeout { limit: timeout });
        }
    }
    // 5. token 预算——需要加法
    if let Some(max_tokens) = self.config.max_tokens {
        let used = total_usage.input_tokens + total_usage.output_tokens;
        if used >= max_tokens {
            return Some(BudgetStop::MaxTokens { used, limit: max_tokens });
        }
    }
    None
}
}

五道检查的优先级经过精心排序:

  1. Shutdown 最先——原子加载是 ~1 CPU 周期的操作,且用户主动中断必须立即响应
  2. 迭代次数次之——简单整数比较,是最常见的停止原因
  3. Idle progress timeout 第三——默认 300 秒无任何 reporter 进展,说明 loop 可能卡死
  4. Activity timeout 第四——max_timeout 不是无条件墙钟 kill;只在总时长超限且最近无进展时触发
  5. Token 预算最后——需要加法运算,且大部分配置不设置 token 上限(None

每种停止原因携带不同的上下文数据(BudgetStop 枚举,crates/octos-agent/src/agent/budget.rs:11-17):

#![allow(unused)]
fn main() {
pub(super) enum BudgetStop {
    Shutdown,
    MaxIterations,
    MaxTokens { used: u32, limit: u32 },     // 包含已用和上限
    ActivityTimeout { limit: Duration },      // 总时长超限且无近期活动
    IdleProgressTimeout { limit: Duration },  // 默认 300s 无进展
}
}

这些上下文数据被传递给 report_budget_stop() 方法(crates/octos-agent/src/agent/budget.rs:82-123),生成对应的进度事件通知用户——"Reached max iterations"、"Token budget exceeded (1000 of 500)"、"Activity timeout" 等具体信息。ActivityTrackingReporter 会在每个 progress event 时刷新 last_activity_atcrates/octos-agent/src/agent/activity.rs:60-82),所以长任务只要持续产出进展,不会因为单纯墙钟增长被误杀。

5.2.4 消息修复管线

在每次 LLM 调用前,消息历史需要经过一条集中在 prepare_conversation_messages() 的准备管线(crates/octos-agent/src/agent/loop_compaction.rs:17-53),并把每类变更记录到 LoopRepairReasoncrates/octos-agent/src/agent/turn_state.rs:47-56):

  1. trim_to_context_window():截断过长的历史消息以适应模型的上下文窗口
  2. normalize_system_messages():合并多个系统消息,确保系统提示的正确位置
  3. repair_message_order():修复消息顺序(某些 Provider 要求严格的 user→assistant→user 交替)
  4. repair_tool_pairs():确保每个工具调用都有对应的工具结果
  5. synthesize_missing_tool_results():为缺失的工具结果生成占位响应(如 "[result unavailable]"
  6. truncate_old_tool_results():截断过早的工具结果以节省上下文空间
  7. normalize_tool_call_ids():统一跨 Provider 的 tool_call_id 前缀与字符集合

为什么需要这么多修复?原因有三:

上下文压缩和并发写入的副作用。 当对话历史经过 compaction(详见第 8 章)或 speculative / overflow 分支并发写入 session 时,工具调用和工具结果的配对关系可能被打散。repair_message_order() 会从整个消息列表收集匹配 tool result 并放回 assistant 后面,repair_tool_pairs()synthesize_missing_tool_results() 则处理孤立或缺失结果。

Provider 格式差异。 Anthropic 要求 tool result 紧跟在包含 tool_call 的 assistant 消息之后,不能插入其他消息。OpenAI 则允许 tool result 与 tool_call 之间有间隔。repair_message_order() 根据当前 Provider 的要求重排消息。

LLM 的不可靠输出。 LLM 有时会生成重复的 tool_call_id,或返回格式不完整的工具调用。normalize_tool_call_ids() 在 LLM 调用前清理这些问题,避免 Provider API 因为重复 ID 而报错。

5.2.5 工具数量警告

在第一次迭代中,如果注册的工具数超过 25 个(crates/octos-agent/src/agent/loop_runner.rs:735-740),系统会打印警告。这是因为大多数 LLM 在工具列表过长时表现下降——可能出现"空响应"或选择困难。建议通过 always: false 策略或 tool_policy deny 列表减少活跃工具数。

5.2.6 LLM 调用与空响应重试

LLM 调用(crates/octos-agent/src/agent/loop_runner.rs:754-814)经过 hooks 系统,并包含智能重试:

#![allow(unused)]
fn main() {
let response = match self.call_llm_with_hooks(&messages, &tools, &config).await {
    Ok(r) => r,
    Err(e) if e.to_string().contains("empty response after") => {
        // 空响应——重试一次,AdaptiveRouter 可能切换到其他 Provider
        self.call_llm_with_hooks(&messages, &tools, &config).await?
    }
    Err(e) => return Err(e),
};
}

这个重试逻辑处理一个特殊场景:LLM 返回了空响应(没有文本、没有工具调用、没有错误)。这通常发生在 Provider 过载或模型处理失败时。重试一次让 AdaptiveRouter 有机会选择不同的 Provider(详见第 3 章)。

Hooks 允许用户在 LLM 调用前后注入自定义逻辑(详见第 14 章)。before-hook 可以拒绝调用(返回 exit code 1),after-hook 可以观察响应。

5.2.7 流式消费与自适应超时

流式响应消费(crates/octos-agent/src/agent/streaming.rs:32-239)使用 tokio::select! 同时等待三个事件:

#![allow(unused)]
fn main() {
let event = tokio::select! {
    event = stream.next() => event,           // 流事件到达
    _ = self.wait_for_shutdown() => {         // 优雅关停信号
        break;
    }
    _ = tokio::time::sleep(timeout) => {      // 超时
        break;
    }
};
}

三个 future 竞争,先完成的决定执行路径。如果 shutdown 信号在流式传输过程中到达,Agent 立即停止消费,不等待流结束。

流式响应的事件类型:

  • TextDelta:逐 token 的文本输出,实时转发给用户
  • ReasoningDelta:推理模型的思维链输出(如 o1 的内部推理)
  • ToolCallDelta:工具调用参数的增量构建——工具名和参数 JSON 逐块到达
  • Usage:token 使用量更新
  • Done:流结束信号

自适应超时crates/octos-agent/src/agent/streaming.rs:61-75)使用两阶段策略:

#![allow(unused)]
fn main() {
let ttft_secs = (30 + input_tokens_estimate as u64 / 1000).min(180);
let timeout = if got_first_chunk {
    Duration::from_secs(30)       // Phase 2: token 间隔 30s
} else {
    Duration::from_secs(ttft_secs) // Phase 1: TTFT = 30s + 1s/1K tokens
};
}
阶段计算公式100K tokens 输入理由
TTFT30 + tokens/1000(max 180s)130s模型处理大输入需要时间
Inter-chunk固定 30s30s流一旦开始应持续到达

TTFT 的自适应设计至关重要:如果对一个包含整个代码库上下文(100K+ tokens)的请求使用固定 30 秒超时,几乎必然触发误判。1s/1K tokens 的线性增长让超时与输入大小成正比。


5.3 stop_reason 决策树

LLM 响应的 stop_reason 决定了循环的走向。octos 定义了五种 stop_reason(crates/octos-llm/src/types.rs:26-41):

flowchart TD
    SR["stop_reason"] --> ET["EndTurn<br/>模型认为任务完成"]
    SR --> TU["ToolUse<br/>模型想调用工具"]
    SR --> MT["MaxTokens<br/>输出达到上限"]
    SR --> SS["StopSequence<br/>命中停止序列"]
    SR --> CF["ContentFiltered<br/>安全过滤器拦截"]

    ET --> Return1["退出循环<br/>返回完整响应"]
    SS --> Return1
    TU --> LD["循环检测"]
    LD -->|"未检测到"| ToolExec["并行执行工具<br/>join_all"]
    LD -->|"检测到循环"| Warn["返回恢复内容或终止警告<br/>不执行本批工具"]
    ToolExec --> Continue["继续循环"]
    Warn --> ReturnLoop["退出当前 turn"]
    MT --> Return2["退出循环<br/>返回部分响应"]
    CF --> Return3["退出循环<br/>返回安全提示"]

图 5-2:stop_reason 决策树。 ToolUse 是唯一触发循环继续的分支。

5.3.1 EndTurn / StopSequence

模型自然结束(crates/octos-agent/src/agent/loop_runner.rs:849-865)。这是最常见的退出路径——LLM 认为任务已完成,返回最终文本。

5.3.2 ToolUse — 工具执行

这是 Agent Loop 的核心路径(crates/octos-agent/src/agent/loop_runner.rs:866-1013)。当 LLM 返回工具调用请求时:

  1. 循环检测:检查工具调用模式是否重复(见 5.4 节)
  2. 并行执行:交给 handle_tool_use() 统一执行、记录 files、token、structured metadata
  3. 结果注入:将工具结果作为 Tool 角色的消息添加到历史中
  4. spawn_only fast return:如果本轮启动的是后台任务,当前 turn 返回“后台工作已启动”
  5. 继续循环:普通工具结果进入消息历史后,回到迭代开始

5.3.3 MaxTokens

LLM 的输出达到了 max_output_tokens 限制(crates/octos-agent/src/agent/loop_runner.rs:1014-1029)。这通常意味着 LLM 的回答被截断了。循环退出,返回截断的内容。

5.3.4 ContentFiltered

LLM 的安全过滤器拦截了输出(crates/octos-agent/src/agent/loop_runner.rs:1030-1051)。这可能因为用户请求涉及敏感内容,或 LLM 的输出触发了 Provider 的内容策略。循环退出,返回安全提示消息。


5.4 循环检测:防止 Agent 陷入死循环

5.4.1 问题场景

Agent 可能陷入死循环:反复读取同一个文件、反复执行同一个失败的命令、或者在"修改→测试→失败→修改"的循环中无法收敛。如果不加检测,50 次迭代会全部浪费在无意义的重复上。

5.4.2 检测算法

LoopDetector(crates/octos-agent/src/loop_detect.rs:11-16)使用哈希签名检测工具调用模式的重复:

#![allow(unused)]
fn main() {
pub struct LoopDetector {
    signatures: Vec<u64>,  // 工具调用哈希的环形缓冲区
    window: usize,         // 最大窗口大小(默认 12)
}
}

每次工具调用时,将 工具名 + 参数 JSON 哈希为 u64 签名,追加到缓冲区。然后检查最近的签名序列是否存在长度为 1、2 或 3 的重复模式(crates/octos-agent/src/loop_detect.rs:29-60)。

检测条件crates/octos-agent/src/loop_detect.rs:74-81):同一模式连续出现 3 次以上才触发检测。例如:

  • 模式长度 1:[A, A, A] → 检测到(同一个工具调用重复 3 次)
  • 模式长度 2:[A, B, A, B, A, B] → 检测到(AB 对重复 3 次)
  • 模式长度 3:[A, B, C, A, B, C, A, B, C] → 检测到(ABC 序列重复 3 次)

5.4.3 检测后的处理

检测到循环后(crates/octos-agent/src/agent/loop_runner.rs:866-920),当前 process_message 不会继续执行同一批工具。它先尝试 dispatch_shell_retry_recovery():如果这是反复 shell 尝试导致的 spiral,运行时会通过 LoopRetryState 返回最近可用的 shell 输出;否则返回一个去重后的 terminal warning:

⚠️ Loop detected: you appear to be repeating the same tool calls.
Please try a different approach.

这个消息是当前 turn 的返回内容,而不是写入消息历史后继续执行。loop_detected_recently 会保证同一 burst 里不会反复发出相同 warning;如果再次触发,会升级成 terminal error(crates/octos-agent/src/agent/mod.rs:168-173)。

这个改变比旧版“注入警告后继续”更保守:检测到重复工具模式时先停住本轮,避免继续消耗工具调用和 token;真正合理的轮询应通过后台任务、spawn_only 或显式状态查询建模,而不是让主 loop 无界重复。


5.5 Token 预算管理

5.5.1 累积追踪

每次 LLM 调用后,token 使用量通过 LoopTurnState::record_usage() 累积到 total_usagecrates/octos-agent/src/agent/turn_state.rs:103-120,调用点见 loop_runner.rs:843-847):

#![allow(unused)]
fn main() {
turn.record_usage(
    response.usage.input_tokens,
    response.usage.output_tokens,
    tracker,
);
}

工具执行产生的 token(如果工具内部调用了子 Agent 或 LLM)也会先由 execute_tools() 聚合,再在 handle_tool_use() 中调用同一个 turn.record_usage() 累加(crates/octos-agent/src/agent/execution.rs:1328-1348; crates/octos-agent/src/agent/loop_runner.rs:1428-1469)。

5.5.2 实时上报

TokenTrackercrates/octos-agent/src/agent/mod.rs:93-112)使用原子计数器实时更新 token 使用量:

#![allow(unused)]
fn main() {
pub struct TokenTracker {
    pub input_tokens: AtomicU32,
    pub output_tokens: AtomicU32,
}
}

这些原子计数器被进度上报器(ProgressReporter)读取,用于在 CLI 或 Web UI 中显示实时的 token 消耗。Ordering::Relaxed 足够——token 计数不需要严格的顺序保证,最终一致性即可。

5.5.3 成本计算

流式消费完成后(crates/octos-agent/src/agent/streaming.rs:242-258),系统使用 octos-llm 的定价模块计算本次响应和累计会话的成本,并通过 reporter 上报。这让用户在交互过程中实时看到 API 成本。


5.6 流式消费的自适应超时

流式响应的超时策略(streaming.rs)比简单的固定超时更加精细:

超时类型计算公式最大值场景
首 token (TTFT)30s + 1s/1K input tokens180s等待 LLM 开始响应
token 间隔固定 30s30s正常流式传输中

TTFT 的自适应设计考虑到了一个现实问题:输入越长(比如包含大量源码上下文),LLM 处理所需的时间越长。固定的 30 秒超时在处理 100K+ tokens 的输入时会频繁触发误判。1s/1K tokens 的线性增长让超时与输入大小成正比,180s 上限防止无限等待。


5.7 源码走读:核心 200 行

将主循环的关键路径提炼为约 200 行(来自 loop_runner.rs),带中文注释:

#![allow(unused)]
fn main() {
// === process_message_inner 的关键路径(简化版)===
loop {
    // 1. 预算检查:iteration / token / activity / idle / shutdown
    if let Some(stop) = turn.check_budget(self, activity.as_ref()) {
        if !self.try_budget_grace_call(&stop, &mut retry_state, turn.iteration()) {
            turn.record_budget_stop(&stop);
            return Ok(ConversationResponse { content: stop.message(), /* ... */ });
        }
    }

    let iteration = turn.advance_iteration();
    self.beat_heartbeat(iteration)?;
    self.reporter().report(ProgressEvent::Thinking { iteration });

    // 2. 工具 LRU 与三层 compaction
    self.tools.tick();
    self.tools.auto_evict();
    if iteration == 1 {
        self.maybe_run_preflight_compaction(&mut messages);
    }
    let protected_ids = collect_protected_tool_call_ids(&messages);
    self.run_tier1_compaction(&mut messages, &protected_ids);

    // 3. 消息准备:trim + system normalize + tool pair/order repair + id normalize
    prepare_conversation_messages(self, &mut messages, &mut turn);
    self.maybe_run_turn_compaction(&mut messages, iteration);

    // 4. 调用 LLM。Anthropic 可带 tier-2 context_management payload。
    let call_config = with_tier2_context_management(&config, self);
    let (mut response, streamed) = match self
        .call_llm_with_hooks(&messages, &tools_spec, &call_config, iteration, &total_usage, &mut turn)
        .await
    {
        Ok(r) => r,
        Err(e) => match self.handle_loop_error_with_dispatch(&e, &mut retry_state, iteration, &mut messages) {
            LoopErrorAction::Retry => continue,
            LoopErrorAction::Bail => return Err(e),
        },
    };
    Self::normalize_inline_invokes(&mut response);

    // 5. 累积 token 使用量,并同步 TokenTracker。
    turn.record_usage(response.usage.input_tokens, response.usage.output_tokens, tracker);

    // 6. stop_reason 决策
    match response.stop_reason {
        StopReason::EndTurn | StopReason::StopSequence => {
            self.emit_cost_update(turn.total_usage(), &response.usage);
            return Ok(ConversationResponse { /* ... */ });
        }
        StopReason::ToolUse => {
            // 检测到重复工具模式时,不再继续执行这一批工具。
            for tc in &response.tool_calls {
                if let Some(warning) = loop_detector.record(&tc.name, &tc.arguments) {
                    if let Some(recovered) =
                        self.dispatch_shell_retry_recovery(&messages, &mut retry_state, iteration)
                    {
                        return Ok(ConversationResponse { content: recovered, /* ... */ });
                    }
                    return Ok(ConversationResponse {
                        content: self.dedup_loop_warning(warning)?,
                        /* ... */
                    });
                }
            }

            if let Err(e) = self.handle_tool_use(&response, &mut messages, /* ... */).await {
                match self.handle_loop_error_with_dispatch(&e, &mut retry_state, iteration, &mut messages) {
                    LoopErrorAction::Retry => continue,
                    LoopErrorAction::Bail => return Err(e),
                }
            }

            if self.tools.spawn_only_was_invoked() {
                return Ok(ConversationResponse { content: "Background work started...".into(), /* ... */ });
            }
        }
        StopReason::MaxTokens => {
            return Ok(ConversationResponse { /* ... */ });
        }
        StopReason::ContentFiltered => {
            return Ok(ConversationResponse { content: safety_message(response), /* ... */ });
        }
    }
}
}

注:以上代码经过简化以突出核心逻辑,实际实现包含更多错误处理、日志和边界条件。完整代码见 crates/octos-agent/src/agent/loop_runner.rs


工程决策侧栏:为什么 Agent Loop 本身不是 Actor Model

很多并发系统(如 Akka、Erlang/OTP)使用 Actor Model——每个 Agent 是一个 Actor,通过消息传递通信。octos 的会话层确实由 SessionActor 串行化同一 session 的输入,但 Agent Loop 本身选择了更直接的“typed async loop + 显式 runtime state”模型。

方案一:Actor Model

优势:

  • 天然的状态隔离——每个 Actor 封装自己的状态
  • 消息传递避免共享状态——不需要锁
  • 成熟的错误恢复模式(supervision tree)

劣势:

  • 引入 Actor 框架(如 actix)增加依赖和学习成本
  • 工具执行需要请求-响应语义,Actor 的异步消息传递会增加复杂度
  • Agent 的状态本质上是线性的(消息历史 + 迭代计数),不需要 Actor 的并发状态管理

方案二:typed async loop + session actor 边界(octos 的选择)

优势:

  • 直观的顺序逻辑——循环的每一步自然对应 Agent 的行为阶段
  • Tokio 的异步运行时已经提供了并发能力(join_all 并行执行工具)
  • 会话层负责串行化同一 session 的消息,Agent Loop 内部专注于 turn-local 状态机

劣势:

  • 跨 Agent 协调需要显式的 channel 通信
  • 没有内置的 supervision tree

octos 的理由: Agent Loop 的核心是顺序的——接收→思考→行动→观察→思考→...。在这个链条中,并发只出现在"行动"阶段(多个工具并行执行、后台 spawn_only 任务独立交付)。把主路径保持为 loop + typed state machine,可以让预算、compaction、retry bucket、harness event 这些控制面保持可审计,而不是被 Actor 消息协议分散。


5.8 主干演进:typed recovery state machine

当前主分支里的 Agent Loop 已经不只是“循环调用 LLM、执行工具、继续下一轮”。运行时把错误恢复建模成一条 typed control flow:HarnessError 先把原始 eyre::Report 归类成稳定 variant,再由 RecoveryHint 映射到 LoopDecision,最后由 loop 决定继续、压缩、升级或退出(crates/octos-agent/src/harness_errors.rs:93-233; crates/octos-agent/src/agent/loop_state.rs:126-148)。

失败类型RecoveryHintLoopDecision运行时含义
RateLimited / Network / TimeoutBackoffRetryContinue暂态失败,下一轮可继续
ContextOverflowCompactContextCompactAndRetry先压缩上下文,再重试
ProviderUnavailableSwitchProviderRotateAndRetry语义上应换 provider lane
Authentication / InvalidRequest / ContentFilteredFailFastEscalate配置或请求本身不可恢复
DelegateDepthExceededFailFastEscalate防止子任务递归扩散
InternalBugEscalate运行时 invariant broken

这里有一个容易写错的边界:ProviderUnavailable 的语义是 RotateAndRetry,但当前 handle_loop_error_with_dispatch 里没有 agent 内部的 provider lane 切换 hook;代码会记录 warning 并 bail,把 lane rotation 留给外层 provider chain 或调用者处理(crates/octos-agent/src/agent/loop_runner.rs:336-350)。所以书中不能把它描述成“当前 loop 内自动切换 provider”。

LoopRetryState 也不是一个全局 retry counter。它为每个 HarnessError variant 维护独立 bucket 和 hard limit;超过 limit 返回 Exhausted,不会无限重试(crates/octos-agent/src/agent/loop_state.rs:70-104, crates/octos-agent/src/agent/loop_state.rs:171-253)。ContextOverflow 默认只允许有限次 compact,DelegateDepthExceeded 默认一次后收敛,shell spiral 虽然不是 HarnessError,也通过同一 retry ledger 记录为 shell_spiral

stateDiagram-v2
    [*] --> ObserveError
    ObserveError --> Continue: BackoffRetry and bucket ok
    ObserveError --> CompactAndRetry: ContextOverflow and bucket ok
    ObserveError --> RotateAndRetry: ProviderUnavailable and bucket ok
    ObserveError --> Escalate: FailFast or Bug
    ObserveError --> Exhausted: bucket > limit
    CompactAndRetry --> RunCompaction
    RunCompaction --> NextIteration
    Continue --> NextIteration
    RotateAndRetry --> BailInCurrentRelease: no in-band lane hook

这条路径已经实际接入主循环:CompactAndRetry 分支会调用 turn compaction helper,然后返回 Retry 继续外层循环(crates/octos-agent/src/agent/loop_runner.rs:321-339)。因此 Ch8 中的上下文压缩不只是 token 预算优化,而是 Agent Loop 的错误恢复机制之一。

最后,retry bucket 还可以跨 turn 存活。PersistentRetryStateGuard 构造时从共享 Arc<Mutex<LoopRetryState>> hydrate,drop 时写回;未配置持久化 handle 的 session 则保持旧的“每轮新状态”行为(crates/octos-agent/src/agent/loop_runner.rs:126-170)。这给本章一个重要分层:消息、summary 和 workspace contract 是 prompt-visible state;retry bucket、grace eligibility 和 task lifecycle 是 runtime control state;validator ledger、harness event sink、cost ledger 则是 durable evidence state。

5.9 Agent Loop 与 coding harness 的边界

最新主分支增加了 Codex-compatible coding tool contract 和 agent/goal/loop 控制面,但 Agent Loop 本身仍然是本章描述的 typed async loop。apply_patchexec_commandupdate_planrequest_user_inputspawn_agentwait_agent 等工具会作为普通 tool call 进入 ToolUse 分支;后台任务完成、goal continue 或 loop fire 这类事件则由 TaskSupervisor / AgentOrchestrator / MasterContinuationScheduler 安排后续 turn,而不是绕过 loop 直接修改模型状态。

这条边界很重要:

  • 支持 autonomous coding agent:模型可以用补丁、命令 session、计划、用户输入请求、子 Agent 生命周期和动态工具发现完成代码任务,工具结果仍通过消息历史/structured metadata 回到 loop。
  • 不是隐藏 Actor runtime:主 loop 不负责实时调度多个 agent 互相对话;session actor 和 supervisor 管的是运行时所有权、取消、artifact 和 continuation。
  • 不是 self-evolving optimizer:pipeline validator、artifact ledger、skill-evolve、goal/loop primitive 提供后续优化底座,但当前 loop 没有 DSPy/GEPA 式自动 prompt/program search。

因此,Octos 的 autonomous coding 能力应理解为“后端监督的工具合约 + 受控 continuation”接入 Agent Loop,而不是把 Agent Loop 改造成一个自我演化的多 Agent 社会。

5.10 本章回顾

Agent Loop 是 octos 的灵魂——一个精心编排的 while 循环:

  1. 预算检查:五道门禁(shutdown → iterations → idle progress timeout → activity timeout → tokens),确保 Agent 不会无限运行,也不会误杀仍在持续上报进展的长任务。50 次迭代上限是默认安全阀。

  2. 消息修复:7 类 repair reason 在每次 LLM 调用前规范化消息历史,处理上下文压缩、并发写入副作用和 Provider 间的格式差异。

  3. stop_reason 决策:五种分支中只有 ToolUse 触发循环继续。EndTurn 是正常退出,MaxTokens 是截断退出,ContentFiltered 是安全退出。

  4. 循环检测:哈希签名 + 模式匹配(长度 1/2/3,重复 3 次),检测到后停止当前工具批次,优先返回 shell 恢复内容,否则返回去重后的 terminal warning。

  5. Token 追踪:原子计数器实时更新,支持 CLI/Web UI 的成本显示。

  6. 流式超时:自适应 TTFT(与输入大小成正比),避免长上下文场景的误判。

  7. typed recoveryHarnessErrorLoopRetryStateLoopDecision 让错误恢复成为有界状态机;CompactAndRetry 已经接入主循环,provider lane rotation 则仍由外层 provider chain 承担。

  8. coding harness 边界:Codex-compatible 工具和 agent/goal/loop 控制面通过 ToolUse、supervisor 和 continuation scheduler 接入主循环;它支持 autonomous coding workflow,但不是完整 self-evolving optimizer 或任意互联的多 Agent runtime。

下一章将深入工具系统——Agent Loop 中"行动"阶段的核心:内置工具、插件工具和 spawn_only 后台任务如何注册、调用和安全管控。


延伸阅读

  • ReAct 框架:Yao et al., "ReAct: Synergizing Reasoning and Acting in Language Models"(2023)——Agent 循环的理论基础
  • Function Calling:OpenAI "Function calling" 文档——理解 LLM 如何请求工具调用
  • Tokio select!:Tokio 官方文档 "select" 章节——理解多 future 竞争的模式
  • Circuit Breaker 模式:Michael Nygard, Release It!(Pragmatic Bookshelf)——生产系统的韧性模式

思考题

  1. 迭代上限的权衡:50 次迭代上限对于简单任务(如回答问题)太高,对于复杂任务(如大规模重构)可能太低。你会如何设计一个自适应的迭代上限?

  2. 循环检测的局限:当前的哈希签名方法只检测精确重复。如果 Agent 每次传递的参数略有不同(如文件名多一个空格),检测就会失效。你会如何改进?

  3. 消息修复的必要性:7 类 repair reason 处理了大量边界情况。如果 octos 只支持一个 Provider(如只支持 Anthropic),哪些修复可以省略?

  4. Actor Model 的场景:本章的工程决策侧栏选择了简单循环而非 Actor Model。如果 octos 需要支持多个 Agent 协作(如一个规划 Agent 分配任务给多个执行 Agent),设计会如何改变?


版本演化说明 本章按当前 ../octos/crates/octos-agent/src/agent/ 源码撰写。后续审查应重点核对 loop_runner.rsloop_state.rsloop_compaction.rsbudget.rsstreaming.rsharness_errors.rs,以及 octos-cli/src/api/agent_orchestrator.rs / master_continuation_scheduler.rs 与主 loop 的连接边界。Agent Loop 的主要变化已经集中在 typed recovery、tiered compaction、idle progress timeout、后台任务交付和 coding harness continuation。

第 6 章:工具系统:内置工具的设计模式

定位:本章深入 Agent Loop 中"行动"阶段的核心——工具系统。展示 Tool trait 的设计、ToolRegistry 的注册与 LRU 淘汰机制、ToolPolicy 的 deny-wins 安全语义,以及参数安全措施。前置依赖:第 5 章。适用场景:想理解 Agent 工具架构的 AI 应用开发者(读者 C),以及想为 octos 贡献新工具的开发者(读者 D)。

Agent 的"智能"来自 LLM,但 Agent 的"能力"来自工具。没有工具,Agent 只能生成文本;有了工具,Agent 可以读写文件、执行命令、搜索网页、管理 Git 仓库。当前源码里已经不能再把 builtins 概括成"15 个基础工具":ToolRegistry::with_builtins_and_permissions() 同时注册传统文件/搜索/Web/workspace 工具、Codex-compatible coding 工具面、动态工具发现入口和一个未接后端的 image_generation typed stub;gitcode_structure 仍受 Cargo feature 控制,configure_toolspawnmessagesend_fileread_task_outputsynthesize_researchmanage_skillsmodel_check 等则由 chat、gateway、session actor 在不同运行模式下继续注入。理解这个分层注册模型,比记住一个固定数字更重要(../octos/crates/octos-agent/src/tools/registry.rs:1168-1284../octos/crates/octos-cli/src/api/coding_tool_contract.rs:1-180../octos/crates/octos-cli/src/commands/chat.rs../octos/crates/octos-cli/src/commands/gateway/gateway_runtime.rs../octos/crates/octos-cli/src/session_actor.rs)。

但工具带来能力的同时也带来风险:每个工具调用都是一个潜在的攻击面。如何在开放能力的同时控制风险?octos 的答案是三道防线:ToolPolicy 控制哪些工具可用,参数验证控制输入安全,symlink-safe I/O 控制文件系统访问边界。


6.1 Tool trait:最小化的工具接口

Tool trait(../octos/crates/octos-agent/src/tools/mod.rs:436-492)定义了所有工具的统一接口:

#![allow(unused)]
fn main() {
pub trait Tool: Send + Sync {
    fn name(&self) -> &str;
    fn description(&self) -> &str;
    fn input_schema(&self) -> serde_json::Value;
    fn tags(&self) -> &[&str] { &[] }
    async fn execute(&self, args: &serde_json::Value) -> Result<ToolResult>;
    async fn execute_with_context(
        &self,
        _ctx: &ToolContext,
        args: &serde_json::Value,
    ) -> Result<ToolResult> { self.execute(args).await }
    fn as_any(&self) -> &dyn std::any::Any { &() }
    fn concurrency_class(&self) -> ConcurrencyClass { ConcurrencyClass::Safe }
}
}

设计上,Tool trait 有三层职责:

声明部分name() + description() + input_schema())构成 ToolSpec,发送给 LLM 让它知道有哪些工具可用以及如何调用。input_schema() 返回 JSON Schema 格式的参数描述。

执行部分execute())接收 LLM 传来的参数 JSON,执行实际操作,返回 ToolResult

#![allow(unused)]
fn main() {
pub struct ToolResult {
    pub output: String,              // 返回给 LLM 的文本输出
    pub success: bool,               // 是否成功
    pub file_modified: Option<PathBuf>, // 修改的文件
    pub files_to_send: Vec<PathBuf>,  // 需要发送给用户的文件
    pub tokens_used: Option<TokenUsage>, // 子 Agent 工具的 token 消耗
    pub structured_metadata: Option<serde_json::Value>, // 给宿主/UI 的结构化侧信道
}
}

集成部分 由四个扩展点承载:execute_with_context() 让迁移后的工具读取 ToolContext,而不破坏旧的 execute() 签名;tags() 为工具打能力标签;as_any() 允许框架在极少数情况下向下转型访问具体工具,例如 activate_tools 需要在 Agent 构造完成后注入 ToolRegistry 回指(../octos/crates/octos-agent/src/agent/mod.rs:384-394);concurrency_class() 则把工具标记为 SafeExclusive,供批量执行器决定并发还是串行。

structured_metadata 是另一个近期新增的宿主侧通道。它不会改变传统的文本输出,但允许 run_pipeline 这类工具把 per-node cost rows 带回 session actor,再通过 UI/API completion metadata 交给成本面板渲染(../octos/crates/octos-agent/src/tools/mod.rs:392-415)。

tags() 不只是"分类标签"。当前源码里它至少影响两层过滤:

  • ToolPolicy::require_tags 通过 is_allowed_with_tags() 过滤 provider 视角可见的工具。
  • ToolRegistry::set_context_filter() 通过上下文标签裁剪 specs() 输出。

这两层都把"空标签工具"视为 universal tool,也就是默认放行(../octos/crates/octos-agent/src/tools/policy.rs:91-113../octos/crates/octos-agent/src/tools/registry.rs:410-443../octos/crates/octos-agent/src/tools/registry.rs:585-593)。

还有一个容易忽略的细节:迁移期的工具上下文有两条路径。新工具可以通过 execute_with_context() 显式读取 ToolContext;旧插件仍可通过 TOOL_CTX task-local 读取同一份上下文。这让 trait 演进保持向后兼容,同时允许长任务工具异步上报进度、携带附件、权限、文件缓存和 subagent 路由信息(../octos/crates/octos-agent/src/tools/mod.rs:3-30../octos/crates/octos-agent/src/tools/mod.rs:214-307../octos/crates/octos-agent/src/agent/execution.rs:963-1002)。


6.2 ToolRegistry:注册与 LRU 淘汰

6.2.1 注册机制

ToolRegistry(../octos/crates/octos-agent/src/tools/registry.rs:57-1018)是工具的中央管理器。更准确地说,它不是"一次性注册所有工具",而是提供一个基础注册表,然后让 chat、gateway、session actor 在此之上叠加各自需要的工具。

with_builtins_and_permissions() 当前注册的是两层能力:传统基础工具层,以及为了兼容 Codex-style coding agent/harness 而新增的编码工具层。

工具层工具名功能边界
文件/编辑read_file, write_file, edit_file, diff_edit, apply_patch读写文件、精确替换、diff/patch 编辑;写入类工具受文件访问策略约束
命令运行shell, exec_command, write_stdin, bash执行命令、维持 PTY/长命令 session、向运行中 session 写 stdin;bash 是 Codex-compatible alias
计划/输入update_plan, request_user_input给前端/宿主提供结构化计划和用户输入请求
子 Agent 生命周期spawn_agent, send_input, resume_agent, wait_agent, close_agent, delegateCodex-compatible subagent 控制面;delegatespawn_agent + wait_agent 的一调用封装
搜索/Webglob, grep, list_dir, web_search, web_fetch, browser文件搜索、内容搜索、网页检索、页面抓取和浏览器自动化
Workspacecheck_workspace_contract, workspace_log, workspace_show, workspace_diff工作区契约检查与历史/diff 查询
图像/发现view_image, tool_search, tool_suggest, image_generation图片元数据检查、动态工具发现;image_generation 当前是 typed unsupported stub
Feature-gatedgit, code_structureGit 操作与 AST 结构分析,分别受 git / ast feature 控制

真正的运行时注册是分层的:

层次注册位置典型工具
基础层../octos/crates/octos-agent/src/tools/registry.rs:1168-1284传统工具 + Codex-compatible coding 工具 + 动态工具发现
合约层../octos/crates/octos-cli/src/api/coding_tool_contract.rscoding.tool_contract.v1、P0 required tools、工具状态与 capability
配置层../octos/crates/octos-agent/src/tools/registry.rsconfigure_tool,以及带配置的 web_search/web_fetch/browser
Chat 模式追加../octos/crates/octos-cli/src/commands/chat.rs:217-315spawnsynthesize_researchrecall_memorysave_memoryrun_pipeline、插件/MCP 工具
Gateway 基础追加../octos/crates/octos-cli/src/commands/gateway/gateway_runtime.rs:615-698带账号目录的插件/MCP 工具、configured web/browser、provider policy
Per-session 追加../octos/crates/octos-cli/src/session_actor.rs:2122-2310read_task_outputmessagesend_filespawncron、per-session run_pipeline

这也是为什么只看 tools/mod.rs 的模块导出会产生错觉:那里列出的是"框架可用的工具类型",不是"当前进程默认已经注册的工具集合"(../octos/crates/octos-agent/src/tools/mod.rs:575-663)。

ToolRegistry 还做了两件容易被忽略的工作:

  • specs() 结果会缓存,只有注册表发生变动时才失效,避免每轮都重建整份 ToolSpec 列表(../octos/crates/octos-agent/src/tools/registry.rs:66-68../octos/crates/octos-agent/src/tools/registry.rs:410-443)。
  • cwd 绑定工具和非 cwd 绑定工具被分开处理。切换到 per-user workspace 时,rebind_cwd() 只重建前者,后者共享原来的 Arc<dyn Tool>;当前 cwd-bound 集合也包含 check_workspace_contract 和 workspace history/diff 工具(../octos/crates/octos-agent/src/tools/registry.rs:933-980)。

6.2.2 Codex-compatible 工具面:模型可见,不等于 UI 本地能力

coding_tool_contract.rs 把这层工具面定义成 coding.tool_contract.v1,contract id 为 codex-compatible-coding-v1。P0 required tools 包括 apply_patchexec_commandwrite_stdinupdate_planrequest_user_inputspawn_agentsend_inputresume_agentwait_agentclose_agent;这些名字是给模型和 AppUI 协商的稳定工具面,不要求前端自己实现文件编辑、命令执行或子代理控制。

这里最容易过度解读的是子 Agent 能力。spawn_agent 可以在绑定 native spawn delegate 后走 TaskSupervisor / native specialist 路径,wait_agentclose_agent 也围绕 supervisor 状态工作;但 send_input 在 model-visible tool 层当前只把输入记录到 supervisor metadata,返回结构化的 delivered: false / delivery: "supervisor_metadata",还不是“向正在运行的子 Agent 实时送入对话消息”的通道。也就是说,Octos 已经提供 Codex-compatible autonomous coding harness 所需的工具拼写、状态合约和后台生命周期面,但不能把它描述成完全自组织、任意互聊的 multi-agent runtime。

tool_search / tool_suggest 也值得单独看:它们读的是 registry 的 live catalog cell,registry 每次 mutation 后都会刷新这个 catalog。因此动态发现面能反映 chat/gateway/profile/MCP/plugin 之后的真实工具可见性,而不是启动时的静态快照。image_generation 则是反例:它被注册为模型可见工具,调用时返回 coding_tool_unsupported typed error,因为当前 profile 尚未绑定实际图像生成后端。

6.2.3 LRU 淘汰机制

LLM 的工具调用是通过在请求中包含 ToolSpec 实现的,每个 ToolSpec 都占用上下文窗口 token。当前 octos 不是靠单一机制控制工具膨胀,而是采用了三层组合:

  1. 启动时按组预延迟(defer_group()),先把低频工具从 specs() 中拿掉。
  2. 运行时用 LRU 把长时间不用的非核心工具移入 deferred 集合。
  3. 真正执行某个 deferred 工具时,再自动激活对应组,不要求 LLM 先显式调用 activate_tools

其中第二层由 ToolLifecycle 驱动(../octos/crates/octos-agent/src/tools/mod.rs:498-560):

#![allow(unused)]
fn main() {
pub struct ToolLifecycle {
    pub(crate) last_used: HashMap<String, u32>,  // 工具名 → 最后使用的迭代号
    pub(crate) iteration: u32,                   // 当前迭代计数器
    pub(crate) base_tools: HashSet<String>,      // 永不淘汰的核心工具
    pub(crate) max_active: usize,                // 默认 15
    pub(crate) idle_threshold: u32,              // 默认 5
}
}
参数默认值含义
max_active15同时活跃的最大工具数
idle_threshold5空闲 N 次迭代后可被淘汰
flowchart TD
    Tick["每次 LLM 调用前 tick()"] --> Count["统计活跃工具数"]
    Count -->|"≤ 15"| OK["保持不变"]
    Count -->|"> 15"| Find["查找候选"]
    Find --> Filter["过滤:非 base_tools<br/>且空闲 ≥ 5 次迭代"]
    Filter --> Sort["按空闲时长排序<br/>最久未用的优先"]
    Sort --> Evict["淘汰至 ≤ 15"]
    Evict --> Deferred["工具移入延迟池<br/>下次使用时自动激活"]

图 6-1:LRU 工具淘汰流程。 被淘汰的工具不会被删除,而是移入 deferred 集合;specs() 不再暴露它们,但 registry 仍保留其实现对象。

6.2.4 淘汰算法源码走读

tick() 很简单,但它的调用位置很关键:Agent 主循环会在每轮请求 LLM 之前先 tick(),再 auto_evict()。这意味着淘汰发生在下一轮工具声明构造之前,而不是在工具调用结束时异步清理(../octos/crates/octos-agent/src/agent/loop_runner.rs:703-708../octos/crates/octos-agent/src/tools/registry.rs:755-797)。

选择候选的核心逻辑在 find_evictable()../octos/crates/octos-agent/src/tools/mod.rs:547-570):

#![allow(unused)]
fn main() {
pub fn find_evictable(&self, active_tools: &[&str]) -> Vec<String> {
    if active_tools.len() <= self.max_active {
        return Vec::new();  // 未超限,不淘汰
    }

    let mut candidates: Vec<(&str, u32)> = active_tools.iter()
        .filter(|name| !self.base_tools.contains(**name))   // 排除核心工具
        .map(|name| (*name, self.last_used.get(*name).copied().unwrap_or(0)))
        .filter(|(_, last)| self.iteration.saturating_sub(*last) >= self.idle_threshold)
        .collect();                                          // 只取空闲 ≥ 5 的

    candidates.sort_by_key(|(_, last)| *last);              // 最旧的优先淘汰
    let to_evict = active_tools.len().saturating_sub(self.max_active);
    candidates.into_iter().take(to_evict)                   // 只淘汰超出部分
        .map(|(name, _)| name.to_string()).collect()
}
}

三个关键设计选择:

base_tools 过滤。 shellread_file 等核心工具永远不是淘汰候选——即使所有 15 个槽位都被核心工具占满。

idle_threshold 保护。 只有空闲 ≥ 5 次迭代的工具才被考虑。这防止了"刚用完就被淘汰"的抖动。

最小淘汰量。 只淘汰 active_count - max_active 个——恰好让活跃数降回 15,而非激进清理所有候选。

被淘汰的工具去哪了? ToolRegistry::auto_evict() 会把这些名字写入 deferred 集合,并让 specs() 缓存失效(../octos/crates/octos-agent/src/tools/registry.rs:763-797)。

重新激活发生在哪里? 不是一个单独的 activate_on_demand() 方法,而是直接写在 ToolRegistry::execute_with_context() 里:如果要执行的工具当前在 deferred 集合中,就先找到其所属分组并调用 activate(),然后再进入参数检查和实际执行(../octos/crates/octos-agent/src/tools/registry.rs:823-897)。

activate_tools 的角色是什么? 它是一个可选的"元工具",用于把 deferred 工具列表显式展示给 LLM 并支持批量加载,不是自动激活的唯一入口。只有在 registry 里确实存在 deferred 工具时,gateway/profile factory 才会注册它;注册之后还要在 Agent 构造完成后调用 wire_activate_tools() 填回 ToolRegistry 的弱引用(../octos/crates/octos-cli/src/commands/gateway/gateway_runtime.rs:1025-1050../octos/crates/octos-cli/src/commands/gateway/profile_factory.rs:743-755../octos/crates/octos-agent/src/tools/activate_tools.rs:8-107../octos/crates/octos-agent/src/agent/mod.rs:384-394../octos/crates/octos-cli/src/session_actor.rs:2499-2500)。

LRU 状态是 per-session 的。 在 Gateway/Serve 模式下,每个 session actor 持有自己的 ToolRegistry(详见第 11 章),LRU 计数器在会话之间完全独立。

spawn_only 和 deferred 是两套不同语义。 spawn_only 不是 LRU 延迟池的一部分。PluginLoader 只是给工具打上 spawn_only 标记;注释里还保留了早期"hidden from specs"的说法,但当前实现明确不 defer,而是让工具继续对 LLM 可见,由执行循环在调用点自动后台化(../octos/crates/octos-agent/src/plugins/loader.rs:145-176../octos/crates/octos-agent/src/plugins/loader.rs:362-456)。

主会话里如果命中 spawn_onlyagent/execution.rs 会先执行 provider policy 拦截,然后注册 supervised background task,并优先向 LLM 返回一个小型 task_handle JSON envelope;只有当 read_task_output 当前不可见时,才退回旧式文本消息。完整输出仍由 SubAgentOutputRouter/BackgroundResultSender 送到 UI,LLM 若要查看中间结果,应调用 read_task_output(task_handle, mode=...),避免把大段后台输出重新塞回上下文(../octos/crates/octos-agent/src/agent/execution.rs:220-307../octos/crates/octos-agent/src/agent/execution.rs:924-960../octos/crates/octos-agent/src/tools/registry.rs:165-209../octos/crates/octos-cli/src/session_actor.rs:2122-2139)。

而在 subagent 场景里,SpawnTool 会主动 clear_spawn_only(),让这些工具按普通工具同步执行,因为子代理本身就已经是后台上下文(../octos/crates/octos-agent/src/tools/spawn.rs:2148-2150../octos/crates/octos-agent/src/tools/spawn.rs:2453-2455)。

还有一个实现层面的细节值得注意:gateway 在 base registry 上先把 messagesend_filespawnactivate_tools 这些名字加入 base_tools,虽然这些工具实例要等到 session actor 内部才真正注册。这样做依赖的是 ToolLifecycle 的"按名称 pin 住"语义,以及 snapshot_excluding() 会把 base set 复制到子 registry,从而让后续 per-session 注入的这些工具天然不会被 LRU 淘汰;read_task_output 则在 session actor 注册后再追加进 base set,确保 task_handle envelope 指向的读取工具不会在长会话中被淘汰(../octos/crates/octos-cli/src/commands/gateway/gateway_runtime.rs:1001-1023../octos/crates/octos-agent/src/tools/registry.rs:595-640../octos/crates/octos-cli/src/session_actor.rs:2122-2139)。

6.2.5 并发调度:Safe 并发,Exclusive 串行

工具系统现在还把"能不能并发执行"提升为 trait 级语义。ConcurrencyClass::Safe 表示只读或无副作用工具,可以和其他 Safe 工具并发;ConcurrencyClass::Exclusive 表示会写文件、启动 shell、更新状态或有其它可观察副作用,必须串行执行(../octos/crates/octos-agent/src/tools/mod.rs:392-492)。

Agent 收到一批 tool calls 后,会先检查这一批里是否存在 Exclusive 工具:

flowchart TD
    Batch["LLM 返回一批 tool_calls"] --> Classify["读取每个工具的 concurrency_class()"]
    Classify -->|"全部 Safe"| Parallel["spawn 每个工具任务<br/>join_all 等待结果"]
    Classify -->|"任一 Exclusive"| Serial["按 LLM 顺序串行执行"]
    Serial --> Cancel["前一个失败时<br/>后续调用返回 synthetic cancelled"]

这比"所有工具都并发"更保守,也比"所有工具都串行"更高效。文件读取、搜索等 Safe 工具仍可并行;涉及写入、shell、状态变更的工具则通过 Exclusive 防止同一 turn 内互相踩踏(../octos/crates/octos-agent/src/agent/execution.rs:1192-1272)。


6.3 ToolPolicy:deny-wins 安全语义

ToolPolicy(../octos/crates/octos-agent/src/tools/policy.rs:26-224)控制哪些工具可用、哪些被禁止。当前实现不只是 allow/deny 两列,而是三维策略:

#![allow(unused)]
fn main() {
pub struct ToolPolicy {
    pub allow: Vec<String>,
    pub deny: Vec<String>,
    pub require_tags: Vec<String>,
}
}

其中 allow / deny 决定名字级别可见性,require_tags 决定标签级别可见性;两者组合时仍然遵循 deny-wins,并且 deny 会带上 policy_denyrobot_tier_gate 这类 metrics reason(../octos/crates/octos-agent/src/tools/policy.rs:26-118)。

6.3.1 deny-wins 规则

#![allow(unused)]
fn main() {
pub fn is_allowed(&self, tool_name: &str) -> bool {
    // 1. 先检查 deny 列表——deny 始终优先
    for entry in &self.deny {
        if entry_matches(entry, tool_name) {
            return false;
        }
    }
    // 2. 空 allow 列表 = 允许所有未被 deny 的工具
    if self.allow.is_empty() {
        return true;
    }
    // 3. 非空 allow 列表 = 只允许列表中的工具
    self.allow.iter().any(|entry| entry_matches(entry, tool_name))
}
}

deny-wins 意味着:如果一个工具同时出现在 allow 和 deny 列表中,它会被禁止。这是安全策略的基本原则——明确禁止的规则不应被任何允许规则覆盖。

6.3.2 通配符与分组

策略支持三种匹配扩展:

通配符web_* 匹配 web_searchweb_fetch。只支持尾部通配符(前缀匹配)。

分组group:fs 展开为 ["read_file", "write_file", "edit_file", "diff_edit"]

标签要求:当 require_tags 非空时,工具必须至少命中一个要求标签;但空标签工具仍然放行,作为"通用工具"存在(../octos/crates/octos-agent/src/tools/policy.rs:91-113)。

当前预定义分组包括:

分组包含工具
group:fsread_file, write_file, apply_patch, edit_file, diff_edit
group:runtimeshell, exec_command, write_stdin, bash
group:webweb_search, web_fetch, browser
group:searchglob, grep, list_dir
group:sessionsspawn, spawn_agent, send_input, resume_agent, wait_agent, close_agent, delegate
group:memoryrecall_memory, save_memory
group:researchsearch, synthesize_research, deep_crawl
group:adminmanage_skills, configure_tool, model_check
group:mediamofa_comic, mofa_slides, mofa_infographic, mofa_cards, fm_tts, fm_voice_list
group:delegateddelegate_task, spawn, send_message, message, save_memory, execute_code

这里有个容易误解的点:分组是全局策略词汇表,不是"当前模式下肯定已经注册的工具表"。例如 group:admin 里同时列了 manage_skillsconfigure_toolmodel_check,但在 chat 模式里通常只有 configure_tool,在 gateway 模式才可能同时具备三者。group:delegated 则是 delegate child 的 canonical deny list,用来阻断再委派、后台 spawn、用户消息、memory write 和任意代码执行这类越权面。defer_group()activate() 都会先检查工具名是否真的存在于当前 registry,不存在的名字会被静默跳过(../octos/crates/octos-agent/src/tools/policy.rs:153-224../octos/crates/octos-agent/src/tools/registry.rs:656-690)。

6.3.3 Provider 级策略

当前配置字段不是 tools.byProvider,而是顶层的 tool_policy_by_provider。它按"精确 model ID 优先,其次 provider 名"做匹配(../octos/crates/octos-cli/src/config.rs:62-69../octos/crates/octos-cli/src/commands/chat.rs:688-701)。例如:

{
  "tool_policy_by_provider": {
    "claude-sonnet-4-20250514": {
      "deny": ["browser", "deep_search"]
    },
    "gemini": {
      "allow": ["group:fs", "group:search"],
      "require_tags": ["code"]
    }
  }
}

这里要区分两套 API:

  • apply_policy() 是"硬裁剪"。它直接 retain(),把不允许的工具从 registry 里物理删除。
  • set_provider_policy() 是"软过滤"。工具对象仍然保留,但 specs() 会把它们藏起来,execute() 也会再次检查并拒绝调用。

这种分层很重要:全局配置里的 tool_policy 适合做系统级最小权限,tool_policy_by_provider 则适合针对不同模型做差异化曝光,而不破坏底层 registry 的完整性。retain() 还会同步清理 stale spawn_onlyspawn_only_messagesdeferred 状态,避免策略裁剪后还残留后台化或激活入口(../octos/crates/octos-agent/src/tools/registry.rs:460-498../octos/crates/octos-agent/src/tools/registry.rs:567-582../octos/crates/octos-agent/src/tools/registry.rs:839-896)。


6.4 参数安全:1MB 限制与非分配估算

6.4.1 1MB 参数大小限制

工具调用的参数大小被限制在 1MB(../octos/crates/octos-agent/src/tools/registry.rs:876-886):

#![allow(unused)]
fn main() {
const MAX_ARGS_SIZE: usize = 1_048_576; // 1 MB
}

这防止了 LLM 生成巨大的参数(比如将整个文件内容作为 edit_file 的参数),避免内存耗尽或下游处理超时。

6.4.2 estimate_json_size:零分配的大小估算

参数大小检查不通过 serde_json::to_string() 序列化后计算长度,而是通过递归遍历 JSON 值树估算大小(../octos/crates/octos-agent/src/tools/registry.rs:26-54):

#![allow(unused)]
fn main() {
fn estimate_json_size(value: &serde_json::Value) -> usize {
    match value {
        serde_json::Value::Null => 4,
        serde_json::Value::Bool(true) => 4,
        serde_json::Value::Bool(false) => 5,
        serde_json::Value::Number(n) => n.to_string().len(),
        serde_json::Value::String(s) => {
            let escapes = s.bytes()
                .filter(|&b| matches!(b, b'\"' | b'\\' | b'\n' | b'\r' | b'\t'))
                .count();
            s.len() + escapes + 2
        }
        serde_json::Value::Array(arr) => {
            2 + arr.iter().map(estimate_json_size).sum::<usize>() + arr.len().saturating_sub(1)
        }
        serde_json::Value::Object(obj) => {
            2 + obj.iter().map(|(k, v)| k.len() + 3 + estimate_json_size(v)).sum::<usize>()
                + obj.len().saturating_sub(1)
        }
    }
}
}

这个估算是 O(N) 时间、O(depth) 栈空间——不做堆分配,只遍历已有的 JSON 树。对于 1MB 级别的检查,精确到字节的准确性不重要,量级正确即可。

文件系统防护其实分成两层:

  • resolve_path() 只做路径规范化和 ../绝对路径阻断,不访问文件系统,也不解决符号链接。
  • 真正的文件读写则交给 read_no_follow() / write_no_follow(),在 Unix 上通过 O_NOFOLLOW 原子地拒绝符号链接;目录类操作如 list_dir 才会单独使用 reject_symlink() 做防御补丁。

对应实现见 ../octos/crates/octos-agent/src/tools/mod.rs:680-813。文件读写工具本身只是调用这些 helper,例如 read_file 在解析完路径后走 read_no_follow()../octos/crates/octos-agent/src/tools/read_file.rs:97-150),write_file / edit_file 则分别调用 write_no_follow()../octos/crates/octos-agent/src/tools/write_file.rs:86-107../octos/crates/octos-agent/src/tools/edit_file.rs:90-138)。

在 Unix 平台上,关键代码只有一行:

#![allow(unused)]
fn main() {
// Unix 平台
opts.custom_flags(libc::O_NOFOLLOW);
}

O_NOFOLLOWopen() 系统调用在目标是符号链接时直接返回 ELOOP 错误,而不是跟随链接打开目标文件。这消除了 TOCTOU(Time-of-Check-Time-of-Use)竞态条件:

没有 O_NOFOLLOW 的场景:

  1. 检查 /workspace/config.json 是否在允许范围内 ✓
  2. 攻击者将 /workspace/config.json 替换为指向 /etc/passwd 的符号链接
  3. 打开 /workspace/config.json,实际读取了 /etc/passwd

O_NOFOLLOW

  1. 打开 /workspace/config.json,如果是符号链接,立即返回 ELOOP

检查和打开合并为一个原子操作,消除了竞态窗口。


工程决策侧栏:为什么是"预延迟 + LRU"的混合策略

工具管理有三种策略可选:

方案一:全量注册(所有工具始终可见)

优势:

  • 简单——不需要淘汰逻辑
  • LLM 始终能调用任何工具

劣势:

  • 30+ 工具的 ToolSpec 可能消耗 3,000-5,000 token 的上下文窗口
  • 对于 128K 窗口的模型,5,000 token 的工具声明占比虽小,但对于 8K 窗口的小模型是不可接受的
  • 过多选项可能导致 LLM 选择困难("工具过载")

方案二:纯手动按需加载(只有 activate_tools,没有自动激活)

优势:

  • 最节省上下文窗口

劣势:

  • LLM 无法请求它不知道的工具,需要额外的发现机制
  • 很容易出现"先猜一个不存在的工具名,再被迫重试"的额外轮次

方案三:预延迟 + LRU + 自动激活(当前 octos 的选择)

Gateway/ProfileFactory 在初始可见工具过多时,会先把 adminsessionswebruntimemedia 等低频分组预先 defer_group();这样进入第一轮 LLM 调用前,工具面板就已经被压缩到较小规模(../octos/crates/octos-cli/src/commands/gateway/gateway_runtime.rs:1025-1050../octos/crates/octos-cli/src/commands/gateway/profile_factory.rs:743-755)。

接下来,运行中的 tick() + auto_evict() 再负责回收长期闲置的非核心工具;如果 LLM 直接请求了一个 deferred 工具,execute() 会自动把它所属的整组重新激活。activate_tools 仍然保留,但它更像一个"批量发现和预热工具",而不是唯一通道。

这套混合策略的关键,不是把一切都藏起来,而是尽量减少"为了解锁工具而多跑一轮模型"的概率。


6.5 本章回顾

工具系统是 Agent 能力的载体:

  1. Tool traitname()/description()/input_schema() 构成 LLM 可见的 ToolSpec,execute()/execute_with_context() 执行实际操作,tags()/as_any()/concurrency_class() 则承担过滤、框架集成与并发 admission 这几类扩展职责。

  2. ToolRegistry:真正的重点不是固定工具数量,而是分层注册。基础 registry 当前已经包含 Codex-compatible coding 工具面;配置注入、chat/gateway 追加、per-session 追加继续决定当前模式下的最终工具集合。

  3. 工具曝光控制:当前实现是"预延迟 + LRU + 自动激活"的混合模型,不是单纯的 LRU。activate_tools 是显式发现入口,但直接执行 deferred 工具也会自动唤醒对应分组;spawn_only 不属于 deferred,而是主会话自动后台化并通过 task_handle/read_task_output 做上下文隔离。

  4. ToolPolicy:deny-wins 语义确保安全策略不被覆盖。除了 allow/deny,还支持 require_tagsapply_policy()set_provider_policy() 分别对应硬裁剪与软过滤。

  5. 参数与文件安全:1MB 大小限制 + 零分配估算防止 DoS。路径规范化负责阻断 traversal,O_NOFOLLOW 负责原子拒绝符号链接,从而消除读写文件时的 TOCTOU 竞态。

下一章将深入安全体系的其他层次——从沙箱隔离到 prompt 注入防御(详见第 7 章)。


延伸阅读

  • JSON Schema:https://json-schema.org/ — 理解 input_schema() 返回的工具参数描述格式
  • TOCTOU 竞态:CWE-367 "Time-of-check Time-of-use" — 理解 O_NOFOLLOW 防御的攻击模式
  • LLM 工具调用:Anthropic "Tool use" 文档 — 理解 LLM 如何选择和调用工具
  • LRU 缓存算法:经典的最近最少使用淘汰策略

思考题

  1. 工具声明的 token 成本:假设每个 ToolSpec 平均消耗 150 token,15 个活跃工具消耗 2,250 token。如果上下文窗口只有 8K token,工具声明就占了 28%。你会如何进一步压缩 ToolSpec 的 token 占用?

  2. deny-wins 的局限:deny-wins 策略能防止工具被直接调用,但如果 Agent 通过 shell 工具执行 curl 命令来替代被禁的 web_fetch,策略就被绕过了。你会如何应对这种间接调用?

  3. 自定义工具的安全审查:如果用户通过 MCP 或 Plugin 添加自定义工具,这些工具不受 octos 的 O_NOFOLLOW 保护。你会如何设计一个工具沙箱来隔离第三方工具?


版本演化说明 本章按当前源码撰写。阅读后续版本时,优先核对 ../octos/crates/octos-agent/src/tools/registry.rs:1168-1284../octos/crates/octos-cli/src/api/coding_tool_contract.rs../octos/crates/octos-agent/src/tools/coding_tools.rs../octos/crates/octos-cli/src/commands/chat.rs../octos/crates/octos-cli/src/commands/gateway/gateway_runtime.rs../octos/crates/octos-cli/src/session_actor.rs 这几处真实注册点,而不是只看 tools/mod.rs 的导出列表。工具类型会继续扩展,但"分层注册、软硬两级策略、预延迟与运行时激活并存、编码工具合约由后端声明"这四条主线更稳定。

第 7 章:安全纵深:从沙箱到 Prompt 注入防御

定位:本章以纵深防御的视角,从最外层的沙箱隔离到最内层的 prompt 注入检测,逐层展示 octos 的安全体系。前置依赖:第 6 章。适用场景:所有四类读者——Rust 开发者学习安全编码模式,AI 应用开发者学习 Agent 安全实践,octos 贡献者理解安全架构的设计理由。

AI Agent 的安全挑战独特而严峻:Agent 不只是处理数据——它执行代码、读写文件、发起网络请求。每一次工具调用都是一个潜在的攻击向量。更糟糕的是,Agent 的输入(用户消息、网页内容、文件内容)可能被恶意构造,通过 prompt 注入诱导 Agent 执行未授权操作。

octos 的安全策略是纵深防御——多层独立的安全屏障,任何单层失败都不会导致系统沦陷:

flowchart TB
    subgraph "第一层:进程隔离"
        SB["沙箱<br/>bwrap / sandbox-exec / Windows helper / Docker"]
    end
    subgraph "第二层:网络安全"
        SSRF["SSRF 防护<br/>私有 IP 阻断 + DNS 失败关闭"]
    end
    subgraph "第三层:工具安全"
        TP["工具策略<br/>deny-wins + SafePolicy"]
    end
    subgraph "第四层:输出清理"
        SAN["凭据脱敏<br/>7 类凭据 + data URI/hex 清理"]
    end
    subgraph "第五层:输入防护"
        PG["Prompt Guard<br/>5 类威胁检测"]
    end
    subgraph "第零层:编译期保证"
        UC["deny(unsafe_code)<br/>+ SecretString"]
    end

    SB --> SSRF --> TP --> SAN --> PG
    UC -.->|"贯穿所有层"| SB

图 7-1:octos 安全纵深分层。 每一层独立工作,即使某一层被绕过,后续层仍然提供保护。


7.1 沙箱后端与自动选择

Shell 命令执行是 Agent 最危险的能力。octos 通过沙箱将命令执行隔离在受限环境中(../octos/crates/octos-agent/src/sandbox/)。

7.1.1 自动检测与选择

create_sandbox()../octos/crates/octos-agent/src/sandbox/mod.rs:226-313)不是“按平台写死一张表”,而是执行一条有序探测链:

  1. 如果 sandbox.enabled = false,直接返回 NoSandbox
  2. 如果显式配置了 SandboxMode::{Bwrap,Macos,Docker,AppContainer,None},按指定模式创建
  3. 如果是 SandboxMode::Auto,则按顺序检查:
    • Linux 且 bwrap 在 PATH 中
    • macOS 且 sandbox-exec 可用
    • Windows 且 octos-sandbox helper 可用
    • Docker 可用
    • 否则退回 NoSandbox

这几点很关键。第一,Windows 自动模式检查的是 octos-sandbox helper,而不是抽象意义上的 “AppContainer 能力”;helper 既会在当前可执行文件同目录中查找,也会回退到 PATH 查找。第二,NoSandbox 是明确的失败回退路径:源码会打印警告,说明 shell 命令将“without isolation”运行,而不是静默降级。

7.1.2 Bwrap(Linux)

Bwrap(bubblewrap)是 Flatpak 项目的沙箱工具,使用 Linux namespaces 提供轻量级隔离(../octos/crates/octos-agent/src/sandbox/bwrap.rs:14-50)。octos 当前的包装过程更准确地说分成 8 步:

  1. 环境清理bwrap.rs:18-21):移除 BLOCKED_ENV_VARS 中的 18 个危险变量
  2. 只读系统绑定bwrap.rs:23-28):/usr/lib/lib64/bin/sbin/etc--ro-bind 挂载
  3. 工作目录绑定bwrap.rs:30-32):用户工作区以 --bind 读写挂载
  4. 临时文件系统bwrap.rs:34-35):--tmpfs /tmp 提供挥发性 scratch space
  5. 最小设备与 proc 视图bwrap.rs:37-39):显式挂载 --dev /dev--proc /proc
  6. 网络隔离bwrap.rs:41-43):当 allow_network = false 时附加 --unshare-net
  7. 进程生命周期控制bwrap.rs:45-47):--unshare-pid + --die-with-parent + --chdir
  8. 命令执行bwrap.rs:48):最后才以 sh -c 执行目标命令

这说明 Bwrap 这一层的职责不是“审计命令是否安全”,而是把命令放进一个更小的执行宇宙里:只读系统目录、受控工作区、可选断网、独立 PID 视图。

7.1.3 macOS sandbox-exec 与 SBPL 注入防护

macOS 使用 sandbox-exec 运行沙箱,策略用 SBPL(Seatbelt Profile Language)编写(sandbox/macos.rs)。SBPL 是一种 Lisp 风格的语言,使用括号分隔的表达式——这意味着用户可控的路径名中的括号是潜在的注入向量

octos 的防护措施(macos.rs:22-32):

#![allow(unused)]
fn main() {
// 检查 cwd 是否包含 SBPL 元字符
if cwd_str.bytes().any(|b| b < 0x20 || b == b'(' || b == b')' || b == b'\\' || b == b'"') {
    tracing::error!("cwd contains SBPL metacharacters, refusing to execute");
    // 返回一个只输出错误信息的命令,而非绕过沙箱执行
    return error_command();
}
}

如果工作目录路径包含 ()\" 等 SBPL 元字符,octos 拒绝执行——返回一个只输出错误消息的命令,而不是跳过沙箱执行原始命令。这是失败关闭(fail-closed)原则的体现。

另一个细节:macOS 上 /tmp 是指向 /private/tmp 的符号链接。SBPL 的 subpath 规则基于真实路径(canonical path)。如果用户传入 /tmp/work 但 SBPL 规则写的是 /tmp/work,写操作会被拒绝(因为真实路径是 /private/tmp/work)。octos 通过 std::fs::canonicalize() 解析真实路径(macos.rs:43-59),并对解析后的路径再次检查 SBPL 元字符。

7.1.4 Docker

Docker 后端(../octos/crates/octos-agent/src/sandbox/docker.rs:36-123)提供容器级隔离,同时也有更高运行时开销:

  • Mount 模式:工作目录挂载为容器卷
  • 资源限制:CPU、内存、PID 数量限制
  • 网络隔离:可选的 --network=none
  • 容器 hardening:--security-opt no-new-privileges--cap-drop ALL
  • 危险 bind mount 阻断:拒绝或跳过 docker.sock/etc/proc/sys/dev 等宿主逃逸高风险来源(../octos/crates/octos-agent/src/sandbox/docker.rs:9-28../octos/crates/octos-agent/src/sandbox/docker.rs:66-117

7.1.5 环境变量清理

无论使用哪个后端,所有沙箱都会清理 18 个危险环境变量(../octos/crates/octos-agent/src/sandbox/mod.rs:23-49):

类别变量攻击向量
Linux 动态链接LD_PRELOAD, LD_LIBRARY_PATH, LD_AUDIT注入恶意共享库
macOS 动态链接DYLD_INSERT_LIBRARIES, DYLD_LIBRARY_PATH 等 5 个注入恶意 dylib
运行时注入NODE_OPTIONS, PYTHONSTARTUP, PYTHONPATH, PERL5OPT, RUBYOPT, RUBYLIB, JAVA_TOOL_OPTIONS在子进程中注入代码
Shell 启动BASH_ENV, ENV, ZDOTDIR修改 Shell 启动行为

BLOCKED_ENV_VARS 定义在沙箱模块里,但当前源码里的复用范围已经明显超出“沙箱 + MCP”这个最初口径。除了各沙箱后端与 MCP stdio server(../octos/crates/octos-agent/src/mcp.rs:432-445)之外,它还至少用于:

  • Hooks 子进程(../octos/crates/octos-agent/src/hooks.rs:788-792
  • Browser / site crawl 启动 Chrome(../octos/crates/octos-agent/src/tools/browser.rs:52-55../octos/crates/octos-agent/src/tools/site_crawl.rs:94-96
  • 执行环境抽象的 env 过滤(../octos/crates/octos-agent/src/subprocess_env.rs:128-137
  • 插件加载器与 CLI 进程管理(../octos/crates/octos-agent/src/plugins/loader.rs:354-355../octos/crates/octos-cli/src/process_manager.rs:286-336
  • octos-plugin lifecycle sandbox 与 octos-swarm dispatch gate 也维护同步语义,避免硬件/插件子进程重新引入动态链接或运行时注入变量。

更准确的理解方式是:BLOCKED_ENV_VARS 已经演化成“启动外部进程时的共享注入黑名单”,而不只是 sandbox backend 的内部细节。


7.2 SSRF 防护

当 Agent 通过 web_fetchbrowser 工具发起网络请求时,SSRF(Server-Side Request Forgery)保护确保请求不会到达内部网络(../octos/crates/octos-agent/src/tools/ssrf.rs)。

7.2.1 私有 IP 阻断

is_private_ip()ssrf.rs:88-116)阻断以下地址范围:

IPv4

  • 127.0.0.0/8(回环)
  • 10.0.0.0/8172.16.0.0/12192.168.0.0/16(私有)
  • 169.254.0.0/16(链路本地——包含 AWS 元数据端点 169.254.169.254
  • 0.0.0.0(未指定)

IPv6

  • ::1(回环)、::(未指定)
  • fc00::/7(ULA,唯一本地地址)
  • fe80::/10(链路本地)
  • fec0::/10(站点本地,已弃用但仍可路由)
  • ff00::/8(多播)
  • ::ffff:x.x.x.x(IPv4 映射的 IPv6 地址——防止通过 IPv6 语法绕过 IPv4 检查)

7.2.2 三阶段 SSRF 验证

check_ssrf_with_addrs()ssrf.rs:21-64)实现三阶段验证:

阶段 1:主机名字符串检查ssrf.rs:27-29)。快速检查 localhostlocalhost. 和字面 IP 地址(如 192.168.1.1),立即拒绝已知危险主机。

阶段 2:字面 IP 跳过ssrf.rs:31-36)。如果 URL 中的 host 是字面 IP(已通过阶段 1 验证),不需要 DNS 解析,直接放行。

阶段 3:DNS 解析 + 结果验证ssrf.rs:38-63)。对域名进行 DNS 解析,检查每一个返回的 IP 地址。如果任何一个 IP 是私有地址,拒绝整个请求。

7.2.3 DNS 失败关闭

阶段 3 的关键设计是失败关闭(fail-close):

#![allow(unused)]
fn main() {
match tokio::net::lookup_host(format!("{host}:{port}")).await {
    Ok(addrs) => {
        for addr in addrs {
            if is_private_ip(&addr.ip()) {
                return Err("DNS resolved to private IP".into());
            }
        }
        Ok(SsrfCheckResult { resolved_addrs: safe_addrs })
    }
    Err(e) => {
        // DNS 失败 → 阻断请求,不是放行!
        Err(format!("DNS resolution failed — blocking request (fail closed): {e}"))
    }
}
}

如果 DNS 解析失败,请求被阻断而非放行。这防止了 DNS 重绑定攻击的一个变种:攻击者在检查时让 DNS 解析失败(如果默认放行就能绕过检查),在实际请求时返回内部 IP。

7.2.4 IPv4 映射的 IPv6 地址

一个经常被遗忘的攻击向量:::ffff:192.168.1.1 是一个合法的 IPv6 地址,但它实际指向 IPv4 的 192.168.1.1。如果 SSRF 防护只检查 IPv4 的 is_private() 而不处理 IPv6 的 mapped 地址,攻击者可以用 IPv6 语法绕过检查。

octos 在 is_private_ip()ssrf.rs:96-113)中显式处理了这种情况:

#![allow(unused)]
fn main() {
// IPv6 检查包含 mapped IPv4
|| v6.to_ipv4_mapped().is_some_and(|v4| is_private_v4(v4))
|| v6.to_ipv4().is_some_and(|v4| is_private_v4(v4))
}

7.3 Prompt 注入检测

Prompt 注入是 AI Agent 特有的攻击向量。octos 的 prompt guard(../octos/crates/octos-agent/src/prompt_guard.rs:1-296)把它当作一层 defense-in-depth:模块级 API 可以扫描任意文本,但当前主执行链上的接线点是在工具输出回写消息历史之前,由 sanitize_tool_output() 调用(../octos/crates/octos-agent/src/agent/execution.rs:1164../octos/crates/octos-agent/src/sanitize.rs:88-95)。

7.3.1 五类威胁

类别示例模式严重性
SystemOverride"忽略之前所有指令"
RoleConfusion"System: 你现在是 DAN"
ToolCallInjection{"name": "shell", "arguments": ...}
SecretExtraction"显示系统提示/API 密钥"
InstructionInjection"从现在开始你必须..."

7.3.2 检测与处理

检测使用 11 个正则表达式模式(prompt_guard.rs:116-192),覆盖多种表述方式。匹配到的内容按严重性处理:

  • 高 / 中:记录 warn! 日志,并把命中的 span 替换为 [injection-blocked:<threat-kind>]
  • :只打 debug 日志,不修改文本

实现上还有两个值得注意的细节(prompt_guard.rs:217-295):

  1. 替换按 反向顺序 进行,避免前面的修改破坏后续 span 偏移
  2. 如果多重威胁导致原 span 失效,代码会退回到“从原位置附近搜索 matched 文本”,最后才做全串搜索,而不是简单 replacen(_, _, 1)

7.3.3 已知局限

prompt_guard.rs 的模块头注释(prompt_guard.rs:1-19)把边界说得很清楚:这不是安全边界,只是日志与内容去激活层。已知绕过方式包括:

  • Base64 编码
  • URL 编码
  • HTML 实体
  • Unicode 同形字(homoglyphs)
  • 零宽字符
  • RTL override 字符

这些绕过不是“实现漏了几个 regex”那么简单,而是纯文本模式匹配的结构性上限。因此本章必须把 prompt guard 放在正确的位置上理解:真正的约束来自沙箱、工具策略以及必要时的 human-in-the-loop hook;prompt guard 负责降低朴素明文注入直接进入上下文的概率。


7.4 凭据脱敏

7.4.1 七类凭据模式 + 两类高噪声模式

sanitize.rs../octos/crates/octos-agent/src/sanitize.rs:1-95)把输出清理拆成两层:先移除高噪声/高风险片段,再脱敏具体凭据模式。

模式匹配对象正则
OPENAI_KEY_REOpenAI API Keysk-[A-Za-z0-9_-]{20,}
ANTHROPIC_KEY_REAnthropic API Keysk-ant-[A-Za-z0-9_-]{20,}
AWS_KEY_REAWS Access Key IDAKIA[0-9A-Z]{16}
GITHUB_TOKEN_REGitHub Token(?:ghp_|gho_|ghs_|ghr_|github_pat_)...
GITLAB_TOKEN_REGitLab PATglpat-[A-Za-z0-9_-]{20,}
BEARER_REBearer TokenBearer\s+[A-Za-z0-9_.+/=-]{20,}
SECRET_ASSIGN_RE通用密钥赋值(?i)password|secret|api_key...=...

上表是 7 类凭据模式;此外还有两类“不是凭据本身,但会污染上下文或携带敏感载荷”的模式:

  • DATA_URI_RE:base64 数据 URI
  • HEX_RE:64+ 连续十六进制串,覆盖 SHA-256、SHA-512、原始 key material 等

7.4.2 脱敏策略

检测到凭据后,保留前 4 个可见字符作为上下文参考,其余替换为 [credential-redacted]。例如:

sk-proj-abc123... → sk-p...[credential-redacted]

保留前缀让开发者在调试时能快速识别是哪种类型的凭据被脱敏了。

7.4.3 工具输出清理

sanitize_tool_output() 在每次工具执行后应用,按顺序清理(sanitize.rs:88-95):

  1. Base64 数据 URI → [base64-data-redacted]
  2. 长十六进制串 → [hex-redacted]
  3. 各类凭据 → 保留前缀 + [credential-redacted]
  4. Prompt 注入内容 → [injection-blocked:<kind>]

7.5 ShellTool SafePolicy

ShellTool::new() 默认就注入 SafePolicy::default()../octos/crates/octos-agent/src/tools/shell.rs:33-41),因此它不是“可选增强项”,而是 shell 工具的默认前置检查。execute() 会先跑 policy,再决定是拒绝、要求批准,还是继续进入沙箱执行(../octos/crates/octos-agent/src/tools/shell.rs:189-244)。

7.5.1 危险命令拒绝

6 个 deny 模式(直接拒绝执行):

rm -rf /          # 删除根文件系统
rm -rf /*         # 删除根目录下所有文件
dd if=             # 原始磁盘操作
mkfs               # 格式化文件系统
:(){:|:&};:        # Fork bomb
chmod -R 777 /     # 递归修改根目录权限

4 个 ask 模式(需要用户确认,非交互环境下等同于拒绝):

sudo               # 提权操作
rm -rf             # 递归删除(不限于根目录)
git push --force   # 强制推送
git reset --hard   # 硬重置

7.5.2 Whitespace 归一化

在匹配前,命令字符串经过空白字符归一化(policy.rs:76-78)——多个空格、Tab、换行都被压缩为单个空格。这防止了 rm -rf /rm\t-rf\t/ 的简单绕过。

7.5.3 词边界检测

模式匹配使用词边界检测(policy.rs:84-103),防止误判。例如,"sudo" 只在作为独立单词时匹配,不会匹配 "pseudocode" 中的子串。

7.5.4 SafePolicy 不是安全边界

源码文档对这点说得比“不是安全边界”更狠(../octos/crates/octos-agent/src/policy.rs:36-46):它只是在 whitespace-normalized 字符串上匹配一个很短的 deny/ask 列表,能抓住的主要是 rm -rf /、fork bomb 这种显眼事故。Shell 元字符、变量展开、编码技巧,以及任何不在列表里的危险命令都可以绕过它。

因此,SafePolicy 的真实定位不是“阻止恶意攻击者”,而是降低 LLM 误生成明显危险命令时的爆炸半径。真正的执行边界仍然是沙箱。另一个常被忽略的细节是:对 Ask 决策,当前 ShellTool 会先尝试 TOOL_APPROVAL_CTX;没有交互式 requester 时直接拒绝,有 requester 时会发出 ToolApprovalRequest,用户拒绝则返回失败(../octos/crates/octos-agent/src/tools/shell.rs:207-241)。


7.6 基础设施安全

7.6.1 deny(unsafe_code)

workspace 级别的 deny(unsafe_code)../octos/Cargo.toml:40-41)把“自有代码中不写 unsafe”提升成了 workspace 约束。当前成员不仅包括核心 runtime crate,还包括 octos-clioctos-pipelineoctos-pluginoctos-sandboxoctos-swarm 以及多个 app/platform skills(../octos/Cargo.toml:1-30)。这一点比“具体有多少个 crate、多少行代码”更重要,因为真正被固定下来的是工程纪律,而不是某个会漂移的数字。

7.6.2 SecretString

API 密钥使用 secrecy crate 的 SecretString 类型存储。当前 octos-llm 中的 OpenAI、Anthropic、OpenRouter、Gemini、Embedding/OpenAI Responses provider 都把 api_key 字段定义为 SecretString,只有在真正组装 HTTP header 时才显式 expose_secret()(例如 ../octos/crates/octos-llm/src/openai.rs:95,322,431../octos/crates/octos-llm/src/anthropic.rs:24,130,213../octos/crates/octos-llm/src/gemini.rs:24,110,255)。这比“日志里会不会打印出来”更进一步:它让明文暴露点在代码里变成显式、可审查的调用点。


工程决策侧栏:workspace 级 deny(unsafe_code) 的实践意义

deny(unsafe_code) 在 Rust 社区中并不罕见,但把它提升到 workspace 级别,约束 CLI、agent runtime、pipeline、sandbox helper 与 skills 相关 crate 一起遵守,是一个值得讨论的决策。

支持的理由:

  • 对于一个执行用户代码的 Agent 平台,内存安全漏洞的后果特别严重——攻击者可能通过 prompt 注入触发内存安全 bug
  • 消除了代码审查中检查 unsafe 正确性的负担——没有 unsafe 就没有这个负担
  • 所有系统交互通过 std::fsstd::processtokio::fs 等安全抽象完成,标准库的 unsafe 代码由 Rust 团队维护

代价:

  • 无法使用某些需要 unsafe 的优化(如 SIMD 加速的 JSON 解析器 simd-json
  • 某些平台特定功能(如 Windows AppContainer)需要通过独立的辅助二进制程序(octos-sandbox)实现
  • 依赖的第三方 crate 仍然可以包含 unsafe——deny(unsafe_code) 只约束自己的代码

octos 的判断: 对于一个执行不可信输入(LLM 生成的工具调用参数)的系统,消除自有代码中的内存安全风险是值得付出性能代价的。第三方 crate 的 unsafe 由 crate 作者和社区审计负责。


7.7 本章回顾

octos 的安全体系是纵深防御的实践:

  1. 沙箱隔离:自动模式按 bwrap -> sandbox-exec -> Windows helper -> Docker -> NoSandbox 链路探测后端,并配合 18 个环境变量清理隔离命令执行。

  2. SSRF 防护:IPv4/IPv6 私有地址全面阻断 + DNS 失败关闭,防止内部网络探测。

  3. 工具策略:deny-wins 语义 + SafePolicy 危险命令拦截,控制 Agent 的行为边界。

  4. 凭据脱敏:7 类凭据模式 + 2 类高噪声模式,工具输出在回写历史前统一清理。

  5. Prompt Guard:5 类威胁、11 个检测模式,中高严重性会被去激活,但它只是附加层,不是安全边界。

  6. 基础设施deny(unsafe_code) 消除内存安全漏洞,SecretString 防止凭据泄漏到日志。

没有任何单一安全措施是完美的——SafePolicy 可以被 Shell 元字符绕过,Prompt Guard 可以被编码变体绕过。但每一层都缩小了攻击面,让攻击者需要同时绕过多层防御才能造成损害。这就是纵深防御的价值。


延伸阅读

  • OWASP Top 10 for LLM Applications:https://owasp.org/www-project-top-10-for-large-language-model-applications/
  • Bubblewrap (bwrap):https://github.com/containers/bubblewrap — Linux 用户空间沙箱
  • macOS Sandbox Profile Language:Apple 开发者文档 "Sandbox Design Guide"
  • SSRF 攻击:PortSwigger Web Security Academy "Server-side request forgery" — 理解 SSRF 攻击向量
  • Prompt Injection:Simon Willison, "Prompt injection attacks against GPT-3" — prompt 注入的早期研究

思考题

  1. 沙箱逃逸:假设攻击者通过 prompt 注入让 Agent 在沙箱内执行了恶意命令,但命令被沙箱限制在工作目录内。如果工作目录本身包含 .git/hooks/ 目录,攻击者能否通过修改 git hooks 在下次 git commit 时逃逸沙箱?

  2. DNS 重绑定:octos 的 SSRF 防护在请求前解析 DNS 并检查 IP。一种更高级的攻击是让 DNS 返回两个 IP(一个安全的外部 IP 通过检查,一个内部 IP 用于实际连接)。这种攻击在 octos 的实现中是否可行?

  3. Prompt 注入的根本解决方案:正则表达式检测本质上是在与攻击者玩猫鼠游戏。你认为 prompt 注入有根本性的解决方案吗?如果有,是什么?如果没有,最好的缓解策略是什么?

  4. 凭据脱敏的过度与不足:当前的规则既可能误判(把正常的 64+ 字符 hex 串当作敏感数据),也可能漏判(不在 7 类已知凭据模式中的自定义 token)。你会如何在精确性和覆盖率之间取得平衡?


版本演化说明 本章按当前 octos 主分支源码更新。后续阅读时,优先核对 ../octos/crates/octos-agent/src/sandbox/../octos/crates/octos-agent/src/tools/ssrf.rs../octos/crates/octos-agent/src/prompt_guard.rs../octos/crates/octos-agent/src/sanitize.rs../octos/crates/octos-agent/src/policy.rs../octos/crates/octos-agent/src/tools/shell.rs,以及 octos-plugin/octos-swarm 中复用 BLOCKED_ENV_VARS 和 SafePolicy 语义的子进程入口。

第 8 章:上下文管理:让 Agent 在有限窗口中高效工作

定位:本章展示 octos 如何通过上下文压缩(compaction)、保真度分级(fidelity)、提示层构建(prompt layer)和系统提示防篡改(prompt guard)四种机制,在有限的 LLM 上下文窗口中高效工作。前置依赖:第 5 章。适用场景:想理解上下文窗口管理策略的 AI 应用开发者(读者 C),以及需要优化 Agent 上下文使用的开发者(读者 B/D)。

LLM 的上下文窗口是稀缺资源。即使是 200K token 的窗口,一个复杂任务也可能在 10-20 次迭代后耗尽——每次迭代的工具调用参数和结果都在累积。当窗口接近满时,有两个选择:停止(放弃未完成的任务),或压缩(丢弃部分信息但继续工作)。octos 选择了后者。


8.1 Context Compaction:80% 触发的压缩策略

8.1.1 触发条件

Compaction 的预算检查发生在 [../octos/crates/octos-agent/src/agent/compaction.rs:11-41],它把上下文窗口先乘以 0.8,再除以 SAFETY_MARGIN = 1.2([../octos/crates/octos-agent/src/compaction.rs:42-49])。这等于一边预留 20% 的“新一轮对话空间”,一边再为 token 估算误差留出缓冲。

8.1.2 触发逻辑源码走读

80% 阈值的实际检查代码在 [../octos/crates/octos-agent/src/agent/compaction.rs:19-24]:

#![allow(unused)]
fn main() {
let window = self.llm.context_window();
let budget = (window as f64 * 0.8 / SAFETY_MARGIN) as u32;

let total: u32 = messages.iter().map(estimate_message_tokens).sum();
if total <= budget {
    return;  // 未超出预算,不压缩
}
}

注意实际预算是 window * 0.8 / 1.2 ≈ window * 0.67——80% 阈值再除以 1.2 的安全系数,因为 token 估算不完全精确。对于 128K 窗口的模型,实际触发点约在 85K tokens。

8.1.3 保留边界的确定

find_recent_boundary()([../octos/crates/octos-agent/src/compaction.rs:62-90])是压缩算法的核心——它决定了哪些消息保留原样、哪些被压缩:

#![allow(unused)]
fn main() {
pub(crate) fn find_recent_boundary(messages: &[Message], budget: u32, system_tokens: u32) -> usize {
    let mut recent_tokens = 0u32;
    let mut count = 0usize;
    let mut split = messages.len();

    // 从最后一条消息向前扫描
    for i in (1..messages.len()).rev() {
        let msg_tokens = estimate_message_tokens(&messages[i]);
        count += 1;

        // 保留至少 6 条,且不超过预算的一半
        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;  // 向前回退,包含 Tool 消息对应的 Assistant 消息
    }

    split
}
}

这段代码的核心洞察是不对称保护:最近 6 条消息无条件保留(count >= MIN_RECENT_MESSAGES 之前不检查预算),但同时不让最近消息超过预算的一半(budget / 2)。这确保了压缩后仍有足够空间给旧消息的摘要。

工具组不可分割。 最后一个 while 循环向前回退,确保 split 点不会落在 Tool 消息上。如果 split 指向 Tool 消息,它属于一个 Assistant→Tool 的配对组——切割会导致孤立的 Tool 消息让 LLM 困惑。

8.1.3 压缩策略

压缩目标是将旧消息压缩到预算的 40%(BASE_CHUNK_RATIO = 0.4,[../octos/crates/octos-agent/src/compaction.rs:48-49])。对每条旧消息调用 summarize_message()([../octos/crates/octos-agent/src/compaction.rs:135-181]):

消息类型压缩方式
User"> User: {content}" + [media omitted]
Assistant(有工具调用)"Called {tool_name}"
Assistant(纯文本)首行摘要(200 字符截断)
Tool 结果状态(ok/error)+ 输出前 100 字符
System保留为上下文摘要

8.1.4 Compaction 触发流程

flowchart TD
    Start["每次迭代开始"] --> Count["估算消息总 token 数"]
    Count --> Check{"总量 > window × 0.67?"}
    Check -->|"否"| Skip["不压缩,继续"]
    Check -->|"是"| Boundary["find_recent_boundary()<br/>确定保留边界"]
    Boundary --> Recent["最近 6+ 条消息<br/>保留原样"]
    Boundary --> Old["较早消息"]
    Old --> Summarize["summarize_message()<br/>工具名 + 首行摘要"]
    Summarize --> Replace["替换原始消息为摘要"]
    Replace --> Continue["继续 Agent 迭代"]

图 8-1:Compaction 触发流程。 80% × 1/1.2 ≈ 67% 是实际触发点。保留边界不会在工具调用组中间切割。

8.1.5 压缩策略源码走读

summarize_message()([../octos/crates/octos-agent/src/compaction.rs:135-181])对每种消息类型采用不同的压缩策略:

#![allow(unused)]
fn main() {
fn summarize_message(msg: &Message, context: &[Message]) -> String {
    match msg.role {
        MessageRole::User => {
            // 用户消息:首行 + 媒体标记
            let media_note = if msg.media.is_empty() { "" } else { " [media omitted]" };
            format!("> User: {}{}", first_line(&msg.content, 200), media_note)
        }
        MessageRole::Assistant => {
            let mut parts = Vec::new();
            if let Some(ref calls) = msg.tool_calls {
                for call in calls {
                    // 关键:只保留工具名,完全丢弃参数
                    parts.push(format!("- Called {}", call.name));
                }
            }
            if !msg.content.is_empty() {
                let prefix = if msg.tool_calls.is_some() { "  " } else { "> Assistant: " };
                parts.push(format!("{}{}", prefix, first_line(&msg.content, 200)));
            }
            parts.join("\n")
        }
        MessageRole::Tool => {
            let tool_name = find_tool_name(msg, context);
            let status = if msg.content.starts_with("Error:") { "error" } else { "ok" };
            // 工具结果:状态 + 前 100 字符
            format!("  -> {}: {} - {}", tool_name, status, first_line(&msg.content, 100))
        }
        MessageRole::System => {
            format!("> Context: {}", first_line(&msg.content, 200))
        }
    }
}
}

工具参数剥离是最有效的压缩手段。考虑一个 write_file 工具调用——参数可能包含几百行的代码文件内容(上千 token)。压缩后变成一行 "- Called write_file"(约 5 token),压缩比高达 200:1。

测试验证了这个行为([../octos/crates/octos-agent/src/compaction.rs] 的 test_compact_strips_tool_arguments):

#![allow(unused)]
fn main() {
fn test_compact_strips_tool_arguments() {
    let messages = vec![
        assistant_tool_call("write_file", "tc1"),  // 参数包含 "/secret/file"
        tool_result("tc1", "File written."),
    ];
    let summary = compact_messages(&messages, 10000);
    assert!(summary.contains("Called write_file"));    // 工具名保留
    assert!(!summary.contains("/secret/file"));        // 参数完全消失
}
}

首行摘要([../octos/crates/octos-agent/src/compaction.rs:183-196]):first_line() 函数提取消息的第一行非空文本,UTF-8 安全截断到指定字符数(用户消息 200 字符,工具结果 100 字符)。信息密度在首行最高——LLM 的回复通常以结论或摘要开头。

还有一个容易漏掉的细节:如果“最近消息区”本身就已经超过预算,代码不会强行生成摘要,而是退回到 fallback_truncate(),从尾部向前保留消息,并继续避免把 tool-call group 拆开([../octos/crates/octos-agent/src/agent/compaction.rs:39-41]、[../octos/crates/octos-agent/src/agent/compaction.rs:218-230])。这说明 compaction 不是无条件的“摘要优先”,而是“能摘要就摘要,摘要也放不下就退化为截断”。

8.1.4 Fidelity 四档模式

压缩后的消息保真度可以分为四个级别:

档位保留内容丢弃内容适用场景
Full完整消息最近 6 条消息
Truncate内容截断到 N 字符尾部内容中等重要的历史消息
Compact首行 + 工具名称参数、详细输出远期历史
Summarytyped SessionSummary 或自然语言摘要原始消息细节远期历史、跨压缩轮次的任务状态

实际实现要分两层看:legacy extractive helper 默认仍主要使用 Compact 级别(首行摘要 + 工具名);但当前主分支已经有 LlmIterativeSummarizer,可以生成 typed SessionSummary,并在连续 3 次 LLM summary 失败后锁定回 extractive fallback([../octos/crates/octos-agent/src/summarizer.rs:7-17]、[../octos/crates/octos-agent/src/summarizer.rs:155-180])。因此 Summary 不再是“未来预留”,而是一个可由 compaction policy 选择的更高保真状态层。


8.2 Prompt Layer:分层系统提示构建

系统提示不是一个静态字符串——它由多个层次的信息组合而成。PromptLayerBuilder([../octos/crates/octos-agent/src/prompt_layer.rs:21-122])负责这个组装过程。

8.2.1 自动发现

discover() 方法([../octos/crates/octos-agent/src/prompt_layer.rs:56-80])从工作目录自动发现项目指令文件,但它的语义是按类别命中第一个可用文件,不是把目录中所有候选文件全部叠加:

文件名用途
CLAUDE.md.octos/instructions.md.claude/instructions.md项目指令层;按顺序查找,命中第一个非空文件就停止
AGENTS.md.octos/agents.mdagents.mdAgent 描述层;同样只取第一个命中的文件

真正被“层叠”的,是 build() 里的四类内容:基础 prompt、项目指令、AGENTS 描述,以及通过 with_extra() 注入的额外运行时层([../octos/crates/octos-agent/src/prompt_layer.rs:82-102])。换句话说,项目目录里不会同时把 CLAUDE.md.octos/instructions.md 都装进去;但它们之上仍然可以继续叠加 runtime extra layers。

8.2.2 大小限制

MAX_PROMPT_FILE_SIZE = 64 * 1024([../octos/crates/octos-agent/src/prompt_layer.rs:10-19])——单个提示文件最大 64KB。这防止了恶意或意外的巨大文件耗尽上下文窗口。


8.3 Steering:会话中消息注入

Steering 模块([../octos/crates/octos-agent/src/steering.rs:1-45])定义了一套“会话中途注入消息”的原语:

#![allow(unused)]
fn main() {
pub enum SteeringMessage {
    FollowUp(Message),      // 注入用户追加问题
    SystemReminder(String), // 系统级提醒
    RequestPause,           // 暂停等待用户输入
    Cancel,                 // 取消当前任务
}
}

它通过异步 channel(默认缓冲 16,[../octos/crates/octos-agent/src/steering.rs:31-45])实现,接口包括 channel()SteeringMessage 和非阻塞的 drain_pending()

但这里必须和当前实现状态区分开:steering.rs 文件头部的 TODO 明确写着“还需要把 SteeringReceiver 接进 Agent Loop,才能在迭代间 drain 待处理消息并处理 Cancel/RequestPause”([../octos/crates/octos-agent/src/steering.rs:1-7])。也就是说,这个接口已经定义并有测试,但当前源码里还不是主循环的已接线能力

因此,像“用户在任务执行中途追加一句话”“系统在超时前注入提醒”这些都是 steering 的目标场景,而不是今天这个版本已经稳定走通的运行时路径。


8.4 Prompt Guard:系统提示防篡改

Prompt Guard 已在第 7 章介绍了其 prompt 注入检测功能。在上下文管理的视角下,更准确的说法是:它为“把外部文本重新送回上下文”这一步提供一个额外的 defang 层。

scan() 会按正则匹配五类威胁:系统覆盖、角色混淆、工具调用注入、秘密提取、通用指令注入([../octos/crates/octos-agent/src/prompt_guard.rs:27-213])。sanitize_injection() 则只对 Medium/High 命中的 span 做替换,替换结果是 [injection-blocked:{kind}] 这样的标记;Low 级别只记录 debug 日志,不改写文本([../octos/crates/octos-agent/src/prompt_guard.rs:215-295])。

更关键的是接线位置。当前源码里,Prompt Guard 的已验证主路径是 sanitize_tool_output():先移除 base64 data URI、长 hex 和常见凭据,再调用 sanitize_injection(),最后才把工具结果回灌到对话历史([../octos/crates/octos-agent/src/sanitize.rs:88-95]、[../octos/crates/octos-agent/src/agent/execution.rs:1161-1165])。所以它现在主要保护的是工具输出回流这条链路;模块本身当然能扫描任意文本,但书里不应把它写成“当前已经统一改写所有用户输入”。

最后还要记住它的边界:源码注释明确写着 “Not a security boundary”。它能拦住朴素的明文注入,却挡不住 base64、Unicode 同形字、零宽字符等绕过;真正的安全控制仍然是 sandbox、tool policy 和 human-in-the-loop hook([../octos/crates/octos-agent/src/prompt_guard.rs:1-19])。


工程决策侧栏:为什么 80% 而非动态阈值

方案一:动态阈值(根据任务复杂度调整)

优势:

  • 简单任务可以推迟压缩(比如问答场景不需要保留太多历史)
  • 复杂任务提前压缩(为后续迭代预留更多空间)

劣势:

  • 需要预测任务剩余迭代数——而这几乎不可能准确预测
  • 复杂度评估本身消耗上下文和计算资源
  • 可调参数增加了配置负担和不可预测性

方案二:预测式压缩(基于历史 token 增长率)

优势:

  • 根据实际增长趋势动态调整

劣势:

  • token 增长率不稳定(工具调用的输出大小高度可变)
  • 预测错误可能导致提前压缩(丢失信息)或延迟压缩(溢出风险)

方案三:固定 80% 阈值(octos 的选择)

80% 是一个经过实践验证的平衡点:20% 的预留空间足以容纳一次典型迭代(系统提示 + 用户消息 + LLM 响应 + 一次工具调用结果),同时不会过早触发压缩导致不必要的信息损失。

固定阈值的核心优势是可预测性——开发者和用户可以准确知道什么时候会发生压缩,不需要理解复杂的动态逻辑。在 AI Agent 这种本身就充满不确定性的系统中,基础设施层面的确定性是珍贵的。


8.5 主干演进:contract-gated compaction

Ch5 已经看到,ContextOverflow 不再只是一个普通错误:它会进入 LoopDecision::CompactAndRetry,由主循环先触发 turn compaction helper,再继续下一轮。也就是说,compaction 已经从“节省 token 的优化”变成了 Agent Loop 的恢复路径之一([../octos/crates/octos-agent/src/agent/loop_runner.rs:318-343])。

这也改变了上下文管理的边界。压缩后的状态不能只是一段自然语言摘要,否则很容易丢掉任务约束、artifact 约束或 validator 结果。当前源码把状态分成三层:

状态层例子是否应该写进 prompt
prompt-visible statemessages、typed SessionSummary、workspace contract 摘要
runtime control stateLoopRetryState、grace eligibility、task lifecycle
durable evidence statevalidator ledger、harness event sink、cost ledger不直接写入,但必须可回放

SessionSummary 因此不是长期记忆,也不是普通聊天摘要;它是 typed compaction summary,用来在有限窗口内保留 goal、constraints、decisions、files 和 next steps([../octos/crates/octos-agent/src/summarizer.rs:104-180]、[../octos/crates/octos-core/src/task.rs:217-263])。长期记忆仍由 Ch4 的 memory 系统负责。

当前主分支的 compaction 已经是三层结构,而不是单一摘要函数:

层级源码位置作用关键边界
Tier 1 MicroCompaction../octos/crates/octos-agent/src/compaction_tiered.rs:38-181每轮本地清理 stale/oversized tool result,把内容替换为 typed ToolResultPlaceholder保留 tool_call_id,并允许 protected_tool_call_ids 跳过仍被 retry/artifact 引用的结果
Tier 2 API MicroCompaction../octos/crates/octos-agent/src/compaction_tiered.rs:185-290为 Anthropic-compatible provider 构造 context_management.clear_tool_uses_20250919 payload只是 request-time decoration;非 Anthropic provider 不发送
Tier 3 FullCompactor../octos/crates/octos-agent/src/compaction_tiered.rs:292-429../octos/crates/octos-agent/src/compaction.rs:550-671触发完整 summary + contract artifact preservation 检查复用 CompactionRunner,并在 tier3 后可清空 file-state cache,避免 [FILE_UNCHANGED] stale identity

Agent loop 的接线也对应这三层:preflight compaction 在第一轮 LLM 调用前执行;Tier 1 在每轮调用前运行;Tier 2 在构造 ChatConfig 时注入;TurnEnd full compaction 在消息修复和系统消息归一化之后执行([../octos/crates/octos-agent/src/agent/compaction.rs:83-179]、[../octos/crates/octos-agent/src/agent/loop_runner.rs:715-754]、[../octos/crates/octos-agent/src/agent/loop_runner.rs:1136-1145])。

flowchart LR
    H[History window] --> T1[Tier 1<br/>local tool-result placeholder]
    T1 --> T2[Tier 2<br/>Anthropic context_management payload]
    T2 --> T3[Tier 3<br/>full compactor]
    T3 --> R[Recent messages preserved]
    T3 --> S[Typed SessionSummary or extractive summary]
    T3 --> A[Preserved artifacts check]
    A --> P[Workspace policy]
    P --> V[Validator ledger]
    V --> G{Required validators pass?}
    G -->|yes| L[Loop continues / terminal success allowed]
    G -->|no| F[Terminal success blocked]

validator preservation 是这套机制的关键。validators.rs 里的 outcome 会以 schema version JSONL 形式持久化;required validator 的失败会阻止 terminal success,optional validator 的失败只产生 warning([../octos/crates/octos-agent/src/validators.rs:107-144]、[../octos/crates/octos-agent/src/workspace_git.rs:662-755])。所以压缩策略不是“尽可能短”,而是“在变短的同时保住可验证契约”。这也是后续 Ch9 讨论 Harness validator runner、Ch12 讨论 workflow artifact gate 的基础。

8.6 本章回顾

  1. Context Compaction:80% 触发,保留最近 6 条完整消息,旧消息压缩到 40% 预算。工具参数剥离和首行摘要是主要压缩手段。

  2. Fidelity 四档:Full(完整)→ Truncate(截断)→ Compact(首行摘要)→ Summary(typed SessionSummary / 摘要),从近到远递减保真度;当前已有 LLM iterative summary,但默认仍可回退到 extractive。

  3. Prompt Layer:按类别发现首个可用的项目指令文件和 AGENTS 文件,再与 base prompt、extra layers 组装成最终系统提示;单文件上限 64KB。

  4. Steering:当前源码已经定义了消息注入通道与消息类型,但主循环尚未正式接线;它更像一个已设计好的运行时扩展点。

  5. contract-gated compaction:当前主分支把 compaction 接入 CompactAndRetry,并通过 Tier 1/2/3、typed SessionSummary、workspace policy 和 validator ledger 保证压缩后仍能维持任务约束。

  6. Prompt Guard:一个 regex-based 的 defense-in-depth 层。当前主要接在工具输出清洗链上,对 Medium/High 风险做 defang,而不是把它当成完整安全边界。


延伸阅读

  • Context Window 管理:Anthropic "Long context window tips" — 长上下文使用的最佳实践
  • RAG vs 长上下文:比较检索增强生成与大窗口直接输入的 trade-off
  • 信息检索中的摘要:Luhn 的自动文摘方法——理解首行摘要的理论基础

思考题

  1. 压缩信息的恢复:当前的 compaction 是不可逆的——被压缩的消息无法恢复原始内容。如果在压缩后 Agent 需要回顾早期工具调用的详细参数,应该怎么办?

  2. 智能摘要 vs 提取式摘要:当前的压缩是提取式的(首行、工具名)。如果使用 LLM 生成抽象式摘要,能否在同等 token 预算下保留更多信息?代价是什么?

  3. 多 Agent 上下文共享:如果两个 Agent 协作处理同一个任务,它们的上下文窗口如何共享?独立压缩还是协调压缩?


版本演化说明 本章按当前 octos 主分支源码更新。后续阅读时,优先核对 ../octos/crates/octos-agent/src/agent/compaction.rs../octos/crates/octos-agent/src/compaction.rs../octos/crates/octos-agent/src/compaction_tiered.rs../octos/crates/octos-agent/src/summarizer.rs../octos/crates/octos-agent/src/prompt_layer.rs../octos/crates/octos-agent/src/steering.rs../octos/crates/octos-agent/src/prompt_guard.rs 和 validator/workspace contract 相关入口。

第 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

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 解析器。它做的是两步简化处理:

  1. split_frontmatter() 找到首尾 --- 之间的 frontmatter 块(../octos/crates/octos-agent/src/skills.rs:235-252
  2. fm_value() 从简单的 key: value 行里读取 namedescriptionrequires_binsrequires_envalways 等字段(../octos/crates/octos-agent/src/skills.rs:178-232,255-276

这意味着 skill 元数据的设计目标不是“表达力最大”,而是“足够稳定、足够便宜”。available 的判断也来自这里:requires_bins 里的命令都能找到、requires_env 里的环境变量都存在,skill 才算可用(../octos/crates/octos-agent/src/skills.rs:196-212)。

9.1.2 SkillsLoader 与分层覆盖

SkillsLoader 本身只维护一个“技能目录列表”,真正的优先级是在 runtime 里组装出来的(../octos/crates/octos-agent/src/skills.rs:31-176)。当前 gateway 路径的优先级是:

  1. data_dir/skills
  2. project_dir/skills
  3. project_dir/bundled-app-skills
  4. OCTOS_SKILLS_PATH 指定的额外目录
  5. 编译进二进制的 built-in skills

这套层次来自 ../octos/crates/octos-cli/src/commands/gateway/gateway_runtime.rs:526-527,646-667../octos/crates/octos-cli/src/commands/gateway/profile_factory.rs:538-617。实现方式也很有意思:loader 先放入 builtins,再按“低优先级目录先扫描,高优先级目录后覆盖”的顺序遍历,并通过 retain 删掉同名旧 skill(../octos/crates/octos-agent/src/skills.rs:68-108)。

这里需要特别区分配置继承本地 skill 继承:子账号可以继承父 profile 的 LLM/search/apps/email 等结构化配置,但 customer-installed skills 不从父账号继承。skills_scope.rs 明确把 account skills 限定为当前账号自己的 data_dir/skills,plugin dirs 也只返回当前 account 的 skills 目录(../octos/crates/octos-cli/src/skills_scope.rs:1-38)。这避免父账号安装的本地可执行扩展在子账号中被静默启用。

所以这不是简单的“工作区覆盖全局”三层模型,而是一个更细的 layered view。读者如果只记住“当前账号 skills 最高优先级;project/bundled/env/builtin 提供共享基线;父账号 customer skills 不进入子账号”,就已经抓住当前实现的主线了。

9.1.3 XML 技能索引

build_skills_summary() 会把当前可见的 skill 集合转成 XML,注入系统提示(../octos/crates/octos-agent/src/skills.rs:137-154):

<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 摘要不是单纯的“可用技能列表”,它还是模型可见的 技能目录索引

9.1.4 spawn_only:自动后台化,而不是隐藏工具

spawn_only 标记定义在 plugin/skill manifest 的工具项上(../octos/crates/octos-agent/src/plugins/manifest.rs:98-116),但它的运行时语义并不在 manifest 里,而在 registry 和 agent 执行循环里:

  • PluginLoader 会把这些工具名登记为 spawn_only../octos/crates/octos-agent/src/plugins/loader.rs:93-113
  • ToolRegistry 为它们维护自定义提示文案和任务跟踪状态(../octos/crates/octos-agent/src/tools/registry.rs:123-178
  • 主 agent 发现某次 tool call 命中 spawn_only 时,不同步执行,而是直接后台 tokio::spawn 一个任务,立刻向模型返回 spawn_only_message../octos/crates/octos-agent/src/agent/execution.rs:105-245

这意味着 spawn_only 不是“从 ToolSpec 里隐藏掉”。按当前实现,它们仍然注册在工具系统里并对模型可见;差别只是调用时会被自动后台化。

更进一步,resolve_extras() 还会在 skill package 含有 spawn_only 工具时自动把 SKILL.md 本身注入 prompt fragments(../octos/crates/octos-agent/src/plugins/extras.rs:52-61)。这样模型既能看到工具,也能同时拿到“什么时候该用这个后台工具”的提示上下文。

而到了 subagent 场景,registry 会调用 clear_spawn_only() 清空这些标记,因为“subagent 本身就是后台上下文”,此时工具会像普通工具一样直接执行(../octos/crates/octos-agent/src/tools/registry.rs:136-143)。


9.2 Plugins 轨道:本地可执行工具与 skill package extras

如果说 Skills 负责改变 Agent 的“思维方式”,Plugins 负责的就是让 Agent 真正调用外部程序完成工作。

9.2.1 runtime manifest:不只是工具声明

当前 runtime 热路径使用的是 ../octos/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”已经不够了。当前这个结构还支持:

  • mcp_servers
  • hooks
  • prompts.include
  • binaries
  • spawn_only
  • spawn_only_message
  • env / env_allowlist
  • risk
  • concurrency_class

因此它更接近一个 skill package runtime manifest。如果 manifest.tools 为空,但声明了 MCP、hooks 或 prompt fragments,PluginLoader 会跳过可执行文件搜索,照样把 extras 装进系统(../octos/crates/octos-agent/src/plugins/loader.rs:167-179)。

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”稍复杂(../octos/crates/octos-agent/src/plugins/tool.rs:124-419):

  • runtime 实际执行的是经过 hash 校验后写出的 ._verified 副本
  • argv 第一个参数是 tool name
  • stdin 发送 JSON 参数
  • stderr 逐行读出并转成 ToolProgress 事件
  • stdout 优先按结构化 JSON 解析
  • 如果 stdout 不是合法 JSON,runtime 会退回到“原样拼接 stdout + stderr 文本”

结构化 stdout 还支持比 output/success 更丰富的语义:

  • file_modified
  • files_to_send

此外 runtime 还会尝试从 out 参数或输出文本里自动探测生成文件,并触发自动回传(../octos/crates/octos-agent/src/plugins/tool.rs:321-403)。所以 Plugin 协议的真实价值是:把“外部进程”包装成“可流式报告进度、可自动回传文件的 Tool”。

9.2.3 安全与运行时约束

Plugin 这一层的安全措施有几道是必须写清楚的。

第一道:可执行发现是保守的。 PluginLoader 只把“子目录 + manifest.json”当成候选项。真正找二进制时,会依次尝试:

  1. manifest.name
  2. 目录名
  3. main
  4. 目录中任意可执行且非隐藏、非 .json/.md/.toml/.tar.gz 的文件

逻辑在 ../octos/crates/octos-agent/src/plugins/loader.rs:181-211

第二道:SHA-256 校验不是“验完原文件再直接运行”。 Loader 先把原始字节读进内存,再对内存字节做 hash 校验,然后把同一份已验证字节写到 ._verified 文件,后续真正执行的是这份副本(../octos/crates/octos-agent/src/plugins/loader.rs:226-271)。这一步的目的是封住典型 TOCTOU 窗口。

第三道:资源与环境约束。

  • 100MB 可执行文件上限(../octos/crates/octos-agent/src/plugins/loader.rs:213-224
  • 继承 BLOCKED_ENV_VARS 黑名单(../octos/crates/octos-agent/src/plugins/loader.rs:273-275../octos/crates/octos-agent/src/plugins/tool.rs:140-148
  • tool 级 env / env_allowlist 采用严格语义:manifest 显式列出 env 时,只允许这些变量进入 plugin;未显式列出时保留 legacy 兼容路径,但 secret-like 额外环境变量必须走 allowlist(../octos/crates/octos-agent/src/plugins/tool.rs:859-893
  • 运行时注入 OCTOS_WORK_DIR 给 plugin 放输出文件(../octos/crates/octos-agent/src/plugins/tool.rs:150-164
  • 默认超时其实是 600 秒,不是 30 秒(../octos/crates/octos-agent/src/plugins/tool.rs:35-48);manifest 的 timeout_secs 只是覆盖默认值(../octos/crates/octos-agent/src/plugins/loader.rs:276-279

第四道:风险与并发类别不是装饰字段。

risk 会进入运行时审批路径:high / critical 风险工具强制请求交互式 approval;如果当前环境没有 approval bridge,runtime 会安全拒绝,而不是绕过审批继续执行。low 风险默认不触发审批,medium / unknown 当前主要用于显式呈现风险(../octos/crates/octos-agent/src/plugins/manifest.rs:136-144, ../octos/crates/octos-agent/src/plugins/tool.rs:772-820)。

concurrency_class 当前识别 safeexclusive。未知值不会被乐观当作 safe,而是记录告警并在执行侧 fail closed 到 Exclusive,避免一个声明错误的 plugin 被并行执行到破坏共享状态(../octos/crates/octos-agent/src/plugins/manifest.rs:220-263, ../octos/crates/octos-agent/src/plugins/tool.rs:711-730)。

第五道:Unix 上的符号链接拒绝。 is_executable()symlink_metadata() 检查文件类型,只接受普通文件,不接受符号链接(../octos/crates/octos-agent/src/plugins/loader.rs:332-340)。这不是全部安全边界,但能减少 link-swap 这类替换攻击面。

9.2.4 runtime PluginLoaderoctos-plugin SDK 的边界

这一章最容易写错的地方,是把仓库里的两层代码混成一层。

当前 runtime 热路径../octos/crates/octos-agent/src/plugins/loader.rs

  • 扫描调用方传入的目录
  • 逐个加载子目录 manifest
  • 解析 extras
  • 查找并校验可执行文件
  • 生成 verified copy
  • 注册工具到 ToolRegistry
  • 单个 plugin 失败时 warn! 并跳过,不影响其他插件加载(../octos/crates/octos-agent/src/plugins/loader.rs:73-140

../octos/crates/octos-plugin 则是 SDK / tooling crate,提供的是另一层抽象:

  • discover_plugins():按来源优先级扫描目录并去重(../octos/crates/octos-plugin/src/discovery.rs:20-56
  • check_requirements():做 bins/env/os 三类 gating(../octos/crates/octos-plugin/src/gating.rs:37-123
  • richer manifest:id/type/requires/install/...../octos/crates/octos-plugin/src/manifest.rs:76-202

两层有关联,但不能混为一谈。当前主 agent runtime 并不是“每次都先跑 octos-plugin::discover_plugins() 再加载”,而是直接走 octos-agent 自己的 PluginLoader。如果你写的是 runtime tool,要看 octos-agent/src/plugins/*;如果你写的是校验器、市场、安装器、离线发现逻辑,要看 octos-plugin crate。

octos-plugin 的 gating 模型仍然值得理解,因为它定义了生态层的约束语义:

检查方法失败处理
Binarywhich 检查依赖程序是否在 PATH 中标记 unavailable / 跳过
Env检查必要环境变量是否存在标记 unavailable / 跳过
OS检查当前平台是否在允许列表中标记 unavailable / 跳过

还有一个很小但真实的细节:gating 把 darwinmacos 当成等价别名,避免 manifest 和 Rust 平台字符串不一致时误伤(../octos/crates/octos-plugin/src/gating.rs:73-104)。


9.3 MCP 集成:标准协议,不等于“远程插件”

MCP(Model Context Protocol)是标准化的工具与上下文集成协议。octos 的 MCP client 位于 ../octos/crates/octos-agent/src/mcp.rs,支持两条接入路径。

9.3.1 Stdio vs HTTP POST(可选 SSE 响应)

特性Stdio 传输HTTP 传输
连接方式本地子进程 + stdin/stdout JSON-RPCHTTP POST JSON-RPC
响应格式单行 JSON普通 JSON 或 text/event-stream
延迟极低(本地 IPC)受网络与远端服务影响
主要安全面子进程环境清理SSRF 检查 + DNS pinning
适用场景本地 MCP server远程 MCP 服务

把第二条路径直接叫成“HTTP-SSE transport”会误导读者。当前实现其实是:

  • 请求通过 HTTP POST 发送 JSON-RPC(../octos/crates/octos-agent/src/mcp.rs:179-198
  • client 用 Accept: application/json, text/event-stream 同时接受 JSON 或 SSE(../octos/crates/octos-agent/src/mcp.rs:182-183
  • 如果响应 content-type 包含 text/event-stream,再从 SSE body 中提取最后一个 data: 事件作为 JSON-RPC 结果(../octos/crates/octos-agent/src/mcp.rs:225-255

因此更准确的说法是:HTTP POST,SSE 只是可选响应封装

另一个容易被漏掉的点是会话亲和:如果服务端返回 mcp-session-id,client 会保存它,并在后续请求中回放为 Mcp-Session-Id header(../octos/crates/octos-agent/src/mcp.rs:189-205)。

9.3.2 启动与安全约束

MCP client 在两个不同阶段做不同的安全控制。

发现阶段:schema 约束。 启动 server 后,client 会先跑 initialize,再调用 tools/list,并对每个 tool 的 input_schema 做验证(../octos/crates/octos-agent/src/mcp.rs:308-361,500-524):

约束作用点
最大嵌套深度10validate_schema()
最大序列化大小64KBvalidate_schema()

超出限制的 tool 不会让整个 server 启动失败,而是 跳过该 tool 并记录 warning。

执行阶段:transport 约束。

约束适用面
单行响应上限1MB仅 stdio read_line_limited()
tool call 超时60 秒McpTool::execute()
env 清理BLOCKED_ENV_VARS仅 stdio transport
SSRF + DNS pinning开启仅 HTTP transport

这里最需要纠正的误解是:1MB 不是所有 MCP 响应的统一全局上限。它只作用在 stdio transport 的单行 JSON-RPC 响应上(../octos/crates/octos-agent/src/mcp.rs:20-21,118-143)。HTTP 路径当前没有对整个响应体加同样的总字节限制;它依赖的是 SSRF 检查、DNS pinning、状态码检查和 60 秒请求超时。

9.3.3 名称保护与注册

MCP client 发现到的 tool 不会直接无条件塞进 registry。注册前还有一道保护:PROTECTED_NAMES../octos/crates/octos-agent/src/mcp.rs:454-497)列出了 19 个内置工具名,MCP tool 如果发生同名碰撞会被直接跳过。

这一步的意义不是美观,而是防止远端 MCP server 静默替换核心能力。例如,如果没有这层保护,一个外部 server 理论上可以注册一个同名 shellbrowser,把模型对“内置工具”的调用流量劫持过去。


工程决策侧栏:为什么需要三种扩展机制

维度SkillsPluginsMCP
核心作用改提示与上下文跑本地可执行工具接外部协议化工具服务器
主要载体SKILL.mdmanifest.json + executableJSON-RPC server
运行边界无独立执行边界外部进程 + verified copy本地/远程连接
典型增值点低成本行为定制进度流、文件回传、后台任务跨 Agent 平台复用
安全面可用性检查而非隔离hash 校验 + env 清理 + work dirSSRF + schema 验证 + 名称保护

为什么不能统一成一种?

因为它们解决的不是同一类问题。Skills 让模型学会“怎么想”,Plugin 让系统学会“怎么做”,MCP 让系统学会“怎么接别人的能力”。

如果把 Skills 也做成 Plugin,会让纯提示定制被迫带上二进制、协议和运行时安全成本。反过来,如果把 Plugin 做成纯 Skill,又无法提供真实执行、进度流和文件产出。MCP 看起来和 Plugin 都像“工具扩展”,但它追求的是协议互操作,而不是本地 runtime 集成的最低摩擦。


9.4 Harness 工程契约:ABI、事件与验证器

Skills、Plugins 和 MCP 解决的是“能力如何进入 octos”。Harness 解决的是另一个问题:这些能力运行之后,如何把结果变成可验证、可观测、可升级的工程契约。

9.4.1 ABI versioning:schema_version 不是 manifest version

abi_schema.rs 集中维护 runtime payload 的 schema version,包括 WorkspacePolicyCompactionPolicyHookPayloadProgressEventTaskResultSessionSummary、swarm dispatch、cost attribution、routing decision、credential pool config 和 harness error event(../octos/crates/octos-agent/src/abi_schema.rs:1-142)。这些版本描述的是 octos runtime 能理解的序列化形状,而不是 plugin / skill 自身的发布版本。

关键规则是 fail closed:check_supported(kind, found, supported) 遇到未来版本会返回 typed error,而不是静默忽略新字段(../octos/crates/octos-agent/src/abi_schema.rs:144-186)。这让外部 skill 可以随 runtime 演进,同时避免旧 runtime 错读新 payload。

9.4.2 OCTOS_EVENT_SINK:结构化 side-channel

外部 app skill 不一定需要链接 octos runtime,但它需要一种方式报告 progress、error、validator、cost 或 swarm 事件。Harness 通过环境变量提供这条 side-channel:OCTOS_EVENT_SINK 指向本地 JSONL sink,OCTOS_SESSION_ID / OCTOS_TASK_IDOCTOS_HARNESS_SESSION_ID / OCTOS_HARNESS_TASK_ID 提供关联上下文(../octos/crates/octos-agent/src/harness_events.rs:1-38)。

这不是普通日志文件。stdout 仍是工具结果协议;event sink 是受 HarnessEvent schema 校验的结构化事件 ABI,单行事件有大小上限,写入前会 validate,再 append 到 JSONL(../octos/crates/octos-agent/src/harness_events.rs:116-132)。

sequenceDiagram
    participant Skill as app skill process
    participant Sink as OCTOS_EVENT_SINK jsonl
    participant Runtime as octos-agent runtime
    participant API as /api/events/harness
    Skill->>Sink: HarnessEvent(progress/error/validator)
    Runtime->>Sink: tail + validate + fold into task snapshot
    Runtime->>API: broadcast typed frame

9.4.3 Validator runner:不是 shell hook

validator runner 是 workspace contract 的安全执行器。命令 validator 会复用 SafePolicy,清理 BLOCKED_ENV_VARS,超时后终止子进程,并把 evidence 写到 <workspace_root>/.octos/validator-evidence/../octos/crates/octos-agent/src/validators.rs:1-24)。outcome 以带 schema_version 的 JSONL ledger 持久化,required validator 失败会阻止 terminal success,optional validator 失败只产生 warning。

这和普通 hook 的区别在于:hook 主要改变执行前后的控制流;validator 负责给 artifact 和 workspace contract 提供可回放证据。它是 Ch8 的 contract-gated compaction 和 Ch12 的 workflow artifact gate 的共同基础。

9.4.4 Starter app skills:reference implementation

harness-starter-* 不是玩具 demo,而是 manifest、concurrency、artifact、validator 和 lifecycle smoke test 的 reference implementation。harness-starter-audio 的 smoke test 不只检查 manifest 能解析,还检查 synthesize_clip 必须声明 concurrency_class = "exclusive",因为它写 audio/<slug>.wav;同一个测试还验证 primary_audio artifact 和 file_size_min:$primary_audio:4096 validator(../octos/crates/app-skills/harness-starter-audio/tests/harness_smoke.rs:23-79)。

harness-starter-report 则展示了更小的 report contract:reports/*.md、completion file_exists、verify file_size_min、failure notification(../octos/crates/app-skills/harness-starter-report/workspace-policy.toml:1-29)。读者写新 app skill 时,应把这些 starter 当成工程清单,而不是只复制业务代码。

文件作用需要检查什么
manifest.json工具声明name、input schema、risk、concurrency class
workspace-policy.tomlartifact contractprimary artifact、completion validator、failure action
tests/harness_smoke.rs工程质量门manifest 可解析、artifact 匹配、validator 可满足、lifecycle 可投射

9.5 本章回顾

  1. Skills:通过 SKILL.md 和少量 frontmatter 元数据改变模型上下文。runtime 会把多层目录压成一个去重后的 skill 视图,再生成 XML 摘要注入系统提示。

  2. Plugins:把本地可执行程序包装成 Tool,同时承载 skill package extras。runtime PluginLoader 负责发现、hash 校验、verified copy、env allowlist、risk、concurrency class、工作目录注入和非致命跳过。

  3. spawn_only:不是隐藏工具,而是把工具调用自动后台化。主 agent 返回即时消息,后台任务继续跑;subagent 上下文里则把它恢复成普通工具。

  4. MCP:不是“远程插件”,而是标准化协议接入。octos 当前支持 stdio 和 HTTP POST(可选 SSE 响应),并用 schema 验证、名称保护、SSRF 与 DNS pinning 约束风险。

  5. 架构边界../octos/crates/octos-agent/src/plugins/* 是当前 runtime 热路径;../octos/crates/octos-plugin 是 SDK / tooling crate。把这两层分清,读源码时就不会迷路。

  6. Harness:ABI versioning、event sink、validator runner 和 starter app skills 共同定义扩展工程契约,让外部能力可验证、可观测、可升级。

Part 2 到此结束。下一章开始 Part 3,从单机会话推进到消息总线与多会话编排。


延伸阅读

  • Model Context Protocol:https://modelcontextprotocol.io/
  • JSON-RPC 2.0:https://www.jsonrpc.org/specification
  • Bubblewrap / sandbox-exec:理解本地可执行扩展为什么必须配合进程级安全边界

思考题

  1. Skills 的边界:如果一个需求同时需要提示注入和真实执行能力,你会把逻辑拆成 SKILL.md + Plugin,还是尽量压缩成单一 package?为什么?

  2. Plugin 信任链:当前 verified copy 解决了 TOCTOU,但如果 manifest 与原始二进制一起被替换,hash 仍然会“自洽”。你会如何把信任链再往前推进一层?

  3. HTTP MCP 的响应体:stdio 路径有 1MB 单行上限,HTTP 路径当前没有等价的全局 body cap。你会不会补这一层?如果补,应该放在哪一层最合适?


版本演化说明 本章按当前 octos 主分支源码更新。后续阅读时,优先核对 ../octos/crates/octos-agent/src/skills.rs../octos/crates/octos-agent/src/plugins/../octos/crates/octos-agent/src/mcp.rs../octos/crates/octos-agent/src/abi_schema.rs../octos/crates/octos-agent/src/harness_events.rs../octos/crates/octos-agent/src/validators.rs../octos/crates/octos-plugin/src/../octos/crates/app-skills/harness-starter-*

第 10 章:octos-bus:消息总线、频道抽象与会话持久化

定位:本章深入 octos-bus crate(当前 ../octos/crates/octos-bus/ 约 30K 行 Rust 源文件),展示如何用 Channel trait 抽象统一多种消息频道,以及会话管理、消息分片、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 统一了这些差异。


10.1 Channel trait:统一消息接口

Channel trait(../octos/crates/octos-bus/src/channel.rs:17-248)定义了所有频道的统一接口:

当前版本的 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

10.1.1 流式编辑三步法

对于支持消息编辑的频道(supports_edit() 返回 true),octos 使用三步法实现流式输出:

  1. send_with_id():发送初始消息(可能只有几个 token),返回平台消息 ID
  2. edit_message():随着 LLM 流式输出,不断更新同一条消息的内容(../octos/crates/octos-bus/src/channel.rs:85-93
  3. finish_stream():流结束后发送最终版本;默认实现仍回退到 edit_message()../octos/crates/octos-bus/src/channel.rs:117-129

这种模式让用户看到 Agent 的回复逐渐生成,而不是等待完整响应后一次性显示。Telegram 和 Discord 都支持这种模式。对于不支持编辑的频道(如邮件),退回到等待完整响应后一次性发送。

10.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../octos/crates/octos-bus/src/channel.rs:95-200)。旧路径会从 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/wssend_raw_sse_bound() 是 bus trait 中保留的兼容/内部扩展点,不代表新客户端还应该使用 chat SSE。health_check() 也是同一轮演进的一部分:它让 admin dashboard 可以以统一方式展示频道健康状态,而不是把健康探测散落到每个频道自己的管理接口里(../octos/crates/octos-bus/src/channel.rs:241-262)。

10.1.3 AgentHandle 对称设计

消息总线使用 AgentHandle / BusPublisher../octos/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 信号。

10.1.4 is_allowed:发送者鉴权

is_allowed()../octos/crates/octos-bus/src/channel.rs:27-30)在消息路由到 Agent 之前检查发送者是否有权使用 Agent。默认实现返回 true(允许所有人),各频道可以覆盖实现自定义鉴权逻辑,例如 Telegram 可以限制只有特定 chat_id 的用户才能访问。


10.2 消息 Coalescing:5 级切割策略

当 Agent 的回复超过频道的字符限制时,需要将长消息分割为多个短消息。octos-bus 的 coalescing 算法(../octos/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]" 的截断块(../octos/crates/octos-bus/src/coalesce.rs:46-57)。

UTF-8 安全:硬切时使用 is_char_boundary() 回退到安全的字符边界(与 octos-core 的 truncate_utf8 使用相同的策略,详见第 2 章)。

平台特定限制../octos/crates/octos-bus/src/coalesce.rs:5-24):

频道字符限制配置方法
Telegram4,000ChunkConfig::telegram()
Discord1,900ChunkConfig::discord()
Slack3,900ChunkConfig::slack()
Email无限制不调用 coalescing
默认4,000ChunkConfig::default_limit()

10.2.1 find_break_point:核心分割逻辑

find_break_point()../octos/crates/octos-bus/src/coalesce.rs:84-120)是切割的核心——但真正的切割过程分两步:先在 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') 和“最多跳过一个前导空格”的小处理,则让最终发出去的块看起来更干净,不会把原始分隔符原样带到下一条消息开头。


10.3 Session 管理

10.3.1 Session 结构体

Session(../octos/crates/octos-bus/src/session.rs:482-503)是对话的持久化单元:

#![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>,
}
}

10.3.2 JSONL 持久化与文件命名

当前源码的 Session 持久化比“一个 JSONL 文件”稍复杂一些,核心有两个事实。

第一,JSONL 文件的第一行不是消息,而是 SessionMeta 元数据,后续每一行才是 Message../octos/crates/octos-bus/src/session.rs:454-480../octos/crates/octos-bus/src/session.rs:1365-1381../octos/crates/octos-bus/src/session.rs:2132-2149)。所以它不是“纯消息流”,而是“头一行 schema/meta + 后续消息行”的轻量日志格式。SessionMeta 现在也承载 titletitle_manualchild_contracts,这使侧边栏标题和后台子任务状态不必塞进普通消息文本。

第二,当前代码同时支持旧布局和新布局

  1. SessionManager 仍支持 legacy flat layout:data/sessions/{encoded-key}[_{hash}]?.jsonl../octos/crates/octos-bus/src/session.rs:1096-1146
  2. SessionActor 使用的 SessionHandle 优先采用 per-user layout:data/users/{encoded_base_key}/sessions/{topic_or_default}.jsonl,并在打开时自动迁移旧文件(../octos/crates/octos-bus/src/session.rs:1688-1859

只有在 legacy flat 布局里,文件名才由下面这两部分构成:

  1. Percent-encoded SessionKey../octos/crates/octos-bus/src/session.rs:129-140):将 SessionKey 中的路径不安全字符(/:#)编码为 %2F%3A%23
  2. FNV-1a 64-bit hash 后缀../octos/crates/octos-bus/src/session.rs:116-127../octos/crates/octos-bus/src/session.rs:1117-1146):当编码后的名字过长、需要截断时,追加稳定哈希,避免“截断后同名前缀”碰撞

例如,旧布局中的长 key 可能落成 telegram%3A12345_0123ABCD....jsonl;而新布局则更像 users/telegram%3A12345/sessions/default.jsonl

Schema 版本CURRENT_SESSION_SCHEMA = 1../octos/crates/octos-bus/src/session.rs:15-16),为未来格式迁移预留。

写入也分两类:

  • 日常追加消息走 append_to_disk(),新文件先写 metadata,再逐条 append message 行(../octos/crates/octos-bus/src/session.rs:1312-1388../octos/crates/octos-bus/src/session.rs:2252-2319
  • 需要重写整个会话时走 rewrite(),使用 write-then-rename 的原子替换模式(../octos/crates/octos-bus/src/session.rs:1390-1443../octos/crates/octos-bus/src/session.rs:2130-2174

当前 rewrite 的临时文件名也做了并发修正:rewrite_tmp_path() 使用进程 PID + 单调 counter 生成 {target}.jsonl.{pid}-{seq}.tmp,避免同一个父 session 被多个后台子任务同时 rewrite 时共享单一 .tmp 文件(../octos/crates/octos-bus/src/session.rs:91-114)。这不是性能优化,而是正确性修复:共享 temp path 会让两个 writer 互相截断,最后只有一个 rename 成功,另一个子任务可能被误标成 orphaned。

10MB 文件限制:单个会话文件最大 10MB,防止失控的对话历史耗尽磁盘(../octos/crates/octos-bus/src/session.rs:685-686../octos/crates/octos-bus/src/session.rs:1197-1208../octos/crates/octos-bus/src/session.rs:2274-2294)。

10.3.3 /new Fork 机制

用户发送 /new 命令创建新会话时,底层对应的是 fork(parent_key, new_chat_id, copy_messages)../octos/crates/octos-bus/src/session.rs:1445-1465)。它不是“新建一个空白会话”,而是:

  1. 从父会话复制最近 copy_messages 条消息
  2. 记录 parent_key
  3. 为新 key 重写一个新的 session 文件

这意味着 /new 在当前实现里更接近“带最近上下文的分支”,而不是“只继承配置、不带历史”。

10.3.4 SessionManager 与 LRU 缓存

SessionManager(../octos/crates/octos-bus/src/session.rs:688-706)管理 admin/命令侧看到的会话缓存;而真正在线处理消息时,SessionActor 会转而持有自己的 SessionHandle,避免所有活跃会话共用一个大锁(../octos/crates/octos-bus/src/session.rs:1688-1702)。

  • LRU 内存缓存:活跃会话在内存中保持,减少磁盘 I/O
  • 惰性加载:不活跃的会话按需从磁盘加载
  • 布局兼容:同时扫描 legacy flat layout 和 per-user layout
  • 用户列表性能边界list_top_level_sessions*() 会跳过 child-**.tasks 等内部 topic,避免用户目录下大量后台子会话拖慢 /api/sessions../octos/crates/octos-bus/src/session.rs:716-760../octos/crates/octos-bus/src/session.rs:934-947
  • 同 key 写入串行化persist_message_through_canonical_path() 通过 per-key Tokio mutex 串行化 SessionActorApiChannel/chat 路径的同 session 写入,避免多个独立 SessionHandle 同时看到相同长度并返回重复 sequence(../octos/crates/octos-bus/src/session.rs:1704-1759

10.3.5 thread_id 持久化:新写入 fail closed,旧记录 load-time synthesis

AppUI 的聊天界面不再只是“一个 session 里顺序追加消息”,而是把消息分组成 thread。当前规则写在 derive_thread_id_for_new_write()synthesize_thread_ids()../octos/crates/octos-bus/src/session.rs:265-359):

路径UserAssistant / ToolSystem
new writeclient_message_id,缺失则 UUIDv7必须由调用方预先 stamp thread_id,否则拒绝写入None
legacy load用旧 client_message_idsynth_{seq} 补齐继承当前 thread,缺失时合成稳定 idNone

这个分裂很重要。旧实现会在写 Assistant/Tool 时“回看最近一个 user”来推导 thread;在 rapid-fire 并发 turn 下,内存历史可能已经被另一个 sibling user 改写,导致 assistant 被归到错误气泡。新写入路径因此 fail closed:Assistant/Tool 没有预先绑定 thread_id 就返回错误,并打 octos_session_persist_total{outcome="rejected_unbound_assistant"} 指标(../octos/crates/octos-bus/src/session.rs:1033-1094../octos/crates/octos-bus/src/session.rs:2052-2104)。

legacy JSONL 不能直接套用这个规则,因为历史文件本来没有 thread_id 字段。加载时的 synthesize_thread_ids() 只在内存里补齐旧记录,让旧 transcript 能按 thread 渲染;下一次新写入再进入 fail-closed 规则。也就是说,octos-bus 把“兼容历史数据”和“保护新写入正确性”拆成了两条路径。

10.3.6 durable commit observer:message/persisted 只在真正提交后发出

MessageCommitObserver 是 Session 层给 UI Protocol 的 durable commit hook(../octos/crates/octos-bus/src/session.rs:18-89)。add_message_with_seq() 的顺序是:

  1. 必要时给 User 派生 thread_id,或要求 Assistant/Tool 已经预绑定。
  2. append_to_disk(),失败则直接返回。
  3. 再更新内存 Session::messages
  4. 最后触发 observer,携带 SessionKeyMessage 和 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(../octos/crates/octos-bus/src/session.rs:71-89)。这条边界能避免一个常见错误:不要把 message/persisted 理解成“准备写入”,它表示“这一行已经 durable visible”。octos-cli 的 UI Protocol 测试也锁定了这个顺序和去重行为(../octos/crates/octos-cli/src/api/ui_protocol.rs)。

10.3.7 child-session contract:后台子任务不是只靠消息文本追踪

Session metadata 还持久化 ChildSessionContract,用于把 background spawn / subagent 的生命周期回写到父 session(../octos/crates/octos-bus/src/session.rs:386-434../octos/crates/octos-bus/src/session.rs:454-480)。它记录:

  • task_idtask_label
  • parent_session_keychild_session_key
  • workflow_kindcurrent_phase
  • terminal_stateCompleted / RetryableFailure / TerminalFailure
  • join_stateJoined / Orphaned
  • failure_actionRetry / Escalate
  • output_files

这说明 child session 不是“几条普通消息加一个 parent_key”就结束了。父 session 需要 durable contract 来判断后台任务是否完成、是否 joined、失败后应重试还是升级。这个 contract 也解释了为什么 rewrite temp path 必须唯一:多个子任务可能同时把 terminal outcome 写回同一个父 session metadata。


10.4 Coalescing 源码走读

让我们深入 split_message() 的完整实现(../octos/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
}
}

关键设计点:

  1. 提前返回:空字符串直接返回空 Vec,短消息返回单块
  2. 先做 UTF-8 安全窗口,再找语义断点:避免把 find_break_point() 变成“逻辑断点 + 编码边界”双重职责
  3. 边界清洗trim_end()、去掉前导换行、最多跳过一个空格,让 chunk 之间的视觉边界更自然
  4. MAX_CHUNKS 保护:超过上限时插入独立截断块,而不是静默丢尾部

10.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 而失败。


10.5 频道实现概览

octos-bus 通过 feature flags 按需编译各频道实现。每个频道实现 Channel trait 的具体方法:

频道连接方式特殊能力
TelegramLong polling (teloxide)消息编辑、AtomicBool 优雅关停
DiscordWebSocket gateway (serenity)消息去重(MessageDedup)
SlackWebSocket (tokio-tungstenite)Block Kit 格式支持
WhatsAppNode.js bridge WebSocketBaileys bridge、独立桥接进程
EmailIMAP/SMTP (async-imap + lettre)异步收发、附件
飞书 / LarkWebSocket / webhook + axum加密消息验证、区域化 endpoint
TwilioWebhook/API (axum)SMS/MMS/RCS/WhatsApp Business
企业微信HTTP webhook (axum)加密消息
MatrixHTTP APIAppService 模式、多用户桥接
WeCom BotWebSocket long connection群机器人通道
QQ BotWebSocket gatewayOfficial QQ Bot API v2
WeChatWeChat bridge WebSocketwechat-bridge 子进程接入
APIREST / UI Protocol WebSocket / 兼容事件 hook (axum)编程式接入与 AppUI 控制面

每个频道实现都是独立的——Telegram 频道的 bug 不会影响 Discord,因为它们是不同的代码路径,通过不同的 feature flag 编译。需要注意的是,octos chat 的 CLI readline 不在 octos-bus 的 Cargo feature 频道列表中;bus 侧当前 feature-gated 频道以 apitelegramdiscordslackwhatsappemailfeishutwiliowecommatrixwecom-botqq-botwechat 为主。这种隔离设计是 octos-bus 约 30K 行 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。


10.6 Session 持久化的工程细节

10.6.1 FNV-1a 哈希

在 legacy flat layout 里,当编码后的 SessionKey 需要截断时,文件名会追加 FNV-1a 64-bit 哈希后缀(../octos/crates/octos-bus/src/session.rs:116-127../octos/crates/octos-bus/src/session.rs:1117-1146)。这是一个非密码学哈希函数,优势在于实现极简且跨 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
}
}

10.6.2 Percent-encoding

encode_path_component()../octos/crates/octos-bus/src/session.rs:129-140)将 SessionKey 中的特殊字符编码为 URL 安全格式。这防止了 telegram:12345 这样的 key 被文件系统解释为目录路径(因为 : 在某些文件系统中是特殊字符)。

10.6.3 write-then-rename 原子性

整会话 rewrite() 路径的原子性通过两步操作实现:

  1. 写入唯一临时文件 {session_file}.{pid}-{seq}.tmp
  2. rename() 临时文件为正式文件

在 Unix/Linux 上,rename() 是原子操作——要么完全成功(新文件替换旧文件),要么完全失败(旧文件保持不变)。即使进程在 rename() 之前崩溃,也只会留下一个孤立的 .tmp 文件,不影响正式会话文件。


10.7 本章回顾

  1. Channel trait:当前是 26 方法接口,但只有 name()start()send() 没有默认实现;流式编辑、typing、embed、health check 都是按需覆盖的扩展层。
  2. Coalescing:5 级语义切割(段落→换行→句子→空格→硬切),MAX_CHUNKS=50 防 DoS,UTF-8 安全,超限时会追加独立的 truncation chunk。
  3. Session:JSONL 文件第一行是 metadata,不是消息;当前同时兼容 legacy flat layout 和 per-user layout,/new/fork 会复制最近 N 条消息并记录 parent_key
  4. Thread binding:新写入路径要求 Assistant/Tool 预先绑定 thread_id,legacy load 则只做内存补齐;bound channel API 防止并发 turn 的流式增量落到错误气泡。
  5. Durable observer 与 child contractmessage/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 格式规范

思考题

  1. 频道抽象的边界:某些频道支持富文本(Slack Block Kit、飞书 Rich Text),但 Channel::send() 只接受纯文本。你会如何扩展 trait 以支持富文本,同时保持向后兼容?
  2. 会话恢复:如果 octos 进程崩溃,JSONL 文件的最后一行可能不完整。你会如何实现崩溃恢复?

版本演化说明 本章分析基于 ../octos 当前 main 分支源码。octos-bus crate 位于 ../octos/crates/octos-bus/src/;相较早期版本,本章已更新 thread-bound streaming、per-user session layout、durable commit observer、child-session contract、per-key persist lock 与唯一 rewrite temp path 等主分支语义。

第 11 章:并发模型:Tokio 异步架构实战

定位:本章展示 octos 如何利用 Tokio 异步运行时实现生产级并发——从 per-session actor 到 actor 内部的 per-message task 派生,从信号量限流到工具并发和优雅关停。前置依赖:第 5 章、第 10 章。适用场景:想理解 Rust 异步并发实战模式的开发者(读者 B),以及需要调优并发参数的运维人员(读者 D)。

单用户 CLI 模式下,Agent 是单线程顺序执行的——不需要考虑并发。但当 octos 作为 Gateway 或 Serve 模式运行时,多个用户同时发送消息,每个会话还可能夹杂取消、后台子任务结果、UI/API 状态投递与溢出消息。当前源码已经不是早期“每条消息直接 spawn + shared Mutex”的简单模型,而是一个分层并发结构:Gateway 主循环负责接入,ActorRegistry 负责会话生命周期,每个 session actor 自己拥有工具、会话文件句柄和用户工作区,然后再在 actor 内部按需派生消息任务、工具任务和后台 subagent。


11.1 分层 Spawn:会话、消息、工具、子 Agent

当前 octos 的 tokio::spawn() 不是只出现在一个地方,而是分布在四个层级,外加一层专门管理 spawn_only 生命周期的状态监督器:

  1. 会话级 actorActorRegistry 为新 session 创建 actor,ActorFactory::spawn() 最终通过 tokio::spawn(actor.run()) 启动一个长期存活的 per-session 任务(../octos/crates/octos-cli/src/session_actor.rs:1494-1608../octos/crates/octos-cli/src/session_actor.rs:2505-2559
  2. 消息级 agent task:在 API / speculative 路径下,actor 会再把当前消息的主 Agent 调用派生成独立任务,这样 actor 自己还能继续轮询 inbox,及时接收取消、overflow、后台结果和状态事件(../octos/crates/octos-cli/src/session_actor.rs:4280-4535
  3. 工具级任务:单轮 LLM 返回多个 tool call 时,execute_tools() 会为每个工具各自 tokio::spawn(),然后用 join_all() 汇总结果(../octos/crates/octos-agent/src/agent/execution.rs:44-60../octos/crates/octos-agent/src/agent/execution.rs:105-245
  4. 后台子 Agent / spawn_onlyspawn 工具的 background 模式会再起一个长期子 Agent;spawn_only 工具也会在工具执行层单独起后台任务(../octos/crates/octos-agent/src/tools/spawn.rs:2282-3024../octos/crates/octos-agent/src/agent/execution.rs:220-455

这种分层 spawn 的好处是并发边界清晰:会话级隔离保证状态所有权,消息级派生保证 actor 还能继续响应控制消息,工具级并发保证单轮性能,后台子 Agent 则把长任务从主对话流中剥离出去。spawn_only 不是 fire-and-forget;它的可见状态由 TaskSupervisor 维护。Tokio 的 JoinHandle 还把 panic 封装为 JoinError,避免一个子任务直接把整条并发链路拖垮。

11.2 Session Actor:会话级状态所有权

虽然不同用户的消息并行处理,但同一会话的核心状态必须有唯一 owner。否则两条几乎同时到达的消息可能并发修改消息历史、工具注册表、sandbox 工作区和背景任务状态,结果就是经典的“状态没锁住,但语义已经乱了”。

octos 使用 session actor 模式(../octos/crates/octos-cli/src/session_actor.rs)实现这个 owner 语义——每个会话由一个独立的 tokio 任务(actor)管理:

#![allow(unused)]
fn main() {
// session_actor.rs 关键常量
const ACTOR_INBOX_SIZE: usize = 32;          // actor mailbox 容量
pub const DEFAULT_IDLE_TIMEOUT_SECS: u64 = 1800; // 空闲 30 分钟后回收
const MAX_OVERFLOW_TASKS: u32 = 5;           // speculative overflow 并发上限
const MAX_PENDING_PER_SESSION: usize = 50;   // 非活跃 session 的待发送缓冲上限
}

每个 session actor 不只是“有个队列”而已,它还拥有自己的 ToolRegistrySessionHandle、per-user workspace、取消标志、流式 reporter 和后台任务接线(../octos/crates/octos-cli/src/session_actor.rs:2428-2560)。这正是 session actor 模式的核心:共享状态不再散落到每条消息任务里,而是由 actor 作为 owner 持有。

11.2.1 ActorMessage:类型安全的消息分发

Session actor 通过 ActorMessage 枚举接收消息(../octos/crates/octos-cli/src/session_actor.rs:1412-1468):

#![allow(unused)]
fn main() {
pub enum ActorMessage {
    /// 用户消息——触发 Agent 迭代
    Inbound {
        message: InboundMessage,
        image_media: Vec<String>,
        attachment_media: Vec<String>,
        attachment_prompt: Option<String>,
    },
    /// 后台子 Agent 的结果——注入为系统消息,不触发额外 LLM 调用
    BackgroundResult {
        task_label: String,
        content: String,
        kind: BackgroundResultKind,
        media: Vec<String>,
        originating_thread_id: Option<String>,
        ack: Option<oneshot::Sender<bool>>,
    },
    /// 后台任务状态变化——推送到 SSE
    TaskStatusChanged { task_json: String },
    /// 取消当前操作
    Cancel,
    /// spawn_only 失败恢复提示
    RecoveryHint { /* task_id, tool_name, prompt, originating_client_message_id */ },
}
}

Rust 的枚举让消息类型在编译期确定——不可能发送一个 actor 不理解的消息类型。当前 ActorMessage 也说明了并发模型的演进:除了用户输入和取消,它还承载 background result、任务状态变更、附件上下文和 spawn_only 失败恢复提示。

11.2.2 ActorRegistry:会话生命周期管理

ActorRegistry../octos/crates/octos-cli/src/session_actor.rs:1494-1735)管理所有 session actor 的生命周期:

#![allow(unused)]
fn main() {
pub struct ActorRegistry {
    actors: HashMap<String, ActorHandle>,        // 活跃 actor 表
    factory: Arc<ActorFactory>,                  // 默认 Agent 工厂
    profile_factories: HashMap<String, Arc<ActorFactory>>,  // Profile 特定工厂
    semaphore: Arc<Semaphore>,                   // 并发限制
    out_tx: mpsc::Sender<OutboundMessage>,       // 输出通道
    pending_messages: PendingMessages,           // 缓冲消息
}
}

当新消息到达时,dispatch() 会先按 session key + profile 解析 actor key,再做三件事:

  • 发现 actor 已结束:先回收死 actor,避免向失效 mailbox 发消息(../octos/crates/octos-cli/src/session_actor.rs:1568-1573
  • 缺少 actor:调用 factory.spawn(...) 创建一个新 actor,并把 system_prompt_override / sender_user_id / profile factory key 等上下文挂在 ActorHandle 上,供后续 respawn 使用(../octos/crates/octos-cli/src/session_actor.rs:1575-1598
  • actor 已存在:优先 try_send();若 mailbox 已满,先给用户发一个“仍在处理中,你的消息已排队”的反馈,再退回到阻塞 send()../octos/crates/octos-cli/src/session_actor.rs:1608-1626

ActorHandle::is_finished() 通过检查 JoinHandle::is_finished() 判断 actor 是否已退出——这是零开销的,不需要额外的心跳机制。

11.2.3 Mailbox、背压与溢出不是一回事

这几个上限名字很像,但语义完全不同:

  1. ACTOR_INBOX_SIZE = 32:这是 actor mailbox 容量。它限制的是“同一个 actor 还没来得及 recv() 的消息数”
  2. MAX_PENDING_PER_SESSION = 50:这是非活跃 session 的待发送缓冲上限。缓冲的是 outbound reply,不是 inbound user message(../octos/crates/octos-cli/src/session_actor.rs:80-102../octos/crates/octos-cli/src/session_actor.rs:2608-2616
  3. MAX_OVERFLOW_TASKS = 5:这不是排队长度,而是 speculative 模式下“同一个 session 允许并发跑多少个 overflow agent task”的上限。超过时 actor 会立即回一个 busy 响应,而不是继续排队(../octos/crates/octos-cli/src/session_actor.rs:5268-5300

这三个阈值分别保护 mailbox、inactive-session buffering 和会话内受控并发。如果把它们都理解成“队列大小”,就会误读 octos 的真实背压设计。

11.2.4 并发模型全景图

flowchart TD
    subgraph "消息接入"
        TG["Telegram"]
        DC["Discord"]
        API["REST API"]
    end

    subgraph "并发控制"
        SEM["Semaphore<br/>max=10"]
        REG["ActorRegistry"]
    end

    subgraph "会话隔离"
        A1["Session Actor A<br/>ToolRegistry + SessionHandle + workspace"]
        A2["Session Actor B<br/>ToolRegistry + SessionHandle + workspace"]
        A3["Session Actor C<br/>ToolRegistry + SessionHandle + workspace"]
    end

    subgraph "Actor 内部派生"
        M1["agent_task"]
        T1["tool_1"]
        T2["tool_2"]
        T3["tool_3"]
        O1["overflow task<br/>(optional)"]
    end

    TG & DC & API -->|"inbound_tx"| REG
    REG -->|"dispatch"| A1 & A2 & A3
    A1 -->|"acquire permit"| SEM
    SEM --> A1 & A2 & A3
    A1 --> M1
    M1 -->|"join_all"| T1 & T2 & T3
    A1 -. spec mode .-> O1

图 11-1:octos 并发模型全景。 消息先进入 ActorRegistry,再由 session actor 持有状态;Semaphore 限制的是活跃处理数,不是 actor 数量。actor 内部再按需派生 agent_task、tool task 和 speculative overflow task。


11.3 信号量限流

无限制的并发会话会耗尽系统资源(CPU、内存、LLM API 配额)。Arc<Semaphore> 限制同时活跃的处理数,默认值来自 GatewayConfig.max_concurrent_sessions = 10../octos/crates/octos-cli/src/config.rs:720-777),具体 semaphore 在 Gateway Runtime 里创建(../octos/crates/octos-cli/src/commands/gateway/gateway_runtime.rs:1401-1406)。

#![allow(unused)]
fn main() {
// 获取许可——如果已有 10 个活跃处理,新消息在此等待
let _permit = self.semaphore.acquire().await?;
// 处理消息...
drop(_permit); // 释放许可,允许下一个等待的消息进入
}

这个 permit 获取发生在 actor 真正开始处理消息时,而不是在 actor 创建时(../octos/crates/octos-cli/src/session_actor.rs:4307-4310../octos/crates/octos-cli/src/session_actor.rs:5814-5818)。因此,一个空闲 actor 可以常驻内存,但不会占用并发槽位;只有正在跑 LLM / tool / overflow 逻辑的会话才会消耗 permit。

信号量而非自定义计数器的优势是:acquire().await 自动挂起等待任务,不消耗 CPU;任务完成或 panic 时 permit 会通过 RAII 自动释放,不容易泄漏。

11.4 工具并发:join_all

在单次 Agent 迭代内,LLM 可能请求多个工具调用(如同时读取 3 个文件)。工具执行层会为每个 tool call 分别 tokio::spawn(),然后用 join_all() 风格的 fan-in 汇总结果(../octos/crates/octos-agent/src/agent/execution.rs:44-60../octos/crates/octos-agent/src/agent/execution.rs:105-245):

#![allow(unused)]
fn main() {
let handles: Vec<_> = tool_calls.iter()
    .map(|tc| tokio::spawn(execute_tool(tc)))
    .collect();
let results = futures::future::join_all(handles).await;
}

并行执行工具是 Agent 性能的关键优化——如果 3 个文件读取各需 10ms,串行执行需要 30ms,并行只需 ~10ms。

这里还有一个很有工程味的细节:工具任务不是只传 tool name 和 args;spawn_tool_task() 会把 reporter、hooks、file_state_cache、agent definitions、subagent output router、cost accountant、parent session key 和 spawn depth 一起克隆进任务上下文(../octos/crates/octos-agent/src/agent/execution.rs:108-158)。这让并发工具仍然继承本轮 Agent 的权限、观测和工作区边界。

11.5 子 Agent 双模式

octos 支持两种子 Agent 执行模式,而这两种模式都是并发边界设计的一部分:

11.5.1 同步阻塞模式

当 Agent 在主循环中调用需要子 Agent 的工具时,工具在当前迭代内同步等待子 Agent 完成。spawn 工具的 mode = "sync" 分支会构造子 Agent、继承父会话的工具策略/缓存/输出路由/压缩策略,然后通过 run_task_with_m8_9_recovery() 执行子任务,并在成功路径上继续跑 completion-phase validators,最后把子 Agent 输出作为当前 tool result 返回(../octos/crates/octos-agent/src/tools/spawn.rs:2133-2280)。

这适用于结果立即需要的场景——比如搜索结果需要在下一次 LLM 调用中使用。

11.5.2 后台异步模式(spawn 工具)

spawn 工具的 background 模式会 tokio::spawn(async move { ... }) 起一个完全独立的后台 Agent。主 Agent 立即继续执行,不等待后台任务完成;后台闭包会快照父任务的 task id、originating thread id、hooks、workspace policy、父缓存和 spawn depth,再在完成后优先通过 direct background result sender 回到 session actor(../octos/crates/octos-agent/src/tools/spawn.rs:2308-2354../octos/crates/octos-agent/src/tools/spawn.rs:2550-2570../octos/crates/octos-agent/src/tools/spawn.rs:2961-3024../octos/crates/octos-cli/src/session_actor.rs:1138-1168)。

#![allow(unused)]
fn main() {
// spawn 工具的简化逻辑
tokio::spawn(async move {
    let sub_agent = Agent::new(config);
    sub_agent.run_task(task).await;
    // 结果通过消息通知用户,不返回给主 Agent
});
// 主 Agent 立即继续
}

从用户体验看,后台结果又分两类:

  • BackgroundResultKind::Notification 会作为后台通知直接发给用户,并携带可选媒体和 originating thread id
  • BackgroundResultKind::Report 会先经过 prepare_background_report_result() 整理;长报告会落到 memory bank,再把可读摘要作为后台通知发出(../octos/crates/octos-cli/src/session_actor.rs:3888-3930

11.5.3 TaskSupervisor:spawn_only 的状态真相

spawn_only 工具的难点不在于“能不能 tokio::spawn 一个任务”,而在于后台任务启动后如何让前端、父 Agent 和控制 API 都看到同一份生命周期真相。当前源码把这部分抽成了 TaskSupervisor../octos/crates/octos-agent/src/task_supervisor.rs:1-9)。

TaskSupervisor 维护后台任务 ledger:任务创建后进入 Spawned,执行时进入 Running,最后落到 CompletedFailedCancelled 这类终态(../octos/crates/octos-agent/src/task_supervisor.rs:88-123)。更细的运行时阶段还包括 ExecutingToolResolvingOutputsVerifyingOutputsDeliveringOutputsCleaningUp,最终再映射到对外可见的 QueuedRunningVerifyingReadyFailedCancelled../octos/crates/octos-agent/src/task_supervisor.rs:152-262)。

这里有两个容易被低估的生产约束:

  • fan-out 上限:单个父任务默认最多 200 个子任务,环境变量 OCTOS_MAX_CHILDREN_PER_PARENT 可以覆盖;超过上限后,supervisor 不会继续无界派生,而是 poison parent 并把仍活跃的孩子标成 failed(../octos/crates/octos-agent/src/task_supervisor.rs:29-52../octos/crates/octos-agent/src/task_supervisor.rs:1390-1484)。
  • workspace contract 先于状态更新:工作区边界、输出声明和 artifact 验证在 execution.rs 热路径中完成后,supervisor 才接收状态更新;也就是说它记录的是已经通过执行层约束检查的状态,而不是任意后台线程自报的状态。
  • 终态不被迟到事件复活mark_running()mark_runtime_state()mark_completed()mark_failed() 都先检查 terminal state,避免用户 cancel 后迟到 worker 再把任务改回 Running / Completed / Failed(../octos/crates/octos-agent/src/task_supervisor.rs:1543-1745)。

这解释了为什么 AppUI 可以安全地提供 task list / cancel / restart 这类控制能力:它读写的是 supervisor 维护的受控状态机,而不是从日志里猜测后台任务进度。

11.5.4 Lifecycle projection:MCP 与 Harness 看到同一套状态

TaskSupervisor 的价值还在于它把后台任务状态投射给不同控制面。octos mcp-serve 暴露的 run_octos_session 并不会把内部工具调用逐条流给外层 MCP caller;它通过 lifecycle observer 标记 RunningVerifying,最后把 session aggregate outcome 转成 ReadyFailed../octos/crates/octos-cli/src/commands/mcp_serve.rs:16-35../octos/crates/octos-agent/src/mcp_server.rs:9-34../octos/crates/octos-agent/src/mcp_server.rs:410-493)。

这意味着外层 orchestrator 看到的是“一个 octos session 的终态和 artifact”,而不是内部 loop 的每个 token 或工具事件。相反,operator dashboard 和 /api/events/harness 可以通过 harness events / metrics 观察 background spawn、sub-agent dispatch、swarm dispatch 和 cost attribution。并发状态因此有两种投影:

flowchart LR
    T[TaskSupervisor lifecycle] --> MCP[MCP run_octos_session outcome]
    T --> UI[AppUI task/list cancel restart]
    T --> H[Harness events + metrics]
    MCP --> O[Outer orchestrator sees aggregate result]
    UI --> U[Operator controls background tasks]
    H --> D[Dashboard / live gates observe typed events]

这个边界很重要:MCP Serve 是 coarse-grained session dispatch,不是把 octos 内部工具目录直接暴露出去;Harness events 是 operator 观察面,不是任务结果协议。

11.5.5 AgentOrchestrator:把后台任务投射成 agent lifecycle

最新主分支又在 octos-cli/src/api/agent_orchestrator.rs 里补了一层控制面:AgentOrchestrator trait。它的职责不是替代 TaskSupervisor,而是把 supervisor 维护的后台任务、native specialist 和 AppUI 需要的 agent 状态投射到同一套生命周期 API:list/status/output/artifacts/interrupt/close,以及 get_goal/set_goal/clear_goal/create_loop/list_loops/control_loop

这层实现有两个需要同时写清的事实:

  • 已经落地的部分InProcessAgentOrchestrator 能把 TaskSupervisor 的 background task mirror 成 agent state;run_native_specialist 会注册 native_agent 任务、设置 runtime policy stamp、启动子 Agent、推送 output/artifact 更新,并在完成或失败时回写 supervisor。
  • 尚未完全泛化的部分:trait 上的 spawn_agent / send_input / wait_agent / resume_agent 有默认 unsupported 实现,注释也明确 JSON-RPC bridge/production impls 仍是后续 wiring;model-visible send_input 也不是实时子 Agent stdin,而是先落到 supervisor metadata。

因此 Octos 当前更准确的定位是:backend-supervised orchestration + profile/session-scoped agent runtime。它已经有受监督的子任务、native specialist、artifact lifecycle、goal/loop API primitives 和 continuation scheduler,但还不是“所有 agent 任意互联、互相实时对话、自主组织”的 multi-agent society。

flowchart TD
    Spawn["spawn_agent / native specialist"] --> Supervisor["TaskSupervisor<br/>runtime state"]
    Supervisor --> Store["SupervisorStore<br/>JSONL events + snapshot"]
    Supervisor --> Orchestrator["InProcessAgentOrchestrator<br/>agent lifecycle projection"]
    Orchestrator --> UI["AppUI agent/* methods<br/>status output artifacts close"]
    Orchestrator --> Continuation["MasterContinuationScheduler<br/>child/goal/loop wakeups"]
    Continuation --> Master["master agent continuation turn"]

图 11-3:从后台任务到 agent lifecycle 的投射。 TaskSupervisor 仍是任务状态真相;AgentOrchestrator 把它映射为 AppUI/控制面理解的 agent;SupervisorStore 负责持久化 supervisor ledger;MasterContinuationScheduler 负责在 child completion、scatter-join、goal/loop 等事件后安排 master agent 的后续 turn。

持久化层也不再只是内存 map。supervisor_store.rs 定义了 append-only supervisor-events.jsonlsupervisor-snapshot.json 和 lock 文件,记录 group、child、heartbeat、artifact、terminal state 和 pending continuation。配合 configure_supervisor_store() 的启动恢复逻辑,未完成 continuation 可以在进程重启后重新入队。这个设计把“后台任务能继续被控制面看见”从内存状态推进到了 durable ledger。

master_continuation_scheduler.rs 则负责后续 turn 的排队和去重。它用稳定 dedupe key 绑定 group/session/profile/reason/child/goal/loop/metadata,并按原因设优先级:手动 external wakeup 最高,其次 goal continue/wrap-up,再到 child/scatter-join completion,loop fire 最低。这个优先级说明它不是任意自我循环,而是“当明确事件发生且运行时 idle 时,安排 master 继续”的受控机制。


11.6 优雅关停

当 octos 收到 Ctrl-C 时,当前源码里的关停链路其实有两层 flag,而不是一个全局布尔值走完整个系统:

  1. Gateway 级 shutdown flag:Ctrl-C handler 置位,Gateway Runtime 主循环和 session actor 自己会看这个标志(../octos/crates/octos-cli/src/commands/gateway/gateway_runtime.rs:1320-1328../octos/crates/octos-cli/src/commands/gateway/gateway_runtime.rs:1458-1488../octos/crates/octos-cli/src/session_actor.rs:3060-3064
  2. Per-session cancelled flag:session actor 通过 .with_shutdown(cancelled.clone()) 传给 Agent,本轮任务里的 check_budget()wait_for_shutdown() 实际读的是这个 flag(../octos/crates/octos-cli/src/session_actor.rs:2441-2448../octos/crates/octos-agent/src/agent/budget.rs:34-65../octos/crates/octos-agent/src/agent/streaming.rs:14-29
#![allow(unused)]
fn main() {
// session actor:取消当前任务
self.cancelled.store(true, Ordering::Release);

// agent:在预算检查时观察取消标志
if self.shutdown.load(Ordering::Acquire) {
    return BudgetStop::Shutdown;
}
}

Release / Acquire 语义确保:actor 写入取消标志后,Agent 线程在读取时不会看到旧值。Gateway 的 Ctrl-C flag 也使用同样的序关系(../octos/crates/octos-cli/src/commands/gateway/gateway_runtime.rs:1320-1328)。

优雅关停的流程:

  1. Ctrl-C handler 置位 Gateway shutdown flag
  2. Runtime 主循环会在下一次取到 inbound 后、真正 dispatch 之前停止继续处理新消息
  3. 进入 shutdown 阶段,最多等待 1 秒让 actor_registry.shutdown_all() 收尾;超时后交给 runtime teardown,避免 CLI 长时间卡住
  4. 并发停止 persona / heartbeat / cron / channels 等后台服务(../octos/crates/octos-cli/src/commands/gateway/gateway_runtime.rs:1830-1848

11.6.1 关停的四个阶段

优雅关停不是一个简单的 process::exit()——它是一个有序的资源释放过程:

  1. 停止继续 dispatch 新消息:Gateway Runtime 会在 shutdown notify 或下一条 inbound 到来时检查 shutdown flag 并跳出主循环(../octos/crates/octos-cli/src/commands/gateway/gateway_runtime.rs:1458-1488
  2. 等待 actor 结束shutdown_all() 会 drop actor senders,并等待 join handle 完成;Gateway Runtime 当前只等 1 秒,防止 hung actor 阻塞 CLI 退出(../octos/crates/octos-cli/src/session_actor.rs:1720-1735../octos/crates/octos-cli/src/commands/gateway/gateway_runtime.rs:1830-1839
  3. actor 内部完成本轮清理:session actor 自己会在 loop 边界检查 global_shutdown / cancelled,随后退出(../octos/crates/octos-cli/src/session_actor.rs:3037-3068
  4. 停后台服务与频道:最后并发 stop persona / heartbeat / cron / channel manager(../octos/crates/octos-cli/src/commands/gateway/gateway_runtime.rs:1841-1848

11.6.2 Ordering 语义为什么重要

#![allow(unused)]
fn main() {
// 错误:使用 Relaxed
shutdown.store(true, Ordering::Relaxed);   // 主线程
if shutdown.load(Ordering::Relaxed) { ... } // Agent 线程
// Agent 线程可能看到 stale 值——在多核 CPU 上,store 可能还在写缓冲中
}

Release / Acquire 配对确保了 happens-before 关系:发起取消的一方先 store(true, Release),执行任务的一方再 load(Acquire),这样取消事件前的状态变更不会在另一个线程里乱序消失。

Relaxed 在这里不够——虽然 x86 架构上的 Relaxed 几乎等同于 Acquire/Release(因为 x86 的内存模型较强),但在 ARM 等弱内存模型架构上,Relaxed 可能导致 Agent 线程在检测到 shutdown 为 true 之后仍然看到 stale 的消息队列状态。


11.7 冷启动优势:常驻进程 vs fork/exec

传统的 per-request fork/exec 模型会为每条消息重复创建进程、加载运行时、初始化 Provider 与工具注册表,然后处理完就退出。octos 的 Gateway/Serve 模式采用常驻进程 + Tokio 异步运行时,把这类初始化尽量前移到启动阶段:

┌──────────────────────────────────────────────────────────┐
│ fork/exec 模型(每条消息)                                  │
│                                                          │
│  fork → load runtime → init provider → build tools →     │
│  process message → exit                                  │
│  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ │
│  每条消息都重复做初始化                                      │
└──────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────┐
│ octos 常驻进程模型                                         │
│                                                          │
│  一次启动:init main provider stack + build tools + start │
│            channels/services                             │
│  ──────────────────────────────────────────────────────── │
│  每条消息:dispatch → actor.send() → process              │
│  重复开销缩小为 actor 查找/创建与排队                       │
└──────────────────────────────────────────────────────────┘

具体来说,Gateway 启动时会先为主 profile构建默认的 LLM/provider stack;如果配置了 fallback models,会在这一阶段一并构造 RetryProviderProviderChainAdaptiveRouter 所需的候选链路,然后再包进 SwappableProvider 供主 profile 会话复用(../octos/crates/octos-cli/src/commands/gateway/gateway_runtime.rs:288-355)。

目标 profile 则走另一条路径:首次消息分发到某个 profile 时,Gateway 按需构建该 profile 的 ActorFactory,并缓存到 ActorRegistry::profile_factories;后续同一 profile 的新会话复用这个工厂,而不是每条消息都重新初始化(../octos/crates/octos-cli/src/session_actor.rs:1521-1549../octos/crates/octos-cli/src/commands/gateway/profile_factory.rs)。

PROFILE_PROMPT_CACHE_CAP = 128 的 prompt cache 也是同类优化,但它只用于“目标 profile 尚未注册独立 factory”的 fallback 路径:这时 Gateway 会把从 profile store 读取到的 system prompt 临时缓存在内存里,避免反复访问磁盘(../octos/crates/octos-cli/src/commands/gateway/gateway_runtime.rs:58../octos/crates/octos-cli/src/commands/gateway/gateway_runtime.rs:1758-1770)。

之后每条消息的处理路径只有:

  1. ActorRegistry::dispatch() 查找或创建 session actor
  2. actor.tx.send(message) 将消息发到 actor 的 mailbox
  3. Actor 开始真正处理消息时才获取 Semaphore permit,然后进入 Agent 迭代

空闲 actor 常驻内存但不占用并发槽位(详见 11.3 信号量限流)——Semaphore permit 在 actor 真正开始处理消息时才获取,不在 actor 创建时获取(../octos/crates/octos-cli/src/session_actor.rs:4307-4310)。这意味着 1000 个会话可以同时存在,但只有 10 个在活跃处理。

这类优势可以从源码确认,但不能仅凭源码推出固定的毫秒级收益。真实延迟仍取决于 Provider、工具链、网络和部署环境;本章能确定的是“初始化被前移并按 profile/session 复用”,而不是某个通用的 benchmark 数字。

常驻模型的另一个实际收益是会话级短期状态可以持续累积。同一会话的 actor 默认存活 30 分钟(DEFAULT_IDLE_TIMEOUT_SECS = 1800),因此该 actor 持有的工具注册表和 LRU 热度统计能在会话生命周期内持续更新;fork/exec 模型则天然做不到这种跨消息的短期状态保留。


11.8 Heartbeat 与 Cron

octos 支持定时触发 Agent 会话,三种调度类型:

类型示例精度
Every每 5 分钟固定间隔
Cron0 9 * * 1-5Cron 表达式
At每天 09:00固定时间点

定时任务通过 cron crate 解析表达式,在 Tokio 运行时中注册定时器。触发时创建新的会话消息,经过正常的消息处理管线。


工程决策侧栏:为什么从共享 Mutex 演化到 Session Actor

方案一:共享 Mutex<SessionState>

优势:实现最直接,“同一时刻只能处理一条消息”的语义也很容易表达。

劣势:真正麻烦的不是锁本身,而是锁里到底该放什么。工具注册表、用户工作区、后台结果回流、UI/API 状态投递、取消信号,这些状态如果散落在锁外,语义竞态依然存在。

方案二:完全无状态的 spawn-per-message

优势:并行度高,消息来了就起任务,几乎不需要长期存活结构。

劣势:每次都要重建工具与会话上下文;后台结果路由、消息背压和 overflow 控制会散落在多个任务之间,难以形成一个稳定的 owner。

方案三:Session Actor(当前源码的选择)

优势:mailbox、ToolRegistry、SessionHandle、用户工作区、取消标志、background result injection 都收敛到一个 owner 上;并发点从“谁都能改状态”变成“actor 内部何时显式派生子任务”。

代价:实现明显更复杂,必须处理 inbox 满载、actor respawn、overflow 并发和 shutdown 协调。但对于 octos 这种长会话、多工具、可中断的 Agent,这个复杂度换来了更稳的运行时边界。


11.8 本章回顾

  1. 分层并发:octos 现在是“Gateway dispatch → session actor → actor 内部消息任务 / 工具任务 / 子 Agent”这套分层 spawn 结构,不是单一的 per-message spawn 模型。
  2. Session Actor:每个会话都有自己的 ToolRegistry、SessionHandle、workspace 和 mailbox,状态所有权清晰。
  3. Semaphore 限流:默认 10 个活跃处理槽位;permit 在真正处理消息时获取,而不是在 actor 创建时占坑。
  4. 工具与后台任务join_all 负责单轮工具并发,spawn / spawn_only 负责把长任务从主回路拆出去;TaskSupervisor 负责 spawn_only 的状态 ledger、取消态和 fan-out 上限。
  5. Lifecycle projection:MCP Serve、AppUI 和 Harness events 看到的是同一套任务生命周期的不同投影:外层 orchestrator 收 aggregate outcome,operator 控制后台任务,dashboard 观察 typed events;AgentOrchestrator 进一步把后台任务投射成 agent lifecycle,并通过 durable supervisor store 与 master continuation scheduler 支撑恢复和后续 turn。
  6. 优雅关停:Gateway shutdown flag 和 per-session cancelled flag 分层配合,配上 Release/Acquire 语义,让接入停止、任务取消、actor 回收和服务 stop 有明确边界。

延伸阅读

  • Tokio 教程:https://tokio.rs/tokio/tutorial — 异步 Rust 运行时
  • Rust Atomics and Locks:Mara Bos 的书,https://marabos.nl/atomics/ — 理解 Release/Acquire 语义
  • 结构化并发:Nathaniel J. Smith, "Notes on structured concurrency" — 理解 spawn + join 的模式

思考题

  1. Actor 内部还需要多少锁? 当前 actor 已经提供了状态 owner,但 SessionHandlePendingMessages 等局部资源仍然用了 Mutex。如果未来要支持更复杂的跨会话共享缓存,锁应该留在 actor 内,还是再抽出独立协调层?
  2. 信号量的公平性:当 10 个并发槽位全部占满时,等待的消息按什么顺序获得许可?Tokio 的 Semaphore 是 FIFO 的吗?

版本演化说明 本章分析基于 ../octos 当前 main 分支源码。若你在更早的设计文档或旧书稿里见过“per-message spawn / per-session Mutex 是核心模型”的说法,应以现在的 session_actor.rsTaskSupervisoragent_orchestrator.rssupervisor_store.rsmaster_continuation_scheduler.rs、MCP lifecycle observer 和 harness event surfaces 为准:核心并发边界已经演化为 session actor + per-actor state ownership + supervised background lifecycle + agent lifecycle projection。

第 12 章:octos-pipeline:DOT 图驱动的工作流引擎

定位:本章对照 ../octos/crates/octos-pipeline/src/,解释 octos 如何把 Graphviz DOT 图解析成带类型的 PipelineGraph,以及执行器如何在顺序节点、静态并行、动态并行、检查点恢复、deadline 护栏和父会话资源继承之间切换。前置依赖:第 5 章、第 8 章、第 11 章。适用场景:需要理解多步骤 Agent 编排机制的开发者。

当任务已经不是“单个 Agent 循环 + 几次工具调用”能解决的问题时,就需要显式的工作流编排。典型例子是:先规划研究角度,再并发检索,再汇总分析,最后生成报告。octos-pipeline 解决的就是这类问题,但它的当前实现和“传统 DAG 调度器”的直觉并不完全一样:它既有图结构,也有运行时分支选择、并发汇合、模型路由和工具策略继承;同时还通过 PipelineHostContext 继承父 session 的文件缓存、子 Agent 输出路由、任务监督器和成本账本,让 pipeline 节点不再像早期实现那样孤立运行。


12.1 DOT 图如何进入运行时

12.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=...]
  • 节点属性:handlermodeltoolsconvergeplanner_model
  • 边:A -> B
  • 形状到 Handler 的隐式映射,例如 Msquare 会映射到 Noop

12.1.2 手写 parser,而不是第三方 DOT 库

入口是 ../octos/crates/octos-pipeline/src/parser.rs:21-23parse_dot(),真实工作发生在 DotParser::parse()../octos/crates/octos-pipeline/src/parser.rs)。这是一个手写 parser,不依赖外部 DOT 解析库。

当前实现比“只支持一小撮语法”的简化说法要丰富一些:

  • digraph 名称是可选的;如果模型生成了 digraph { ... },parser 会把图 ID 设成 "pipeline"../octos/crates/octos-pipeline/src/parser.rs
  • 支持图级属性 graph [key=value],目前会落到 labeldefault_model../octos/crates/octos-pipeline/src/parser.rs
  • 支持 subgraph name { ... },并把子图中的节点归档到 PipelineGraph.subgraphs../octos/crates/octos-pipeline/src/parser.rs../octos/crates/octos-pipeline/src/graph.rs:21-24
  • 支持边链式写法 a -> b -> c,属性会应用到链上的每一条边(../octos/crates/octos-pipeline/src/parser.rs
  • 支持 ///* */,以及额外的 # 行注释;后者明显是在为 LLM 生成的 DOT 做容错(../octos/crates/octos-pipeline/src/parser.rs
  • 如果边引用了未显式声明的节点,parser 会自动补出默认节点定义(../octos/crates/octos-pipeline/src/parser.rs

这一层的结果不是“松散的 JSON 树”,而是带语义的 PipelineGraph。其核心结构在 ../octos/crates/octos-pipeline/src/graph.rs:10-24../octos/crates/octos-pipeline/src/graph.rs:91-140

  • PipelineGraphidlabeldefault_modelnodesedgessubgraphs
  • PipelineNode 除了 prompthandler,还包含 modelcontext_windowmax_output_tokenstoolsgoal_gatemax_retriestimeout_secssuggested_nextconvergeworker_promptplanner_modelmax_tasksdeadline_secsdeadline_actioncheckpoints

12.1.3 属性到节点语义的映射

节点构建发生在 build_node()../octos/crates/octos-pipeline/src/parser.rs:527-576)。这里有几个实现细节决定了 DOT 的“作者体验”:

  • Handler 解析顺序是:显式 handler= 优先,其次是 shape= 到 Handler 的映射,最后默认 Codergen../octos/crates/octos-pipeline/src/parser.rs:527-533../octos/crates/octos-pipeline/src/graph.rs:204-230
  • tools="a,b,c" 会被拆成字符串列表;如果用户写了 tools="",解析结果会是一个只含空字符串的列表,后面执行器会把它当成“显式禁用所有工具”处理(../octos/crates/octos-pipeline/src/parser.rs:535-538../octos/crates/octos-pipeline/src/handler.rs
  • timeout_secs 不只接受整数秒,也接受 900s15m2h 这类后缀写法(../octos/crates/octos-pipeline/src/parser.rs
  • goal_gate 允许用 true/false/yes/no/1/0 表达(../octos/crates/octos-pipeline/src/parser.rs:520-524
  • deadline_secs 支持 ms/s/m/h 和小数秒;deadline_action 支持 abortskipescalateretry:N / retry(N)../octos/crates/octos-pipeline/src/parser.rs:540-545../octos/crates/octos-pipeline/src/parser.rs:579-627
  • checkpoint="true"checkpoint="name1,name2" 会解析成 MissionCheckpoint 声明(../octos/crates/octos-pipeline/src/parser.rs:629-667

这意味着 DOT 在 octos 里不是“纯拓扑描述”,而是一个轻量的工作流 DSL。


12.2 六种节点语义,而不是五种

HandlerKind 的真实枚举在 ../octos/crates/octos-pipeline/src/graph.rs:184-201。当前源码是 6 种,不是 5 种:

类型运行时落点关键属性作用
Codergen../octos/crates/octos-pipeline/src/handler.rs:186-758prompt model tools context_window max_output_tokens派生完整子 Agent
Shell../octos/crates/octos-pipeline/src/handler.rs:760-834prompt timeout_secs执行 shell 命令
Gate../octos/crates/octos-pipeline/src/handler.rs:836-949prompt计算条件,不做人机等待
Noop../octos/crates/octos-pipeline/src/handler.rs:951-965透传输入
Parallel../octos/crates/octos-pipeline/src/executor.rs:1439-1684converge对已有下游节点做静态 fan-out
DynamicParallel../octos/crates/octos-pipeline/src/executor.rs:1686-2033prompt worker_prompt planner_model max_tasks converge先规划任务,再动态 fan-out

还有一个容易忽略但很重要的事实:并不是 6 种都对应一个 Handler 实现。真正实现了 Handler trait 的只有 CodergenShellGateNoop../octos/crates/octos-pipeline/src/handler.rs)。ParallelDynamicParallel 不是独立 handler 类型,而是 PipelineExecutor::execute_graph() 里的专门分支(../octos/crates/octos-pipeline/src/executor.rs:1439-2033)。

12.2.1 Codergen:节点就是一个子 Agent

CodergenHandler 会为节点创建一个完整的 octos_agent::Agent,而不是做一次简化版 LLM 调用(../octos/crates/octos-pipeline/src/handler.rs:186-758)。这意味着节点天然继承了主 Agent 的很多能力:工具调用、循环式执行、token 统计、文件修改回传、进度事件上报。

它的关键行为有几层:

  1. Provider 解析。 如果节点声明了 model,并且执行器配置了 ProviderRouter,handler 会走 router.resolve(),然后再包一层 capability-compatible fallback provider(../octos/crates/octos-pipeline/src/handler.rs)。这不是单纯的“model name -> provider”映射,而是带回退链的解析。
  2. 上下文窗口覆盖。 context_window 会包装成 ContextWindowOverride../octos/crates/octos-pipeline/src/handler.rs)。
  3. 工具注册。 节点初始工具集来自 ToolRegistry::with_builtins(),然后应用一次性缓存的 plugin registration,避免每个节点重复做插件 SHA 校验和可执行文件读取(../octos/crates/octos-pipeline/src/handler.rs:21-124)。
  4. 工具策略。 节点自己的 tools= 决定 allowlist;但即便允许了很多工具,handler 仍会额外 deny spawnrun_pipelinesend_filemessage,避免子节点递归失控;随后还会 clear_spawn_only(),让原本 spawn_only 的插件工具在 pipeline worker 内同步执行(../octos/crates/octos-pipeline/src/handler.rs:520-545)。
  5. 父会话资源继承。 如果 run_pipeline 从 session actor 内触发,节点 worker 会继承父会话的 FileStateCacheSubAgentOutputRouterAgentSummaryGeneratorCostAccountant 和 parent session key(../octos/crates/octos-pipeline/src/host_context.rs:29-84../octos/crates/octos-pipeline/src/handler.rs:662-683)。
  6. 系统提示词与任务输入分离。 执行器会先把 {input}prompt 中移除,只保留角色/约束类说明;真正的前驱输出通过 TaskKind::Code.instruction 传给子 Agent(../octos/crates/octos-pipeline/src/executor.rs:2061-2075../octos/crates/octos-pipeline/src/handler.rs:708-717)。

一个和旧稿差异很大的点是:当前节点并没有 max_iterations 这种 DOT 属性。CodergenHandler 内部把 AgentConfig.max_iterations 固定成 30(../octos/crates/octos-pipeline/src/handler.rs:620-633)。真正可调的是:

  • timeout_secs
  • max_output_tokens
  • context_window
  • model
  • tools
  • max_retries

另外,max_output_tokens 的默认行为也不是“全局 4096”。如果节点没写这个属性,handler 会退回到 provider 自身的最大输出能力(../octos/crates/octos-pipeline/src/handler.rs:613-624)。这对长报告生成很关键。

12.2.2 Shell:最简单,但语义很清楚

ShellHandler 是最容易完整理解的一种实现(../octos/crates/octos-pipeline/src/handler.rs:760-834):

  • 命令来源是 node.prompt,没有就退回 ctx.input
  • 非 Windows 下执行 sh -c,Windows 下执行 cmd /C
  • 默认超时 300 秒,可由 timeout_secs 覆盖
  • 非零退出码映射成 OutcomeStatus::Fail
  • 进程启动失败或超时映射成 OutcomeStatus::Error

这个区分很重要,因为执行器只会对 Error 做重试(../octos/crates/octos-pipeline/src/executor.rs:2473-2495)。也就是说:

  • “测试跑了但失败”是业务失败,不重试
  • “命令根本没起来”或“超时”才是系统错误,可重试

12.2.3 Gate:当前是条件节点,不是人工审批节点

这是 Ch12 里最容易写错的一块。

当前执行器注册的是 GateHandler../octos/crates/octos-pipeline/src/executor.rs:1299-1319),而 GateHandler 的真实语义是:

  • node.prompt 当成条件表达式
  • 对直接前驱的 NodeOutcome 求值;单前驱保持原始状态,多前驱按 Error > Fail > Skipped > Pass 聚合
  • 返回 PassFail
  • content 直接透传,不发起人机交互(../octos/crates/octos-pipeline/src/handler.rs:836-949

如果 prompt 为空,它默认把条件视为 "true",于是变成一个 pass-through gate(../octos/crates/octos-pipeline/src/handler.rs:907-949)。

human_gate.rs 的确存在,而且提供了 HumanInputProviderChannelInputProviderHumanRequestHumanResponse,默认超时 5 分钟(../octos/crates/octos-pipeline/src/human_gate.rs:14-140)。但我对照当前源码后可以明确说:这些抽象没有接进 PipelineExecutor::build_handlers()execute_graph() 主路径(../octos/crates/octos-pipeline/src/executor.rs:1299-1319)。所以“Gate = 人工审批节点”已经不是当前实现的准确说法。

更准确的表述应该是:

  • GateHandler 是已接线的条件节点
  • human_gate.rs 是 crate 已提供、但尚未接入默认执行器的人机输入抽象

12.2.4 Parallel:静态 fan-out,执行真实下游节点

Parallel 是当前章节里最值得补深度的一种类型,因为它不是“动态生成 worker”,而是把图里已经存在的下游节点并发跑掉(../octos/crates/octos-pipeline/src/executor.rs:1439-1684)。

它的执行过程是:

  1. 收集当前节点所有 outgoing edges 的 target,作为并发目标(../octos/crates/octos-pipeline/src/executor.rs:1445-1450
  2. 要求当前节点必须声明 converge,否则验证阶段直接报错(../octos/crates/octos-pipeline/src/validate.rs
  3. 在派发前检查 pipeline 生命周期级 fan-out 总量上限,默认 MAX_PIPELINE_FANOUT_TOTAL = 500;超过上限直接拒绝整批派发,避免半批 worker 已经启动(../octos/crates/octos-pipeline/src/executor.rs:55-68../octos/crates/octos-pipeline/src/executor.rs:1488-1509
  4. 为每个目标节点克隆 PipelineNode,做变量替换,并在未显式声明模型时填入 graph.default_model../octos/crates/octos-pipeline/src/executor.rs:1536-1549
  5. 为每个 LLM-call 分支预留成本预算,然后查它自己的 handler 并并发执行(../octos/crates/octos-pipeline/src/executor.rs:1551-1560../octos/crates/octos-pipeline/src/executor.rs:1588-1605
  6. process_worker_results() 合并内容、token、summary 和 node outcome(../octos/crates/octos-pipeline/src/executor.rs:556-642../octos/crates/octos-pipeline/src/executor.rs:1621-1634
  7. 把汇总后的文本写回“当前 parallel 节点”的 completed 结果,再跳到 converge 节点(../octos/crates/octos-pipeline/src/executor.rs:1647-1683

这里有两个实现细节值得记住:

  • Parallel 的并发度受 ExecutorConfig.max_parallel_workers 限制,靠 tokio::sync::Semaphore 实现;run_pipeline 工具默认配置为 8(../octos/crates/octos-pipeline/src/executor.rs:1513-1518../octos/crates/octos-pipeline/src/tool.rs:325-347
  • 执行器会用 parallel_executed 记住那些已经在 fan-out 阶段跑过的真实图节点,后续顺序遍历遇到它们时只选边,不重复执行(../octos/crates/octos-pipeline/src/executor.rs:1341-1342../octos/crates/octos-pipeline/src/executor.rs:1374-1393../octos/crates/octos-pipeline/src/executor.rs:1631-1634

此外,结果合并并不只是简单字符串拼接。process_worker_results() 之后还会调用 resolve_search_result_files(),自动扫描 worker 输出里提到的研究目录,把 _search_results.md 的内容内联进 merge 结果;如果输出里没有路径,还会扫描工作目录下最近的 research/ 子目录(../octos/crates/octos-pipeline/src/executor.rs:636-758)。这说明当前 Pipeline 已经针对“研究型 fan-out -> 汇总型 converge”做了专门优化。

12.2.5 DynamicParallel:先规划,再合成 worker 节点

DynamicParallelParallel 的根本区别是:它不直接跑现成的图节点,而是先让 LLM 规划出任务列表,再为每个任务合成一个临时 PipelineNode../octos/crates/octos-pipeline/src/executor.rs:1786-1844)。

主流程在 ../octos/crates/octos-pipeline/src/executor.rs:1686-2033

  1. 解析 planner_model -> node.model -> graph.default_model 的 planner provider 选择链(../octos/crates/octos-pipeline/src/executor.rs:1721-1729
  2. node.prompt 作为规划提示词;若为空则退回内置 planner prompt(../octos/crates/octos-pipeline/src/executor.rs:1731-1736
  3. 期望模型返回纯 JSON 数组;若少于 2 个任务或解析失败,则退回 fallback_tasks()../octos/crates/octos-pipeline/src/executor.rs:404-553../octos/crates/octos-pipeline/src/executor.rs:1750-1778
  4. worker_prompt 里的 {task} 替换为具体任务说明,生成一批 synthetic Codergen 节点(../octos/crates/octos-pipeline/src/executor.rs:1786-1844
  5. 派发前同样检查 pipeline 生命周期级 fan-out 总量上限,并为每个 synthetic worker 预留成本预算(../octos/crates/octos-pipeline/src/executor.rs:1873-1895../octos/crates/octos-pipeline/src/executor.rs:1906-1924
  6. 并发执行这些 synthetic 节点,合并结果后跳到 converge 节点(../octos/crates/octos-pipeline/src/executor.rs:1948-2033

它还有两个旧稿完全没写到的行为:

  • node.model 可以写成逗号分隔的 model pool,例如 "cheap,strong,cheap";执行器会把 worker 轮询分配到不同模型上(../octos/crates/octos-pipeline/src/executor.rs:1791-1816
  • 当前 DynamicParallel 没有Parallel 那样再套一层 semaphore;单次 fan-out 主要依赖 max_tasks,默认值是 8;跨整个 pipeline 运行还受 MAX_PIPELINE_FANOUT_TOTAL = 500 保护(../octos/crates/octos-pipeline/src/executor.rs:1719../octos/crates/octos-pipeline/src/executor.rs:1873-1895

所以如果你问“当前实现里哪种并发更受控”,答案其实是:静态 Parallel 的并发度控制更硬,DynamicParallel 更依赖 planner 输出和 max_tasks 自我约束。

12.2.6 Noop:占位,但也很实用

NoopHandler 就是把 ctx.input 原样返回(../octos/crates/octos-pipeline/src/handler.rs:951-965)。它有两个常见用途:

  • 作为 start / finish 这类结构节点
  • 作为某些条件分支的汇合点或透传点

12.3 执行引擎不是“拓扑排序器”,而是带路由的图遍历器

flowchart TD
    DOT["DOT / pipeline name / file path"] --> Parse["parse_dot()"]
    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 节点并 join_all"]
    Workers --> Merge
    Normal --> Select["select_next_edge()"]
    Merge --> Select
    Select -->|有后继| Loop
    Select -->|无后继 / goal_gate 成功 / Error| Done["PipelineResult"]

图 12-1:当前 PipelineExecutor 的真实主路径。 它不是先做一次全图拓扑排序,再机械执行所有节点;而是从 start node 出发,在循环里按节点类型分流,并在每一步重新决定下一条边。

12.3.1 run() 的实际阶段

PipelineExecutor::run()../octos/crates/octos-pipeline/src/executor.rs:801-979,可以概括成七步:

  1. parse_dot() 解析 DOT
  2. validate() 跑 lint 规则
  3. build_handlers() 构建常规 handler registry
  4. find_start_node() 决定入口节点
  5. 如有 CostAccountant,先打开 pipeline 级预算 reservation
  6. execute_graph() 进入主循环
  7. 若 pipeline 成功,跑 terminal validators;最终成功才提交 pipeline 级成本归因,否则 reservation 自动退款

这里最重要的纠偏是:第四步之后不是“拓扑遍历整个 DAG”,而是 current_node_id 驱动的增量遍历(../octos/crates/octos-pipeline/src/executor.rs:1322-2470)。这也是为什么 suggested_next、条件边、label 匹配这些运行时路由策略都能生效。

12.3.2 验证规则和 start node 选择

验证器在 ../octos/crates/octos-pipeline/src/validate.rs,当前不只是“检查一下条件能不能 parse”。

比较重要的几条:

  • Rule 1:必须能找到 start node(start 节点,或唯一一个无入边节点)(../octos/crates/octos-pipeline/src/validate.rs
  • Rule 2:不可达节点只是 warning,不是 error(../octos/crates/octos-pipeline/src/validate.rs
  • Rule 6:边条件必须能被 condition parser 解析(../octos/crates/octos-pipeline/src/validate.rs
  • Rule 13 / 14:paralleldynamic_parallel 都必须声明有效的 converge../octos/crates/octos-pipeline/src/validate.rs
  • 图中不能有环;环检测发生在 validate 阶段,不等执行时才爆炸(../octos/crates/octos-pipeline/src/graph.rs:26-88

这意味着 octos-pipeline 当前仍然要求 DAG,但执行方式不是“静态 DAG 调度器”,而是“受 DAG 约束的动态图遍历器”。

12.3.3 条件语言和边选择顺序

条件表达式的 grammar 写在 ../octos/crates/octos-pipeline/src/condition.rs。当前运行时真正支持的核心写法是:

  • outcome.status == "pass"
  • outcome.status != "fail"
  • outcome.contains("keyword")
  • !exprexpr && exprexpr || expr

例如:

test -> deploy   [condition="outcome.status == \"pass\""]
test -> rollback [condition="outcome.status == \"fail\""]
report -> refine [condition="outcome.contains(\"missing data\")"]

旧稿里那种 success / failure 简写已经不符合当前 parser。

更值得写清楚的是,执行器选边不是“第一个命中就走”,而是一个 5 步算法(../octos/crates/octos-pipeline/src/executor.rs:2597-2657):

  1. 先评估所有带条件的边
  2. 如果有多个条件命中,按 weight 选最高权重
  3. 若无条件命中,检查节点的 suggested_next
  4. 再看 edge label 是否出现在 outcome content 里
  5. 最后才在无条件边里按权重选;如果还没有,就退回目标名最小的边

还有一个微妙但重要的实现现状:condition.rs 的 grammar 虽然支持 context.key == "value",但 select_next_edge()GateHandler 走的是 evaluate(),不是 evaluate_with_context()../octos/crates/octos-pipeline/src/condition.rs../octos/crates/octos-pipeline/src/handler.rs:907-914../octos/crates/octos-pipeline/src/executor.rs:2611-2619)。也就是说,context.* 目前是“语法已定义、主路径未喂值”的状态;真正稳定可用的还是 outcome.* 相关条件。

12.3.4 进度、统计和终止条件

PipelineStatusBridge 定义在 ../octos/crates/octos-pipeline/src/executor.rs:267-307,桥接了两类外部可见状态:

  • status_words:当前节点或 fan-out worker 的状态文案
  • token_tracker:所有子 Agent 的 token 聚合

CodergenHandler 内部子 Agent 产出 ProgressEvent 时,PipelineNodeReporter 会把事件重新转成 run_pipeline 的进度消息,并且现在也会转发 per-node cost update(../octos/crates/octos-pipeline/src/handler.rs:126-184)。所以前端看到的 pipeline 进度,不只是“现在在第几个节点”,而是能继续细到“某个 worker 正在调用哪个工具、花了多少 token / 成本”。

执行终止有三种常见路径:

  • 当前节点没有 outgoing edges(../octos/crates/octos-pipeline/src/executor.rs:2446-2467
  • 某个 goal_gate=true 节点成功,提前结束 pipeline(../octos/crates/octos-pipeline/src/executor.rs:2391-2417
  • 某节点返回 OutcomeStatus::Error,整个 pipeline 直接停止(../octos/crates/octos-pipeline/src/executor.rs:2420-2433
  • 某节点触发 deadline_secs,按 deadline_action 选择 abort / skip / retry / escalate(../octos/crates/octos-pipeline/src/executor.rs:2498-2595

最终返回的是 PipelineResult../octos/crates/octos-pipeline/src/executor.rs:248-265),其中除了 output / success / token_usage,还有:

  • node_summaries:每个节点的 model、耗时、token 和 success
  • files_modified:所有节点写出的文件,去重后汇总
  • node_costs:每个 LLM-call 节点的预算预留、实际估算成本、token 和是否绑定到账本

12.3.5 当前生效的模型选择路径

当前主路径真正生效的模型选择机制有两层:

  • 图级默认:graph [default_model="cheap"]
  • 节点覆盖:node [model="strong"]

这两层分别在 parser 中落到 PipelineGraph.default_modelPipelineNode.model../octos/crates/octos-pipeline/src/parser.rs../octos/crates/octos-pipeline/src/parser.rs:553-556),然后在执行时由 execute_graph()CodergenHandler 联合应用(../octos/crates/octos-pipeline/src/executor.rs:2077-2080../octos/crates/octos-pipeline/src/handler.rs)。

ModelStylesheet 模块本身是存在的,支持 * / handler:codergen / node:critical_analysis 这类 selector(../octos/crates/octos-pipeline/src/stylesheet.rs:28-104)。但我对照当前源码后,没有找到它被 PipelineExecutorRunPipelineToolPipelineDiscovery 调用的路径。换句话说:

  • ModelStylesheet 是 crate 已导出的能力
  • 当前默认执行路径用的仍然是 default_model + node.model

如果书里把 ModelStylesheet 写成主路径,会高估它在当前版本里的实际地位。

12.3.6 父会话继承、成本账本与 workspace policy

当前 run_pipeline 已经不是“在 pipeline 内部重新开一套孤立资源”。工具入口会从 TOOL_CTX 快照 PipelineHostContext,把父 session 的共享资源传给执行器(../octos/crates/octos-pipeline/src/tool.rs:314-347../octos/crates/octos-pipeline/src/host_context.rs:29-84):

  • FileStateCache:节点 worker 复用父会话的文件状态缓存,避免每个节点重新建立一套视图
  • SubAgentOutputRouter / AgentSummaryGenerator:节点里再触发后台子 Agent 时,输出和摘要仍走父会话的路由
  • TaskSupervisor:顺序节点会注册成 pipeline:<node_id> 子任务,并在完成、失败或跳过时更新状态(../octos/crates/octos-pipeline/src/executor.rs:1195-1221../octos/crates/octos-pipeline/src/executor.rs:2120-2133../octos/crates/octos-pipeline/src/executor.rs:2318-2343
  • CostAccountant:pipeline 级 reservation 在运行开始打开,成功后以累计 token 提交;节点级 reservation 只用于派发前预算闸门,节点完成后形成 node_costs 给 UI / SSE 使用(../octos/crates/octos-pipeline/src/executor.rs:883-891../octos/crates/octos-pipeline/src/executor.rs:981-1046../octos/crates/octos-pipeline/src/executor.rs:1223-1258../octos/crates/octos-pipeline/src/executor.rs:2290-2316

同时,RunPipelineTool 会从工作目录读取 workspace policy,自动构造 PipelineContext。这让 pipeline 节点继承 compaction policy,terminal 阶段跑 workspace validators;如果配置了 validators_by_node,单个节点完成后也会跑 per-node validators(../octos/crates/octos-pipeline/src/tool.rs:77-125../octos/crates/octos-pipeline/src/executor.rs:1075-1193../octos/crates/octos-pipeline/src/executor.rs:2270-2288)。

这部分和第 8 章的上下文压缩、第 11 章的 TaskSupervisor 是同一条工程线:pipeline 不再只是“多节点 DAG”,而是被纳入父 session 的观测、预算和 workspace contract 边界。

12.3.7 human_gatecheckpointrun_dir 的真实位置

这一章还有三个容易被写成“默认能力”的模块,但当前更准确的定位需要拆成两层:crate 能力、执行器可选接线、默认工具路径。

  • human_gate.rs:提供 channel-based human input 抽象,默认超时 5 分钟,但未接入 PipelineExecutor../octos/crates/octos-pipeline/src/human_gate.rs:14-140
  • checkpoint.rs:提供 CheckpointStore / FileSystemCheckpointStore,执行器现在有 ExecutorConfig.checkpoint_store 可选字段;运行开始会从 store 构造 resume skip set,节点成功后会持久化 DOT 中声明的 checkpoint../octos/crates/octos-pipeline/src/checkpoint.rs:127-224../octos/crates/octos-pipeline/src/executor.rs:195-216../octos/crates/octos-pipeline/src/executor.rs:330-336../octos/crates/octos-pipeline/src/executor.rs:2363-2389
  • run_dir.rs:提供 RunDirNodeStatusPipelineRunSummary,约定运行目录是 {working_dir}/.octos/runs/{run_id}/...,但默认 RunPipelineTool 仍没有把它接成自动 run directory(../octos/crates/octos-pipeline/src/run_dir.rs:17-114../octos/crates/octos-pipeline/src/tool.rs:325-347

所以现在不能再说 checkpoint 完全是“未接线模块”:它已经是执行器的可选能力,只是 RunPipelineTool 默认把 checkpoint_store 设为 None。准确结论是:默认 run_pipeline 不自动做人机审批、不自动写 run_dir,也不默认启用 checkpoint;但自定义 executor 配置可以启用 checkpoint resume / persist。

12.3.8 run_pipeline 工具如何把 pipeline 暴露给 Agent

对最终用户来说,最常见的入口不是直接 new PipelineExecutor,而是 RunPipelineTool../octos/crates/octos-pipeline/src/tool.rs:19-462)。

它有几层很实际的工程化包装:

  • 先尝试把输入当成 inline DOT;若 parse 失败,再尝试把图名解析成预置 pipeline(../octos/crates/octos-pipeline/src/tool.rs:152-211
  • 会自动修正常见的 LLM DOT 错误,比如 digraph{、缺图名、代码围栏包裹(../octos/crates/octos-pipeline/src/tool.rs:481-514
  • 可按名称、路径或内联 DOT 解析 pipeline;搜索路径包括项目级 .octos/pipelines、用户级 data_dir/pipelinesdata_dir/skills,额外还能挂 octos_home/skills../octos/crates/octos-pipeline/src/discovery.rs:14-114../octos/crates/octos-pipeline/src/tool.rs:127-132
  • 对整个 pipeline 施加 60-1800 秒的总超时钳制;结束后会置位共享 shutdown flag,通知所有 worker 停止(../octos/crates/octos-pipeline/src/tool.rs:349-368
  • 如果 pipeline 没产出 markdown 文件但有文本输出,工具会合成一个临时 .md 报告,保证 spawn_only 交付路径有可发送文件(../octos/crates/octos-pipeline/src/tool.rs:386-443
  • node_costs 会投射到 ToolResult.structured_metadata,供 session actor 把 per-node cost 带回 UI/API completion metadata(../octos/crates/octos-pipeline/src/tool.rs:444-479

这里还有一个值得写进书里的“实现与提示词分离”现象:run_pipelineinput_schema() 会明确告诉模型“不要显式写 model=,系统会自动选择模型”(../octos/crates/octos-pipeline/src/tool.rs:249-285),但运行时引擎本身依然支持 default_model / node.model。也就是说:

  • 这是对 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 不是大多数工程团队的日常配置语言,学习成本略高

12.4 本章回顾

  1. octos-pipeline 当前不是“5 种 handler”,而是 6 种 HandlerKind;其中 ParallelDynamicParallel 其实是执行器分支,不是独立 Handler 实现。
  2. Gate 在当前版本里是条件节点,不是默认接线的人机审批节点;human_gate.rs 只是已存在但尚未接入执行主路径的抽象。
  3. PipelineHostContext 让 pipeline 节点继承父 session 的缓存、输出路由、任务监督和成本账本;这让 pipeline 成为 session runtime 的一部分,而不是旁路执行器。
  4. 真正生效的模型选择路径是 graph.default_model + node.modelModelStylesheet 仍不是默认主路径。CheckpointStore 已经是执行器可选接线,但默认 RunPipelineTool 仍未启用;RunDir 仍是相邻模块。

延伸阅读

  • Graphviz DOT Language:https://graphviz.org/doc/info/lang.html
  • DAG 调度:可以对照 Airflow / Prefect 看“静态 DAG 调度器”和 octos 这种“带运行时路由的图遍历器”之间的差异

思考题

  1. condition.rs 已经支持 context.* grammar,但执行器当前没有把上下文 map 接进去。你会把这层语义接到 select_next_edge(),还是保留 outcome-only 的简单模型?
  2. DynamicParallel 当前没有额外 semaphore,单次 fan-out 主要靠 max_tasks,全局靠 MAX_PIPELINE_FANOUT_TOTAL。对于高成本 provider,还是否应该给 dynamic workers 加一个和 Parallel 相同的 semaphore?

版本演化说明 本章分析基于当前 ../octos main 分支中 crates/octos-pipeline/src/ 的实现。书中凡是涉及 GateModelStylesheetCheckpointStoreRunDirPipelineHostContext、成本账本和 workspace policy 的地方,都应区分“模块存在”“执行器可选接线”和“默认 RunPipelineTool 路径”,而不是仅凭模块是否存在来下结论。

第 13 章:四种运行模式与配置体系

定位:本章展示 octos 的四种运行模式(CLI/Gateway/Serve/MCP Serve)以及配置体系的层次结构和热加载机制。前置依赖:第 10 章、第 5 章。适用场景:需要部署和配置 octos 的运维人员和开发者(读者 D),以及想理解运行时架构选择的开发者(读者 B)。

同一套代码,四种运行姿态——这是 octos 作为"Agent 操作系统"的核心设计理念。


13.1 四种运行模式

13.1.1 CLI 模式(octos chat

交互式终端对话(crates/octos-cli/src/commands/chat.rs)。启动 multi-threaded Tokio 运行时(8MB 栈大小,crates/octos-cli/src/commands/chat.rs:69-74),提供 readline 风格的输入界面。

#![allow(unused)]
fn main() {
// chat.rs:69-78
let runtime = tokio::runtime::Builder::new_multi_thread()
    .enable_all()
    .thread_stack_size(8 * 1024 * 1024)  // 8MB 栈——深递归场景需要
    .build()?;
}

8MB 栈大小(而非 Tokio 默认的 2MB)是因为 Agent 的调用链可能很深——特别是嵌套子 Agent 和递归工具调用场景。

退出命令支持多种格式:exitquit/exit/quit:qcrates/octos-cli/src/commands/chat.rs:66-67)。

CLI 参数(crates/octos-cli/src/commands/chat.rs:22-64)支持覆盖配置文件中的关键设置:--cwd--provider--model--max-iterations--verbose。命令行参数优先于配置文件。

13.1.2 Gateway 模式(octos gateway

后台守护进程(crates/octos-cli/src/commands/gateway/)。启动 ChannelManager 监听多个消息频道,将收到的消息路由到 Agent 处理。

GatewayRuntimecrates/octos-cli/src/commands/gateway/gateway_runtime.rs:54-95)持有 Gateway 的核心运行时状态:消息层(agent_handlechannel_mgr)、会话分发(actor_registrysession_dispatcheractive_sessions)、热加载状态(system_promptmax_historyconfig_rx)以及 persona/heartbeat/cron 等后台服务。

Gateway 支持 Profile / 子账号模式。UserProfile.parent_id 用来标记子账号;当前主分支会让子账号继承父 Profile 的结构化 config.llm contract,并在缺省时继承 searchdeep_crawlappsemail,同时把父级 env_vars 作为 base、子账号同名变量覆盖父级(crates/octos-cli/src/profiles.rs:1236-1273; crates/octos-cli/src/commands/gateway/gateway_runtime.rs:221-245)。如果 Gateway 由 ProcessManager 启动,子账号进程还会收到 --parent-profile 参数和父级 env vars 注入(crates/octos-cli/src/process_manager.rs:275-291)。

GatewayDispatchercrates/octos-cli/src/gateway_dispatcher.rs:35-44)从主循环中提取出可测试的命令分发逻辑,支持 /new(新建会话)、/switch(切换 Profile)等内部命令。

13.1.3 Serve 模式(octos serve

Web 服务器(crates/octos-cli/src/commands/serve.rs)。默认端口 50080,默认绑定 127.0.0.1crates/octos-cli/src/commands/serve.rs:214-222)——安全默认值,外部访问需要显式指定 --host 0.0.0.0

提供 Web Dashboard、REST 端点、非 chat 的事件/兼容接口,以及 AppUI 使用的 UI Protocol WebSocket(/api/ui-protocol/ws)。通过 axum 框架构建,AppState 持有全局状态(Provider、工具注册表、会话管理器等)。当前主分支已经删除 chat SSE transport:POST /api/chat?stream=trueGET /api/chat/streamGET /api/sessions/:id/events/stream 不再是 chat 事件通道;标准 chat transport 是 /api/ui-protocol/wscrates/octos-cli/src/api/router.rs:91-137)。

更准确地说,Serve 模式现在是控制面汇聚点,而不是“把 chat 包成 HTTP”。启动时它会组合 Config / ProfileStore、LLM Provider / RetryProvider、ToolRegistry / ToolPolicy、SessionManager、REST/UI Protocol、兼容 WebSocket/事件 harness,以及 swarm dispatch state。swarm state 还会把 config.tool_policy 和注入型环境变量 denylist 投射成 DispatchPolicy::from_agent_gates(tool_policy, true),避免 swarm backend 绕过 native tool policy(crates/octos-cli/src/commands/serve.rs:1128-1156)。

最新主分支还让 Serve 成为 coding/autonomy capability 的声明点。UI Protocol 的 SessionOpened.capabilities 可以包含 coding.tool_contract.v1coding.autonomy.v1coding.agent_control.v1coding.goal_runtime.v1coding.loop_runtime.v1 等 feature;其中 coding.tool_contract.v1 的 payload 来自后端对当前 ToolRegistry、deferred tool set、policy view 和 known model-visible tools 的解析,而不是前端猜测。AppUI 因此可以知道 apply_patchexec_commandspawn_agentwait_agent 这类工具是 available、deferred、disabled_by_policy、missing 还是 unimplemented。

这也改变了 Serve 与 autonomous coding agent 的关系:Serve 不只是给人类 UI 提供 chat transport,它还提供模型工具合约、agent lifecycle 查询、goal/loop runtime primitive 的控制面。但当前实现仍是 backend-supervised orchestration:goal/loop runtime primitives 已经定义,master continuation scheduler 也能排队 child/goal/loop wakeup;完整“无人值守长期自我驱动”的调度闭环仍在增量接线中,不能把它写成已经完成的 self-evolving runtime。

flowchart TD
    Serve[octos serve] --> Config[Config + ProfileStore]
    Serve --> Provider[LlmProvider / RetryProvider]
    Serve --> Tools[ToolRegistry + ToolPolicy]
    Serve --> Sessions[SessionManager]
    Serve --> UI[REST + UI Protocol + Event Harness]
    Serve --> Swarm[SwarmState]
    UI --> Coding[ coding.tool_contract.v1 ]
    UI --> Agents[ agent/* lifecycle + artifacts ]
    UI --> Goals[ session/goal/* + loop/* ]
    Tools --> Policy[DispatchPolicy::from_agent_gates]
    Tools --> Coding
    Policy --> Swarm

13.1.4 MCP Serve 模式(octos mcp-serve

octos mcp-serve 不是给人直接聊天的入口,而是把 octos 暴露成 MCP server,供外层 orchestrator 调用(crates/octos-cli/src/commands/mcp_serve.rs:1-5)。默认 transport 是 stdio,也支持 HTTP transport;HTTP 模式要求通过 OCTOS_MCP_SERVER_TOKEN 配置 bearer token(crates/octos-cli/src/commands/mcp_serve.rs:7-11)。

这个入口的关键差异是:每次 run_octos_session 调用都会加载 profile 配置,构造 LLM,标记任务 Running,创建 Agent,运行 prompt,验证 artifact,最后把任务转成 Ready 或 Failed(crates/octos-cli/src/commands/mcp_serve.rs:13-30)。换句话说,MCP Serve 的职责不是维护一个长期交互 UI,而是把 octos 的 Agent 能力包装成可由外部系统调度的任务执行接口。

这里还要避免一个误解:MCP Serve 不会把 octos 内部工具目录直接暴露给外层系统。mcp_server.rs 明确只暴露一个 session-level tool:run_octos_session;外层 caller 得到的是 aggregate outcome,看不到内部 tool calls、iteration events 或 progress stream(crates/octos-agent/src/mcp_server.rs:1-34)。

flowchart LR
    Orchestrator[MCP client / outer agent] --> Tool[run_octos_session]
    Tool --> Dispatch[RealSessionDispatch]
    Dispatch --> Agent[Agent::run_task]
    Agent --> Contract[Workspace contract + validators]
    Contract --> Outcome[McpSessionOutcome]
    Outcome --> Orchestrator
维度CLIGatewayServeMCP Serve
入口octos chatoctos gatewayoctos serveoctos mcp-serve
用户交互终端 readline消息频道Web UI + REST API + UI Protocol + coding/autonomy capabilitiesMCP client / orchestrator
并发模型单会话多频道多会话多用户多会话外部调度驱动的任务调用
默认端口50080stdio;HTTP 默认 127.0.0.1:4033
栈大小8MB默认默认默认
适用场景开发调试消息 botAPI 集成、Web 部署被上层 agent / IDE / 自动化系统编排

13.1.5 四种模式的架构关系

flowchart LR
    subgraph "共享基础"
        Agent["Agent<br/>LLM + Tools + Memory"]
        Config["Config<br/>Provider + Policy + Hooks"]
    end

    subgraph "CLI 模式"
        CLI["octos chat<br/>readline 循环"]
    end

    subgraph "Gateway 模式"
        GW["octos gateway<br/>ChannelManager"]
        TG["Telegram"]
        DC["Discord"]
        SL["Slack"]
    end

    subgraph "Serve 模式"
        SV["octos serve<br/>axum Web 服务器"]
        REST["REST API"]
        Events["Event Harness / legacy WS"]
        UIP["UI Protocol WS"]
        UI["Web Dashboard"]
    end

    subgraph "MCP Serve 模式"
        MCP["octos mcp-serve<br/>MCP server"]
        ORCH["外层 orchestrator"]
    end

    Config --> Agent
    Agent --> CLI
    Agent --> GW
    Agent --> SV
    Agent --> MCP
    GW --> TG & DC & SL
    SV --> REST & Events & UIP & UI
    MCP --> ORCH

图 13-1:四种运行模式共享 Agent 核心。 Config 和 Agent 是共同基础,四种模式只在接入层和调度方式上不同。

13.1.6 共同的启动模式

四种模式共享相似的启动流程(Command Pattern):

  1. 解析 CLI 参数(clap derive)
  2. 加载配置文件(优先级链)
  3. 初始化 tracing 日志(7 天轮转,JSON 格式可选)
  4. 创建 Provider 和 Agent,或在任务调用时按 profile 构造 Provider 和 Agent
  5. 进入各自的运行循环

13.2 配置体系

13.2.1 优先级层次

<cwd>/.octos/config.json > <data_dir>/config.json(通常 ~/.octos/config.json) > legacy platform config dir(如 ~/.config/octos/config.json) > 内置默认值

本地配置优先于数据目录配置,允许不同项目使用不同的 Provider、模型和工具策略。<data_dir> 由调用方按 --data-dir > OCTOS_HOME > ~/.octos 解析后传入;~/.config/octos/config.json 这类平台配置目录只是兼容旧路径,加载时会提示迁移到 ~/.octos/config.jsoncrates/octos-cli/src/config.rs:845-873)。

13.2.2 Provider 自动检测

当用户只指定模型名而未指定 Provider 时,octos 通过模型名前缀自动匹配(详见第 3 章 Provider 注册表):

  • claude-* → Anthropic
  • gpt-* → OpenAI
  • gemini-* → Google
  • deepseek-* → DeepSeek

13.2.3 热加载

Config Watcher(crates/octos-cli/src/config_watcher.rs:1-5)每 5 秒轮询配置文件(crates/octos-cli/src/config_watcher.rs:51-68),通过 SHA-256 hash 检测变更。

ConfigChange 枚举(crates/octos-cli/src/config_watcher.rs:15-25)区分两类变更:

类型可热加载项实现方式
HotReloadsystem_prompt(crates/octos-cli/src/config_watcher.rs:144-148RwLock<String> 直接替换
HotReloadmax_history(crates/octos-cli/src/config_watcher.rs:151-156AtomicUsize 原子更新
不触发 RestartRequiredprovider, modelWatcher 不再把 provider/model 变更归类为重启项;运行中切换仍走 model_check/SwappableProvider
RestartRequiredbase_url, api_key_env需要重建 HTTP 客户端(crates/octos-cli/src/config_watcher.rs:117-121
RestartRequiredsandbox, mcp_servers, hooks需要重建隔离环境或外部连接(crates/octos-cli/src/config_watcher.rs:123-130
RestartRequiredgateway.queue_mode, gateway.channels影响消息分发主循环(crates/octos-cli/src/config_watcher.rs:133-163

13.2.4 SwappableProvider:运行时模型切换,而不是文件热加载

当前实现需要区分两条路径:

  1. 配置文件热加载:Config Watcher 只会把 system_promptmax_history 作为 HotReload 发给主循环;Gateway 收到后分别写入 RwLock<String>AtomicUsizecrates/octos-cli/src/config_watcher.rs:175-180; crates/octos-cli/src/commands/gateway/gateway_runtime.rs:1335-1355)。
  2. 运行时模型切换:Gateway 启动时把当前 LLM 包装为 SwappableProvidercrates/octos-cli/src/commands/gateway/gateway_runtime.rs:256-257);当用户调用 model_check 工具执行切换时,SwitchModelTool 才会显式调用 swappable.swap(new_chain)crates/octos-cli/src/tools/switch_model.rs:290-295)。

换句话说,编辑磁盘上的 config.json 并不会让正在运行的 Gateway 自动切换 provider/model。当前版本里,Watcher 已经不会把 provider/model 变更当作 RestartRequired 报警,但也不会把它们包含在 HotReload payload 里自动应用。安全的心智模型是:system_prompt/max_history 可以文件热加载;provider/model 可以在会话内显式切换;如果你希望磁盘配置里的 provider/model 成为新的启动态,仍应重启进程,让新配置在启动路径中重新构造 Provider 链。

SwappableProvider 本身的关键实现位于 crates/octos-llm/src/swappable.rs:16-23,50-56

#![allow(unused)]
fn main() {
pub fn swap(&self, new_provider: Arc<dyn LlmProvider>) {
    let model_id = leak_str(new_provider.model_id().to_string());
    let provider_name = leak_str(new_provider.provider_name().to_string());
    *self.inner.write().unwrap() = new_provider;
    *self.cached_model_id.write().unwrap() = model_id;
    *self.cached_provider_name.write().unwrap() = provider_name;
}
}

Box::leak()String 转换为 &'static str——代价是一小段永不释放的内存(每次模型切换泄漏几十个字节),换来的是 model_id()provider_name() 可以在不持有 inner 读锁的情况下返回字符串引用。对于一个长期运行的服务,这点内存泄漏完全可接受。

Config Watcher 的安全性:Watcher 在一次轮询中读取所有配置文件并计算 hash,避免了先检查-再读取的 TOCTOU 竞态。如果配置文件解析失败,保留上一次的有效配置并打印警告,不会崩溃。

为什么用轮询而非 inotify? 跨平台兼容性。inotify 是 Linux 特有的,macOS 用 kqueue,Windows 用 ReadDirectoryChangesW。5 秒轮询 + SHA-256 hash 在所有平台上一致工作,且开销极小(一次 SHA-256 计算 < 1 微秒)。


13.3 Feature Flags

octos 通过 Cargo feature flags 控制条件编译:

Feature启用内容
apiWeb API 服务器、监控、OTP、用户管理
admin-bot管理 Bot 能力,在 api 之上附加 Telegram 管理接口
telegramTelegram 频道集成
discordDiscord 频道集成
slackSlack 频道集成
whatsappWhatsApp bridge 频道
email邮件收发集成
feishu飞书/Lark 频道
twilioTwilio webhook/API 频道
wecom企业微信 webhook/API 频道
matrixMatrix AppService 频道
wecom-bot企业微信 Bot WebSocket 频道
qq-botQQ Bot WebSocket 频道
wechatWeChat WebSocket bridge 频道
gitGit 操作工具
astAST 代码结构分析

这让用户可以编译最小化的 octos 版本——只需 CLI 功能时,不引入 Web 服务器和频道集成的依赖。需要注意的是,BrowserTool 是默认内置工具,不对应单独的 Cargo feature;按需编译主要控制的是 API 能力、各频道集成,以及 octos-agent/gitoctos-agent/ast 这类较重工具依赖。完整 feature 依赖关系见附录 D。


工程决策侧栏:热加载 vs 全重启的边界划分

热加载的核心问题是"什么可以安全替换,什么不可以"。

系统提示可以热加载,因为它是无状态的文本——下一次 LLM 调用使用新提示即可,不影响进行中的会话。

Provider/模型需要区分两种情形:对话内显式切换可以通过 SwappableProvider 完成,但这条路径由 model_check 工具触发;直接编辑配置文件里的 provider/model,当前 ConfigWatcher 不会自动应用到运行中的 Gateway,也不会再把这类变更报成必须重启。与之相对,base_url 和 api_key_env 明确属于重启项,因为它们影响底层 HTTP 客户端的构造(连接池、TLS 配置),运行时替换可能导致进行中的请求失败。

Hooks不能热加载,因为 Hook 的 circuit breaker 状态(连续失败计数)需要重新初始化。如果热加载只替换命令但不重置计数器,一个之前被熔断的 Hook 永远不会恢复。

简单规则:文件热加载只覆盖已经接入 ConfigWatcher 的字段(目前是文本和历史窗口),需要重建连接的仍然重启;SwappableProvider 解决的是受控的运行时切换,不是通用配置热更新。


13.4 本章回顾

  1. 四种模式:CLI(终端交互)、Gateway(消息 bot)、Serve(Web/API/AppUI)、MCP Serve(外部 orchestrator 调用),同一代码库四种入口。
  2. 配置层次:本地 > 全局 > 默认,Provider 自动检测简化配置。
  3. 热加载:SHA-256 轮询检测。文件热加载当前只覆盖 system_promptmax_history;provider/model 的运行时切换走 SwappableProvider + model_check 工具;base_urlhooksMCP 等仍需重启。
  4. Serve 控制面:Serve 汇聚 REST、UI Protocol、event harness、profile、tool policy、swarm state 和 coding/autonomy capability,不只是 chat 的 HTTP 外壳;chat 事件通道以 /api/ui-protocol/ws 为准。
  5. MCP Serve 边界:MCP Serve 只暴露 session-level run_octos_session,外层 orchestrator 收到 aggregate outcome,而不是内部工具事件流。
  6. Feature Flags:按需编译,最小化部署体积。

延伸阅读

  • 12-Factor App:https://12factor.net/ — 特别是 Config 和 Processes 章节
  • axum 框架:https://docs.rs/axum/latest/axum/ — octos Serve 模式的 Web 框架

思考题

  1. 模式融合:如果需要在同一进程中同时运行 Gateway(消息 bot)和 Serve(Web API),架构需要做什么改变?
  2. 配置验证:当前配置文件在运行时解析和验证。如果提供一个 octos config validate 命令做离线验证,你会检查哪些内容?

版本演化说明 本章分析基于当前 ../octos main 分支。当前运行模式已经是 CLI / Gateway / Serve / MCP Serve 四入口;Serve 默认端口为 50080,配置优先级以 <cwd>/.octos/config.json<data_dir>/config.json 为主,legacy platform config dir 仅作兼容。

第 14 章:生产化:认证、监控与部署

定位:本章展示 octos 从开发工具到生产系统需要的最后一块拼图——认证、Hooks 生命周期、监控和多租户配置。前置依赖:第 13 章。适用场景:需要将 octos 部署到生产环境的运维人员(读者 D),以及想理解生产级系统设计模式的开发者(读者 B)。

一个系统从"能跑"到"能上线"之间的距离,往往比代码量暗示的更大。认证、监控、Hook 系统、多租户隔离——这些不是功能,而是信任的基础设施。


14.1 认证三流

14.1.1 OAuth PKCE

octos 当前只为 OpenAI 实现了 PKCE(Proof Key for Code Exchange)流程;其他 Provider 仍走 paste-token 路径(../octos/crates/octos-cli/src/auth/oauth.rs, ../octos/crates/octos-cli/src/commands/auth.rs)。

PKCE 的核心思想:传统的 OAuth 授权码流程中,恶意应用可以拦截 authorization code 并冒充合法应用。PKCE 通过在授权请求中嵌入一个"证明密钥"来防止这种攻击——只有知道原始 verifier 的应用才能用 code 换取 token。

octos 的 PKCE 实现../octos/crates/octos-cli/src/auth/oauth.rs:25-44):

#![allow(unused)]
fn main() {
fn generate_pkce() -> PkceChallenge {
    // 1. Verifier = 2 个 UUID v4 拼接 = 64 个十六进制字符
    let verifier = format!("{}{}", Uuid::new_v4().as_simple(), Uuid::new_v4().as_simple());

    // 2. Challenge = SHA-256(verifier) 的 Base64-URL 编码(无 padding)
    let mut hasher = Sha256::new();
    hasher.update(verifier.as_bytes());
    let challenge = base64_url_encode(&hasher.finalize());

    PkceChallenge { verifier, challenge }
}
}

为什么用 2 个 UUID 拼接? RFC 7636 要求 verifier 长度在 43-128 字符之间。单个 UUID v4 的 simple 格式是 32 个十六进制字符(不够),两个拼接得到 64 个(满足要求)。

授权流程的五个步骤:

  1. 生成 PKCE verifier + challenge 对
  2. 生成随机 state 参数(UUID v4,防 CSRF)
  3. 打开浏览器跳转到 Provider 的授权页面(携带 challenge)
  4. 本地启动 HTTP 服务器(localhost:1455/auth/callback../octos/crates/octos-cli/src/auth/oauth.rs:18-21)接收回调
  5. 用 authorization code + verifier 换取 access token

14.1.2 Device Code Flow

对于无浏览器环境(如远程服务器),OpenAI 还支持 device code flow——显示一个 URL 和代码,用户在另一台设备上完成认证。

14.1.3 Paste-token

最简单的认证方式——用户直接粘贴 API key。适用于不支持 OAuth 的 Provider。

14.1.4 凭据存储

凭据存储在 ~/.octos/auth.json,Unix 下以 0600 权限写入(仅所有者可读写)。Serve 主路由里的 admin/test token 校验使用常量时间比较,避免明显的时序侧信道(../octos/crates/octos-cli/src/auth/store.rs:1-20, ../octos/crates/octos-cli/src/auth/store.rs:79-116, ../octos/crates/octos-cli/src/api/router.rs:639-669)。

14.1.5 API 安全

Serve 模式的 HTTP 服务器默认绑定 127.0.0.1(仅本地访问)。需要外部访问时通过 --host 0.0.0.0 显式开启——安全默认值原则。


14.2 Hooks 生命周期

sequenceDiagram
    participant Agent
    participant HookExecutor
    participant Hook as Hook 进程

    Agent->>HookExecutor: before_tool_call(shell, args)
    HookExecutor->>HookExecutor: 检查 circuit breaker
    alt 未熔断
        HookExecutor->>HookExecutor: sanitize_payload()
        HookExecutor->>Hook: spawn(argv) + stdin JSON
        Hook-->>HookExecutor: exit 0 (allow)
        HookExecutor-->>Agent: 继续执行工具
    else exit 1 (deny)
        Hook-->>HookExecutor: exit 1
        HookExecutor-->>Agent: 阻止工具调用
    else 连续 3 次失败
        HookExecutor->>HookExecutor: 禁用 Hook (circuit break)
        HookExecutor-->>Agent: 跳过,继续执行
    end

图 14-1:Hook 执行时序。 before_tool_call 是最常用的 Hook 事件。Circuit breaker 在 3 次连续失败后自动禁用 Hook。

Hooks 让用户在 Agent 执行的关键节点注入自定义逻辑(../octos/crates/octos-agent/src/hooks.rs)。

14.2.1 核心事件与扩展事件

最常用的是 4 个 Agent 热路径事件:

事件时机典型用途
before_tool_call工具调用前审批、参数修改、日志
after_tool_call工具调用后结果过滤、审计
before_llm_callLLM 调用前提示修改、请求拦截
after_llm_callLLM 调用后响应过滤、监控

但当前 HookEvent 不止 4 种。源码还包含 on_resumeon_turn_endbefore_spawn_verifyon_spawn_verifyon_spawn_completeon_spawn_failure,用于会话恢复、回合结束和后台/子任务生命周期(../octos/crates/octos-agent/src/hooks.rs:28-42)。因此书里可以把 4 个事件作为核心模型讲解,但不能把 HookEvent 描述成只有 4 个枚举值。

14.2.2 HookConfig 与 HookPayload

每个 Hook 的配置(../octos/crates/octos-agent/src/hooks.rs:44-57):

#![allow(unused)]
fn main() {
pub struct HookConfig {
    pub event: HookEvent,        // 触发的生命周期事件
    pub command: Vec<String>,    // argv 数组——无 Shell 解释
    pub timeout_ms: u64,         // 超时(默认 5000ms)
    pub tool_filter: Vec<String>, // 仅对这些工具触发;空数组 = 所有工具
}
}

tool_filter 不是单个可选字符串,而是工具名列表。例如 ["shell", "write_file"] 表示只在这两个工具上触发;空数组则表示所有工具都匹配。

HookPayload../octos/crates/octos-agent/src/hooks.rs:70-145)是传递给 Hook 进程的 JSON 数据:

事件类型Payload 字段
before_tool_calltool_name, arguments(已脱敏/截断), tool_id
after_tool_calltool_name, result(已脱敏/截断), tool_id, success, duration_ms
before_llm_callmodel, message_count, iteration
after_llm_callmodel, iteration, stop_reason, has_tool_calls, input_tokens, output_tokens, provider_name, latency_ms, cumulative_input_tokens, cumulative_output_tokens, session_cost, response_cost
resume/turn/spawn 事件turn_summary, task_id, task_label, parent_session_key, child_session_key, workflow_kind, current_phase, output_files, failure_action
所有事件session_id, profile_id(来自 HookContext

schema_version 是 Hook payload 的持久 ABI 版本;另外 HookPayloadEnricher 可以在序列化前注入 domain-specific domain_data,超长 JSON 会被替换成 {"truncated": true} 标记(../octos/crates/octos-agent/src/hooks.rs:63-75, ../octos/crates/octos-agent/src/hooks.rs:538-557, ../octos/crates/octos-agent/src/hooks.rs:607-635)。这让机器人、硬件或企业策略集成可以在不扩展核心枚举的情况下附加自己的上下文。

14.2.3 Shell 协议

Hook 命令以 argv 数组执行(无 Shell 解释,防止注入),通过 stdin 接收 JSON payload,通过 exit code 返回决策:

Exit Code含义行为
0Allow继续执行
1Deny仅在 before_tool_call / before_llm_call / before_spawn_verify 上阻止操作;after-hook 中视为错误
2Modifybefore_tool_call / before_spawn_verify 可用;将 stdout 解析为新的 payload JSON
其他Error计入失败次数,可能触发 circuit breaker

敏感数据保护../octos/crates/octos-agent/src/hooks.rs:148-183):

#![allow(unused)]
fn main() {
const MAX_PAYLOAD_FIELD_BYTES: usize = 1024; // 1KB
const SENSITIVE_TOOLS: &[&str] = &["shell", "write_file", "read_file"];
}
  • 敏感工具(shell、write_file、read_file)的参数被替换为 {"redacted": true}
  • 其他工具参数截断到 1KB(UTF-8 安全截断)
  • 防止 Hook 进程(可能是第三方脚本)看到文件内容或 Shell 命令

Session 上下文(session_idprofile_id)注入到所有 Payload 中(../octos/crates/octos-agent/src/hooks.rs:104-108, ../octos/crates/octos-agent/src/hooks.rs:492-496),让 Hook 可以实现基于会话或用户的差异化策略。

14.2.4 Hook 执行源码走读

execute_hook()../octos/crates/octos-agent/src/hooks.rs:767-848)展示了安全执行外部进程的完整模式。下面这段是按当前实现压缩后的等价摘录

#![allow(unused)]
fn main() {
async fn execute_hook(&self, hook: &HookConfig, payload_json: &str) -> Result<(i32, String)> {
    let (program, args) = hook.command.split_first()
        .ok_or_else(|| eyre!("empty hook command"))?;
    let program = expand_tilde(program);  // ~/script.sh -> /home/user/script.sh
    let expanded_args: Vec<String> = args.iter().map(|a| expand_tilde(a)).collect();

    let mut cmd = tokio::process::Command::new(&program);
    cmd.args(&expanded_args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
    for var in BLOCKED_ENV_VARS { cmd.env_remove(var); }
    let mut child = cmd.spawn()?;

    if let Some(mut stdin) = child.stdin.take() {
        let _ = stdin.write_all(payload_json.as_bytes()).await;
        let _ = stdin.shutdown().await;
    }

    let stdout_handle = child.stdout.take();
    let stderr_handle = child.stderr.take();

    match tokio::time::timeout(Duration::from_millis(hook.timeout_ms), child.wait()).await {
        Ok(Ok(status)) => {
            let stdout = read_stdout(stdout_handle).await;
            log_stderr(stderr_handle, &hook.command).await;
            Ok((status.code().unwrap_or(2), stdout))
        }
        Err(_) => {
            let _ = child.kill().await;  // 超时 kill 防止僵尸进程
            Err(eyre!("hook timed out after {}ms", hook.timeout_ms))
        }
    }
}
}

为便于阅读,这里把源码中内联展开的 stdout/stderr 读取与日志逻辑折叠成了 read_stdout() / log_stderr() 两步;实际实现没有这两个独立函数。

argv 数组而非 shell 字符串Command::new(program).args(args) 直接传递参数给 execve(),不经过 shell 解释。这关闭了 shell 注入攻击面。

tilde 展开:因为不经过 shell,~/script.sh 不会自动展开。expand_tilde() 安全地将 ~ 替换为 $HOME

14.2.5 Circuit Breaker

每个 Hook 维护一个 AtomicU32 失败计数器。默认阈值是 3,也可以通过 HookExecutor::with_threshold() 配置;达到阈值后自动跳过该 Hook,并用 compare_exchange(CAS)确保警告只打印一次(../octos/crates/octos-agent/src/hooks.rs:575-590, ../octos/crates/octos-agent/src/hooks.rs:657-677):

#![allow(unused)]
fn main() {
let failures = hook_failures[i].fetch_add(1, Ordering::Relaxed) + 1;
if failures >= threshold {
    if hook_failures[i].compare_exchange(failures, threshold + 1, Ordering::Relaxed, Ordering::Relaxed).is_ok() {
        warn!("Hook {:?} disabled after {} failures", hook.command, threshold);
    }
    continue;
}
}

成功调用重置计数器为 0。这防止了有 bug 的 Hook 进程持续崩溃拖慢整个系统;但如果 Hook 已被熔断并持续被跳过,当前实现没有后台半开探测,需要配置变更或进程重启才能重新尝试。


14.3 可观测性与控制面:metrics、tracing、事件流 + AppUI

octos 的可观测性不是一个单一的 metrics endpoint——它由 Prometheus 指标、结构化 tracing、UI Protocol 进度流和 harness typed SSE 共同组成,各自覆盖不同的观测需求。当前主分支还增加了一条更强的交互控制面:AppUI 使用的 UI Protocol WebSocket。它不只是“看见事件”,还允许前端对会话、审批、diff 和后台任务执行受控操作。

14.3.1 Prometheus 指标

Serve 模式暴露 Prometheus 指标端点(crates/octos-cli/src/api/metrics.rs:1-76)。MetricsReporter 装饰了 Agent 的 ProgressReporter trait,在转发事件的同时记录指标:

指标名类型标签用途
octos_tool_calls_totalCountertool, success每个工具的调用次数和成功率
octos_tool_call_duration_secondsHistogramtool每个工具的执行时间分布
octos_llm_tokens_totalCounterdirection (input/output)记录 CostUpdate 上报的累计 session token 值,更适合趋势观察

这三个指标看似简单,但组合使用可以回答生产环境中最关键的问题:

  • 哪个工具最慢? octos_tool_call_duration_seconds 按 tool 标签分桶,P99 延迟一目了然
  • LLM 活跃度是否异常? octos_llm_tokens_total 的变化趋势可以帮助发现高消耗会话或流量尖峰
  • 哪个工具最不可靠? octos_tool_calls_total{success="false"} / octos_tool_calls_total = 工具失败率

MetricsReportermetrics.rs:40-75)的设计值得注意——它不是独立的采集器,而是 Agent 事件流的装饰器。每次工具调用完成时,Agent 通过 ProgressReporter trait 上报事件,MetricsReporter 拦截事件、记录指标、然后将事件透传给下游(如 UI Protocol 进度流)。这避免了在 Agent 热路径中添加额外的采集逻辑。

但这里有一个实现细节必须说清:MetricsReporter 当前直接把 CostUpdate 里的 session_input_tokens/session_output_tokens 累加进 Counter,而这两个字段本身就是“当前会话的累计值”,不是“本次调用新增的 delta”(crates/octos-cli/src/api/metrics.rs:63-70, crates/octos-agent/src/agent/streaming.rs:242-257)。因此它更适合做趋势监控,不适合直接拿来当精确账单或实时成本真值。

14.3.2 结构化 Tracing

octos 使用 tracing crate 实现结构化日志(crates/octos-cli/src/main.rs:64-131):

  • 日志轮转:7 天滚动日志(tracing-appenderdaily rotation),自动清理旧文件
  • JSON 模式OCTOS_LOG_JSON=1 将控制台 layer 切成 JSON 格式,适合接入 ELK/Loki 等日志聚合系统
  • Compact 模式:默认人类可读的紧凑格式,包含时间戳、级别、span 上下文
  • 日志目录:Serve 模式可写入 ~/.octos/logs/serve.YYYY-MM-DD.log;文件 layer 仍是 compact 格式

当前实现中,tracing 主要通过 tracing::info!/warn!/debug! 宏在关键路径上打点,而非系统性的 #[instrument] 标注。这意味着分布式 tracing(跨服务 trace ID 传播)尚未实现——这是图表中标注的"缺分布式 tracing"差距。对于单进程部署,结构化日志已经足够;但在多 Gateway 实例或 Gateway + 外部 MCP 服务器的部署架构中,缺少 trace ID 会让跨组件的请求追踪变得困难。

14.3.3 UI Protocol 与 harness events:前端可观测性

当前主分支中,聊天和 turn 级前端可观测性已经不再走 chat SSE。router.rsapi/mod.rs 明确记录:POST /api/chat?stream=trueGET /api/chat/streamGET /api/sessions/:id/events/stream 这组 chat SSE 传输已删除,新的聊天客户端应统一使用 /api/ui-protocol/wscrates/octos-cli/src/api/router.rs:97-107, crates/octos-cli/src/api/mod.rs:5-8)。

这不意味着 Agent 的离散进度事件消失了,而是传输层从单向 SSE 迁移到了带 ledger、cursor 和 capability negotiation 的 UI Protocol WebSocket。BoundedChannelReporter 仍然实现 ProgressReporter,把 Agent 产生的 ProgressEvent 通过 event_to_json() 转成 JSON,并在绑定 turn 时给每个 payload 写入 thread_id,再交给 UI Protocol 的 progress mapping 和 notification 流(crates/octos-cli/src/api/ui_protocol.rs:1039-1088, crates/octos-cli/src/api/events.rs:120-190, crates/octos-cli/src/api/ui_protocol_progress.rs:648-670)。

下面列的是 UI Protocol 进度流里最有观测价值的离散事件:

进度事件触发时机Payload
tool_start工具开始执行{"type":"tool_start","tool":"shell"}
tool_end工具完成{"type":"tool_end","tool":"shell","success":true}
tool_progress工具中间进度{"type":"tool_progress","tool":"shell","message":"..."}
thinkingLLM 开始思考{"type":"thinking","iteration":3}
responseLLM 开始响应{"type":"response","iteration":3}
cost_update累计 token/成本更新{"type":"cost_update","input_tokens":1234,"output_tokens":567,"session_cost":0.02}

这让前端 Dashboard 可以实时显示:Agent 当前在第几轮迭代、正在调用哪个工具、工具执行到哪一步、累计花了多少 token/钱。这种细粒度的过程可见性是 AI Agent 可观测性的独特需求——传统 Web 服务的 metrics 只关注请求级别的延迟和吞吐量,而 Agent 的用户需要看到思考过程。UI Protocol 相比旧 chat SSE 的关键变化是:它不是临时流式响应,而是和会话 ledger、cursor replay、capability-gated control methods 绑定在一起,适合 AppUI 在断线重连和多 pane 场景下保持一致状态。

cost_update 事件特别有运营价值:Agent 在每次 LLM 调用后产生累计 session_costcrates/octos-agent/src/agent/streaming.rs:242-257),UI Protocol progress mapping 会把它投影成 token/cost status(crates/octos-cli/src/api/ui_protocol_progress.rs:648-670)。这比事后查看 Prometheus 指标更及时——用户可以在对话过程中就决定是否继续。

当前主分支还提供专门的 harness event SSE:GET /api/events/harness。它不是通用日志流,而是面向 dashboard、validator 和 live gate 的 typed event stream。kinds 参数支持 SwarmDispatchswarm_dispatchswarm-dispatch 这类写法归一化,并兼容 top-level kind 与 nested payload.kind 两种 frame(crates/octos-cli/src/api/events_harness.rs:32-130)。

观测层入口解决的问题
tracing结构化日志开发者调试与故障定位
Prometheus/metrics聚合统计、告警、容量趋势
UI Protocol/api/ui-protocol/wsturn 级进度、ledger replay、capability-gated 控制
harness events/api/events/harnesstask / sub-agent / swarm 的结构化实时事件

14.3.4 AppUI / UI Protocol:从观察到控制

Serve 模式还暴露 /api/ui-protocol/wscrates/octos-cli/src/api/router.rs:95-101)。这条路径不是 REST 或 SSE 的替代品,而是 AppUI 的双向控制通道:前端通过 WebSocket 发送 JSON-RPC 风格请求,后端返回结构化结果并推送会话事件。

session/open 是这条协议的入口。后端会返回 SessionOpened,其中包含当前 profile、workspace root、cursor、pane snapshots 和协商后的 capabilities(crates/octos-core/src/ui_protocol.rs:1570-1597; crates/octos-cli/src/api/ui_protocol.rs:1450-1475)。这比普通 SSE 多了两个关键能力:

  • 能力协商:协议 schema version 当前为 2,并通过 feature flags 暴露 approval.typed.v1pane.snapshots.v1session.workspace_cwd.v1harness.task_control.v1 等能力(crates/octos-core/src/ui_protocol.rs:24-41, crates/octos-core/src/ui_protocol.rs:712-825)。
  • 方法级门控task/listtask/canceltask/restart_from_node 这类后台任务控制方法必须具备 harness.task_control.v1 capability,否则返回 unsupported capability,而不是静默降级(crates/octos-core/src/ui_protocol.rs:55-69)。

这条控制面把生产运维从“观察 Agent 发生了什么”推进到“在明确能力边界内干预 Agent”。例如 AppUI 可以列出后台任务、取消任务或从某个节点重启任务;后端对应的 handler 会把取消结果映射成 UiTaskRuntimeState::Cancelled,而不是只在日志里记录一行文本(crates/octos-cli/src/api/ui_protocol.rs:2510-2650)。

能力协商的细节也很重要:如果客户端没有发送 feature header,后端返回 first_server_slice,让旧客户端仍能 discovery;如果客户端发送了 feature header,后端只返回“客户端请求且服务端支持”的交集,未知 feature 会被丢弃。task/listtask/canceltask/restart_from_node 只有在协商到 harness.task_control.v1 时才出现在 supported_methodscrates/octos-core/src/ui_protocol.rs:748-825; crates/octos-cli/src/api/ui_protocol.rs:479-534)。

sequenceDiagram
    participant AppUI
    participant Serve as octos serve UI WS
    participant Core as UiProtocolCapabilities
    AppUI->>Serve: session/open + X-Octos-Ui-Features
    Serve->>Core: for_negotiated_features(requested)
    Core-->>Serve: supported_features + supported_methods
    Serve-->>AppUI: SessionOpened(capabilities)
    AppUI->>Serve: task/list
    Serve-->>AppUI: ok only if harness.task_control.v1 negotiated

需要注意的是,UI Protocol 不应该被当成“浏览器可以随便调用的内部总线”。它的价值正在于结构化能力协商和方法门控:前端能做什么由后端 capability 决定,新增控制能力时也应先进入 capability schema,而不是直接暴露临时 WebSocket 命令。

14.3.5 Coding / Agent / Goal / Loop 控制面

在最新主分支里,AppUI 的控制面不止是 task list/cancel/restart。octos-core/src/ui_protocol.rs 还声明了 coding.tool_contract.v1coding.autonomy.v1coding.agent_control.v1coding.goal_runtime.v1coding.loop_runtime.v1 等 feature flag,并把 agent/listagent/status/readagent/output/readagent/artifact/listagent/artifact/readsession/goal/getsession/goal/setsession/goal/clearloop/createloop/list 这类方法纳入 capability-gated method set。

这条线的关键是:能力声明由后端从实际运行时状态推导coding.tool_contract.v1 会把当前 profile/session 的工具状态写成 availablealiaseddeferreddisabled_by_policymissingunimplementedcoding.image_generation.v1 虽有词汇常量,但因为没有后端绑定,不会作为可用能力广告。这样前端不会把未实现的模型工具误渲染成可用按钮,模型也能收到 typed unsupported error,而不是泛化的“工具不存在”。

Agent lifecycle 则由 AgentOrchestratorTaskSupervisor 共同支撑。受监督后台任务、native specialist 和 artifact 更新会被投射成 agent 状态;supervisor_store.rs 用 append-only JSONL event ledger 加 snapshot 保存 group/child/artifact/terminal/continuation 状态;master_continuation_scheduler.rs 用 dedupe key 和优先级控制 child completion、scatter-join、goal/loop wakeup 后是否安排 master agent 继续。它们共同提供的是可观测、可恢复、可门控的 orchestration runtime

这不是完整的 self-evolving / DSPy / GEPA 系统。源码里存在 skill-evolve、pipeline validator、artifact ledger、goal/loop runtime primitive 和 continuation scheduler 这些“后续可优化”的底座,但没有实现自动 prompt/program search、GEPA-style reflection optimizer 或 DSPy teleprompter。生产文档里应把它写成“具备演化方向的运行时 substrate”,而不是“已经能自我进化”。

flowchart TD
    UI["AppUI / UI Protocol WS"] --> Contract["coding.tool_contract.v1<br/>tool status + policy view"]
    UI --> AgentCtl["coding.agent_control.v1<br/>agent/list status output artifacts"]
    UI --> GoalCtl["coding.goal_runtime.v1<br/>session/goal get set clear"]
    UI --> LoopCtl["coding.loop_runtime.v1<br/>loop create/list/control"]
    AgentCtl --> Orchestrator["AgentOrchestrator"]
    Orchestrator --> Supervisor["TaskSupervisor"]
    Supervisor --> Store["SupervisorStore<br/>events + snapshot"]
    Supervisor --> Scheduler["MasterContinuationScheduler"]

图 14-2:Coding autonomy 控制面。 UI Protocol 只暴露经过 capability negotiation 的方法;真实任务状态仍由后端 supervisor/orchestrator/store/scheduler 维护。


14.4 多租户:Profile + Account + User + Session 隔离

octos 的多租户不是单一层面的"租户 ID"字段,而是 Profile、子账号、用户工作区和会话 actor 共同组成的隔离模型。当前主分支还需要区分两条运行路径:

  • standalone Gateway 路径:一个 Gateway 进程内通过 ActorRegistryActorFactory 管理多个 profile/session。
  • admin/process-manager 路径ProcessManager 会为每个 profile 启动独立的 octos gateway 子进程,并通过 --profile--parent-profile--data-dir--cwd 等参数把 profile 配置和数据目录传给子进程(crates/octos-cli/src/process_manager.rs:241-283)。

因此本节的"多租户隔离"要分成两层看:配置和数据目录是 profile/account 级的;是否进程级隔离取决于运行入口。生产管理面通常走 process-manager 模式,单独的 octos gateway 则仍是同进程 actor 模型。

14.4.1 Profile / Account 层:租户配置与子账号

UserProfile(crates/octos-cli/src/profiles.rs:18-40)是租户的顶层抽象:

#![allow(unused)]
fn main() {
pub struct UserProfile {
    pub id: String,                    // 唯一标识(slug 格式)
    pub name: String,                  // 显示名称
    pub enabled: bool,                 // 是否自动启动
    pub data_dir: Option<String>,      // 数据目录覆盖
    pub public_subdomain: Option<String>, // 子账号公开子域
    pub parent_id: Option<String>,     // 子账号继承
    pub config: ProfileConfig,         // 内联 LLM/工具/频道配置
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}
}

每个 Profile 可以有独立的:

  • LLM contract 和模型路由:Profile A 用 Claude,Profile B 用 GPT-4o 或 fallback chain
  • 工具策略:Profile A 允许 shell,Profile B 禁止
  • 系统提示:不同 Profile 的 Agent 行为完全不同
  • 频道绑定:Profile A 接入 Telegram,Profile B 接入 Discord

子账号创建规则create_sub_account() 要求父 profile 存在,并且父 profile 本身不能已经是子账号;子账号 ID 采用 {parent_id}--{sub_account_id} 形式,还会校验公开子域不冲突(crates/octos-cli/src/profiles.rs:1180-1228)。Admin API 提供 GET /api/admin/profiles/:id/accounts 列出子账号,以及 POST /api/admin/profiles/:id/accounts 创建子账号(crates/octos-cli/src/api/admin.rs:1290-1385)。

子账号继承:当前主分支的继承粒度已经不是旧的顶层 provider/model/base_url/api_key_envresolve_effective_profile() 会让子账号继承父 profile 的完整 config.llm contract;searchdeep_crawlappsemail 只有在子账号未配置时才继承;env_vars 以父级为 base,再由子账号覆盖同名变量(crates/octos-cli/src/profiles.rs:1236-1273)。process-manager 启动子账号 gateway 时还会传入 --parent-profile,并把父级 env vars 注入子进程,子账号自己的 env vars 优先(crates/octos-cli/src/process_manager.rs:275-291)。

这带来的心智模型是:父账号提供共享能力契约,子账号提供接入面和差异化覆盖。例如父账号统一配置 LLM、搜索和邮件,子账号只配置自己的频道凭据、公开子域和少量 env override。

不继承 customer-installed skills:子账号的 skills/plugin 目录是严格按当前 account 解析的。skills_scope.rs 明确说明 sub-account 不继承父 profile 安装的 customer skills;resolve_account_skills_dir()build_account_plugin_dirs() 都只返回当前账号自己的 data_dir/skillscrates/octos-cli/src/skills_scope.rs:1-38)。这避免了父账号安装的本地可执行扩展在子账号里静默获得执行边界。

ActorRegistry 为命中 profile factory 的会话复用同一个 ActorFactorycrates/octos-cli/src/session_actor.rs:145,168-178)。这个工厂内部持有共享的 Arc<dyn LlmProvider>,因此同一 profile 的会话会复用同一套 provider stack;不同 profile 可以有不同工厂——这是 Provider 级隔离的实现方式。

14.4.2 User 层:文件系统隔离

在 Profile 内部,每个用户(由 SessionKey 中的 channel:chat_id 标识)拥有独立的文件系统工作区。Session actor 在创建时构建 per-user 路径(crates/octos-cli/src/session_actor.rs:518-534):

{data_dir}/users/{encoded_base_key}/
├── workspace/          # 工具的工作目录
├── sessions/
│   ├── default.jsonl   # 默认会话历史
│   └── research.jsonl  # #research 主题的会话

需要注意:当前按 user 隔离的是 workspace/sessions/。长期记忆与 memory bank 仍然挂在 profile/global data_dir/memory/ 下,不是每个用户单独一个 memory/ 目录(crates/octos-memory/src/memory_store.rs:20-26, crates/octos-cli/src/commands/gateway/gateway_runtime.rs:386-392)。

路径隔离是双重保障

  1. 应用级resolve_path() 将所有工具的文件操作限制在 workspace/ 目录内
  2. 内核级:macOS 的 sandbox-exec SBPL 配置文件将文件系统访问限制在同一路径——即使工具代码有 bug 试图读取其他用户的目录,操作系统也会拒绝

每个 session actor 创建独立的沙箱实例(session_actor.rs:545),沙箱的 cwd 指向该用户的 workspace/。这意味着用户 A 的 shell 工具在用户 A 的工作目录中执行,即使在同一个 Gateway 进程中,也无法访问用户 B 的文件。

14.4.3 Session 层:会话级状态所有权

在 User 层之下,每个会话(同一用户可能有多个主题的会话)拥有独立的运行时状态(详见第 11 章 Session Actor):

  • ToolRegistry:每个 session actor 持有自己的工具注册表(含独立的 LRU 计数器)
  • 对话历史:通过 SessionHandle 管理,per-session JSONL 文件
  • 取消标志cancel_flag: AtomicBool,一个会话的取消不影响同一用户的其他会话

sender_user_id 传播:频道层的用户 ID 通过 DispatchParams::sender_user_idsession_actor.rs:43)传递到 actor,再通过 METADATA_SENDER_USER_ID 常量(session_actor.rs:17)注入到所有出站消息的 metadata 中。这让 Matrix AppService 等支持多用户代理的频道可以"以用户身份发送消息",而非以 bot 身份发送。

14.4.4 隔离层次总结

flowchart TD
    subgraph "Admin / ProcessManager 路径"
        PM["ProcessManager"]
        PPROC["parent gateway process<br/>--profile parent.json"]
        SPROC["sub-account gateway process<br/>--profile child.json<br/>--parent-profile parent.json"]
        PM --> PPROC
        PM --> SPROC
    end

    subgraph "Parent Profile"
        PCFG["config.llm<br/>search / deep_crawl / apps / email"]
        PENV["env_vars base"]
        PSK["parent data_dir/skills<br/>仅父账号可见"]
    end

    subgraph "Sub-account Profile"
        SCFG["channels / gateway / public_subdomain"]
        SENV["env_vars override"]
        SSK["child data_dir/skills<br/>不继承父 skills"]
        SUSER["User workspace<br/>users/{channel:chat_id}/workspace"]
        SSESSION["Session Actor<br/>JSONL history + cancel flag"]
    end

    PCFG -->|"inherit if missing / llm contract copied"| SCFG
    PENV -->|"base"| SENV
    PSK -. not inherited .-> SSK
    SCFG --> SUSER
    SUSER --> SSESSION

    subgraph "Standalone Gateway 路径"
        REG["ActorRegistry"]
        FA["ActorFactory per profile"]
        REG --> FA
        FA --> SSESSION
    end

图 14-3:Profile / 子账号 / User / Session 的隔离关系。 父账号提供共享能力契约,子账号继承结构化配置但保持自己的频道、公开子域、数据目录和 customer skills。生产管理面可以为每个 profile 启动 gateway 子进程;standalone Gateway 则在同一进程里用 actor/factory 维护会话隔离。

当前隔离的边界可以概括为:

  • Profile / Account:隔离 LLM contract、策略、频道、env、skills 和数据目录;子账号只继承明确允许的结构化配置。
  • Process:process-manager 模式下按 profile 启动 gateway 子进程;standalone Gateway 模式下仍是同进程 actor 隔离。
  • User:隔离 workspace/sessions/,但长期 memory 仍挂在 profile/global data dir 下。
  • Session:隔离 ToolRegistry、JSONL history、取消标志和运行时 actor 状态。

资源隔离仍不是完整的容器级隔离:即使 process-manager 将 profile 拆成子进程,如果没有 cgroup/container/系统级限额,一个 profile 的 CPU 或内存压力仍可能影响同一宿主机上的其他 profile。


14.5 生产控制面:setup、admin token、SMTP secret 与 profile 进程

如果把前面的认证、AppUI、多租户和 process-manager 放在一起看,Serve 模式在生产环境里的角色其实是控制面。它不只是提供 /api/chat,还持有一组和部署生命周期相关的状态对象:AdminTokenStoreSetupStateStoreProfileStoreProcessManagerUserStoreAuthManager 和配置文件路径(../octos/crates/octos-cli/src/api/mod.rs:123-149, ../octos/crates/octos-cli/src/commands/serve.rs:487-496)。

flowchart TD
    Serve["octos serve AppState"]
    Router["admin / setup / smtp routes"]
    AdminToken["admin_token.json<br/>salt + sha256 hash"]
    SetupState["setup_state.json<br/>wizard progress"]
    SmtpSecret["smtp_secret.json<br/>SMTP password"]
    Config["config.json<br/>dashboard_auth.smtp + mode"]
    ProfileStore["ProfileStore<br/>profile config"]
    ProcessManager["ProcessManager<br/>gateway child processes"]
    Gateway["octos gateway<br/>per profile process"]
    AuthManager["AuthManager<br/>OTP + sessions"]

    Serve --> Router
    Serve --> ProfileStore
    Serve --> ProcessManager
    Serve --> AuthManager
    Router --> AdminToken
    Router --> SetupState
    Router --> SmtpSecret
    Router --> Config
    ProfileStore --> ProcessManager
    ProcessManager --> Gateway
    SmtpSecret --> AuthManager
    Config --> AuthManager

图 14-4:生产控制面总图。 Serve 聚合 setup wizard、admin token rotation、SMTP 配置、profile 配置和 gateway 子进程管理;真正执行消息处理的 gateway 可以由 process-manager 按 profile 启停。

几个边界要写清:

  • admin_token.json 不是明文 token,而是带 16 字节随机 salt 的 sha256(salt || token) 记录;保存时使用临时文件 + rename,并在 Unix 下设为 0600../octos/crates/octos-cli/src/admin_token_store.rs:13-54, ../octos/crates/octos-cli/src/admin_token_store.rs:86-105)。
  • 一旦 admin_token.json 存在,它就成为 admin auth 的权威来源;bootstrap auth_token 被忽略,直到 operator 执行 reset-token 清掉该文件。文件损坏时 admin 分支 fail closed(../octos/crates/octos-cli/src/api/router.rs:639-655)。
  • setup wizard 状态保存在 setup_state.json,记录 wizard_completed_atwizard_skippedwizard_last_step_reached,用于 dashboard 跨会话恢复或跳过首次配置流程(../octos/crates/octos-cli/src/setup_state_store.rs:1-21, ../octos/crates/octos-cli/src/api/admin_setup.rs:83-155)。
  • SMTP 配置被拆成两层:host/port/username/from_address 写进 config.jsondashboard_auth.smtp,密码写入 {data_dir}/smtp_secret.json;API 从不返回密码明文,只返回 password_configured../octos/crates/octos-cli/src/api/admin_setup.rs:335-350, ../octos/crates/octos-cli/src/api/admin_setup.rs:384-438)。
  • smtp_secret.json 优先解决“把 SMTP_PASSWORD 明文写进 launchd/systemd 环境”的问题;启动时如果环境变量不存在,Serve 还会尝试从匹配的 profile email 配置、profile env_vars 或 keychain 解析密码作为 fallback(../octos/crates/octos-cli/src/smtp_secret_store.rs:1-10, ../octos/crates/octos-cli/src/commands/serve.rs:162-200, ../octos/crates/octos-cli/src/commands/serve.rs:405-413)。
  • setup/admin/smtp/deployment-mode 的 HTTP 路由都在 admin API 下,由 router 统一挂载(../octos/crates/octos-cli/src/api/router.rs:312-341)。这就是为什么这些状态应被视为生产控制面,而不是普通会话数据。

这个设计的取舍是:octos 没有把所有部署状态都塞进一个大配置文件。长期配置仍在 config.json / profile store;敏感凭据和一次性 setup 状态拆成专门的小 store。好处是权限、轮转和 fail-closed 语义更清楚;代价是运维备份时必须把 {data_dir} 下这些 store 一并纳入,而不能只备份 profile JSON。


工程决策侧栏:为什么 Hooks 用 exit code 而非 JSON 响应

方案一:JSON 响应(stdin/stdout 全部 JSON)

优势:表达力强,可以携带复杂的决策理由和修改后的参数 劣势:Hook 作者需要输出合法 JSON——Shell 脚本很难做到可靠的 JSON 生成

方案二:Exit code + 可选 stdout(octos 的选择)

优势:

  • 最简单的 Hook 只需要 exit 0(允许)或 exit 1(拒绝)
  • Shell 脚本天然支持 exit code
  • 只在 before_tool_call 返回 exit 2 时才需要 JSON stdout,大部分 Hook 不需要修改参数

劣势:

  • exit code 语义有限(只有 allow/deny/modify 三种)
  • 拒绝原因无法通过 exit code 传达(需要 stderr 日志)

选择理由: Hook 的主要用途是审批和日志,90% 的场景只需要 allow/deny 决策。用 exit code 让最简单的 Hook 实现极其轻量——一个 3 行的 Shell 脚本就能实现审批逻辑。只有需要修改参数的高级场景才需要 JSON 输出。


14.6 本章回顾

  1. 认证三流:OpenAI 支持 OAuth PKCE(浏览器环境)和 Device Code(无浏览器);其他 Provider 当前主要走 Paste-token。凭据写入 ~/.octos/auth.json,Serve 主路由对 admin/test token 使用常量时间比较。
  2. Hooks:核心 tool/LLM 事件加 resume/turn/spawn 生命周期事件,共用 Shell 协议(argv 执行、stdin JSON、exit code 决策)。Circuit breaker 默认 3 次失败自动禁用。敏感参数自动脱敏。
  3. 监控:Prometheus 指标 + 结构化日志 + UI Protocol 进度流 + /api/events/harness typed SSE 覆盖不同观测需求;其中 token 指标当前更适合趋势监控,不是精确计费真值。
  4. AppUI 控制面:UI Protocol WebSocket 通过 SessionOpened.capabilities 和方法级 capability gate,把后台任务、审批、diff、agent lifecycle、goal/loop primitive 和 coding tool contract 纳入结构化控制面。
  5. 多租户:Profile/Account、User、Session 分层隔离;子账号继承父账号结构化能力契约,但不继承 customer-installed skills;是否进程级隔离取决于 process-manager 还是 standalone Gateway 入口。
  6. 生产控制面:admin token、setup state、SMTP secret、profile config 和 gateway/serve/process manager 共同构成部署后的运维入口;敏感 secret 应走专用 store,而不是散落在日志或临时环境变量中。

全书 14 章到此结束。附录将提供完整的 Crate 依赖图、工具速查表、配置参考和贡献指南。


延伸阅读

  • OAuth 2.0 PKCE:RFC 7636 "Proof Key for Code Exchange by OAuth Public Clients"
  • Prometheus:https://prometheus.io/docs/introduction/overview/ — 监控系统和时序数据库
  • Circuit Breaker:Martin Fowler, "CircuitBreaker" — 理解熔断器模式的设计理由
  • 常量时间比较subtle crate 文档 — 防止时序侧信道攻击

思考题

  1. Hook 的安全边界:当前 Hooks 通过 argv 执行(无 Shell),但 Hook 命令本身可能是一个恶意程序。你会如何验证 Hook 命令的可信度?
  2. Circuit Breaker 的恢复:当前的实现在成功调用时重置计数器。但如果 Hook 被禁用后永远不再调用,它就永远无法恢复。你会如何设计一个"试探性恢复"机制?
  3. 多租户的资源隔离:process-manager 可以把 profile 拆成 gateway 子进程,但资源限额仍不是自动获得的。如果一个 profile 的 Agent 消耗了过多 CPU 或内存,会影响同宿主机的其他 profile。你会如何实现资源级别的隔离?

版本演化说明 本章分析基于当前 ../octos main 分支。相比早期版本,当前实现已经加入 UI Protocol capability negotiation、/api/events/harness typed SSE、coding/autonomy capability、agent/goal/loop 控制面、setup/admin token store、SMTP secret store、结构化子账号继承和 process-manager per-profile gateway;但资源级隔离和完整 self-evolving optimizer 仍需要额外实现。

附录 A:octos 完整 Crate 依赖图

本附录以当前 ../octos main 分支的 Cargo.toml 为准。图中展开 11 个 octos-* 核心 crate;app-skillsplatform-skills 也是 workspace 成员,但它们是能力二进制程序,依赖形态更接近外部工具,故不展开到核心库依赖图中。

需要注意:最新 agent/goal/loop/coding autonomy 相关实现主要落在 octos-cli 的 API runtime 模块(如 agent_orchestrator.rsgoal_loop_runtime.rsmaster_continuation_scheduler.rssupervisor_store.rs)和 octos-agent 的工具/TaskSupervisor 层,并没有新增独立 octos-autonomy crate。因此 crate 图不会因为这些能力出现新节点;它们是现有 octos-cli -> octos-agent -> octos-core 依赖链上的运行时模块。

内部 Crate 依赖拓扑

graph BT
    subgraph "独立基础设施"
        core["octos-core"]
        plugin["octos-plugin"]
        sandbox["octos-sandbox"]
    end

    subgraph "领域服务"
        llm["octos-llm"]
        memory["octos-memory"]
        bus["octos-bus"]
    end

    subgraph "运行时引擎"
        agent["octos-agent"]
        pipeline["octos-pipeline"]
        swarm["octos-swarm"]
        dora["octos-dora-mcp"]
    end

    subgraph "用户入口"
        cli["octos-cli"]
    end

    llm --> core
    memory --> core
    bus --> core

    agent --> core
    agent --> bus
    agent --> memory
    agent --> llm
    agent --> plugin

    pipeline --> core
    pipeline --> agent
    pipeline --> llm
    pipeline --> memory

    swarm --> agent
    dora --> agent

    cli --> core
    cli --> agent
    cli --> memory
    cli --> llm
    cli --> bus
    cli --> pipeline
    cli --> swarm

箭头方向为"依赖于"。例如 cli --> agent 表示 octos-cliCargo.toml 依赖 octos-agentoctos-sandbox 是 Windows AppContainer 辅助 crate,当前不被其他核心 crate 直接依赖。

各 Crate 关键外部依赖

Crate关键依赖版本用途
octos-coreserde, serde_json, chrono, uuid, eyre1.x, 1.x, 0.4, 1.x, 0.6序列化、时间、ID、错误
octos-llmreqwest, async-trait, futures, secrecy, redb, metrics0.12, 0.1, 0.3, 0.10, 2.x, 0.24HTTP、异步 trait、流式、密钥、凭据池状态、指标
octos-memoryredb, hnsw_rs, bincode, tokio, uuid2.x, 0.3, 1.x, 1.x, 1.x嵌入式 DB、向量搜索、序列化、异步、ID
octos-bustokio, lru, cron, subtle, aes/cbc, teloxide*, serenity*, axum*1.x, 0.16, 0.15, 2.x, 0.8/0.1, 0.17, 0.12, 0.8异步、缓存、定时、常量时间比较、加密、频道/API 集成
octos-agenttokio, async-trait, reqwest, chromiumoxide, gix*, tree-sitter*1.x, 0.1, 0.12, 0.9, 0.79, 0.24Agent 异步运行、工具 HTTP、浏览器自动化、Git/AST feature
octos-pipelineasync-trait, tokio, futures, regex, glob0.1, 1.x, 0.3, 1.x, 0.3Handler 抽象、异步执行、并发、模式匹配、文件匹配
octos-swarmasync-trait, redb, uuid, metrics, tokio0.1, 2.x, 1.x, 0.24, 1.x子 Agent 编排、持久化、ID、指标、异步
octos-dora-mcpasync-trait, tokio, serde, serde_json, eyre0.1, 1.x, 1.x, 1.x, 0.6Dora/MCP 工具桥接、异步、序列化、错误
octos-cliclap, rustyline, axum*, tower-http*, rust-embed*, keyring, metrics-exporter-prometheus*4.x, 15.x, 0.8, 0.6, 8.x, 3.x, 0.16CLI、交互输入、Web/API、静态资源、系统凭据、Prometheus
octos-pluginserde, serde_json, eyre, which, tokio, metrics1.x, 1.x, 0.6, 7.x, 1.x, 0.24Manifest、错误、可执行文件发现、异步、指标
octos-sandboxclap, eyre, rappct**, windows**4.x, 0.6, 0.13, 0.62CLI、错误、Windows AppContainer

* 表示 feature-gated 依赖;** 表示仅在 Windows target 下启用。

Workspace 共享依赖

以下依赖在 [workspace.dependencies] 中统一定义,所有引用 workspace 依赖的 crate 使用相同版本:

  • tokio 1.x(full features):异步运行时
  • serde 1.x(derive)/ serde_json 1.x:序列化框架
  • eyre 0.6 / color-eyre 0.6:错误处理
  • tracing 0.1 / tracing-subscriber 0.3:结构化日志
  • reqwest 0.12(rustls-tls):HTTP 客户端(纯 Rust TLS)
  • redb 2.x:嵌入式持久化
  • axum 0.8 / tower-http 0.6:可选 Web/API 入口

附录 B:工具速查表

本附录以当前 ../octos main 分支的 octos-agent/src/tools/octos-agent/src/tools/policy.rsoctos-cli/src/api/coding_tool_contract.rs 以及 CLI 运行时注册路径为准。octos 的工具不是一个固定列表:基础 registry、Codex-compatible coding 合约、feature flags、profile、Serve/Admin API、MCP、插件和 app-skills 都会影响最终暴露给 Agent 的工具集合。

Codex-compatible 编码工具面

这些工具由 ToolRegistry::with_builtins_and_permissions() 直接注册,并由 coding.tool_contract.v1 对 AppUI/模型声明。它们的定位是让 Octos 可以作为 autonomous coding harness 的后端工具面,而不是要求前端本地实现这些能力。

工具名分组核心参数功能
apply_patchgroup:fsCodex patch envelope 或 path, diff应用补丁;写入受文件访问策略约束
exec_commandgroup:runtimecmd, workdir?, timeout_secs?, tty?, yield_time_ms?执行命令;支持 PTY/长命令 session
write_stdingroup:runtimesession_id, charsexec_command 启动的运行中 session 写入 stdin
bashgroup:runtimecmdCodex-compatible shell alias,共用 shell/exec policy 与 sandbox
update_plan-plan: array写入结构化计划状态
request_user_input-questions: array请求用户输入,返回结构化 metadata
spawn_agentgroup:sessionstask, role?, agent_type?启动受监督的子 Agent;依赖运行时绑定 native spawn delegate
send_inputgroup:sessionsagent_id, input当前记录到 supervisor metadata;不是实时 conversational delivery
resume_agentgroup:sessionsagent_id重新拉起/恢复受监督任务
wait_agentgroup:sessionsagent_id, timeout_ms?等待子 Agent 进入终态或超时
close_agentgroup:sessionsagent_id取消活跃受监督任务或关闭终态任务
delegategroup:sessionstask, role?, timeout_ms?spawn_agent + wait_agent 的一调用封装
view_image-path工作区内图片元数据检查,不返回原始图片字节
tool_search-query, limit?从 live tool catalog 搜索可见工具
tool_suggest-task, limit?根据任务描述建议工具
image_generation-prompt当前仅返回 coding_tool_unsupported,尚未绑定生成后端

核心内置工具

工具名分组核心参数功能
shellgroup:runtimecommand: string在策略和沙箱约束下执行 Shell 命令
read_filegroup:fspath: string, offset?: int, limit?: int读取文件内容,支持分页
write_filegroup:fspath: string, content: string写入文件并记录 workspace 变更
edit_filegroup:fspath: string, old_string: string, new_string: string精确字符串替换
diff_editgroup:fspath: string, diff: string应用 unified diff 风格补丁
globgroup:searchpattern: string, path?: string文件路径模式搜索
grepgroup:searchpattern: string, path?: string, include?: string内容正则搜索
list_dirgroup:searchpath: string列出目录内容
web_searchgroup:webquery: string, count?: int网页搜索
web_fetchgroup:weburl: string, max_chars?: int, extract_mode?: string获取网页内容并抽取文本
browsergroup:weburl?: string, action?: string, selector?: string浏览器自动化
spawngroup:sessionsprompt: string, agent_definition_id?: string, allowed_tools?: string[]后台异步执行子 Agent
recall_memorygroup:memoryquery: string, limit?: int检索长期记忆
save_memorygroup:memoryname: string, content: string保存长期记忆

Feature / 运行时注册工具

工具名来源分组核心参数功能
gitgit feature-command: stringGit 操作与仓库查询
code_structureast feature-path: stringAST 代码结构分析
searchbundled app skill / runtimegroup:researchquery: string单 binary 研究流水线;旧文档中的 deep_search 是 contract/兼容别名
synthesize_researchruntimegroup:researchfindings: array/object综合研究结果
deep_crawlbundled app skill / runtimegroup:researchurl: string, max_depth?: int深度爬取站点
run_pipelineCLI/Gateway/Serve per-session-dot: string 或 pipeline 配置执行 DOT 工作流
model_checkCLI runtimegroup:adminaction: string查询/切换模型配置
configure_toolcore runtimegroup:adminaction: string, tool?: string, key?: string, value?: any查看、设置或重置可配置工具字段
activate_toolscore runtime-tools: string[]激活被延迟隐藏的工具或工具组
manage_skillscore runtimegroup:adminaction: string, name?: string管理本地 skills
check_background_taskscore runtime-status?: string查看后台任务状态
read_task_outputcore runtime-task_id: string, mode: object读取后台任务输出
check_workspace_contractcore runtime-path?: string检查 workspace 合约
workspace_log / workspace_show / workspace_diffworkspace history-Git revision / diff 参数查看 workspace 变更历史
send_filechannel runtime-path: string, caption?: string向用户发送文件
messagechannel runtimegroup:delegated deny listtext: string向频道发送进度消息
send_app_card / show_weather_cardApp UI runtimegroup:media 类能力card/weather payload发送结构化 App 卡片
delegate_taskdelegate runtimegroup:delegated deny listtask: string, allowed_tools?: string[]同步委托子任务

Serve/Admin API 工具

这些工具只在 Serve/Admin API 上下文注册,名称来自 octos-agent/src/tools/admin/

类别工具
Profile 管理admin_list_profiles, admin_profile_status, admin_start_profile, admin_stop_profile, admin_restart_profile, admin_enable_profile, admin_update_profile
系统监控admin_view_logs, admin_system_health, admin_system_metrics, admin_provider_metrics, admin_manage_watchdog
诊断admin_view_sessions, admin_cron_status, admin_check_config
多租户子账号admin_list_sub_accounts, admin_create_sub_account
Skill / 平台管理admin_manage_skills, admin_platform_skills, admin_update_octos

工具分组策略

分组定义来自 octos-agent/src/tools/policy.rs

分组名包含工具典型策略
group:fsread_file, write_file, apply_patch, edit_file, diff_edit文件操作
group:runtimeshell, exec_command, write_stdin, bash命令执行
group:webweb_search, web_fetch, browser网络操作
group:searchglob, grep, list_dir代码搜索
group:sessionsspawn, spawn_agent, send_input, resume_agent, wait_agent, close_agent, delegate后台任务与子 Agent 生命周期
group:memoryrecall_memory, save_memory记忆操作
group:researchsearch, synthesize_research, deep_crawl深度研究
group:adminmanage_skills, configure_tool, model_check管理操作
group:mediamofa_comic, mofa_slides, mofa_infographic, mofa_cards, fm_tts, fm_voice_list媒体生成与语音
group:delegateddelegate_task, spawn, send_message, message, save_memory, execute_code委托子任务的 canonical deny list

策略配置示例

{
  "tool_policy": {
    "allow": ["group:fs", "group:search", "shell"],
    "deny": ["browser", "web_fetch"]
  },
  "tool_policy_by_provider": {
    "ollama": {
      "allow": ["read_file", "shell", "grep"]
    }
  }
}

deny-wins 规则:deny 列表优先于 allow 列表。上例中 browser 被禁止,即使它属于某个允许的分组。profile 级 allow/deny 还支持普通工具名、group:<id><prefix>* 通配符;spawn-only 工具会被保留,以避免后台任务 wiring 被 profile 过滤破坏。

附录 C:配置参考

本附录以当前 ../octos main 分支的 octos-cli/src/config.rsoctos-cli/src/profiles.rsoctos-agent hook/schema 为准。顶层 config.json 面向单实例运行;profile 文件面向 serve/gateway 的多 profile / 多租户运行。

配置文件位置

优先级路径说明
1(最高)<cwd>/.octos/config.json项目本地配置
2<data_dir>/config.json,通常是 ~/.octos/config.json当前数据目录配置
3平台 legacy config dir,例如 ~/.config/octos/config.json兼容旧路径;加载时会提示迁移
4(最低)内置默认值代码中定义的默认值

顶层 Config 字段

字段路径类型默认值说明
versionu32?null配置迁移版本;当前代码常量为 1
providerstring?自动检测/必填取决于运行模式LLM Provider 名称
modelstring?Provider 默认或模型检测模型 ID
base_urlstring?Provider 默认API endpoint 覆盖
api_key_envstring?Provider 默认API key 环境变量名
model_hintsobject?nullOpenAI-compatible 自定义模型行为提示
api_typestring?Provider 默认协议覆盖,例如 openai / anthropic
auth_tokenstring?CLI/env 覆盖或生成Dashboard/admin token
gatewayobject?nullGateway 模式配置,见下节
mcp_serversarray[]MCP server 配置
sandboxobjectSandboxConfig::default()工具沙箱配置
tool_policyobject?null全局工具 allow/deny/tag 策略
tool_policy_by_providerobject{}model/provider 级工具策略,精确 model ID 优先,其次 provider 前缀
embeddingobject?nullHybrid memory embedding provider
fallback_modelsarray[]Provider failover chain 的 fallback 模型
max_iterationsu32?Agent 默认,CLI 可覆盖单条消息最大 Agent 迭代数
hooksarray[]Agent 生命周期 hooks
context_filterstring[][]只向 LLM 暴露匹配 tag 的工具
sub_providersarray[]spawn 子 Agent 可选 provider
adaptive_routingobject?nullAdaptiveRouter 配置
emailobject?nullsend_email / 邮件发送配置
voiceobject?nullASR/TTS 自动转写与语音回复
modeenum"local"local / tenant / cloud
tunnel_domainstring?env 可覆盖云/租户 tunnel domain
base_domainstring?默认兼容 crew.ominix.io公开 profile 域名基座,OCTOS_BASE_DOMAIN 优先
frps_serverstring?env 可覆盖frps server 地址
allow_admin_shellboolfalse是否启用 /api/admin/shell;生产环境应保持关闭
dashboard_authobject?nullapi feature 下的 email OTP 登录配置
monitorobject?nullapi feature 下的 watchdog/alert 配置
credential_poolobject?null顶层 credential pool
content_routingobject?nullContent classifier / Cheap-Strong routing
appui.default_session_cwdpath?nullAppUI session 未声明 cwd capability 时的默认工作目录

Gateway 配置

字段路径类型默认值说明
gateway.channels[]arrayCLI channel频道配置;每项含 typeallowed_senderssettings
gateway.max_historyusize50注入 LLM 的最大历史消息数
gateway.system_promptstring?nullGateway 模式系统提示
gateway.queue_modeenum"collect"followup / collect / steer / interrupt / speculative
gateway.max_sessionsusize1000内存中保留的最大 session 数
gateway.max_concurrent_sessionsusize10最大并发 session
gateway.browser_timeout_secsu64?Browser 默认单个 browser action 超时
gateway.llm_timeout_secsu64?LLM 默认LLM HTTP 总超时
gateway.llm_connect_timeout_secsu64?LLM 默认LLM HTTP 连接超时
gateway.tool_timeout_secsu64?Runtime 默认并行工具调用总等待上限
gateway.session_timeout_secsu64?Runtime 默认单条 session 消息处理上限
gateway.max_output_tokensu32?模型默认LLM 单次输出 token 上限

Serve 配置

serve 的端口是 CLI 参数而非 Config 内嵌字段:octos serve --port <PORT>,默认端口为 50080。历史文档中的 serve.host / serve.port 不是当前 Config 结构体字段。

Provider / Embedding / Routing

字段路径类型默认值说明
fallback_models[].providerstring必填fallback provider
fallback_models[].modelstring?Provider 默认fallback model
fallback_models[].base_urlstring?Provider 默认endpoint 覆盖
fallback_models[].api_key_envstring?Provider 默认API key env
fallback_models[].model_hintsobject?nullOpenAI-compatible 模型提示
fallback_models[].api_typestring?Provider 默认协议覆盖
fallback_models[].cost_per_mf64?null每百万 token 输出成本
fallback_models[].strongbooltrue是否视为强模型
embedding.providerstring"openai"embedding provider
embedding.api_key_envstring?Provider 默认embedding API key env
embedding.base_urlstring?Provider 默认embedding endpoint
adaptive_routing.enabledboolfalse是否启用 AdaptiveRouter
adaptive_routing.modeenum"off"off / hedge / lane
adaptive_routing.latency_threshold_msu6410000延迟软阈值
adaptive_routing.error_rate_thresholdf640.3错误率阈值
adaptive_routing.probe_probabilityf640.1探针概率
adaptive_routing.probe_interval_secsu6460同 provider 探针间隔
adaptive_routing.failure_thresholdu323circuit breaker 连续失败阈值
adaptive_routing.qos_rankingboolfalse是否启用质量/吞吐排序
adaptive_routing.weight_latency/error_rate/priority/costf640.3/0.3/0.2/0.2评分权重
content_routing.enabledboolfalse关闭时所有 turn 视为 Strong

Tool Policy / MCP / Hooks

字段路径类型默认值说明
tool_policy.allowstring[][]allow list;支持工具名、group:<id>prefix*
tool_policy.denystring[][]deny list;deny-wins
tool_policy.require_tagsstring[][]只允许含指定 tag 的工具
tool_policy_by_provider.<name>object{}对指定 model/provider 叠加工具策略
mcp_servers[].commandstring?-Stdio MCP 启动命令
mcp_servers[].argsstring[][]Stdio MCP 参数
mcp_servers[].envobject{}MCP 子进程环境变量
mcp_servers[].urlstring?-HTTP MCP URL
hooks[]object[][]Hook 列表
hooks[].eventstring必填事件名,如 before_tool_call / after_tool_call / on_turn_end
hooks[].commandstring[]必填argv 形式命令,不经过 shell
hooks[].timeout_msu64hook 默认超时毫秒
hooks[].tool_filterstring[][]空数组表示不按工具过滤

Credential / Email / Voice / AppUI

字段路径类型默认值说明
credential_pool.state_pathstring?<data_dir>/credential_pool.redb顶层 pool 状态文件
credential_pool.namestring"default"pool metrics 名称
credential_pool.strategystring"round_robin"fill_first / round_robin / random / least_used
credential_pool.credential_idsstring[][]pool 内 credential IDs
credential_pool.default_cooldown_msu64?null429 无 reset hint 时的 cooldown
email.providerstring必填smtp / feishu / lark
email.smtp_host/smtp_port/username/password/password_env/from_addressmixednullSMTP 配置
email.feishu_app_id/feishu_app_secret/feishu_app_secret_env/feishu_from_address/feishu_regionmixednull / cnFeishu/Lark 邮件配置
voice.auto_asrbooltrue是否自动转写语音
voice.auto_ttsbooltrue是否自动合成语音回复
voice.default_voicestring"vivian"默认 TTS 音色
voice.asr_languagestring?nullASR 语言提示
appui.default_session_cwdpath?nullAppUI 默认 workspace cwd

Dashboard Auth / Monitor

这些字段只在启用 api feature 的 octos serve 构建中生效。未配置 dashboard_auth.smtp 时,OTP 仍可生成,但邮件发送会退化为日志提示。

字段路径类型默认值说明
dashboard_auth.smtp.hoststring必填OTP SMTP server host
dashboard_auth.smtp.portu16465SMTP port;465 为 implicit TLS,587 为 STARTTLS
dashboard_auth.smtp.usernamestring必填SMTP username
dashboard_auth.smtp.password_envstring必填/兼容SMTP password env;新安装优先使用 data-dir secret store
dashboard_auth.smtp.from_addressstring必填OTP 发件地址
dashboard_auth.session_expiry_hoursu6424Dashboard session 有效期
dashboard_auth.allow_self_registrationboolfalse未知 email 是否自动创建用户
dashboard_auth.static_tokensstring[][]E2E 测试用静态 token;生产环境不要配置
monitor.alerts_enabledbooltrue是否启用主动告警
monitor.watchdog_enabledbooltrue是否启用 watchdog 自动重启
monitor.health_check_interval_secsu6460健康检查间隔
monitor.max_restart_attemptsu323自动重启最大尝试次数
monitor.telegram_token_envstring?nullTelegram bot token env
monitor.telegram_alert_chat_idsi64[][]Telegram 告警 chat IDs
monitor.feishu_app_id_envstring?nullFeishu app id env
monitor.feishu_app_secret_envstring?nullFeishu app secret env
monitor.feishu_alert_user_idsstring[][]Feishu 告警 user IDs

Profile 配置

Profile 文件位于 ~/.octos/profiles/<id>.jsonUserProfile 顶层字段包括 idnamepublic_subdomainenableddata_dirparent_idconfigcreated_atupdated_at。子账号通过 parent_id 继承父 profile 的 LLM contract 和低层 env vars。

当前主分支的 profile LLM 配置不再是旧的顶层 provider / model,而是放在 config.llm 下:

字段路径类型说明
config.llm.primary.family_idstring?主模型家族 ID,例如 anthropicopenaimoonshot
config.llm.primary.model_idstring?主模型 ID
config.llm.primary.route.route_idstring?catalog route ID
config.llm.primary.route.labelstring?route 展示名
config.llm.primary.route.base_urlstring?route API endpoint 覆盖
config.llm.primary.route.api_key_envstring?route API key env
config.llm.primary.route.api_typestring?协议覆盖,例如 anthropic / responses
config.llm.primary.model_hintsobject?模型能力、成本、上下文窗口等提示
config.llm.primary.cost_per_mf64?每百万 token 成本
config.llm.primary.strongbool?是否标记为强模型
config.llm.fallbacks[]object[]fallback 模型列表,元素结构同 primary

Profile 还包含 typed sections:search.providersdeep_crawl.page_settle_ms/max_output_charsapps.slidesrobot.realtimechannels[]gatewayemailenv_varshooks[]admin_modesandboxadaptive_routingcost_budgetmatrix.swarm_supervisorcontent_routingcredential_pool

示例配置

{
  // 当前配置版本;缺省也可由迁移逻辑处理
  "version": 1,
  // 主 LLM provider / model
  "provider": "anthropic",
  "model": "claude-sonnet-4-20250514",
  // API key env 覆盖
  "api_key_env": "ANTHROPIC_API_KEY",
  // 单 turn 最大 Agent 迭代数
  "max_iterations": 30,
  // 全局工具策略,deny 优先
  "tool_policy": {
    "deny": ["browser"]
  },
  // 针对特定 provider 的工具策略
  "tool_policy_by_provider": {
    "ollama": {
      "allow": ["read_file", "shell", "grep"]
    }
  },
  // Gateway 运行时配置
  "gateway": {
    "max_concurrent_sessions": 20,
    "queue_mode": "collect",
    "channels": [
      {
        "type": "telegram",
        "allowed_senders": [],
        "settings": { "token_env": "TELEGRAM_BOT_TOKEN" }
      }
    ]
  },
  // MCP server 配置
  "mcp_servers": [
    {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"],
      "env": {}
    }
  ],
  // Hook 使用 argv 数组,不经过 shell
  "hooks": [
    {
      "event": "before_tool_call",
      "command": ["./audit-hook.sh"],
      "timeout_ms": 3000,
      "tool_filter": ["shell"]
    }
  ],
  // AdaptiveRouter 配置
  "adaptive_routing": {
    "enabled": true,
    "mode": "lane",
    "qos_ranking": true
  },
  // AppUI session 默认 cwd
  "appui": {
    "default_session_cwd": "/workspace"
  }
}

附录 D:Feature Flags 一览

本附录以当前 ../octos main 分支的 Cargo workspace 为准。源码审查范围包括根 Cargo.tomlmembers 列表,以及 octos-clioctos-busoctos-agent 三个 crate 的 [features] 段。当前 workspace 采用 “默认最小、按需开启通道/工具” 的策略:default = [],生产部署通过显式 feature 组合选择 API、频道、admin bot 和可选工具能力。

Feature 传播图

flowchart LR
    CLI[octos-cli]
    Bus[octos-bus]
    Agent[octos-agent]

    CLI -->|api| BusApi[octos-bus/api]
    CLI -->|telegram / discord / slack / whatsapp / email / feishu / twilio / wecom / matrix / wecom-bot / qq-bot / wechat| Bus
    CLI -->|git| AgentGit[octos-agent/git]
    CLI -->|ast| AgentAst[octos-agent/ast]
    CLI -->|admin-bot includes api| AdminBot[dep:teloxide + dep:futures + api]

    BusApi --> Bus
    AgentGit --> Agent
    AgentAst --> Agent

这张图体现了三个层次:

  1. octos-cli 是最终二进制入口,负责把用户选择的 feature 转发到下游 crate。
  2. octos-bus 承载多频道接入能力,频道相关 feature 基本都从 CLI 转发到 bus。
  3. octos-agent 承载可选工具能力,gitast 通过 CLI feature 暴露给最终构建。

octos-cli Feature Flags

Feature启用功能额外依赖 / 下游 feature默认开启
default最小 CLI 构建是,且为空
apiWeb API、dashboard、SSE/WebSocket、监控、OTP 登录、Prometheus exporter、用户与 profile 管理dep:axumdep:tower-httpdep:futuresdep:rust-embeddep:metrics-exporter-prometheusdep:lettredep:randdep:sysinfodep:subtleoctos-bus/api
admin-bot管理 Bot;依赖 API 模式dep:teloxidedep:futuresapi
telegramTelegram 频道集成octos-bus/telegram
discordDiscord 频道集成octos-bus/discord
slackSlack 频道集成octos-bus/slack
whatsappWhatsApp 频道集成octos-bus/whatsapp
emailEmail 频道集成octos-bus/email
feishu飞书频道集成octos-bus/feishu
twilioTwilio 频道集成octos-bus/twilio
wecom企业微信回调频道octos-bus/wecom
matrixMatrix 频道集成octos-bus/matrix
wecom-bot企业微信 Bot WebSocket 通道octos-bus/wecom-bot
qq-botQQ Bot WebSocket 通道octos-bus/qq-bot
wechatWeChat WebSocket bridge 通道octos-bus/wechat
gitAgent Git 工具能力octos-agent/git
astAgent AST 代码结构分析能力octos-agent/ast

octos-bus Feature Flags

Feature启用功能额外依赖默认开启
default最小 bus 构建是,且为空
apiAPI/SSE/WebSocket 接入所需 bus 类型axum
telegramTelegram channel 实现teloxide
discordDiscord channel 实现serenity
slackSlack WebSocket channel 实现tokio-tungstenite
whatsappWhatsApp WebSocket channel 实现tokio-tungstenite
feishu飞书 channel 与回调接入tokio-tungsteniteaxumrustlsrustls-native-certs
twilioTwilio webhook/API channelaxum
wecom企业微信 webhook/API channelaxum
matrixMatrix webhook/API channelaxum
wecom-bot企业微信 Bot WebSocket channeltokio-tungsteniterustlsrustls-native-certs
qq-botQQ Bot WebSocket channeltokio-tungsteniterustlsrustls-native-certs
wechatWeChat bridge WebSocket channeltokio-tungstenite
emailEmail channel,含 IMAP/SMTP 与邮件解析async-imaptokio-rustlsrustlswebpki-rootslettremailparse

octos-agent Feature Flags

Feature启用功能额外依赖默认开启
gitGit 操作与 diff 能力dep:gixdep:similar
astAST 代码结构分析,覆盖 Rust/Python/JavaScript/TypeScript parserdep:tree-sitterdep:tree-sitter-rustdep:tree-sitter-pythondep:tree-sitter-javascriptdep:tree-sitter-typescript

octos-agent 当前没有显式 default = [] 行;这表示没有默认 feature 被声明,gitast 仍然只会在上游显式开启时编译。

当前没有 [features] 的 crate

这些 crate 目前没有 Cargo feature flags;它们要么始终作为核心库编译,要么作为独立 app-skill / platform-skill 二进制维护自己的依赖边界:

类别Crate
核心库octos-coreoctos-memoryoctos-llmoctos-pipelineoctos-pluginoctos-swarmoctos-dora-mcp
平台/沙箱octos-sandboxplatform-skills/voice
App skillsnewsdeep-searchdeep-crawlsend-emailaccount-managertimeweatherwechat-bridgepipeline-guardskill-evolve
Harness starter skillsharness-starter-genericharness-starter-reportharness-starter-audioharness-starter-coding

编译示例

# 最小 CLI:不启用 API、频道集成、Git/AST 工具
cargo build -p octos-cli --release

# CLI + Web API / Dashboard
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 "api,telegram,slack,email,feishu,wecom-bot,qq-bot,wechat"

# 开发者完整构建:包括 API、所有频道、Git/AST 工具
cargo build -p octos-cli --release --all-features

设计原则

Feature flags 的工程边界不是“功能分类标签”,而是“依赖树切割线”:

  1. 频道集成放在 octos-bus,CLI 只做 feature 转发,避免 CLI 直接知道每个频道的底层依赖。
  2. api 是服务端能力开关,不只启用 axum,还会连带 dashboard embedding、Prometheus exporter、OTP/email 相关依赖和 bus API 层。
  3. admin-bot 显式依赖 api,因为它不是独立聊天频道,而是服务端管理面的入口。
  4. git / ast 放在 octos-agent,通过 CLI 暴露,避免默认构建拉入较重的 Git 和 tree-sitter 依赖。
  5. app-skill 二进制没有使用 Cargo features;它们通过 workspace member 和 skill manifest 管理分发边界,而不是通过主 CLI 的 feature flag 混编。

附录 E:从源码构建与贡献指南

本附录面向希望直接修改 octos 源码的贡献者。内容以当前 ../octos main 分支为准,主要对齐根 Cargo.tomlCLAUDE.md 的 Build & Test Commands、README 的 source build 说明,以及 .github/workflows/ci.yml 的 CI 分片策略。

环境要求

工具版本要求说明
Rust1.85.0Cargo.tomlrust-version
Edition2024Cargo.toml 的 workspace edition
Cargo resolver2workspace 使用 resolver v2
Git2.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 默认安装集,原因是:

  1. apioctos serve 的前提;不带 api 的二进制会缺少 serve 相关能力。
  2. 频道 feature 只编译对应 transport,不需要的频道可以删掉。
  3. 这个命令只安装 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
Clippycargo 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
Pipelinecargo test -p octos-pipeline
Plugin SDKcargo test -p octos-plugin
Swarmcargo test -p octos-swarm
Agent libcargo test -p octos-agent --lib
Agent integrationcargo test -p octos-agent --tests
CLI libcargo test -p octos-cli --lib
CLI integrationcargo test -p octos-cli --tests
CLI API 认证cargo test -p octos-cli --features api api::auth_handlers
Gateway runtimecargo test -p octos-cli gateway_runtime::tests --features api -- --nocapture
Harness starter skillscargo 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 与 Windows cmd /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:

  1. RED:先写能暴露问题的失败测试,测试名使用 should_<expected>_when_<condition> 风格。
  2. GREEN:写最小实现让目标测试通过。
  3. 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

常用类型包括 featfixrefactordocstestchore。提交前至少确认:

  1. 只包含当前任务相关改动。
  2. 已运行与改动范围匹配的测试。
  3. 文档、spec、示例配置与代码行为一致。
  4. 安全相关变更有专门测试或清晰审查说明。

项目结构速查

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