diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 902ede574ef9..6dc9f82465df 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -661,7 +661,8 @@ async fn maybe_request_codex_apps_auth_elicitation( }; let response = sess .request_mcp_server_elicitation(turn_context, request_id, params) - .await; + .await + .response; if !response .as_ref() .is_some_and(|response| response.action == ElicitationAction::Accept) @@ -1325,7 +1326,8 @@ async fn maybe_request_mcp_tool_approval( ); let decision = parse_mcp_tool_approval_elicitation_response( sess.request_mcp_server_elicitation(turn_context.as_ref(), request_id, params) - .await, + .await + .response, &question_id, ); let decision = normalize_approval_decision_for_mode(decision, approval_mode); diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index 9f27751f7e04..3f687162034d 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -5,6 +5,7 @@ use codex_mcp::ElicitationReviewerHandle; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::mcp_approval_meta::APPROVAL_KIND_KEY as MCP_ELICITATION_APPROVAL_KIND_KEY; use codex_protocol::mcp_approval_meta::APPROVAL_KIND_MCP_TOOL_CALL as MCP_ELICITATION_APPROVAL_KIND_MCP_TOOL_CALL; +use codex_protocol::mcp_approval_meta::APPROVAL_KIND_TOOL_SUGGESTION as MCP_ELICITATION_APPROVAL_KIND_TOOL_SUGGESTION; use codex_protocol::mcp_approval_meta::APPROVALS_REVIEWER_KEY as MCP_ELICITATION_APPROVALS_REVIEWER_KEY; use codex_protocol::mcp_approval_meta::CONNECTOR_DESCRIPTION_KEY as MCP_ELICITATION_CONNECTOR_DESCRIPTION_KEY; use codex_protocol::mcp_approval_meta::CONNECTOR_ID_KEY as MCP_ELICITATION_CONNECTOR_ID_KEY; @@ -21,6 +22,10 @@ use rmcp::model::Meta; use serde_json::Map; const MCP_ELICITATION_DECLINE_MESSAGE_KEY: &str = "message"; +const TOOL_SUGGESTION_ACTION_INSTALL: &str = "install"; +const TOOL_SUGGESTION_ACTION_KEY: &str = "suggest_type"; +const TOOL_SUGGESTION_TOOL_ID_KEY: &str = "tool_id"; +const TOOL_SUGGESTION_TOOL_TYPE_KEY: &str = "tool_type"; #[derive(Debug, PartialEq)] enum GuardianElicitationReview { @@ -33,6 +38,18 @@ struct GuardianMcpElicitationReviewer { session: std::sync::Weak, } +pub(crate) struct McpServerElicitationOutcome { + pub(crate) response: Option, + pub(crate) sent: bool, +} + +#[derive(Debug, PartialEq, Eq)] +struct PluginInstallElicitationTelemetryMetadata { + tool_type: String, + tool_id: String, + tool_name: String, +} + impl GuardianMcpElicitationReviewer { fn new(session: &Arc) -> Self { Self { @@ -70,7 +87,7 @@ impl Session { turn_context: &TurnContext, request_id: RequestId, params: McpServerElicitationRequestParams, - ) -> Option { + ) -> McpServerElicitationOutcome { if self .services .mcp_connection_manager @@ -78,11 +95,14 @@ impl Session { .await .elicitations_auto_deny() { - return Some(ElicitationResponse { - action: codex_rmcp_client::ElicitationAction::Accept, - content: Some(serde_json::json!({})), - meta: None, - }); + return McpServerElicitationOutcome { + response: Some(ElicitationResponse { + action: codex_rmcp_client::ElicitationAction::Accept, + content: Some(serde_json::json!({})), + meta: None, + }), + sent: false, + }; } let server_name = params.server_name.clone(); @@ -98,7 +118,10 @@ impl Session { warn!( "failed to serialize MCP elicitation schema for server_name: {server_name}, request_id: {request_id}: {err:#}" ); - return None; + return McpServerElicitationOutcome { + response: None, + sent: false, + }; } }; codex_protocol::approvals::ElicitationRequest::Form { @@ -154,11 +177,24 @@ impl Session { id, request, }); + let plugin_install_telemetry = plugin_install_elicitation_telemetry_metadata(&event); turn_context .turn_metadata_state .mark_user_input_requested_during_turn(); self.send_event(turn_context, event).await; - rx_response.await.ok() + if let Some(plugin_install_telemetry) = plugin_install_telemetry { + turn_context + .session_telemetry + .record_plugin_install_elicitation_sent( + plugin_install_telemetry.tool_type.as_str(), + plugin_install_telemetry.tool_id.as_str(), + plugin_install_telemetry.tool_name.as_str(), + ); + } + McpServerElicitationOutcome { + response: rx_response.await.ok(), + sent: true, + } } #[expect( @@ -550,6 +586,33 @@ fn metadata_owned_string(meta: &Map, key: &str) -> Option .map(ToOwned::to_owned) } +fn plugin_install_elicitation_telemetry_metadata( + event: &EventMsg, +) -> Option { + let EventMsg::ElicitationRequest(ElicitationRequestEvent { request, .. }) = event else { + return None; + }; + let codex_protocol::approvals::ElicitationRequest::Form { + meta: Some(Value::Object(meta)), + .. + } = request + else { + return None; + }; + if metadata_str(meta, MCP_ELICITATION_APPROVAL_KIND_KEY) + != Some(MCP_ELICITATION_APPROVAL_KIND_TOOL_SUGGESTION) + || metadata_str(meta, TOOL_SUGGESTION_ACTION_KEY) != Some(TOOL_SUGGESTION_ACTION_INSTALL) + { + return None; + } + + Some(PluginInstallElicitationTelemetryMetadata { + tool_type: metadata_owned_string(meta, TOOL_SUGGESTION_TOOL_TYPE_KEY)?, + tool_id: metadata_owned_string(meta, TOOL_SUGGESTION_TOOL_ID_KEY)?, + tool_name: metadata_owned_string(meta, MCP_ELICITATION_TOOL_NAME_KEY)?, + }) +} + fn mcp_elicitation_request_id(id: &RequestId) -> String { match id { rmcp::model::NumberOrString::String(value) => value.to_string(), diff --git a/codex-rs/core/src/session/mcp_tests.rs b/codex-rs/core/src/session/mcp_tests.rs index 31b304faa531..0e3664019b84 100644 --- a/codex-rs/core/src/session/mcp_tests.rs +++ b/codex-rs/core/src/session/mcp_tests.rs @@ -96,6 +96,63 @@ fn guardian_elicitation_review_request_defaults_missing_tool_params() { assert_eq!(arguments, Some(json!({}))); } +#[test] +fn plugin_install_elicitation_telemetry_metadata_requires_install_tool_suggestion() { + let event = EventMsg::ElicitationRequest(ElicitationRequestEvent { + turn_id: Some("turn-1".to_string()), + server_name: "codex_apps".to_string(), + id: codex_protocol::mcp::RequestId::String("request-1".to_string()), + request: codex_protocol::approvals::ElicitationRequest::Form { + meta: Some(json!({ + "codex_approval_kind": "tool_suggestion", + "suggest_type": "install", + "tool_type": "plugin", + "tool_id": "slack@openai-curated", + "tool_name": "Slack", + })), + message: "Install Slack?".to_string(), + requested_schema: json!({ + "type": "object", + "properties": {}, + }), + }, + }); + + assert_eq!( + plugin_install_elicitation_telemetry_metadata(&event), + Some(PluginInstallElicitationTelemetryMetadata { + tool_type: "plugin".to_string(), + tool_id: "slack@openai-curated".to_string(), + tool_name: "Slack".to_string(), + }) + ); + + let enable_event = EventMsg::ElicitationRequest(ElicitationRequestEvent { + turn_id: Some("turn-1".to_string()), + server_name: "codex_apps".to_string(), + id: codex_protocol::mcp::RequestId::String("request-2".to_string()), + request: codex_protocol::approvals::ElicitationRequest::Form { + meta: Some(json!({ + "codex_approval_kind": "tool_suggestion", + "suggest_type": "enable", + "tool_type": "plugin", + "tool_id": "slack@openai-curated", + "tool_name": "Slack", + })), + message: "Enable Slack?".to_string(), + requested_schema: json!({ + "type": "object", + "properties": {}, + }), + }, + }); + + assert_eq!( + plugin_install_elicitation_telemetry_metadata(&enable_event), + None + ); +} + #[test] fn guardian_elicitation_review_request_requires_opt_in() { let request = form_request(meta(json!({ diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index c41d5e348e62..c7e24b5e612b 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -328,13 +328,14 @@ async fn request_mcp_server_elicitation_auto_accepts_when_auto_deny_is_enabled() .await; assert_eq!( - response, + response.response, Some(ElicitationResponse { action: ElicitationAction::Accept, content: Some(json!({})), meta: None, }) ); + assert!(!response.sent); assert!(rx.try_recv().is_err()); } diff --git a/codex-rs/core/src/tools/handlers/list_available_plugins_to_install.rs b/codex-rs/core/src/tools/handlers/list_available_plugins_to_install.rs new file mode 100644 index 000000000000..143eb5a800f2 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/list_available_plugins_to_install.rs @@ -0,0 +1,173 @@ +use codex_tools::LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME; +use codex_tools::ListAvailablePluginsToInstallResult; +use codex_tools::RequestPluginInstallEntry; +use codex_tools::ToolName; +use codex_tools::ToolSpec; + +use crate::function_tool::FunctionCallError; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; +use crate::tools::handlers::list_available_plugins_to_install_spec::create_list_available_plugins_to_install_tool; +use crate::tools::registry::CoreToolRuntime; +use crate::tools::registry::ToolExecutor; + +const MAX_LIST_AVAILABLE_PLUGINS_TO_INSTALL_DESCRIPTION_CHARS: usize = 240; + +pub struct ListAvailablePluginsToInstallHandler { + tools: Vec, +} + +impl ListAvailablePluginsToInstallHandler { + pub(crate) fn new(mut tools: Vec) -> 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, + MAX_LIST_AVAILABLE_PLUGINS_TO_INSTALL_DESCRIPTION_CHARS, + ) + .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(), + } + } +} + +#[async_trait::async_trait] +impl ToolExecutor for ListAvailablePluginsToInstallHandler { + fn tool_name(&self) -> ToolName { + ToolName::plain(LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME) + } + + fn spec(&self) -> Option { + Some(create_list_available_plugins_to_install_tool()) + } + + fn supports_parallel_tool_calls(&self) -> bool { + false + } + + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { + let ToolInvocation { payload, .. } = invocation; + match payload { + ToolPayload::Function { .. } => {} + _ => { + return Err(FunctionCallError::Fatal(format!( + "{LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME} handler received unsupported payload" + ))); + } + } + + let content = serde_json::to_string(&self.result()).map_err(|err| { + FunctionCallError::Fatal(format!( + "failed to serialize {LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME} response: {err}" + )) + })?; + + Ok(boxed_tool_output(FunctionToolOutput::from_text( + content, + Some(true), + ))) + } +} + +impl CoreToolRuntime for ListAvailablePluginsToInstallHandler {} + +fn truncate_to_char_boundary(value: &str, max_chars: usize) -> &str { + match value.char_indices().nth(max_chars) { + Some((index, _)) => &value[..index], + None => value, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_tools::DiscoverableToolType; + use pretty_assertions::assert_eq; + + #[test] + fn list_tool_does_not_support_parallel_calls() { + assert!( + !ListAvailablePluginsToInstallHandler::new(Vec::new()).supports_parallel_tool_calls() + ); + } + + #[test] + fn result_truncates_candidate_descriptions() { + let handler = ListAvailablePluginsToInstallHandler::new(vec![ + RequestPluginInstallEntry { + id: "sample@openai-curated".to_string(), + name: "Sample Plugin".to_string(), + description: Some( + "x".repeat(MAX_LIST_AVAILABLE_PLUGINS_TO_INSTALL_DESCRIPTION_CHARS + 1), + ), + tool_type: DiscoverableToolType::Plugin, + has_skills: true, + mcp_server_names: vec!["sample-mcp".to_string()], + app_connector_ids: vec!["connector-sample".to_string()], + }, + RequestPluginInstallEntry { + id: "calendar@openai-curated".to_string(), + name: "Calendar".to_string(), + description: Some("calendar".to_string()), + tool_type: DiscoverableToolType::Plugin, + has_skills: false, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + }, + ]); + + assert_eq!( + handler.result(), + ListAvailablePluginsToInstallResult { + tools: vec![ + RequestPluginInstallEntry { + id: "calendar@openai-curated".to_string(), + name: "Calendar".to_string(), + description: Some("calendar".to_string()), + tool_type: DiscoverableToolType::Plugin, + has_skills: false, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + }, + RequestPluginInstallEntry { + id: "sample@openai-curated".to_string(), + name: "Sample Plugin".to_string(), + description: Some( + "x".repeat(MAX_LIST_AVAILABLE_PLUGINS_TO_INSTALL_DESCRIPTION_CHARS,) + ), + tool_type: DiscoverableToolType::Plugin, + has_skills: true, + mcp_server_names: vec!["sample-mcp".to_string()], + app_connector_ids: vec!["connector-sample".to_string()], + }, + ], + } + ); + } +} diff --git a/codex-rs/core/src/tools/handlers/list_available_plugins_to_install_spec.rs b/codex-rs/core/src/tools/handlers/list_available_plugins_to_install_spec.rs new file mode 100644 index 000000000000..ac3120753d44 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/list_available_plugins_to_install_spec.rs @@ -0,0 +1,45 @@ +use codex_tools::JsonSchema; +use codex_tools::LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME; +use codex_tools::REQUEST_PLUGIN_INSTALL_TOOL_NAME; +use codex_tools::ResponsesApiTool; +use codex_tools::TOOL_SEARCH_TOOL_NAME; +use codex_tools::ToolSpec; +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_TOOL_NAME}` 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_TOOL_NAME}`. When both a plugin and a connector match, prefer the plugin; use the connector only when its corresponding plugin is already installed.\n" + ); + + 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, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn create_list_available_plugins_to_install_tool_uses_expected_wire_shape() { + assert_eq!( + create_list_available_plugins_to_install_tool(), + ToolSpec::Function(ResponsesApiTool { + name: "list_available_plugins_to_install".to_string(), + description: "# 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`. When both a plugin and a connector match, prefer the plugin; use the connector only when its corresponding plugin is already installed.\n".to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::object( + Default::default(), + Some(Vec::new()), + Some(false.into()), + ), + output_schema: None, + }) + ); + } +} diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 69281acc7d94..5d4832a8c411 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -6,6 +6,8 @@ mod dynamic; pub(crate) mod extension_tools; mod goal; pub(crate) mod goal_spec; +mod list_available_plugins_to_install; +pub(crate) mod list_available_plugins_to_install_spec; mod mcp; mod mcp_resource; pub(crate) mod mcp_resource_spec; @@ -54,6 +56,7 @@ pub use dynamic::DynamicToolHandler; pub use goal::CreateGoalHandler; pub use goal::GetGoalHandler; pub use goal::UpdateGoalHandler; +pub use list_available_plugins_to_install::ListAvailablePluginsToInstallHandler; pub use mcp::McpHandler; pub use mcp_resource::ListMcpResourceTemplatesHandler; pub use mcp_resource::ListMcpResourcesHandler; diff --git a/codex-rs/core/src/tools/handlers/request_plugin_install.rs b/codex-rs/core/src/tools/handlers/request_plugin_install.rs index 3b53fd007ab5..ffb5bb568b5d 100644 --- a/codex-rs/core/src/tools/handlers/request_plugin_install.rs +++ b/codex-rs/core/src/tools/handlers/request_plugin_install.rs @@ -8,17 +8,16 @@ use codex_rmcp_client::ElicitationResponse; use codex_tools::DiscoverableTool; use codex_tools::DiscoverableToolAction; use codex_tools::DiscoverableToolType; +use codex_tools::LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME; use codex_tools::REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE; use codex_tools::REQUEST_PLUGIN_INSTALL_PERSIST_KEY; use codex_tools::REQUEST_PLUGIN_INSTALL_TOOL_NAME; use codex_tools::RequestPluginInstallArgs; -use codex_tools::RequestPluginInstallEntry; use codex_tools::RequestPluginInstallResult; use codex_tools::ToolName; use codex_tools::ToolSpec; use codex_tools::all_requested_connectors_picked_up; use codex_tools::build_request_plugin_install_elicitation_request; -use codex_tools::collect_request_plugin_install_entries; use codex_tools::filter_request_plugin_install_discoverable_tools_for_client; use codex_tools::verified_connector_install_completed; use rmcp::model::RequestId; @@ -38,18 +37,7 @@ use crate::tools::handlers::request_plugin_install_spec::create_request_plugin_i use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -#[derive(Default)] -pub struct RequestPluginInstallHandler { - discoverable_tools: Vec, -} - -impl RequestPluginInstallHandler { - pub(crate) fn new(discoverable_tools: &[DiscoverableTool]) -> Self { - Self { - discoverable_tools: collect_request_plugin_install_entries(discoverable_tools), - } - } -} +pub struct RequestPluginInstallHandler; #[async_trait::async_trait] impl ToolExecutor for RequestPluginInstallHandler { @@ -58,7 +46,7 @@ impl ToolExecutor for RequestPluginInstallHandler { } fn spec(&self) -> Option { - Some(create_request_plugin_install_tool(&self.discoverable_tools)) + Some(create_request_plugin_install_tool()) } fn supports_parallel_tool_calls(&self) -> bool { @@ -142,7 +130,7 @@ impl ToolExecutor for RequestPluginInstallHandler { .find(|tool| tool.tool_type() == args.tool_type && tool.id() == args.tool_id) .ok_or_else(|| { FunctionCallError::RespondToModel(format!( - "tool_id must match one of the discoverable tools exposed by {REQUEST_PLUGIN_INSTALL_TOOL_NAME}" + "tool_id must match one of the discoverable tools returned by {LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}" )) })?; @@ -155,9 +143,10 @@ impl ToolExecutor for RequestPluginInstallHandler { suggest_reason, &tool, ); - let response = session + let elicitation = session .request_mcp_server_elicitation(turn.as_ref(), request_id, params) .await; + let response = elicitation.response; if let Some(response) = response.as_ref() { maybe_persist_disabled_install_request(&session, &turn, &tool, response).await; } @@ -177,6 +166,27 @@ impl ToolExecutor for RequestPluginInstallHandler { .await; } + if elicitation.sent { + let tool_type = match args.tool_type { + DiscoverableToolType::Connector => "connector", + DiscoverableToolType::Plugin => "plugin", + }; + let response_action = match response.as_ref().map(|response| &response.action) { + Some(ElicitationAction::Accept) => "accept", + Some(ElicitationAction::Decline) => "decline", + Some(ElicitationAction::Cancel) => "cancel", + None => "unavailable", + }; + turn.session_telemetry.record_plugin_install_suggestion( + tool_type, + tool.id(), + tool.name(), + response_action, + user_confirmed, + completed, + ); + } + let content = serde_json::to_string(&RequestPluginInstallResult { completed, user_confirmed, diff --git a/codex-rs/core/src/tools/handlers/request_plugin_install_spec.rs b/codex-rs/core/src/tools/handlers/request_plugin_install_spec.rs index d8b0a042c484..ed143283e74f 100644 --- a/codex-rs/core/src/tools/handlers/request_plugin_install_spec.rs +++ b/codex-rs/core/src/tools/handlers/request_plugin_install_spec.rs @@ -1,15 +1,11 @@ -use codex_tools::DiscoverableToolType; use codex_tools::JsonSchema; +use codex_tools::LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME; use codex_tools::REQUEST_PLUGIN_INSTALL_TOOL_NAME; -use codex_tools::RequestPluginInstallEntry; use codex_tools::ResponsesApiTool; -use codex_tools::TOOL_SEARCH_TOOL_NAME; use codex_tools::ToolSpec; use std::collections::BTreeMap; -pub(crate) fn create_request_plugin_install_tool( - discoverable_tools: &[RequestPluginInstallEntry], -) -> ToolSpec { +pub(crate) fn create_request_plugin_install_tool() -> ToolSpec { let properties = BTreeMap::from([ ( "tool_type".to_string(), @@ -35,9 +31,8 @@ pub(crate) fn create_request_plugin_install_tool( ), ]); - let discoverable_tools = format_discoverable_tools(discoverable_tools); let description = format!( - "# Request plugin/connector install\n\nUse this tool only to ask the user to install one known plugin or connector from the list below. The list contains known candidates that are not currently installed.\n\nUse this ONLY when all of the following 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_TOOL_NAME}` is not available, or it has already been called and did not find or make the requested tool callable.\n- The plugin or connector is one of the known installable plugins or connectors listed below. Only ask to install plugins or connectors from this list.\n\nDo not use this tool for adjacent capabilities, broad recommendations, or tools that merely seem useful. Only use when the user explicitly asks to use that exact listed plugin or connector.\n\nKnown plugins/connectors available to install:\n{discoverable_tools}\n\nWorkflow:\n\n1. Check the current context and active `tools` list first. If current active tools aren't relevant and `{TOOL_SEARCH_TOOL_NAME}` is available, only call this tool after `{TOOL_SEARCH_TOOL_NAME}` has already been tried and found no relevant tool.\n2. Match the user's explicit request against the known plugin/connector list above. Only proceed when one listed plugin or connector exactly fits.\n3. If we found both connectors and plugins to install, use plugins first, only use connectors if the corresponding plugin is installed but the connector is not.\n4. If one plugin or connector clearly fits, call `{REQUEST_PLUGIN_INSTALL_TOOL_NAME}` with:\n - `tool_type`: `connector` or `plugin`\n - `action_type`: `install`\n - `tool_id`: exact id from the known plugin/connector list above\n - `suggest_reason`: concise one-line user-facing reason this plugin or connector can help with the current request\n5. After the request flow completes:\n - if the user finished the install flow, continue by searching again or using the newly available plugin or connector\n - if the user did not finish, continue without that plugin or connector, and don't request it again unless the user explicitly asks for it.\n\nIMPORTANT: DO NOT call this tool in parallel with other tools." + "# 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." ); ToolSpec::Function(ResponsesApiTool { @@ -59,74 +54,6 @@ pub(crate) fn create_request_plugin_install_tool( }) } -fn format_discoverable_tools(discoverable_tools: &[RequestPluginInstallEntry]) -> String { - let mut discoverable_tools = discoverable_tools.to_vec(); - discoverable_tools.sort_by(|left, right| { - left.name - .cmp(&right.name) - .then_with(|| left.id.cmp(&right.id)) - }); - - discoverable_tools - .into_iter() - .map(|tool| { - let description = tool_description_or_fallback(&tool); - format!( - "- {} (id: `{}`, type: {}, action: install): {}", - tool.name, - tool.id, - discoverable_tool_type_str(tool.tool_type), - description - ) - }) - .collect::>() - .join("\n") -} - -fn tool_description_or_fallback(tool: &RequestPluginInstallEntry) -> String { - if let Some(description) = tool - .description - .as_deref() - .map(str::trim) - .filter(|description| !description.is_empty()) - { - return description.to_string(); - } - - match tool.tool_type { - DiscoverableToolType::Connector => "No description provided.".to_string(), - DiscoverableToolType::Plugin => plugin_summary(tool), - } -} - -fn plugin_summary(tool: &RequestPluginInstallEntry) -> String { - let mut capabilities = Vec::new(); - if tool.has_skills { - capabilities.push("skills".to_string()); - } - if !tool.mcp_server_names.is_empty() { - capabilities.push(format!("MCP servers: {}", tool.mcp_server_names.join(", "))); - } - if !tool.app_connector_ids.is_empty() { - capabilities.push(format!( - "app connectors: {}", - tool.app_connector_ids.join(", ") - )); - } - if capabilities.is_empty() { - "No description provided.".to_string() - } else { - capabilities.join("; ") - } -} - -fn discoverable_tool_type_str(tool_type: DiscoverableToolType) -> &'static str { - match tool_type { - DiscoverableToolType::Connector => "connector", - DiscoverableToolType::Plugin => "plugin", - } -} - #[cfg(test)] mod tests { use super::*; @@ -135,54 +62,16 @@ mod tests { use std::collections::BTreeMap; #[test] - fn create_request_plugin_install_tool_uses_plugin_summary_fallback() { + fn create_request_plugin_install_tool_uses_expected_wire_shape() { let expected_description = concat!( "# Request plugin/connector install\n\n", - "Use this tool only to ask the user to install one known plugin or connector from the list below. The list contains known candidates that are not currently installed.\n\n", - "Use this ONLY when all of the following 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", - "- The plugin or connector is one of the known installable plugins or connectors listed below. Only ask to install plugins or connectors from this list.\n\n", - "Do not use this tool for adjacent capabilities, broad recommendations, or tools that merely seem useful. Only use when the user explicitly asks to use that exact listed plugin or connector.\n\n", - "Known plugins/connectors available to install:\n", - "- GitHub (id: `github`, type: plugin, action: install): skills; MCP servers: github-mcp; app connectors: github-app\n", - "- Slack (id: `slack@openai-curated`, type: connector, action: install): No description provided.\n\n", - "Workflow:\n\n", - "1. Check the current context and active `tools` list first. If current active tools aren't relevant and `tool_search` is available, only call this tool after `tool_search` has already been tried and found no relevant tool.\n", - "2. Match the user's explicit request against the known plugin/connector list above. Only proceed when one listed plugin or connector exactly fits.\n", - "3. If we found both connectors and plugins to install, use plugins first, only use connectors if the corresponding plugin is installed but the connector is not.\n", - "4. If one plugin or connector clearly fits, call `request_plugin_install` with:\n", - " - `tool_type`: `connector` or `plugin`\n", - " - `action_type`: `install`\n", - " - `tool_id`: exact id from the known plugin/connector list above\n", - " - `suggest_reason`: concise one-line user-facing reason this plugin or connector can help with the current request\n", - "5. After the request flow completes:\n", - " - if the user finished the install flow, continue by searching again or using the newly available plugin or connector\n", - " - if the user did not finish, continue without that plugin or connector, and don't request it again unless the user explicitly asks for it.\n\n", + "Use this tool only after `list_available_plugins_to_install` returns a plugin or connector that exactly matches the user's explicit request.\n\n", + "Do 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\n", "IMPORTANT: DO NOT call this tool in parallel with other tools.", ); assert_eq!( - create_request_plugin_install_tool(&[ - RequestPluginInstallEntry { - id: "slack@openai-curated".to_string(), - name: "Slack".to_string(), - description: None, - tool_type: DiscoverableToolType::Connector, - has_skills: false, - mcp_server_names: Vec::new(), - app_connector_ids: Vec::new(), - }, - RequestPluginInstallEntry { - id: "github".to_string(), - name: "GitHub".to_string(), - description: None, - tool_type: DiscoverableToolType::Plugin, - has_skills: true, - mcp_server_names: vec!["github-mcp".to_string()], - app_connector_ids: vec!["github-app".to_string()], - }, - ]), + create_request_plugin_install_tool(), ToolSpec::Function(ResponsesApiTool { name: "request_plugin_install".to_string(), description: expected_description.to_string(), diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index d75d56404dd9..ab9d02f97cb4 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -7,6 +7,7 @@ use crate::tools::handlers::CodeModeWaitHandler; use crate::tools::handlers::CreateGoalHandler; use crate::tools::handlers::DynamicToolHandler; use crate::tools::handlers::GetGoalHandler; +use crate::tools::handlers::ListAvailablePluginsToInstallHandler; use crate::tools::handlers::ListMcpResourceTemplatesHandler; use crate::tools::handlers::ListMcpResourcesHandler; use crate::tools::handlers::McpHandler; @@ -69,6 +70,7 @@ use codex_tools::ToolOutput; use codex_tools::ToolSpec; use codex_tools::can_request_original_image_detail; use codex_tools::collect_code_mode_exec_prompt_tool_definitions; +use codex_tools::collect_request_plugin_install_entries; use codex_tools::default_namespace_description; use codex_tools::request_user_input_available_modes; use codex_tools::shell_command_backend_for_features; @@ -522,7 +524,10 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut && let Some(discoverable_tools) = context.discoverable_tools.filter(|tools| !tools.is_empty()) { - planned_tools.add_runtime(RequestPluginInstallHandler::new(discoverable_tools)); + planned_tools.add_runtime(ListAvailablePluginsToInstallHandler::new( + collect_request_plugin_install_entries(discoverable_tools), + )); + planned_tools.add_runtime(RequestPluginInstallHandler); } if environment_mode.has_environment() && turn_context.model_info.apply_patch_tool_type.is_some() diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 57bab6e39f9e..ae4b516c6c77 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -19,6 +19,7 @@ use codex_protocol::protocol::SubAgentSource; use codex_tools::DiscoverablePluginInfo; use codex_tools::DiscoverableTool; use codex_tools::ResponsesApiNamespaceTool; +use codex_tools::ResponsesApiTool; use codex_tools::ToolExposure; use codex_tools::ToolName; use codex_tools::ToolSpec; @@ -523,7 +524,10 @@ async fn request_plugin_install_requires_all_discovery_features_and_discoverable }, ) .await; - plan.assert_visible_lacks(&["request_plugin_install"]); + plan.assert_visible_lacks(&[ + "list_available_plugins_to_install", + "request_plugin_install", + ]); } let no_candidates = probe(|turn| { @@ -533,7 +537,10 @@ async fn request_plugin_install_requires_all_discovery_features_and_discoverable ); }) .await; - no_candidates.assert_visible_lacks(&["request_plugin_install"]); + no_candidates.assert_visible_lacks(&[ + "list_available_plugins_to_install", + "request_plugin_install", + ]); let enabled = probe_with( |turn| { @@ -548,7 +555,74 @@ async fn request_plugin_install_requires_all_discovery_features_and_discoverable }, ) .await; - enabled.assert_visible_contains(&["request_plugin_install"]); + enabled.assert_visible_contains(&[ + "list_available_plugins_to_install", + "request_plugin_install", + ]); +} + +#[tokio::test] +async fn install_suggestion_tools_stay_visible_without_tool_search() { + let plan = probe_with( + |turn| { + turn.model_info.supports_search_tool = false; + set_features( + turn, + &[Feature::ToolSuggest, Feature::Apps, Feature::Plugins], + ); + }, + ToolPlanInputs { + discoverable_tools: Some(vec![discoverable_plugin("github", "GitHub")]), + ..ToolPlanInputs::default() + }, + ) + .await; + + plan.assert_visible_contains(&[ + "list_available_plugins_to_install", + "request_plugin_install", + ]); + plan.assert_visible_lacks(&["tool_search"]); +} + +#[tokio::test] +async fn request_plugin_install_description_defers_inventory_to_list_tool() { + let plan = probe_with( + |turn| { + set_features( + turn, + &[Feature::ToolSuggest, Feature::Apps, Feature::Plugins], + ); + }, + ToolPlanInputs { + discoverable_tools: Some(vec![discoverable_plugin("github", "GitHub")]), + ..ToolPlanInputs::default() + }, + ) + .await; + + let ToolSpec::Function(ResponsesApiTool { + description: list_description, + .. + }) = plan.visible_spec("list_available_plugins_to_install") + else { + panic!("expected list_available_plugins_to_install function spec"); + }; + assert!(list_description.contains( + "Returns known plugins and connectors that can be passed to `request_plugin_install`." + )); + + let ToolSpec::Function(ResponsesApiTool { + description: request_description, + .. + }) = plan.visible_spec("request_plugin_install") + else { + panic!("expected request_plugin_install function spec"); + }; + assert!(request_description.contains( + "Use this tool only after `list_available_plugins_to_install` returns a plugin or connector that exactly matches the user's explicit request." + )); + assert!(!request_description.contains("github")); } #[tokio::test] diff --git a/codex-rs/core/tests/suite/request_plugin_install.rs b/codex-rs/core/tests/suite/request_plugin_install.rs index 443ec7495f3f..60db47b2ffee 100644 --- a/codex-rs/core/tests/suite/request_plugin_install.rs +++ b/codex-rs/core/tests/suite/request_plugin_install.rs @@ -22,6 +22,7 @@ use core_test_support::test_codex::test_codex; use serde_json::Value; const TOOL_SEARCH_TOOL_NAME: &str = "tool_search"; +const LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME: &str = "list_available_plugins_to_install"; const REQUEST_PLUGIN_INSTALL_TOOL_NAME: &str = "request_plugin_install"; const DISCOVERABLE_GMAIL_ID: &str = "connector_68df038e0ba48191908c8434991bbac2"; @@ -125,6 +126,12 @@ async fn request_plugin_install_is_available_without_search_tool_after_discovery !tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME), "tools list should not include {TOOL_SEARCH_TOOL_NAME}: {tools:?}" ); + assert!( + tools + .iter() + .any(|name| name == LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME), + "tools list should include {LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}: {tools:?}" + ); assert!( tools .iter() @@ -132,18 +139,26 @@ async fn request_plugin_install_is_available_without_search_tool_after_discovery "tools list should include {REQUEST_PLUGIN_INSTALL_TOOL_NAME}: {tools:?}" ); - let description = - function_tool_description(&body, REQUEST_PLUGIN_INSTALL_TOOL_NAME).expect("description"); - assert!(description.contains( - "Use this tool only to ask the user to install one known plugin or connector from the list below" + let list_description = + function_tool_description(&body, LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME) + .expect("description"); + assert!(list_description.contains( + "The user explicitly asks to use a specific plugin or connector that is not already available in the current context or active `tools` list." )); - assert!(description.contains( + assert!(list_description.contains( "`tool_search` is not available, or it has already been called and did not find or make the requested tool callable." )); + assert!(list_description.contains( + "When both a plugin and a connector match, prefer the plugin; use the connector only when its corresponding plugin is already installed." + )); + + let description = + function_tool_description(&body, REQUEST_PLUGIN_INSTALL_TOOL_NAME).expect("description"); assert!(description.contains( - "Only use when the user explicitly asks to use that exact listed plugin or connector." + "Use this tool only after `list_available_plugins_to_install` returns a plugin or connector that exactly matches the user's explicit request." )); assert!(description.contains("IMPORTANT: DO NOT call this tool in parallel with other tools.")); + assert!(!description.contains(DISCOVERABLE_GMAIL_ID)); assert!(!description.contains("tool_search fails to find a good match")); Ok(()) diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index 6394c73ea2ed..6a77125e70ea 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -8,6 +8,8 @@ use crate::metrics::API_CALL_DURATION_METRIC; use crate::metrics::MetricsClient; use crate::metrics::MetricsConfig; use crate::metrics::MetricsError; +use crate::metrics::PLUGIN_INSTALL_ELICITATION_SENT_METRIC; +use crate::metrics::PLUGIN_INSTALL_SUGGESTION_METRIC; use crate::metrics::PROFILE_USAGE_METRIC; use crate::metrics::RESPONSES_API_ENGINE_IAPI_TBT_DURATION_METRIC; use crate::metrics::RESPONSES_API_ENGINE_IAPI_TTFT_DURATION_METRIC; @@ -230,6 +232,67 @@ impl SessionTelemetry { ); } + /// Records the moment a plugin or connector install elicitation is dispatched. + pub fn record_plugin_install_elicitation_sent( + &self, + tool_type: &str, + tool_id: &str, + tool_name: &str, + ) { + self.counter( + PLUGIN_INSTALL_ELICITATION_SENT_METRIC, + /*inc*/ 1, + &[("tool_type", tool_type)], + ); + log_and_trace_event!( + self, + common: { + event.name = "codex.plugin_install_elicitation_sent", + plugin_install.tool_type = tool_type, + plugin_install.tool_id = tool_id, + plugin_install.tool_name = tool_name, + }, + log: {}, + trace: {}, + ); + } + + /// Records the outcome of a surfaced plugin or connector install suggestion. + pub fn record_plugin_install_suggestion( + &self, + tool_type: &str, + tool_id: &str, + tool_name: &str, + response_action: &str, + user_confirmed: bool, + completed: bool, + ) { + let completed_tag = if completed { "true" } else { "false" }; + self.counter( + PLUGIN_INSTALL_SUGGESTION_METRIC, + /*inc*/ 1, + &[ + ("tool_type", tool_type), + ("response_action", response_action), + ("completed", completed_tag), + ], + ); + log_and_trace_event!( + self, + common: { + event.name = "codex.plugin_install_suggestion", + plugin_install.tool_type = tool_type, + plugin_install.tool_id = tool_id, + plugin_install.tool_name = tool_name, + plugin_install.response_action = response_action, + plugin_install.user_confirmed = user_confirmed, + plugin_install.completed = completed, + }, + log: {}, + trace: {}, + ); + } + pub fn start_timer(&self, name: &str, tags: &[(&str, &str)]) -> Result { let Some(metrics) = &self.metrics else { return Err(MetricsError::ExporterDisabled); diff --git a/codex-rs/otel/src/metrics/names.rs b/codex-rs/otel/src/metrics/names.rs index 17a39816a7c1..429db8116eb1 100644 --- a/codex-rs/otel/src/metrics/names.rs +++ b/codex-rs/otel/src/metrics/names.rs @@ -37,6 +37,8 @@ pub const GOAL_BLOCKED_METRIC: &str = "codex.goal.blocked"; pub const GOAL_TOKEN_COUNT_METRIC: &str = "codex.goal.token_count"; pub const GOAL_DURATION_SECONDS_METRIC: &str = "codex.goal.duration_s"; pub const PROFILE_USAGE_METRIC: &str = "codex.profile.usage"; +pub const PLUGIN_INSTALL_ELICITATION_SENT_METRIC: &str = "codex.plugins.install_elicitation.sent"; +pub const PLUGIN_INSTALL_SUGGESTION_METRIC: &str = "codex.plugins.install_suggestion"; pub const CURATED_PLUGINS_STARTUP_SYNC_METRIC: &str = "codex.plugins.startup_sync"; pub const CURATED_PLUGINS_STARTUP_SYNC_FINAL_METRIC: &str = "codex.plugins.startup_sync.final"; pub const HOOK_RUN_METRIC: &str = "codex.hooks.run"; diff --git a/codex-rs/otel/tests/suite/manager_metrics.rs b/codex-rs/otel/tests/suite/manager_metrics.rs index d95f42dfc512..e13de3f432b0 100644 --- a/codex-rs/otel/tests/suite/manager_metrics.rs +++ b/codex-rs/otel/tests/suite/manager_metrics.rs @@ -2,6 +2,8 @@ use crate::harness::attributes_to_map; use crate::harness::build_metrics_with_defaults; use crate::harness::find_metric; use crate::harness::latest_metrics; +use codex_otel::PLUGIN_INSTALL_ELICITATION_SENT_METRIC; +use codex_otel::PLUGIN_INSTALL_SUGGESTION_METRIC; use codex_otel::Result; use codex_otel::SessionTelemetry; use codex_otel::TelemetryAuthMode; @@ -161,3 +163,100 @@ fn manager_attaches_optional_service_name_tag() -> Result<()> { Ok(()) } + +#[test] +fn manager_records_plugin_install_suggestion_metric() -> Result<()> { + let (metrics, exporter) = build_metrics_with_defaults(&[])?; + let manager = SessionTelemetry::new( + ThreadId::new(), + "gpt-5.1", + "gpt-5.1", + Some("account-id".to_string()), + /*account_email*/ None, + Some(TelemetryAuthMode::ApiKey), + "test_originator".to_string(), + /*log_user_prompts*/ false, + "tty".to_string(), + SessionSource::Cli, + ) + .with_metrics_without_metadata_tags(metrics); + + manager.record_plugin_install_suggestion( + "connector", + "connector_calendar", + "Google Calendar", + "accept", + /*user_confirmed*/ true, + /*completed*/ false, + ); + manager.shutdown_metrics()?; + + let resource_metrics = latest_metrics(&exporter); + let metric = find_metric(&resource_metrics, PLUGIN_INSTALL_SUGGESTION_METRIC) + .expect("plugin install suggestion metric missing"); + let attrs = match metric.data() { + AggregatedMetrics::U64(data) => match data { + MetricData::Sum(sum) => { + let points: Vec<_> = sum.data_points().collect(); + assert_eq!(points.len(), 1); + attributes_to_map(points[0].attributes()) + } + _ => panic!("unexpected counter aggregation"), + }, + _ => panic!("unexpected counter data type"), + }; + + assert_eq!( + attrs, + BTreeMap::from([ + ("completed".to_string(), "false".to_string()), + ("response_action".to_string(), "accept".to_string()), + ("tool_type".to_string(), "connector".to_string()), + ]) + ); + + Ok(()) +} + +#[test] +fn manager_records_plugin_install_elicitation_sent_metric() -> Result<()> { + let (metrics, exporter) = build_metrics_with_defaults(&[])?; + let manager = SessionTelemetry::new( + ThreadId::new(), + "gpt-5.1", + "gpt-5.1", + Some("account-id".to_string()), + /*account_email*/ None, + Some(TelemetryAuthMode::ApiKey), + "test_originator".to_string(), + /*log_user_prompts*/ false, + "tty".to_string(), + SessionSource::Cli, + ) + .with_metrics_without_metadata_tags(metrics); + + manager.record_plugin_install_elicitation_sent("plugin", "slack@openai-curated", "Slack"); + manager.shutdown_metrics()?; + + let resource_metrics = latest_metrics(&exporter); + let metric = find_metric(&resource_metrics, PLUGIN_INSTALL_ELICITATION_SENT_METRIC) + .expect("plugin install elicitation sent metric missing"); + let attrs = match metric.data() { + AggregatedMetrics::U64(data) => match data { + MetricData::Sum(sum) => { + let points: Vec<_> = sum.data_points().collect(); + assert_eq!(points.len(), 1); + attributes_to_map(points[0].attributes()) + } + _ => panic!("unexpected counter aggregation"), + }, + _ => panic!("unexpected counter data type"), + }; + + assert_eq!( + attrs, + BTreeMap::from([("tool_type".to_string(), "plugin".to_string())]) + ); + + Ok(()) +} diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index 4d972af2cfea..7b64776dca53 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -71,6 +71,8 @@ pub use tool_discovery::DiscoverablePluginInfo; pub use tool_discovery::DiscoverableTool; pub use tool_discovery::DiscoverableToolAction; pub use tool_discovery::DiscoverableToolType; +pub use tool_discovery::LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME; +pub use tool_discovery::ListAvailablePluginsToInstallResult; pub use tool_discovery::REQUEST_PLUGIN_INSTALL_TOOL_NAME; pub use tool_discovery::RequestPluginInstallEntry; pub use tool_discovery::TOOL_SEARCH_DEFAULT_LIMIT; diff --git a/codex-rs/tools/src/tool_discovery.rs b/codex-rs/tools/src/tool_discovery.rs index a5beb768a20d..ac5aac904b74 100644 --- a/codex-rs/tools/src/tool_discovery.rs +++ b/codex-rs/tools/src/tool_discovery.rs @@ -5,6 +5,7 @@ use serde::Serialize; const TUI_CLIENT_NAME: &str = "codex-tui"; pub const TOOL_SEARCH_TOOL_NAME: &str = "tool_search"; pub const TOOL_SEARCH_DEFAULT_LIMIT: usize = 8; +pub const LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME: &str = "list_available_plugins_to_install"; pub const REQUEST_PLUGIN_INSTALL_TOOL_NAME: &str = "request_plugin_install"; #[derive(Clone, Debug, PartialEq, Eq)] @@ -99,7 +100,7 @@ pub struct DiscoverablePluginInfo { pub app_connector_ids: Vec, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] pub struct RequestPluginInstallEntry { pub id: String, pub name: String, @@ -110,6 +111,11 @@ pub struct RequestPluginInstallEntry { pub app_connector_ids: Vec, } +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +pub struct ListAvailablePluginsToInstallResult { + pub tools: Vec, +} + pub fn collect_request_plugin_install_entries( discoverable_tools: &[DiscoverableTool], ) -> Vec {