扩展 API · goal 与内部扩展点
Codex 把 skills、记忆、网页搜索、goal 等功能做成编译进来的“扩展”:codex-extension-api 定义十几种贡献者 trait 和按类型取值的分层存储,宿主把它们装进一个不可变的注册表,内核在一轮的固定时机逐个回调。goal 扩展是完整的例子:线程一空闲就自动开下一轮,直到目标完成、受阻、暂停或预算耗尽。
扩展 API · goal 与内部扩展点
用户能直接感到的扩展,最明显的是 /goal:给线程定一个目标,Codex 就一轮接一轮地干下去,直到它认定完成(用法见手册的斜杠命令)。但 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。
装配发生在宿主里。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,内核只读它:
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 读历史快照。
一轮里,扩展点在什么时候被调用
| 贡献者 | 内核调用处(路径均在 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 工具调用。
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 在注册时一次认领六种角色:
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;审查子代理看不到这三个工具,没有持久化状态的线程能看到但调用会失败。整个运转靠“空闲就续跑”:
on_thread_idle 调 continue_if_idle:拿到目标状态锁,读库确认目标仍是 active,用模板 goals/continuation.md 渲染一段续跑提示 item(带上目标、已用与剩余 token),然后:
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 讲了 Stop 钩子如何“拽住想收工的 Agent”,并提醒任何能让循环延续的机制都必须自带刹车。goal 正是一个编译进来的 Stop 钩子:它在线程空闲时再开一轮,刹车就是上面那几道状态转换和预算。区别在于层次:用户写的 Hooks 是外部脚本,改的是单次工具调用或单轮;扩展 API 是给 Codex 自己的功能用的进程内插槽,能拿到分层存储、工具表和模型请求。
上一篇:多 agent · 子代理的派生与协作 · 下一篇:记忆 · 从历史会话里提炼经验