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
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
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
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
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
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
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
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
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
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_callcodex-rs/core-plugins/src/recommended_plugin_install.rs :: hydrate_selected_recommended_plugin_install_metadata
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
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
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
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
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
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/ 目录运行:
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 协议边界。
