diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index f3ba9cc48d..9f16b80a90 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -25,7 +25,7 @@ use codex_tools::ResponsesApiNamespaceTool; use codex_tools::ResponsesApiTool; use codex_tools::ShellCommandBackendConfig; use codex_tools::TOOL_SEARCH_TOOL_NAME; -use codex_tools::TOOL_SUGGEST_TOOL_NAME; +use codex_tools::TOOL_SUGGEST_TOOL_NAMESPACE; use codex_tools::ToolName; use codex_tools::ToolSpec; use codex_tools::ToolsConfig; @@ -856,7 +856,7 @@ async fn tool_suggest_requires_apps_and_plugins_features() { assert!( !tools .iter() - .any(|tool| tool.name() == TOOL_SUGGEST_TOOL_NAME), + .any(|tool| tool.name() == TOOL_SUGGEST_TOOL_NAMESPACE), "tool_suggest should be absent when {disabled_feature:?} is disabled" ); } diff --git a/codex-rs/core/tests/suite/tool_suggest.rs b/codex-rs/core/tests/suite/tool_suggest.rs index 6cb19d01a5..490bb2f217 100644 --- a/codex-rs/core/tests/suite/tool_suggest.rs +++ b/codex-rs/core/tests/suite/tool_suggest.rs @@ -10,19 +10,21 @@ use codex_login::CodexAuth; use codex_models_manager::bundled_models_response; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; +use codex_tools::TOOL_SEARCH_TOOL_NAME; +use codex_tools::TOOL_SUGGEST_TOOL_NAME; +use codex_tools::TOOL_SUGGEST_TOOL_NAMESPACE; use core_test_support::apps_test_server::AppsTestServer; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; +use core_test_support::responses::namespace_child_tool; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::test_codex::test_codex; use serde_json::Value; -const TOOL_SEARCH_TOOL_NAME: &str = "tool_search"; -const TOOL_SUGGEST_TOOL_NAME: &str = "tool_suggest"; const DISCOVERABLE_GMAIL_ID: &str = "connector_68df038e0ba48191908c8434991bbac2"; fn tool_names(body: &Value) -> Vec { @@ -42,22 +44,6 @@ fn tool_names(body: &Value) -> Vec { .unwrap_or_default() } -fn function_tool_description(body: &Value, name: &str) -> Option { - body.get("tools") - .and_then(Value::as_array) - .and_then(|tools| { - tools.iter().find_map(|tool| { - if tool.get("name").and_then(Value::as_str) == Some(name) { - tool.get("description") - .and_then(Value::as_str) - .map(str::to_string) - } else { - None - } - }) - }) -} - fn configure_apps_without_search_tool(config: &mut Config, apps_base_url: &str) { config .features @@ -125,12 +111,15 @@ async fn tool_suggest_is_available_without_search_tool_after_discovery_attempts( "tools list should not include {TOOL_SEARCH_TOOL_NAME}: {tools:?}" ); assert!( - tools.iter().any(|name| name == TOOL_SUGGEST_TOOL_NAME), - "tools list should include {TOOL_SUGGEST_TOOL_NAME}: {tools:?}" + tools.iter().any(|name| name == TOOL_SUGGEST_TOOL_NAMESPACE), + "tools list should include {TOOL_SUGGEST_TOOL_NAMESPACE}: {tools:?}" ); let description = - function_tool_description(&body, TOOL_SUGGEST_TOOL_NAME).expect("description"); + namespace_child_tool(&body, TOOL_SUGGEST_TOOL_NAMESPACE, TOOL_SUGGEST_TOOL_NAME) + .and_then(|tool| tool.get("description")) + .and_then(Value::as_str) + .expect("description"); assert!(description.contains( "Use this tool only to ask the user to install one known plugin or connector from the list below" )); diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index 7c638ba675..38dca2ca8a 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -112,6 +112,7 @@ pub use tool_discovery::DiscoverableToolType; pub use tool_discovery::TOOL_SEARCH_DEFAULT_LIMIT; pub use tool_discovery::TOOL_SEARCH_TOOL_NAME; pub use tool_discovery::TOOL_SUGGEST_TOOL_NAME; +pub use tool_discovery::TOOL_SUGGEST_TOOL_NAMESPACE; pub use tool_discovery::ToolSearchResultSource; pub use tool_discovery::ToolSearchSource; pub use tool_discovery::ToolSearchSourceInfo; diff --git a/codex-rs/tools/src/tool_discovery.rs b/codex-rs/tools/src/tool_discovery.rs index 74977dce38..8443858e73 100644 --- a/codex-rs/tools/src/tool_discovery.rs +++ b/codex-rs/tools/src/tool_discovery.rs @@ -15,7 +15,8 @@ use std::collections::BTreeMap; 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 TOOL_SUGGEST_TOOL_NAME: &str = "tool_suggest"; +pub const TOOL_SUGGEST_TOOL_NAMESPACE: &str = "tool_suggest"; +pub const TOOL_SUGGEST_TOOL_NAME: &str = "tool_suggest_tool"; #[derive(Clone, Debug, PartialEq, Eq)] pub struct ToolSearchSourceInfo { @@ -302,22 +303,26 @@ pub fn create_tool_suggest_tool(discoverable_tools: &[ToolSuggestEntry]) -> Tool "# Tool suggestion discovery\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 wants 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 tool is one of the known installable plugins or connectors listed below. Only ask to install tools from this list.\n\nDo not use tool suggestion for adjacent capabilities, broad recommendations, or tools that merely seem useful. The user's intent must clearly match one listed tool.\n\nKnown plugins/connectors available to install:\n{discoverable_tools}\n\nWorkflow:\n\n1. Check the current context and active `tools` list first. If `{TOOL_SEARCH_TOOL_NAME}` is available, call `{TOOL_SEARCH_TOOL_NAME}` before calling `{TOOL_SUGGEST_TOOL_NAME}`. Do not use tool suggestion if the needed tool is already available, found through `{TOOL_SEARCH_TOOL_NAME}`, or callable after discovery.\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 suggest, use plugins first, only use connectors if the corresponding plugin is installed but the connector is not.\n4. If one tool clearly fits, call `{TOOL_SUGGEST_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 tool can help with the current request\n5. After the suggestion flow completes:\n - if the user finished the install flow, continue by searching again or using the newly available tool\n - if the user did not finish, continue without that tool, and don't suggest that tool again unless the user explicitly asks for it.\n\nIMPORTANT: DO NOT call this tool in parallel with other tools." ); - ToolSpec::Function(ResponsesApiTool { - name: TOOL_SUGGEST_TOOL_NAME.to_string(), - description, - strict: false, - defer_loading: None, - parameters: JsonSchema::object( - properties, - Some(vec![ - "tool_type".to_string(), - "action_type".to_string(), - "tool_id".to_string(), - "suggest_reason".to_string(), - ]), - Some(false.into()), - ), - output_schema: None, + ToolSpec::Namespace(ResponsesApiNamespace { + name: TOOL_SUGGEST_TOOL_NAMESPACE.to_string(), + description: default_namespace_description(TOOL_SUGGEST_TOOL_NAMESPACE), + tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool { + name: TOOL_SUGGEST_TOOL_NAME.to_string(), + description, + strict: false, + defer_loading: None, + parameters: JsonSchema::object( + properties, + Some(vec![ + "tool_type".to_string(), + "action_type".to_string(), + "tool_id".to_string(), + "suggest_reason".to_string(), + ]), + Some(false.into()), + ), + output_schema: None, + })], }) } diff --git a/codex-rs/tools/src/tool_discovery_tests.rs b/codex-rs/tools/src/tool_discovery_tests.rs index 9edbccffaa..e4589c182d 100644 --- a/codex-rs/tools/src/tool_discovery_tests.rs +++ b/codex-rs/tools/src/tool_discovery_tests.rs @@ -62,10 +62,10 @@ fn create_tool_suggest_tool_uses_plugin_summary_fallback() { "- 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 `tool_search` is available, call `tool_search` before calling `tool_suggest`. Do not use tool suggestion if the needed tool is already available, found through `tool_search`, or callable after discovery.\n", + "1. Check the current context and active `tools` list first. If `tool_search` is available, call `tool_search` before calling `tool_suggest_tool`. Do not use tool suggestion if the needed tool is already available, found through `tool_search`, or callable after discovery.\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 suggest, use plugins first, only use connectors if the corresponding plugin is installed but the connector is not.\n", - "4. If one tool clearly fits, call `tool_suggest` with:\n", + "4. If one tool clearly fits, call `tool_suggest_tool` with:\n", " - `tool_type`: `connector` or `plugin`\n", " - `action_type`: `install`\n", " - `tool_id`: exact id from the known plugin/connector list above\n", @@ -97,47 +97,51 @@ fn create_tool_suggest_tool_uses_plugin_summary_fallback() { app_connector_ids: vec!["github-app".to_string()], }, ]), - ToolSpec::Function(ResponsesApiTool { - name: "tool_suggest".to_string(), - description: expected_description.to_string(), - strict: false, - defer_loading: None, - parameters: JsonSchema::object(BTreeMap::from([ - ( - "action_type".to_string(), - JsonSchema::string(Some( - "Suggested action for the tool. Use \"install\"." - .to_string(), - ),), - ), - ( - "suggest_reason".to_string(), - JsonSchema::string(Some( - "Concise one-line user-facing reason why this tool can help with the current request." - .to_string(), - ),), - ), - ( - "tool_id".to_string(), - JsonSchema::string(Some( - "Connector or plugin id to suggest." - .to_string(), - ),), - ), - ( + ToolSpec::Namespace(ResponsesApiNamespace { + name: TOOL_SUGGEST_TOOL_NAMESPACE.to_string(), + description: "Tools in the tool_suggest namespace.".to_string(), + tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool { + name: TOOL_SUGGEST_TOOL_NAME.to_string(), + description: expected_description.to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::object(BTreeMap::from([ + ( + "action_type".to_string(), + JsonSchema::string(Some( + "Suggested action for the tool. Use \"install\"." + .to_string(), + ),), + ), + ( + "suggest_reason".to_string(), + JsonSchema::string(Some( + "Concise one-line user-facing reason why this tool can help with the current request." + .to_string(), + ),), + ), + ( + "tool_id".to_string(), + JsonSchema::string(Some( + "Connector or plugin id to suggest." + .to_string(), + ),), + ), + ( + "tool_type".to_string(), + JsonSchema::string(Some( + "Type of discoverable tool to suggest. Use \"connector\" or \"plugin\"." + .to_string(), + ),), + ), + ]), Some(vec![ "tool_type".to_string(), - JsonSchema::string(Some( - "Type of discoverable tool to suggest. Use \"connector\" or \"plugin\"." - .to_string(), - ),), - ), - ]), Some(vec![ - "tool_type".to_string(), - "action_type".to_string(), - "tool_id".to_string(), - "suggest_reason".to_string(), - ]), Some(false.into())), - output_schema: None, + "action_type".to_string(), + "tool_id".to_string(), + "suggest_reason".to_string(), + ]), Some(false.into())), + output_schema: None, + })], }) ); } diff --git a/codex-rs/tools/src/tool_registry_plan.rs b/codex-rs/tools/src/tool_registry_plan.rs index eff3750edd..c688384f9c 100644 --- a/codex-rs/tools/src/tool_registry_plan.rs +++ b/codex-rs/tools/src/tool_registry_plan.rs @@ -7,6 +7,7 @@ use crate::SpawnAgentToolOptions; use crate::TOOL_SEARCH_DEFAULT_LIMIT; use crate::TOOL_SEARCH_TOOL_NAME; use crate::TOOL_SUGGEST_TOOL_NAME; +use crate::TOOL_SUGGEST_TOOL_NAMESPACE; use crate::ToolHandlerKind; use crate::ToolName; use crate::ToolRegistryPlan; @@ -313,10 +314,13 @@ pub fn build_tool_registry_plan( { plan.push_spec( create_tool_suggest_tool(&collect_tool_suggest_entries(discoverable_tools)), - /*supports_parallel_tool_calls*/ true, + /*supports_parallel_tool_calls*/ false, /*code_mode_enabled*/ false, ); - plan.register_handler(TOOL_SUGGEST_TOOL_NAME, ToolHandlerKind::ToolSuggest); + plan.register_handler( + ToolName::namespaced(TOOL_SUGGEST_TOOL_NAMESPACE, TOOL_SUGGEST_TOOL_NAME), + ToolHandlerKind::ToolSuggest, + ); } if config.has_environment diff --git a/codex-rs/tools/src/tool_registry_plan_tests.rs b/codex-rs/tools/src/tool_registry_plan_tests.rs index 8d3ab5253c..7a4c02fc19 100644 --- a/codex-rs/tools/src/tool_registry_plan_tests.rs +++ b/codex-rs/tools/src/tool_registry_plan_tests.rs @@ -1683,7 +1683,7 @@ fn tool_suggest_is_not_registered_without_feature_flag() { assert!( !tools .iter() - .any(|tool| tool.name() == TOOL_SUGGEST_TOOL_NAME) + .any(|tool| tool.name() == TOOL_SUGGEST_TOOL_NAMESPACE) ); } @@ -1708,7 +1708,7 @@ fn tool_suggest_can_be_registered_without_search_tool() { permission_profile: &PermissionProfile::Disabled, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); - let (tools, _) = build_specs_with_discoverable_tools( + let (tools, handlers) = build_specs_with_discoverable_tools( &tools_config, /*mcp_tools*/ None, /*deferred_mcp_tools*/ None, @@ -1720,14 +1720,17 @@ fn tool_suggest_can_be_registered_without_search_tool() { &[], ); - assert_contains_tool_names(&tools, &[TOOL_SUGGEST_TOOL_NAME]); - let tool_suggest = find_tool(&tools, TOOL_SUGGEST_TOOL_NAME); - assert!(tool_suggest.supports_parallel_tool_calls); + assert_contains_tool_names(&tools, &[TOOL_SUGGEST_TOOL_NAMESPACE]); assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME); - let ToolSpec::Function(ResponsesApiTool { description, .. }) = &tool_suggest.spec else { - panic!("expected function tool"); - }; + let tool_suggest = + find_namespace_function_tool(&tools, TOOL_SUGGEST_TOOL_NAMESPACE, TOOL_SUGGEST_TOOL_NAME); + let ResponsesApiTool { description, .. } = tool_suggest; + assert!(!find_tool(&tools, TOOL_SUGGEST_TOOL_NAMESPACE).supports_parallel_tool_calls); + assert!(handlers.contains(&ToolHandlerSpec { + name: ToolName::namespaced(TOOL_SUGGEST_TOOL_NAMESPACE, TOOL_SUGGEST_TOOL_NAME), + kind: ToolHandlerKind::ToolSuggest, + })); assert!(description.contains( "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." )); @@ -1785,19 +1788,17 @@ fn tool_suggest_description_lists_discoverable_tools() { &[], ); assert!(handlers.contains(&ToolHandlerSpec { - name: ToolName::plain(TOOL_SUGGEST_TOOL_NAME), + name: ToolName::namespaced(TOOL_SUGGEST_TOOL_NAMESPACE, TOOL_SUGGEST_TOOL_NAME), kind: ToolHandlerKind::ToolSuggest, })); - let tool_suggest = find_tool(&tools, TOOL_SUGGEST_TOOL_NAME); - let ToolSpec::Function(ResponsesApiTool { + let tool_suggest = + find_namespace_function_tool(&tools, TOOL_SUGGEST_TOOL_NAMESPACE, TOOL_SUGGEST_TOOL_NAME); + let ResponsesApiTool { description, parameters, .. - }) = &tool_suggest.spec - else { - panic!("expected function tool"); - }; + } = tool_suggest; assert!(description.contains( "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." )); @@ -1830,7 +1831,7 @@ fn tool_suggest_description_lists_discoverable_tools() { "Do not use tool suggestion if the needed tool is already available, found through `tool_search`, or callable after discovery." )); assert!(description.contains( - "If `tool_search` is available, call `tool_search` before calling `tool_suggest`." + "If `tool_search` is available, call `tool_search` before calling `tool_suggest_tool`." )); assert!(!description.contains("targeted lookup")); assert!(!description.contains("broad or speculative searches"));