Skip to content

Plugin安装交互工具

从 ToolSuggest 候选发现、列表与推荐两种规格,到用户确认、缓存刷新和失败结果,追踪插件安装交互工具的真实源码链路。

基于rust-v0.150.0
CodexRustToolsPlugin

Plugin安装交互工具 ​

Codex 的插件安装不是模型直接调用一个“安装函数”。当前实现先由 Session 为本轮准备可发现的 plugin/connector 候选,再根据候选来源选择两种 request_plugin_install 规格;用户确认通过 MCP elicitation 返回后,Core 才刷新 plugin 或 Apps connector 状态并验证是否真的完成。list_available_plugins_to_install 只是把候选快照格式化给模型,不能执行安装。

本文面向已经读过ToolSearch工具、MCP-Tool处理器和Plugin-Skill-App指令注入的读者。本文只研究 ToolSuggest 候选如何进入工具规格、列表/推荐两种 presentation、确认请求、TUI/API 客户端限制、持久禁用和安装后验证,不展开 PluginsManager 的 marketplace 下载算法,也不把“用户接受安装请求”写成“插件已安装”。读完后,读者应能定位一个安装工具为何不可见、候选 id 为什么被拒绝、接受后 completed=false 的原因,以及插件/connector 状态刷新发生在哪个 owner。

1. 候选准备 ​

1.1 三重开关 ​

ToolSuggest 只有在 Feature::ToolSuggest、Feature::Apps 和 Feature::Plugins 同时开启时才启用。三个开关分别控制建议机制、connector 来源和 plugin 来源;缺少任意一个,两个安装工具都不注册。

源码位置:codex-rs/core/src/tools/spec_plan.rs :: tool_suggest_enabled

rust
pub(crate) fn tool_suggest_enabled(turn_context: &TurnContext) -> bool {
    let features = turn_context.config.features.get();
    features.enabled(Feature::ToolSuggest)
        && features.enabled(Feature::Apps)
        && features.enabled(Feature::Plugins)
}

候选进入 tool plan 后,presentation 还会改变最终工具集合:ListTool 同时注册列表与请求工具;RecommendationContext 只注册请求工具,因为推荐列表已经通过上下文提供。

1.2 候选来源 ​

每个 sampling step 的 built_tools 先准备 endpoint recommendation;如果没有 endpoint 候选,再从当前可访问 connectors、已加载 plugin connector ids 和 ToolSuggest 配置中查询 discoverable tools。候选为空时,Tool plan 不注册安装工具。

源码位置:codex-rs/core/src/session/turn.rs :: built_tools

rust
let tool_suggest_candidates =
    if let Some(recommended_plugin_candidates) = endpoint_recommended_plugin_candidates {
        Some(ToolSuggestCandidates {
            tools: recommended_plugin_candidates,
            presentation: ToolSuggestPresentation::RecommendationContext,
        })
    } else {
        let loaded_plugin_app_connector_ids = connector_snapshot
            .connector_ids()
            .iter()
            .map(|connector_id| connector_id.0.clone())
            .collect::<Vec<_>>();
        async {
            if apps_enabled && tool_suggest_is_enabled {
                if let Some(accessible_connectors) = accessible_connectors.as_ref() {
                    match connectors::list_tool_suggest_discoverable_tools_with_auth(
                        &turn_context.config,
                        sess.services.plugins_manager.as_ref(),
                        auth.as_ref(),
                        accessible_connectors.as_slice(),
                        &loaded_plugin_app_connector_ids,
                    )
                    .await
                    {
                        Ok(discoverable_tools) if discoverable_tools.is_empty() => None,
                        Ok(discoverable_tools) => Some(ToolSuggestCandidates {
                            tools: discoverable_tools,
                            presentation: ToolSuggestPresentation::ListTool,
                        }),
                        Err(_) => None,
                    }
                } else {
                    None
                }
            } else {
                None
            }
        }
        .await
    };

这里的 RecommendationContext 与 ListTool 是两条真实来源:endpoint recommendation 来自推荐插件列表;ListTool 来自 connectors/plugin manager 的 discoverable catalog。它们最终都携带 Vec<DiscoverableTool>,但 schema 和安装来源标签不同。

1.3 客户端过滤 ​

codex-tui 当前过滤掉 Plugin 候选,只保留 connector;其他 App Server client 可以接收 plugin 和 connector。这个过滤在候选准备阶段和 handler 查找阶段都存在,因此不能靠直接伪造一个 plugin id 绕过 TUI 限制。

源码位置:codex-rs/tools/src/tool_discovery.rs :: filter_request_plugin_install_discoverable_tools_for_client

rust
pub fn filter_request_plugin_install_discoverable_tools_for_client(
    discoverable_tools: Vec<DiscoverableTool>,
    app_server_client_name: Option<&str>,
) -> Vec<DiscoverableTool> {
    if app_server_client_name != Some("codex-tui") {
        return discoverable_tools;
    }

    discoverable_tools
        .into_iter()
        .filter(|tool| !matches!(tool, DiscoverableTool::Plugin(_)))
        .collect()
}

2. 列表工具 ​

2.1 规格条件 ​

list_available_plugins_to_install 没有参数,描述要求模型只在用户明确指定 plugin/connector、当前工具上下文不可用且 ToolSearch 不可用或已经耗尽时调用。它是候选查询,不是安装动作。

源码位置:codex-rs/core/src/tools/handlers/list_available_plugins_to_install_spec.rs :: create_list_available_plugins_to_install_tool

rust
pub(crate) fn create_list_available_plugins_to_install_tool() -> ToolSpec {
    let description = format!(
        "# List plugin/connector install candidates\n\nUse this tool only when both are true:\n- The user explicitly asks to use a specific plugin or connector that is not already available in the current context or active `tools` list.\n- `tool_search` is not available, or it has already been called and did not find or make the requested tool callable.\n\nReturns known plugins and connectors that can be passed to `request_plugin_install`."
    );

    ToolSpec::Function(ResponsesApiTool {
        name: LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME.to_string(),
        description,
        strict: false,
        defer_loading: None,
        parameters: JsonSchema::object(Default::default(), Some(Vec::new()), Some(false.into())),
        output_schema: None,
    })
}

2.2 候选类型 ​

DiscoverableTool 只有 Connector 和 Plugin 两个变体。Connector 使用 AppInfo 的 id/name/description;Plugin 还携带 skills、MCP server names、App connector ids 和可选 remote plugin id。列表结果把这两种内部对象投影成同一个 RequestPluginInstallEntry。

源码位置:codex-rs/tools/src/tool_discovery.rs :: DiscoverableTool

rust
pub enum DiscoverableTool {
    Connector(Box<AppInfo>),
    Plugin(Box<DiscoverablePluginInfo>),
}

pub struct DiscoverablePluginInfo {
    pub id: String,
    pub remote_plugin_id: Option<String>,
    pub name: String,
    pub description: Option<String>,
    pub has_skills: bool,
    pub mcp_server_names: Vec<String>,
    pub app_connector_ids: Vec<String>,
}

2.3 稳定输出 ​

列表 handler 在构造时按 name 再按 id 排序;结果 description 限制为 240 个字符。这个排序发生在 handler owner 中,因此模型得到的列表不会依赖上游 HashMap 的遍历顺序。

源码位置:codex-rs/core/src/tools/handlers/list_available_plugins_to_install.rs :: ListAvailablePluginsToInstallHandler

rust
pub(crate) fn new(mut tools: Vec<RequestPluginInstallEntry>) -> Self {
    tools.sort_by(|left, right| {
        left.name
            .cmp(&right.name)
            .then_with(|| left.id.cmp(&right.id))
    });
    Self { tools }
}

fn result(&self) -> ListAvailablePluginsToInstallResult {
    ListAvailablePluginsToInstallResult {
        tools: self
            .tools
            .iter()
            .map(|tool| RequestPluginInstallEntry {
                id: tool.id.clone(),
                name: tool.name.clone(),
                description: tool.description.as_ref().map(|description| {
                    truncate_to_char_boundary(description, 240).to_string()
                }),
                tool_type: tool.tool_type,
                has_skills: tool.has_skills,
                mcp_server_names: tool.mcp_server_names.clone(),
                app_connector_ids: tool.app_connector_ids.clone(),
            })
            .collect(),
    }
}

列表输出里的 id 是后续 request_plugin_install 的选择键。handler 不检查用户意图,安装 handler 会再次对 id 和 tool type 做精确匹配。

3. 安装规格 ​

3.1 ListTool模式 ​

ListTool presentation 的 request_plugin_install 要求 tool_type、action_type、tool_id 和 suggest_reason。当前只接受 action install,并要求 id/type 必须来自列表工具返回的同一候选。

源码位置:codex-rs/core/src/tools/handlers/request_plugin_install_spec.rs :: create_request_plugin_install_tool

rust
ToolSuggestPresentation::ListTool => (
    BTreeMap::from([
        ("tool_type".to_string(), JsonSchema::string(Some(
            "Type of discoverable tool to suggest. Use \"connector\" or \"plugin\"."
                .to_string(),
        ))),
        ("action_type".to_string(), JsonSchema::string(Some(
            "Suggested action for the tool. Use \"install\".".to_string(),
        ))),
        ("tool_id".to_string(), JsonSchema::string(Some(
            "Connector or plugin id to suggest.".to_string(),
        ))),
        ("suggest_reason".to_string(), JsonSchema::string(Some(
            "Concise one-line user-facing reason why this plugin or connector can help with the current request."
                .to_string(),
        ))),
    ]),
    vec![
        "tool_type".to_string(),
        "action_type".to_string(),
        "tool_id".to_string(),
        "suggest_reason".to_string(),
    ],
    format!(
        "# Request plugin/connector install\n\nUse this tool only after `{LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}` returns a plugin or connector that exactly matches the user's explicit request.\n\nDo not use it for adjacent capabilities, broad recommendations, or tools that merely seem useful. Pass the returned `tool_type` through directly, and pass the returned `id` as `tool_id`.\n\nIMPORTANT: DO NOT call this tool in parallel with other tools."
    ),
),

3.2 推荐模式 ​

RecommendationContext 来自 <recommended_plugins> endpoint context,不再让模型传 tool_type 和 action_type;只接受 plugin_id 与 suggest_reason。该模式的候选必须是 Plugin,且 description 明确要求 ToolSearch 已经耗尽。

源码位置:codex-rs/core/src/tools/handlers/request_plugin_install_spec.rs :: create_request_plugin_install_tool

rust
ToolSuggestPresentation::RecommendationContext => (
    BTreeMap::from([
        (
            "plugin_id".to_string(),
            JsonSchema::string(Some(
                "The parenthesized plugin ID from the `<recommended_plugins>` list.".to_string(),
            )),
        ),
        (
            "suggest_reason".to_string(),
            JsonSchema::string(Some(
                "Concise one-line user-facing reason why this plugin can help with the current request."
                    .to_string(),
            )),
        ),
    ]),
    vec!["plugin_id".to_string(), "suggest_reason".to_string()],
    "# Suggest a recommended plugin installation".to_string(),
),

两个模式共享同一个 runtime handler,但 presentation 改变了解析类型、候选来源、analytics source 和 TUI 限制。这是一个“同名工具、不同 schema contract”的典型例子。

RequestPluginInstallHandler 明确返回 supports_parallel_tool_calls=false。安装建议包含用户确认、配置写入和 cache refresh,不能与其他工具并发发起;这与 description 中的 “DO NOT call in parallel” 是 runtime 与模型提示的双重约束。

源码位置:codex-rs/core/src/tools/handlers/request_plugin_install.rs :: ToolExecutor for RequestPluginInstallHandler

4. 请求处理 ​

4.1 候选再验证 ​

handler 收到请求后重新过滤当前 client 可用候选,再用 id + type 或 plugin-only 条件查找。模型即使知道一个候选 id,也不能请求一个不在本轮 handler snapshot 中的工具。

源码位置:codex-rs/core/src/tools/handlers/request_plugin_install.rs :: RequestPluginInstallHandler::handle_call

rust
let discoverable_tools = filter_request_plugin_install_discoverable_tools_for_client(
    self.discoverable_tools.clone(),
    turn.app_server_client_name.as_deref(),
);

let tool = discoverable_tools
    .into_iter()
    .find(|tool| {
        tool.id() == requested_tool_id
            && match self.presentation {
                ToolSuggestPresentation::ListTool => {
                    Some(tool.tool_type()) == requested_tool_type
                }
                ToolSuggestPresentation::RecommendationContext => {
                    matches!(tool, DiscoverableTool::Plugin(_))
                }
            }
    })
    .ok_or_else(|| {
        FunctionCallError::RespondToModel(
            "tool id must match the current discoverable candidates".to_string(),
        )
    })?;

4.2 推荐Hydration ​

RecommendationContext 的 endpoint 列表只提供轻量推荐 identity。候选 id/type 匹配后,handler 必须用当前 auth 和 PluginsConfigInput 读取远程插件详情,把 app_connector_ids 等安装 metadata 回填到选中的候选。这个网络请求发生在模型已经选择 plugin、但 elicitation 尚未发送之间。

源码位置:

  • codex-rs/core/src/tools/handlers/request_plugin_install.rs :: RequestPluginInstallHandler::handle_call
  • codex-rs/core-plugins/src/recommended_plugin_install.rs :: hydrate_selected_recommended_plugin_install_metadata
rust
let tool = if self.presentation == ToolSuggestPresentation::RecommendationContext {
    let plugin_id = tool.id().to_string();
    let auth = session.services.auth_manager.auth().await;
    let plugins_config = turn.config.plugins_config_input();
    match codex_core_plugins::hydrate_selected_recommended_plugin_install_metadata(
        &plugins_config,
        auth.as_ref(),
        tool,
    )
    .await
    {
        Ok(Some(tool)) => tool,
        Ok(None) => return Err(recommended_plugins_no_longer_available()),
        Err(err) => {
            warn!(
                plugin_id,
                error = %err,
                "failed to hydrate selected recommended plugin install metadata"
            );
            return Err(recommended_plugin_metadata_retryable());
        }
    }
} else {
    tool
};

详情返回 unavailable 时,模型看到“本 turn 的推荐已不可用”;请求失败时则得到可重试错误,并要求使用同一个 plugin_id 重试。两者都不会发送 elicitation,也不会把缺失 metadata 当成空 connector list 继续安装。

4.3 Reason与客户端 ​

suggest_reason trim 后不能为空。Plugin 安装在当前 TUI client 中明确返回不可用;connector 安装仍可继续。这个判断发生在 elicitation 发送之前。

源码位置:codex-rs/core/src/tools/handlers/request_plugin_install.rs :: RequestPluginInstallHandler::handle_call

rust
let suggest_reason = suggest_reason.trim();
if suggest_reason.is_empty() {
    return Err(FunctionCallError::RespondToModel(
        "suggest_reason must not be empty".to_string(),
    ));
}
if (requested_tool_type == Some(DiscoverableToolType::Plugin)
    || self.presentation == ToolSuggestPresentation::RecommendationContext)
    && turn.app_server_client_name.as_deref() == Some("codex-tui")
{
    return Err(FunctionCallError::RespondToModel(
        "plugin install requests are not available in codex-tui yet".to_string(),
    ));
}

4.4 Elicitation ​

确认请求使用 Codex Apps MCP server 的 elicitation 通道。message 是模型给出的 user-facing reason,meta 携带 tool type/id/name、persist=always、remote plugin id、connector ids 和 suggestion_id;requested schema 为空 object,因为这里等待的是接受/拒绝动作,不是业务表单字段。

源码位置:codex-rs/tools/src/request_plugin_install.rs :: build_request_plugin_install_elicitation_request

rust
pub fn build_request_plugin_install_elicitation_request(
    suggest_reason: &str,
    tool: &DiscoverableTool,
    suggestion_id: &str,
) -> ElicitationRequest {
    ElicitationRequest::Form {
        meta: Some(json!(build_request_plugin_install_meta(
            suggest_reason,
            tool,
            suggestion_id,
        ))),
        message: suggest_reason.to_string(),
        requested_schema: json!({
            "type": "object",
            "properties": {},
        }),
    }
}

handler 使用 request_plugin_install_<call_id> 同时作为 elicitation request id、plugin install analytics 的 suggestion_id,并只对 Plugin 写入该 metadata 字段。Connector elicitation 保持 suggestion_id=None,避免把 plugin correlation 语义错误套到 connector。

5. 安装验证 ​

5.1 接受与完成 ​

用户接受后,handler 根据候选类型执行不同验证:Connector 刷新 Apps tools 并检查 connector 是否 accessible;remote plugin 刷新远程 installed-plugin cache,再检查其 connector ids;本地 curated plugin 重新读取 config/marketplace 并检查 installed。

源码位置:codex-rs/core/src/tools/handlers/request_plugin_install.rs :: verify_request_plugin_install_completed

rust
async fn verify_request_plugin_install_completed(
    session: &Arc<Session>,
    turn: &TurnContext,
    mcp: &McpBinding,
    tool: &DiscoverableTool,
    auth: Option<&CodexAuth>,
) -> bool {
    match tool {
        DiscoverableTool::Connector(connector) => refresh_missing_requested_connectors(
            session,
            turn,
            mcp,
            auth,
            std::slice::from_ref(&connector.id),
            connector.id.as_str(),
        )
        .await
        .is_some_and(|accessible_connectors| {
            verified_connector_install_completed(connector.id.as_str(), &accessible_connectors)
        }),
        DiscoverableTool::Plugin(plugin) => {
            if is_remote_plugin_install_suggestion(&plugin.id) {
                let (_, accessible_connectors) = tokio::join!(
                    refresh_remote_installed_plugins_cache_after_install(
                        session, turn, auth, plugin.id.as_str()
                    ),
                    refresh_missing_requested_connectors(
                        session, turn, mcp, auth, &plugin.app_connector_ids, plugin.id.as_str()
                    )
                );
                return accessible_connectors.is_some_and(|connectors| {
                    all_requested_connectors_picked_up(&plugin.app_connector_ids, &connectors)
                });
            }
            session.reload_user_config_layer().await;
            let config = session.get_config().await;
            let completed = verified_plugin_install_completed(
                plugin.id.as_str(),
                config.as_ref(),
                session.services.plugins_manager.as_ref(),
            );
            let _ = refresh_missing_requested_connectors(
                session,
                turn,
                mcp,
                auth,
                &plugin.app_connector_ids,
                plugin.id.as_str(),
            )
            .await;
            completed
        }
    }
}

5.2 Cache刷新 ​

安装验证会刷新不同 cache:connector 走 hard_refresh_latest_codex_apps_tools,remote plugin 走 build_and_cache_remote_installed_plugin_marketplaces,本地 plugin 走 config reload。刷新失败时 completed 为 false,并通过 warning 保留诊断。

5.3 Connector选择 ​

只有验证成功且候选是 Connector 时,Session 才调用 merge_connector_selection 把 connector 加入当前选择。Plugin 的 connector ids 会参与验证,但不会通过同一条单 connector merge 直接标记完成。

6. 结果与持久化 ​

6.1 结果字段 ​

工具总是返回 JSON 文本,包含 completed、user_confirmed、候选类型、action、id、name 和 trimmed reason。user_confirmed=true 只表示 elicitation action 是 Accept;completed=true 还要求后续 refresh/verification 成功。

源码位置:codex-rs/core/src/tools/handlers/request_plugin_install.rs :: RequestPluginInstallHandler::handle_call

rust
let user_confirmed = response
    .as_ref()
    .is_some_and(|response| response.action == ElicitationAction::Accept);
let completed = if user_confirmed {
    verify_request_plugin_install_completed(&session, &turn, mcp, &tool, auth.as_ref()).await
} else {
    false
};

let content = serde_json::to_string(&RequestPluginInstallResult {
    completed,
    user_confirmed,
    tool_type,
    action_type: DiscoverableToolAction::Install,
    tool_id: tool.id().to_string(),
    tool_name: tool.name().to_string(),
    suggest_reason: suggest_reason.to_string(),
})?;

6.2 永久禁用 ​

用户 Decline 并在 elicitation metadata 中选择 persist=always 时,handler 把 connector/plugin id 写入 tool_suggest.disabled_tools,然后 reload user config。Accept 或普通 Decline 不会写入禁用配置。

源码位置:codex-rs/core/src/tools/handlers/request_plugin_install.rs :: persist_disabled_install_request

rust
fn request_plugin_install_response_requests_persistent_disable(
    response: &ElicitationResponse,
) -> bool {
    if response.action != ElicitationAction::Decline {
        return false;
    }
    response
        .meta
        .as_ref()
        .and_then(Value::as_object)
        .and_then(|meta| meta.get(REQUEST_PLUGIN_INSTALL_PERSIST_KEY))
        .and_then(Value::as_str)
        == Some(REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE)
}

async fn persist_disabled_install_request(
    codex_home: &AbsolutePathBuf,
    tool: &DiscoverableTool,
) -> anyhow::Result<()> {
    ConfigEditsBuilder::new(codex_home)
        .with_edits([ConfigEdit::AddToolSuggestDisabledTool(
            disabled_install_request(tool),
        )])
        .apply()
        .await
}

7. 客户端与诊断 ​

7.1 TUI边界 ​

当前 handler 明确拒绝 TUI 的 Plugin 安装请求,因此 TUI 不能通过同一个 request tool 安装 plugin。这个限制是当前 product client capability,不是候选列表为空。

7.2 Analytics ​

Plugin 候选在 hydration 后、elicitation 前记录 codex_plugin_install_requested,source 区分 legacy_discovery 与 endpoint_recommendation;同一个 suggestion_id 进入 analytics 和 elicitation metadata。完成后还记录 tool type、action、user_confirmed 和 completed。Connector 不进入 plugin-requested analytics 的 plugin 列表。

推荐详情中的 remote plugin id 和 connector ids 只进入 elicitation metadata/analytics,不会注入下一次模型请求正文。集成测试通过对两次 Responses request 做字符串检查,确认安装 identity 不回流模型上下文。

源码位置:codex-rs/core/tests/suite/request_plugin_install.rs :: endpoint_recommendation_hydrates_install_identity_after_selection

7.3 失败定位 ​

遇到安装工具不可见,依次检查三个 feature、候选是否非空、ToolSearch 是否已耗尽、client 过滤和 presentation。遇到 user_confirmed=true/completed=false,检查 Apps refresh、remote marketplace cache、plugin installed 状态或 connector accessibility,而不是重复询问用户。

8. 源码练习 ​

先复述主线:feature gate → endpoint/discoverable 候选 → ListTool 或 RecommendationContext → request schema → candidate id/type 再验证 → recommendation metadata hydration → suggestion id + Apps elicitation → Accept/Decline → plugin/connector refresh → completed 结果与可选 disabled config。

再做三个只读验证:

  • 找到 request_plugin_install_requires_all_discovery_features,解释为什么候选已经传入 tool plan,关闭任意一个 feature 后两个安装工具仍都不注册;
  • 找到 endpoint_recommendation_hydrates_install_identity_after_selection,说明轻量 recommendation 为什么不能直接发送 elicitation,以及 suggestion_id 如何关联 analytics;
  • 找到 endpoint_recommendation_skips_unavailable_plugin_elicitation,区分“推荐已失效”与“详情服务暂时失败”两种模型错误。

在 Codex 源码仓库的 codex-rs/ 目录运行:

bash
rg -n "tool_suggest_enabled|ToolSuggestCandidates|RequestPluginInstallHandler|hydrate_selected_recommended_plugin_install_metadata" core core-plugins tools
cargo test -p codex-core --lib 'tools::handlers::request_plugin_install::tests::' -- --test-threads=1
cargo test -p codex-core --lib 'tools::spec_plan::tests::request_plugin_install_' -- --test-threads=1
RUST_MIN_STACK=8388608 cargo test -p codex-core --test all endpoint_recommendation_hydrates_install_identity_after_selection -- --test-threads=1

这些测试覆盖 schema、feature gate、持久禁用、候选匹配和推荐 metadata;真实 marketplace 下载、OAuth 和 connector 后端仍需在对应服务环境中验证。

这些测试不证明任意第三方 marketplace 都能完成安装,也不能证明用户接受后远程 plugin 与全部 connector 会立即可用;它们限定的是候选、确认、刷新和结果字段的 Core 协议边界。