ARTICLE · 1274085
Codex 源码-Extension API 扩展层——贡献者插槽与三级状态存储
Codex 源码解析系列
第 35 讲:Extension API 扩展层——贡献者插槽与三级状态存储
基于 OpenAI Codex 源码 · 2026-10-29
💡 本讲一句话:Codex 的扩展 API 就是给 core 留的一套"USB 插槽"——web-search、生图、Goals、消息队列各自实现几个 trait,把自己注册进同一个 ExtensionRegistry;core 只认插槽不认功能。读完你能看懂任何新扩展是怎么"插"进来的。
一、为什么需要一层 Extension API
前面几讲拆过 core(会话/轮次循环)、tools(工具注册与路由)、guardian(安全审查)。但你会发现一个矛盾:web-search、image-generation、Goals、消息队列这些功能,代码全在 ext/ 下的独立 crate 里,可 core 的 turn 循环又必须知道"这一轮有哪些工具可用""prompt 要拼哪些片段""审批该问谁"。
如果让 core 直接 use codex_web_search_extension,每加一个功能就要改一次 core——依赖方向反了。Codex 的解法是 codex-extension-api(约 3K LOC):core 只依赖这一层抽象 trait;每个功能 crate 实现其中几个 trait,通过统一的 install() 函数把自己"插"进注册表。core 从头到尾不知道具体是谁在贡献工具。
为什么这样设计:这是典型的依赖倒置——把"功能清单"从 core 的编译期依赖降级为运行期的注册表内容。收益有三:① 新功能可以完全在 ext/ 下开发,core 零改动;② 不同宿主(CLI、app-server)可以装不同的扩展组合;③ trait 默认实现全是空操作,一个功能只需要实现自己关心的插槽。
二、ExtensionRegistryBuilder:先注册,后冻结
注册表分两个阶段:Builder(可变)负责收集贡献者,build() 之后变成不可变的 ExtensionRegistry,用 Arc 共享给整个线程运行时。看注册表本体(ext/extension-api/src/registry.rs 第 146-162 行):
📄 codex-rs/ext/extension-api/src/registry.rs (第 146-162 行)
/// Immutable typed registry produced after extensions are installed. // 注释点明:build() 之后注册表不可变,只能读pub struct ExtensionRegistry<C: Sync> { // C 是宿主配置类型(core 里就是 Config),Sync 保证跨线程共享安全 event_sink: Arc<dyn ExtensionEventSink>, // 事件出口:扩展想通知 UI/客户端时走这里,而不是自己开连接 turn_start_admission: Option<Arc<dyn TurnStartAdmission>>, // 宿主级"准入门禁":关机排空期间拒绝新轮次启动(可缺省) thread_lifecycle_contributors: Vec<Arc<dyn ThreadLifecycleContributor<C>>>, // 线程生命周期插槽:start/ready/resume/idle/stop 五个钩子 turn_lifecycle_contributors: Vec<Arc<dyn TurnLifecycleContributor>>, // 轮次生命周期插槽:turn start/item completed/stop/abort/error config_contributors: Vec<Arc<dyn ConfigContributor<C>>>, // 配置变更插槽:线程配置提交后回调,扩展借此刷新自己的快照 token_usage_contributors: Vec<Arc<dyn TokenUsageContributor>>, // token 用量插槽:模型每回一次包就通知(Goals 用它记账) skill_invocation_contributors: Vec<Arc<dyn SkillInvocationContributor>>, // 技能调用插槽:显式加载/隐式命中技能时回调 context_contributors: Vec<Arc<dyn ContextContributor>>, // prompt 片段插槽:thread/turn/world-state 三个作用域各贡献一段 mcp_server_contributors: Vec<Arc<dyn McpServerContributor<C>>>, // MCP server 解析插槽:扩展可以动态声明要连哪些 MCP server turn_input_contributors: Vec<Arc<dyn TurnInputContributor>>, // 轮次输入插槽:给本轮模型输入追加上下文片段(如技能说明) tool_contributors: Vec<Arc<dyn ToolContributor>>, // 工具插槽:本讲主角,扩展在这里贡献原生工具 tool_lifecycle_contributors: Vec<Arc<dyn ToolLifecycleContributor>>, // 工具生命周期插槽:tool start/finish、MCP 结果后处理(只观察不改写) turn_item_contributors: Vec<Arc<dyn TurnItemContributor>>, // 有序 item 后处理插槽:item 发出前可改写,按注册顺序串行执行 approval_review_contributors: Vec<Arc<dyn ApprovalReviewContributor>>, // 审批决策插槽:链式认领,第一个返回 Some 的说了算(guardian-v2 住这里)}注意两个设计细节。第一,所有插槽都是 Vec——同一类贡献者可以注册多个(比如多个扩展都能贡献工具),core 遍历时按注册顺序处理;审批链更是显式依赖这个顺序:第一个认领的请求就终止遍历。第二,Builder 阶段还允许宿主注入两个"反向能力":with_event_sink()(扩展→宿主的出口)和 turn_start_admission()(宿主→扩展的闸门)。一个管"扩展往外说",一个管"宿主拦扩展"。
为什么这样设计:"Builder → build() → Arc<Registry>"是 Rust 里经典的 builder 模式:可变期集中做依赖装配,之后整个运行时只持有一个不可变快照。这避免了运行中动态增删扩展带来的锁与一致性问题——扩展集合在进程生命周期内是固定的,变化的是每个线程自己的状态(下一节)。
三、ExtensionData:三级作用域 + TypeId 类型擦除存储
扩展之间不能互相 import(否则又耦合了),那它们怎么共享数据?答案是 ExtensionData:一个按 TypeId 索引的类型擦除 Map,挂在三个作用域上——session(进程级)/ thread(线程级)/ turn(轮次级)。每个扩展把自己的私有状态存进对应级别,其他扩展想读就按类型取。核心 API(ext/extension-api/src/state.rs 第 73-95 行):
📄 codex-rs/ext/extension-api/src/state.rs (第 73-95 行)
/// Returns the attached value of type `T`, if one exists. // 文档注释:按类型取,没有就返回 None——不猜、不造 pub fn get<T>(&self) -> Option<Arc<T>> // T: Any + Send + Sync(见下方 where),返回值用 Arc 共享而非拷贝 where T: Any + Send + Sync, // 三个约束缺一不可:Any 才能 downcast,Send+Sync 才能跨线程放进共享存储 { let value = self.entries().get(&TypeId::of::<T>())?.clone(); // TypeId 当 key 查表;? 让"没存过"直接短路成 None Some(downcast_data(value)) // downcast 失败会 unreachable!——同类型存取不可能错,错了就是 bug 该炸 } /// Returns the attached value of type `T`, inserting one from `init` when absent. // get_or_init:懒初始化 pub fn get_or_init<T>(&self, init: impl FnOnce() -> T) -> Arc<T> // 返回 Arc,多次调用拿到同一个实例(单例语义) where T: Any + Send + Sync, // 同上:类型必须可擦除、可跨线程 { let mut entries = self.entries(); // 先拿锁再判断——保证"检查+插入"原子,并发下不会建两份 let value = entries .entry(TypeId::of::<T>()) .or_insert_with(|| Arc::new(init())); // entry API:存在就复用,不存在才跑 init 构造并装箱成 Arc downcast_data(Arc::clone(value)) // clone 的是 Arc 引用计数,不是数据本身——零拷贝共享 }还有两个值得注意的 API:insert_if(value, should_insert)(第 111-124 行)把"检查旧值 + 写入新值"放在同一把锁内完成,防止并发调用方基于过期快照做出错误替换;remove()(第 127-132 行)用于线程销毁时清理私有状态。整个 Map 用 Mutex<HashMap<TypeId, Arc<dyn Any + Send + Sync>>> 实现,锁中毒时直接 PoisonError::into_inner 恢复——fail loud 而不是让线程卡死。
为什么这样设计:三级作用域是刻意的隔离:session 级放进程共享的东西(如 AuthManager),thread 级放"这个会话专属"的配置快照,turn/step 级放单轮临时状态。扩展 A 往 thread_store 里塞的数据,扩展 B 只有知道确切类型才能取到——没有全局命名空间,就不会有意外覆盖。而 TypeId + Any 的组合让 core 完全不需要认识任何具体扩展的类型:它只是把三个 ExtensionData 引用递进每个 trait 方法,谁存谁取,互不干扰。
四、贡献者插槽全景:14 个 trait 各管一段生命周期
所有插槽定义在 ext/extension-api/src/contributors.rs(384 行)。它们共享同一个异步返回类型别名:
📄 codex-rs/ext/extension-api/src/contributors.rs (第 65-66 行)
/// Boxed, sendable future returned by asynchronous extension contributors. // 文档注释:所有异步插槽统一返回这个类型pub type ExtensionFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>; // RPITIT 的替代方案:trait 方法返回具体 future 类型,实现方用 async fn 满足为什么不用 async fn in trait?因为扩展贡献者要跨 crate、跨线程共享(Arc<dyn Trait>),返回的 future 必须能静态断言 Send——Box 化 + 显式 Send bound 是最稳的写法。下面看三个最有代表性的插槽定义:
📄 codex-rs/ext/extension-api/src/contributors.rs (第 93-129 行)
/// Extension contribution that adds prompt fragments during prompt assembly. // 文档注释:prompt 组装期贡献片段pub trait ContextContributor: Send + Sync { // 三个方法全有默认空实现——扩展只挑自己关心的作用域实现 /// Returns thread-scoped context using the supplied extension state. // 线程级上下文:输入稳定,适合放长期有效的说明 fn contribute_thread_context<'a>( &'a self, session_store: &'a ExtensionData, // 把三级存储直接递进来——扩展想读什么级别的状态自己取 thread_store: &'a ExtensionData, ) -> ExtensionFuture<'a, Vec<PromptFragment>> { // 返回片段列表,core 按注册顺序拼进 system prompt Box::pin(async move { let _self = self; let _session_store = session_store; let _thread_store = thread_store; Vec::new() // 默认贡献"空":不实现就等于不参与——opt-in 语义 }) } fn contribute_turn_context<'a>( &'a self, input: TurnContextContributionInput<'a>, // turn 级输入:依赖本轮宿主状态(如当前审批模式)的片段走这里 ) -> ExtensionFuture<'a, Vec<PromptFragment>> { Box::pin(async move { let _self = self; let _input = input; Vec::new() // 同上:默认空实现,保持 opt-in }) } fn contribute_world_state<'a>( &'a self, input: WorldStateContributionInput<'a>, // world-state 作用域:贡献"世界状态"章节(前几讲拆过 world_state.rs) ) -> ExtensionFuture<'a, Vec<WorldStateSectionContribution>> { Box::pin(async move { let _self = self; let _input = input; Vec::new() // 默认空实现:不关心 world state 的扩展零成本跳过 }) }}再看工具插槽和审批链——一个管"贡献能力",一个管"认领决策":
📄 codex-rs/ext/extension-api/src/contributors.rs (第 312-330 行)
/// Extension contribution that exposes native tools owned by a feature. // 文档注释:扩展贡献"原生工具"(非 MCP)pub trait ToolContributor: Send + Sync { // 本讲主角插槽:web-search/生图/goal/skills 都靠它把工具塞进模型视野 /// Returns native tools bound to the supplied extension state. // 线程级工具列表:大多数扩展只需要这一档 fn tools( &self, session_store: &ExtensionData, // 三级存储递进来,扩展按自己的配置快照决定给不给工具 thread_store: &ExtensionData, ) -> Vec<Arc<dyn for<'call> ToolExecutor<ToolCall<'call>>>; // HRTB 的 for<'call>:返回的工具必须能服务任意生命周期的调用 /// Returns native tools bound to one sampling step. // 采样步级工具:更细粒度的作用域 fn tools_for_step( &self, session_store: &ExtensionData, thread_store: &ExtensionData, _step_store: &ExtensionData, // step 级存储:单步临时状态(如 code-mode 的 cell 上下文) ) -> Vec<Arc<dyn for<'call> ToolExecutor<ToolCall<'call>>> { self.tools(session_store, thread_store) // 默认退化成线程级——不需要步级差异的扩展不用实现 }}📄 codex-rs/ext/extension-api/src/contributors.rs (第 360-370 行)
/// Owns the complete approval decision, including whether to consult a reviewer. // 文档注释:审批决策的"完整所有权"pub trait ApprovalReviewContributor: Send + Sync { // guardian-v2 就实现这个——它有权决定"要不要问人/问哪个 agent" /// Claims one request, including a handoff to the user. // "认领"语义:返回 Some 即终止整条链 fn decide<'a>( &'a self, _input: &'a ApprovalDecisionInput<'_>, // 输入含工具名、参数、风险分等——扩展据此判断该不该管 ) -> ExtensionFuture<'a, Option<ApprovalDecision>> { // None = "我不认领,交给下一个";Some = 我说了算(含转交用户) Box::pin(std::future::ready(None)) // 默认不认领:没实现审批逻辑的扩展自动跳过 }}为什么这样设计:把"贡献能力"(ToolContributor/ContextContributor)和"观察事件"(Thread/Turn/ToolLifecycleContributor)分成两组 trait,是因为两者的失败语义完全不同:前者返回空列表就是静默缺席,后者回调里 panic 会打断宿主流程——所以观察类插槽的默认实现全部是 std::future::ready(())(零开销空 future),而贡献类插槽返回空集合。审批链用 Option 表达"认领/不认领",比布尔值 + 结果分离更诚实:决策和归属是同一件事。
14 个插槽按职责可以归成五组:
五、标准扩展模板:web-search 与 image-generation
两个"轻量功能扩展"几乎共用同一套骨架:ThreadLifecycleContributor 播种配置 → ConfigContributor 刷新配置 → ToolContributor 按配置决定给不给工具。看 web-search(ext/web-search/src/extension.rs 第 94-153 行):
📄 codex-rs/ext/web-search/src/extension.rs (第 94-153 行)
impl ThreadLifecycleContributor<Config> for WebSearchExtension { // 播种钩子:线程启动时把"当前配置快照"存进 thread_store fn on_thread_start<'a>( &'a self, input: ThreadStartInput<'a, Config>, // 输入里带 config 引用 + thread_store——扩展自己决定存什么 ) -> ExtensionFuture<'a, ()> { Box::pin(async move { input .thread_store .insert(WebSearchExtensionConfig::from(input.config)); // 把 available/provider/settings 三件套固化成快照,后续 tools() 只读快照不碰活配置 }) }}impl ConfigContributor<Config> for WebSearchExtension { // 刷新钩子:线程配置提交变更后重新播种 fn on_config_changed( &self, _session_store: &ExtensionData, thread_store: &'a ExtensionData, // 只关心 thread 级存储——web-search 的状态本来就是线程专属的 _previous_config: &Config, // previous 故意不用:快照整体替换,不做增量 diff new_config: &Config, ) { thread_store.insert(WebSearchExtensionConfig::from(new_config)); // insert 覆盖旧快照——原子换掉,不存在半新半旧 }}impl ToolContributor for WebSearchExtension { // 工具钩子:每轮组装工具表时被 core 调用 fn tools( &self, session_store: &ExtensionData, thread_store: &ExtensionData, ) -> Vec<Arc<dyn for<'call> ToolExecutor<ToolCall<'call>>>> { // 返回空 Vec = 本轮不暴露 web.run 工具,模型根本看不见它 let Some(config) = thread_store.get::<WebSearchExtensionConfig>() else { return Vec::new(); // 没播种过(理论上不该发生)——fail loud:不给工具而不是猜一个默认值 }; if !config.available { return Vec::new(); // provider 不支持或用户显式 Disabled——静默缺席,不报错不打扰 } vec![Arc::new(WebSearchTool { // 可用才构造真实工具实例 session_id: session_store.level_id().to_string(), // level_id 就是宿主对象 id(session/thread 的 uuid)——借存储拿身份 provider: create_model_provider( config.provider.clone(), Some(self.auth_manager.clone()), // auth_manager 是扩展构造时注入的重依赖,不塞进快照 ), settings: config.settings.clone(), // 搜索模式/上下文大小/域名过滤等,全部来自播种时的快照 originator: thread_store .get::<ThreadOriginator>() .map(|originator| originator.0.clone()), // 调用方身份(谁发起的线程)——用于服务端审计与配额 })] }}pub fn install(registry: &mut ExtensionRegistryBuilder<Config>, auth_manager: Arc<AuthManager>) { // 统一入口:宿主只调这一个函数 let extension = Arc::new(WebSearchExtension { auth_manager }); // 重依赖(auth)挂在扩展值上,三个插槽共享同一份 registry.thread_lifecycle_contributor(extension.clone()); // 同一个 Arc 注册三次——trait object 各自独立,状态却共享 registry.config_contributor(extension.clone()); registry.tool_contributor(extension);}image-generation(ext/image-generation/src/extension.rs 第 50-123 行)结构完全同构,只有两处差异值得注意:① available 的判定条件不同——web-search 还要求 web_search_mode != Disabled,生图只看 provider 是否 OpenAI 系;② install 多收一个闭包参数 resolve_save_root: impl Fn(&Config) -> Option<AbsolutePathBuf>——把"图片存哪"的策略交给宿主决定(app-server 传 config.codex_home),扩展本身不关心路径规则。
为什么这样设计:"播种快照 + 按快照供工具"把两个时间解耦了:配置在 T0 时刻被读一次,工具表在每轮 Tn 组装时只读快照。好处是配置变更(on_config_changed)和工具可见性之间没有竞态——不存在"读到一半配置变了"的窗口。而 install() 把三个插槽绑在同一个 Arc 上,保证播种、刷新、供工具看到的是同一份状态——这是扩展内部一致性的最小契约。
六、重型扩展:goal 占六个插槽,queue 靠 idle 钩子派发
Goals(ext/goal/)是"功能即子系统"的代表:它要跟踪目标进度、记账 token 预算、在工具执行时累计尝试次数,所以一口气实现了六个插槽。看它的 install(ext/goal/src/extension.rs 第 571-602 行):
📄 codex-rs/ext/goal/src/extension.rs (第 571-602 行)
pub fn install_with_backend<C>( // 泛型 C:goal 不绑定具体配置类型,core/app-server 各自传自己的 registry: &mut ExtensionRegistryBuilder<C>, state_dbs: Arc<codex_state::StateRuntime>, // 持久化后端:目标状态落 SQLite(第29讲拆过的 State Runtime) analytics_events_client: AnalyticsEventsClient, // 分析事件出口:goal 完成/失败要上报 metrics_client: Option<MetricsClient>, // OTEL 指标(可缺省——测试环境没有 collector 也能跑) thread_manager: Weak<ThreadManager>, // Weak 引用!避免 goal→thread_manager→extensions→goal 的引用环 goal_service: Arc<GoalService>, // 进程级服务:管理所有线程的 runtime 注册表 goal_config: impl Fn(&C) -> GoalExtensionConfig + Send + Sync + 'static, // 配置解析闭包:宿主决定"goal 开不开、预算多少") where C: Send + Sync + 'static, // 同前:跨线程共享的硬约束{ let extension = Arc::new(GoalExtension::new_with_host_capabilities( // 构造时把 registry.event_sink() 也取走——goal 要主动推事件给 UI state_dbs, analytics_events_client, registry.event_sink(), metrics_client, thread_manager, Arc::clone(&goal_service), goal_config, )); registry.thread_lifecycle_contributor(extension.clone()); // 插槽1:start 时建 GoalRuntimeHandle、resume 时恢复状态 registry.config_contributor(extension.clone()); // 插槽2:配置变更时 set_enabled()——开关可热切换 registry.turn_lifecycle_contributor(extension.clone()); // 插槽3:turn start/stop/abort/error 都影响目标进度 registry.token_usage_contributor(extension.clone()); // 插槽4:每次模型回包记账,逼近预算就触发 steering registry.tool_lifecycle_contributor(extension.clone()); // 插槽5:on_tool_finish 统计"有效尝试次数"推进度 registry.tool_contributor(extension); // 插槽6:贡献 goal 相关工具(如更新目标状态)}它的 on_thread_start(第 100-159 行)展示了扩展如何"认领宿主能力":从 session_source 里解析父线程 id,再经 goal_service 找到根线程的记账状态——子 agent 的目标预算最终挂在根线程上。注意第 104-108 行:SessionSource::SubAgent(SubAgentSource::Review)(guardian 审查子 agent)被显式排除在工具可用范围外——扩展之间通过"宿主递进来的输入"间接协作,而不是互相调用。
queue(ext/queue/src/service.rs)则展示了另一个高频钩子:on_thread_idle。用户排队发的消息不能立刻执行(线程正忙),要等线程空闲时派发:
📄 codex-rs/ext/queue/src/service.rs (第 534-567 行)
impl<C> ThreadLifecycleContributor<C> for QueuedItemService // queue 服务本身就是一个贡献者——服务即扩展where C: Send + Sync + 'static, // 同前:宿主配置类型泛型化{ fn on_thread_resume<'a>(&'a self, input: ThreadResumeInput<'a>) -> ExtensionFuture<'a, ()> { // resume 钩子:记录"这个线程刚被恢复过" Box::pin(async move { if let Ok(thread_id) = ThreadId::from_string(input.thread_store.level_id()) { // level_id → ThreadId,解析失败就静默跳过 self.resumed_threads .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) // 锁中毒恢复而非 panic——后台服务不能因一次异常永久失效 .insert(thread_id); // 标记后,watcher 轮询时能区分"新线程"和"刚 resume 的旧线程" } }) } fn on_thread_idle<'a>(&'a self, input: ThreadIdleInput<'a>) -> ExtensionFuture<'a, ()> { // idle 钩子:派发时机 = 线程真正空闲 Box::pin(async move { if input.cause == ThreadIdleCause::Interrupted { return; // 用户打断导致的"空闲"不派发——打断语义是"停下",不是"接着跑队列" } let Ok(thread_id) = ThreadId::from_string(input.thread_store.level_id()) else { tracing::warn!( level_id = input.thread_store.level_id(), // 日志带上原始 id——方便排查"为什么没派发" "queue extension received an invalid thread id" ); return; // fail loud:记警告后放弃,不猜线程身份 }; let _guard = self.dispatch_guard(thread_id).await; // per-thread 派发锁:同一线程的队列串行消费,不同线程互不阻塞 if let Err(error) = self.dispatch_if_idle(thread_id).await { // 真正取队首 → 走 core 提交(会再过 admission 闸门) tracing::warn!(%thread_id, %error, "failed to dispatch queued user input"); // 失败只记日志——队列项还在库里,下次 idle 重试 } }) }}为什么这样设计:queue 把"何时派发"的判断权交给宿主的 idle 钩子,而不是自己轮询线程状态——扩展不持有线程运行时引用(只有 Weak<ThreadManager>),天然不会阻止线程被销毁。Interrupted 分支尤其关键:用户按 Esc 打断后如果队列立刻自动续跑,体验就是"我明明停了它还在动"。而 goal 的六插槽组合说明这套 API 的表达力上限——一个子系统需要的全部宿主触点(生命周期、配置、用量、工具)都有对应插槽,不需要 core 为它开任何后门。
七、宿主侧:事件出口与关机准入闸门
扩展是"只进不出"的——它不能直接给客户端发消息,必须走 ExtensionEventSink。app-server 的实现(app-server/src/extensions.rs 第 180-216 行)展示了宿主如何把扩展事件翻译成协议通知:
📄 codex-rs/app-server/src/extensions.rs (第 180-216 行)
impl ExtensionEventSink for AppServerExtensionEventSink { // 宿主实现出口 trait:扩展调 emit(),宿主决定怎么送达 fn emit(&self, event: Event) { // 同步入口——扩展不感知网络细节,也不阻塞在 IO 上 match event.msg { EventMsg::ThreadQueueChanged(queue_event) => { // queue 扩展派发后推的事件:UI 要刷新"排队中"列表 let thread_id = queue_event.thread_id; if let Some(listener_command_tx) = self .thread_state_manager .current_listener_command_tx(thread_id) // 优先走 listener FIFO——保证与同线程其他事件严格有序 { let command = ThreadListenerCommand::EmitThreadQueueChanged; if listener_command_tx.send(command).is_ok() { return; // 入队成功即返回:emit() 本身零 IO、零 await,扩展侧无感 } tracing::warn!( "failed to enqueue extension queue update for {thread_id}: listener command channel is closed" // listener 已拆——降级到异步路径 ); } let outgoing = Arc::clone(&self.outgoing); // 没有 listener(如直连客户端):spawn 任务做订阅者解析 + 发送 let thread_state_manager = self.thread_state_manager.clone(); tokio::spawn(async move { let subscribed_connection_ids = thread_state_manager .subscribed_connection_ids(thread_id) .await; // 异步查"谁订阅了这个线程"——可能涉及锁/IO,绝不能放在 emit() 同步路径里 let outgoing = ThreadScopedOutgoingMessageSender::new( Arc::clone(outgoing), subscribed_connection_ids, thread_id, ); outgoing .send_server_notification(ServerNotification::ThreadQueueChanged( // 翻译成 app-server 协议通知发给客户端 ThreadQueueChangedNotification { thread_id: thread_id.to_string(), }, )) .await; }); } msg => { // 其他事件类型(如 goal updated)走同样的 listener→spawn 双路径,此处省略 tracing::debug!(event_id = %event.id, ?msg, "dropping unsupported extension event"); // 不认识的事件记 debug 丢弃——前向兼容:新扩展发旧宿主不懂的事件不炸 } } }同文件第 255-309 行的 emit_warning() 还有两个防御细节:警告消息超过 256 字节时按字符边界截断(第 270-275 行,避免切坏 UTF-8);没有 listener 时先等订阅者出现、最多 10 秒(EXTENSION_WARNING_SUBSCRIBER_TIMEOUT),超时丢弃并记警告——扩展的警告不能反过来把宿主的事件循环拖死。
另一个反向能力是关机准入(ext/extension-api/src/turn_admission.rs,全文仅 12 行):
📄 codex-rs/ext/extension-api/src/turn_admission.rs (第 3-12 行)
/// A host-provided gate checked before Core starts a turn-input submission. // 文档注释:宿主提供的闸门,core 启动轮次前必查/// Implementations return a permit for work admitted before shutdown and // 准入的工作拿到"许可证"(permit)/// Core retains it through submission. `None` skips the start without consuming // core 持有它直到提交完成;None = 放弃启动且不消耗排队输入——下次还能用/// pending input. Steering an existing turn does not acquire a new permit. // steer(转向已有轮次)不占新许可——关机时允许把进行中的事做完/// Memory-only mailbox wakeups and parent-delegated subagent input bypass this // 内存信箱唤醒、父级委派的子 agent 输入绕过闸门——委托工作必须能跑完再退出/// gate so delegated work can finish before exit. Automatic starts remain gated. // 但"自动启动新轮次"(如 queue 派发)仍然受管pub trait TurnStartAdmission: std::fmt::Debug + Send + Sync { // Debug bound:宿主可以在日志里打印闸门状态排查问题 fn admit_turn_start(&self) -> Option<Box<dyn Send>>; // 返回不透明 permit——core 不需要知道许可证内部是什么,持有即有效}为什么这样设计:事件出口和准入闸门是同一枚硬币的两面:前者约束"扩展能说什么",后者约束"宿主允许扩展做什么"。permit 用 Option<Box<dyn Send>> 这种不透明句柄而不是 bool,是因为准入决策可能携带资源(如排空队列的引用计数),"持有到提交完成才释放"的语义只有对象能表达。而 steer/委托输入绕过闸门这条规则,解决的是关机竞态:用户正在收尾的工作必须允许跑完,但新的自动启动要立刻停——这正是 queue 派发路径上会再次经过 admission 的原因。
八、core 侧集成点:插槽在哪里被消费
前面都是"扩展怎么注册",这一节看 core 在哪些位置调用这些插槽。工具收集(core/src/tools/spec_plan.rs 第 332-349 行):
📄 codex-rs/core/src/tools/spec_plan.rs (第 332-349 行)
#[instrument(level = "trace", skip_all)] // trace 级埋点:工具表组装是高频路径,默认不输出pub(crate) fn extension_tool_executors<'a>( // core 侧唯一认识"扩展工具"的函数——依赖方向在这里收口 session: &'a Session, step_store: &'a ExtensionData, // step 级存储:步级差异化工具(如 code-mode)需要它) -> impl Iterator<Item = Arc<dyn for<'call> ToolExecutor<ExtensionToolCall<'call>>> + 'a { // 惰性迭代器:按需构造,不预分配大 Vec session .services .extensions // SessionServices 里持有的就是 build() 后的不可变注册表(Arc) .tool_contributors() .iter() .flat_map(move |contributor| { // flat_map 把每个贡献者的工具列表摊平成一个流——顺序 = 注册顺序 contributor.tools_for_step( &session.services.session_extension_data, // 三级存储原样递入:扩展自己决定用哪一级 &session.services.thread_extension_data, step_store, ) })}其余消费点分布很散但模式一致:core/src/session/turn.rs 第 1118 行 extensions.turn_input_contributors()(轮次输入片段)、core/src/session/world_state.rs 第 295 行与 core/src/session/mod.rs 第 4072/4158 行的 context_contributors()(prompt/world-state 片段)、registry.rs 第 216-226 行的审批链:
📄 codex-rs/ext/extension-api/src/registry.rs (第 215-226 行)
/// Returns the first claimed decision in registration order. // 文档注释:注册顺序 = 优先级,第一个认领者生效 pub async fn decide_approval( &self, input: &crate::ApprovalDecisionInput<'_>, ) -> Option<crate::ApprovalDecision> { // None = 没人认领 → core 走默认审批策略(问用户) for contributor in &self.approval_review_contributors { // 串行遍历:不并发,保证决策可预测、可复现 if let Some(decision) = contributor.decide(input).await { // 每个贡献者拿到完整输入,自己判断管不管 return Some(decision); // 一旦认领立即短路——后面的贡献者根本不会被问 } } None // 全链无人认领:返回 None 让 core 兜底,而不是 panic }为什么这样设计:core 对扩展的调用全部是"取列表 → 遍历 → 聚合",没有任何 if-else 判断具体功能——这就是依赖倒置在调用侧的样子。审批链选择串行而非并发,是因为决策必须可复现:同样的输入、同样的注册顺序,永远得到同一个结果(guardian-v2 排第一就永远先问它)。而工具收集用惰性迭代器 + flat_map,让"扩展没贡献任何工具"的常见路径零分配。
九、app-server 的完整装配清单
把所有扩展装进一个注册表,就是 app-server/src/extensions.rs 第 55-138 行的 thread_extensions()。它的装配顺序(即审批/工具链的优先级顺序):
注意装配里的条件分支:if let Some(state_db)、if let Some(queue_service)——扩展的"存在性"由宿主能力决定,而不是编译期 feature flag。同一个 core crate,在 CLI(无 state_db)和 app-server(有 state_db)下装出来的注册表内容不同,但 core 代码一行不改。
为什么这样设计:"能力探测式装配"让部署形态的差异收敛到宿主入口这一处。对比另一种常见做法——core 里写 #[cfg(feature = "goals")]——扩展的存在会变成编译矩阵的维度,每加一个可选功能 CI 构建时间翻倍。而这里 goal/queue 只是"宿主有没有递依赖进来"的问题,core 的 Cargo.toml 甚至不需要声明它们(由 app-server/cli 各自声明)。
十、设计哲学小结 + 数据流全景
把本讲的四个关键决策串起来:
🔹 依赖倒置:core 只认 trait 插槽,功能 crate 只认 install()——两边互不 import,新增功能零 core 改动。
🔹 三级状态隔离:session/thread/turn 三把 TypeId 钥匙串,扩展私有数据按作用域存放,没有全局命名空间就没有意外覆盖。
🔹 注册顺序即契约:审批链第一个 Some 生效、MCP server 同名后者覆盖前者——所有"多贡献者竞争"的语义都由装配顺序显式决定,可复现。
🔹 双向闸门:event sink 管扩展的输出边界(256 字节截断、10 秒订阅超时),admission permit 管宿主的准入边界(关机排空)——扩展既不能乱说,也不能在宿主不允许时启动新工作。
最后用一张数据流图把"装配 → 播种 → 供工具 → 派发 → 事件出口"串起来:
扩展层数据流(装配 → 运行)
① 装配:thread_extensions() → ExtensionRegistryBuilder
宿主注入 event sink + admission 闸门,按清单 install() 各扩展,build() 冻结成 Arc。
▼
② 播种:on_thread_start → thread_store.insert(配置快照)
web-search/生图固化 available+provider;goal 建 GoalRuntimeHandle 并注册进 goal_service。
▼
③ 供工具:spec_plan.extension_tool_executors() 按注册顺序收集
每个 ToolContributor.tools_for_step() 读快照决定给不给;world_state/turn.rs 同步收集 prompt 片段。
▼
④ 执行:工具路由 + 生命周期钩子
🔹 tool start/finish → goal 累计尝试次数、token usage → goal 记账预算🔹 审批请求 → decide_approval() 链式认领,guardian-v2 优先
▼
⑤ 派发:on_thread_idle → queue.dispatch_if_idle()
非 Interrupted 才派发;per-thread 锁串行消费;提交前再过 admission permit。
▼
⑥ 出口:event_sink.emit() → app-server 通知
🔹 ThreadQueueChanged/GoalUpdated:listener FIFO 优先,否则 spawn 解析订阅者🔹 Warning:256 字节字符边界截断 + 10s 订阅等待超时
📚 系列导航
← 第 34 讲:Plugins / Connectors / Marketplace
→ 第 36 讲:Memories 记忆系统
关注公众号「AI技术推荐官」获取更多源码解析内容















































































