# 扩展 API · goal 与内部扩展点

> Codex 把 skills、记忆、网页搜索、goal 等功能做成编译进来的“扩展”：codex-extension-api 定义十几种贡献者 trait 和按类型取值的分层存储，宿主把它们装进一个不可变的注册表，内核在一轮的固定时机逐个回调。goal 扩展是完整的例子：线程一空闲就自动开下一轮，直到目标完成、受阻、暂停或预算耗尽。

- 作者：David（道雾轩）
- 专栏：Codex 源码解读（https://daiw.net/manual/codex-source.md）
- 最后更新：2026-09-29
- 原文：https://daiw.net/manual/codex-source/extension-api
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

# 扩展 API · goal 与内部扩展点

用户能直接感到的扩展，最明显的是 `/goal`：给线程定一个目标，Codex 就一轮接一轮地干下去，直到它认定完成（用法见手册的[斜杠命令](https://daiw.net/manual/codex/slash-commands)）。但 goal 只是一个例子，skills、记忆、网页搜索、生图、guardian v2 自动审查、git 署名、留言板，也都以同样的方式挂在内核上。这一篇先看这套机制，再用 goal 走一遍。

## 为什么要有扩展 API

仓库根的 `AGENTS.md` 专门有一节讲 `codex-core` 太臃肿，要求“resist adding code to codex-core”。扩展 API 是落实这条的办法：`codex-rs/ext/` 下有 16 个 crate，其中 `codex-extension-api` 只定义契约，其余大多是一个功能一个 crate。它**不是第三方插件接口**：扩展是 Rust 代码，编译进二进制，由宿主在启动时装配；第三方的打包分发走的是[插件与 marketplace](https://daiw.net/manual/codex-source/plugins)。

装配发生在宿主里。app-server 的 `thread_extensions`（`codex-rs/app-server/src/extensions.rs:50`）依次装上：排队消息（可选）、历史笔记、留言板、goal（有状态库时）、git 署名、guardian v2、记忆、托管应用的 MCP 服务器、插件 MCP、网页搜索、生图、skills。`codex-rs/ext/extension-api/notes.md` 还留着一张设计草表，列出每个功能需要哪几类贡献者，例如 goal 对应 `Tool + Runtime`，memories 对应 `Context + Tool + Output`。

## 三样东西：贡献者、注册表、分层存储

**贡献者**是一组 trait，每种对应内核里的一类时机。一个扩展实现其中几种，在 `install` 函数里把自己注册进 `ExtensionRegistryBuilder`；`build()` 之后得到不可变的 `ExtensionRegistry`，内核只读它：

```rust
pub struct ExtensionRegistry<C: Sync> {
    event_sink: Arc<dyn ExtensionEventSink>,
    turn_start_admission: Option<Arc<dyn TurnStartAdmission>>,
    thread_lifecycle_contributors: Vec<Arc<dyn ThreadLifecycleContributor<C>>>,
    turn_lifecycle_contributors: Vec<Arc<dyn TurnLifecycleContributor>>,
    config_contributors: Vec<Arc<dyn ConfigContributor<C>>>,
    token_usage_contributors: Vec<Arc<dyn TokenUsageContributor>>,
    skill_invocation_contributors: Vec<Arc<dyn SkillInvocationContributor>>,
    context_contributors: Vec<Arc<dyn ContextContributor>>,
    mcp_server_contributors: Vec<Arc<dyn McpServerContributor<C>>>,
    turn_input_contributors: Vec<Arc<dyn TurnInputContributor>>,
    tool_contributors: Vec<Arc<dyn ToolContributor>>,
    tool_lifecycle_contributors: Vec<Arc<dyn ToolLifecycleContributor>>,
    model_request_contributors: Vec<Arc<dyn ModelRequestContributor>>,
    turn_item_contributors: Vec<Arc<dyn TurnItemContributor>>,
    approval_review_contributors: Vec<Arc<dyn ApprovalReviewContributor>>,
}
```

（`codex-rs/ext/extension-api/src/registry.rs:154`）

泛型 `C` 是宿主的配置类型，app-server 里就是 `Config`。每个列表按注册顺序调用，顺序有语义：审批评审取第一个认领的决定，MCP 服务器后注册的同名贡献覆盖先注册的。

**分层存储**是 `ExtensionData`：一张以 `TypeId` 为键的表，每种类型一格，取值用 `get`、`get_or_init`、`insert_if`。内核给每个回调递的是存储而不是内核对象（trait 注释原话是 “The host exposes stable identifiers and extension stores instead of core runtime objects”），一共四层：会话（`level_id` 是会话 id）、线程（线程 id）、轮次（轮次 id）、采样步骤。扩展把私有状态挂在合适的层上，比如 goal 的运行时就挂在线程层。宿主还可以在线程启动前用 `ExtensionDataInit` 塞入只读策略，例如限制可用工具的 `ToolPolicy`、给内部会话去掉继承能力的 `SessionIsolation`。

**宿主能力**是另一组 trait，由宿主实现、交给扩展用：`ExtensionEventSink` 发事件和警告（app-server 只转发目标更新与排队变化两类事件，警告截到 256 字节），`ResponseItemInjector` 往进行中的轮次插输入，`ExtensionMetrics` 打点，`ConversationHistorySnapshot` 读历史快照。

## 一轮里，扩展点在什么时候被调用

```mermaid
flowchart TB
  TS[线程启动 on_thread_start<br/>注册完成 on_thread_ready] --> ST[start_task<br/>on_turn_start 任务登记前]
  ST --> RT[常规任务启动<br/>on_turn_start RegularTaskStart]
  RT --> CTX[组装上下文<br/>ContextContributor 与 TurnInputContributor]
  CTX --> TOOLS[组装本步工具表<br/>ToolContributor]
  TOOLS --> REQ[请求模型<br/>ModelRequestContributor 包住响应流]
  REQ --> ITEM[条目完成 on_item_completed<br/>用量 on_token_usage]
  ITEM --> CALL{模型要调工具吗}
  CALL -->|是| TL[ToolLifecycleContributor<br/>dispatch start finish]
  TL --> TOOLS
  CALL -->|否| STOP[on_turn_stop]
  STOP --> IDLE[线程空闲 on_thread_idle]
  IDLE -.->|goal 续跑| ST
```

| 贡献者 | 内核调用处（路径均在 `codex-rs/core/src/` 下） | 时机 |
| --- | --- | --- |
| `ThreadLifecycleContributor` | `Session::new`、`codex_thread.rs`、`tasks/lifecycle.rs`、`session/handlers.rs` | 线程启动、注册完成、从历史恢复、空闲、关停（在会话结束 hook 之后） |
| `ConfigContributor` | `session/mod.rs` | 线程配置变更提交之后，拿到新旧两份配置 |
| `TurnLifecycleContributor` | `tasks/mod.rs` 的 `start_task`、`tasks/regular.rs` | 轮次开始（任务登记前，或常规任务启动时，由贡献者自选阶段）、条目完成、结束、中止、出错 |
| `ContextContributor` | `build_initial_context_with_world_state` 等 | 组装提示词：线程级片段、每轮片段、world state 小节 |
| `TurnInputContributor` | `session/turn.rs` 的 `build_extension_turn_input_items` | 本轮用户输入入账时，追加扩展自己的上下文片段 |
| `ToolContributor` | `tools/spec_plan.rs` 的 `extension_tool_executors` | 每个采样步骤组装工具表 |
| `ToolLifecycleContributor` | `tools/parallel.rs`、`tools/lifecycle.rs` | 工具分发、开始、内置命令开始、MCP 结果发出前、结束、计时 |
| `ApprovalReviewContributor` | `ExtensionRegistry::decide_approval` | 审批请求，第一个认领的说了算 |
| `TokenUsageContributor` | `session/mod.rs` | 每次记下模型返回的 token 用量 |
| `SkillInvocationContributor` | `skills.rs` | 显式加载或隐式调用一个 skill |
| `McpServerContributor` | `mcp.rs` | 解析运行时 MCP 服务器 |
| `ModelRequestContributor` | `model_request.rs` | 请求发出前挂上响应流拦截器 |
| `TurnItemContributor` | `stream_events_utils.rs` | 解析出的条目发出前可以改写 |

最后两种目前没有扩展在用，仓库里只有测试实现。另有一个 `TurnStartAdmission`，是宿主给“开新一轮”加的闸，app-server 用它在关机排空时拒绝再开新轮次。

以 `McpServerContributor` 为例：`codex-mcp-extension` 的 `HostedPluginRuntimeExtension` 在 `apps` 功能开着时贡献保留名 `codex_apps` 的托管应用服务器，关着时发一条 `Remove`；`install_plugins` 装上的协调器从执行环境里选中的插件根目录解析插件自带的 MCP 服务器。内核 `mcp.rs` 先由配置（含已加载的插件）建出服务器目录，再按注册顺序把这些贡献叠加上去，冲突记一条警告；之后的连接与工具目录见 [MCP 工具调用](https://daiw.net/manual/codex-source/mcp-tool-calls)。

## goal：一个完整的扩展

用户侧有三个入口：TUI 的 `/goal <目标>`（以及 `clear`、`edit`、`pause`、`resume`），app-server 的 `thread/goal/set`、`thread/goal/get`、`thread/goal/clear`，以及给模型的三个工具 `get_goal`、`create_goal`、`update_goal`。功能开关是 `goals`（稳定，默认开）。目标存在单独的 SQLite 库 `goals_1.sqlite` 的 `thread_goals` 表里（和线程元数据的 `state_5.sqlite` 分开），每个线程最多一个，状态有 `active`、`paused`、`blocked`、`usage_limited`、`budget_limited`、`complete` 六种，另记 `token_budget`、`tokens_used`、`time_used_seconds`。目标文本最多 4000 个字符；`goals.max_goal_token_budget` 既是预算上限，也是新目标的默认预算。

`codex-goal-extension` 在注册时一次认领六种角色：

```rust
    registry.thread_lifecycle_contributor(extension.clone());
    registry.config_contributor(extension.clone());
    registry.turn_lifecycle_contributor(extension.clone());
    registry.token_usage_contributor(extension.clone());
    registry.tool_lifecycle_contributor(extension.clone());
    registry.tool_contributor(extension);
```

（`codex-rs/ext/goal/src/extension.rs:607`）

线程启动时它在线程层建一个 `GoalRuntimeHandle`；审查子代理看不到这三个工具，没有持久化状态的线程能看到但调用会失败。整个运转靠“空闲就续跑”：

```mermaid
sequenceDiagram
  participant U as 用户或模型
  participant G as goal 扩展
  participant DB as thread_goals 表
  participant T as CodexThread
  U->>G: /goal 目标 或 create_goal
  G->>DB: 写入目标 状态 active
  loop 目标仍是 active
    G->>T: start_turn_if_idle 注入续跑提示
    T->>G: on_tool_finish 与 on_turn_stop
    G->>DB: 累加 tokens_used 与 time_used_seconds
    T->>G: 轮次结束后 on_thread_idle
  end
  Note over G,DB: complete blocked paused 或预算耗尽时退出循环
```

`on_thread_idle` 调 `continue_if_idle`：拿到目标状态锁，读库确认目标仍是 `active`，用模板 `goals/continuation.md` 渲染一段续跑提示 `item`（带上目标、已用与剩余 token），然后：

```rust
        match thread
            .start_turn_if_idle(
                TurnInputRequest::new(TurnInput::ResponseItem(item)).on_start(TurnStartOptions {
                    turn_trigger: Some("goal".to_string()),
                    ..start_options
                }),
            )
            .await
        {
            Ok(StartIfIdleSubmission::Started { turn_id }) => {
                // Turn-stop evaluation takes the same permit, so even a fast response
                // cannot finish before this host-admitted continuation is identified.
                self.inner.accounting_state.mark_goal_continuation(turn_id);
            }
            // ...
        }
```

（`codex-rs/ext/goal/src/runtime.rs:479`）

续跑提示是一段运行时注入的隐藏上下文（`InternalModelContextFragment`，来源标为 `goal`），不是用户消息。`start_turn_if_idle` 会拒绝几种情况：线程不空闲、有待处理的触发型信件、要在 Plan 模式下跑的自动输入、宿主正在排空。app-server 把排队消息扩展注册在最前（注释说它要排在优先级更低的空闲贡献者之前），所以用户排队的消息先于 goal 续跑拿到空闲的线程。

记账在工具结束、轮次结束或中止、外部改目标时发生，写回 `thread_goals`。计入的 token 是“输入减去缓存命中的输入，再加输出”，子代理的用量也记到根线程的目标上；Plan 模式的轮次不计。续跑提示本身要求模型做“完成审计”，而刹车一共有这几道：

- 模型调用 `update_goal`，只能设成 `complete`、`blocked` 或 `paused`（后者须用户明确要求），恢复与预算类状态由用户或系统控制；
- 预算用完：工具结束时记账发现状态变成 `budget_limited`，就把 `goals/budget_limit.md` 的提示插进当前轮次，让模型收尾；
- 轮次出错：用量上限设为 `usage_limited`，其它让轮次终止的错误（不可重试或重试已耗尽）设为 `blocked`；
- 自动续跑的轮次连续 3 次只给出空回复、毫无动作，或连续 3 轮里 Code mode 的 `exec` 工具执行失败且没有任何工具成功，都设为 `blocked`；
- TUI 里中断一个目标轮次时，TUI 顺手把目标设为 `paused`。

## 和《从 LLM 到 Coding Agent》对照

那本的 [Hooks](https://daiw.net/manual/llm-to-agent/hooks) 讲了 Stop 钩子如何“拽住想收工的 Agent”，并提醒任何能让循环延续的机制都必须自带刹车。goal 正是一个编译进来的 Stop 钩子：它在线程空闲时再开一轮，刹车就是上面那几道状态转换和预算。区别在于层次：用户写的 [Hooks](https://daiw.net/manual/codex-source/hooks) 是外部脚本，改的是单次工具调用或单轮；扩展 API 是给 Codex 自己的功能用的进程内插槽，能拿到分层存储、工具表和模型请求。

---

上一篇：[多 agent · 子代理的派生与协作](https://daiw.net/manual/codex-source/multi-agent) · 下一篇：[记忆 · 从历史会话里提炼经验](https://daiw.net/manual/codex-source/memories)
