diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index db76e363ec..fc840bd252 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1408,7 +1408,7 @@ Dynamic tool identifiers follow the same constraints as Responses function tools - `namespace`, when present, must match `^[a-zA-Z0-9_-]+$` and be between 1 and 64 characters. - `namespace` must not collide with reserved Responses runtime namespaces such as `functions`, `multi_tool_use`, `file_search`, `web`, `browser`, `image_gen`, `computer`, `container`, `terminal`, `python`, `python_user_visible`, `api_tool`, `tool_search`, or `submodel_delegator`. -Each dynamic tool may set `deferLoading`. When omitted, it defaults to `false`. Set it to `true` to keep the tool registered and callable by runtime features such as `code_mode`, while excluding it from the model-facing tool list sent on ordinary turns. When `tool_search` is available, deferred dynamic tools are searchable and can be exposed by a matching search result. +Each dynamic tool may set `deferLoading`. When omitted, it defaults to `false`. Set it to `true` to keep the tool registered and callable by runtime features such as `code_mode`, while excluding it from the model-facing tool list sent on ordinary turns. When `tool_search` is available, deferred dynamic tools are searchable and can be exposed by a matching search result. If `tool_search` is unavailable due to model/provider restrictions, deferred dynamic tools are exposed directly. When a dynamic tool is invoked during a turn, the server sends an `item/tool/call` JSON-RPC request to the client: diff --git a/codex-rs/core/src/tools/router_tests.rs b/codex-rs/core/src/tools/router_tests.rs index 9d685e53b6..7f54f29ab4 100644 --- a/codex-rs/core/src/tools/router_tests.rs +++ b/codex-rs/core/src/tools/router_tests.rs @@ -246,7 +246,8 @@ async fn tools_without_handlers_do_not_support_parallel() -> anyhow::Result<()> #[tokio::test] async fn specs_filter_deferred_dynamic_tools() -> anyhow::Result<()> { - let (_, turn) = make_session_and_context().await; + let (_, mut turn) = make_session_and_context().await; + turn.model_info.supports_search_tool = true; let hidden_tool = "hidden_dynamic_tool"; let visible_tool = "visible_dynamic_tool"; let dynamic_tools = vec![ diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 2610bee95c..0e2b2e9033 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -278,6 +278,10 @@ fn namespace_tools_enabled(turn_context: &TurnContext) -> bool { turn_context.provider.capabilities().namespace_tools } +fn tool_search_available(turn_context: &TurnContext) -> bool { + search_tool_enabled(turn_context) && namespace_tools_enabled(turn_context) +} + fn code_mode_enabled(turn_context: &TurnContext) -> bool { turn_context.features.get().enabled(Feature::CodeMode) } @@ -681,12 +685,11 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu } else { let agent_type_description = agent_type_description(turn_context, context.default_agent_type_description); - let exposure = - if search_tool_enabled(turn_context) && namespace_tools_enabled(turn_context) { - ToolExposure::Deferred - } else { - ToolExposure::Direct - }; + let exposure = if tool_search_available(turn_context) { + ToolExposure::Deferred + } else { + ToolExposure::Direct + }; planned_tools.add_with_exposure( SpawnAgentHandler::new(SpawnAgentToolOptions { available_models: turn_context.available_models.clone(), @@ -746,8 +749,18 @@ fn add_mcp_runtime_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut } fn add_dynamic_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { + let can_use_tool_search = tool_search_available(context.turn_context); for tool in context.dynamic_tools { - let Some(handler) = DynamicToolHandler::new(tool) else { + let direct_tool = (tool.defer_loading && !can_use_tool_search).then(|| { + // A deferred tool must stay visible when tool_search cannot expose it. + DynamicToolSpec { + defer_loading: false, + ..tool.clone() + } + }); + let effective_tool = direct_tool.as_ref().unwrap_or(tool); + + let Some(handler) = DynamicToolHandler::new(effective_tool) else { tracing::error!( "Failed to convert dynamic tool {:?} to OpenAI tool", tool.name @@ -774,7 +787,7 @@ fn append_tool_search_executor( planned_tools: &mut PlannedTools, ) { let turn_context = context.turn_context; - if !(search_tool_enabled(turn_context) && namespace_tools_enabled(turn_context)) { + if !tool_search_available(turn_context) { return; } @@ -827,8 +840,7 @@ fn append_extension_tool_executors( reserved_tool_names.insert(ToolName::plain(codex_code_mode::PUBLIC_TOOL_NAME)); reserved_tool_names.insert(ToolName::plain(codex_code_mode::WAIT_TOOL_NAME)); } - if search_tool_enabled(turn_context) - && namespace_tools_enabled(turn_context) + if tool_search_available(turn_context) && planned_tools .runtimes() .iter() diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 2eb6eea5d2..4c30f1c15c 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -553,6 +553,35 @@ async fn mcp_and_tool_search_follow_direct_and_deferred_tool_exposure() { ]); } +#[tokio::test] +async fn deferred_plain_dynamic_tool_is_direct_without_namespace_support() { + let plan = probe_with( + |turn| { + turn.model_info.supports_search_tool = true; + use_bedrock_provider(turn); + }, + ToolPlanInputs { + dynamic_tools: vec![dynamic_tool( + None, + "plain_lookup", + /*defer_loading*/ true, + )], + ..ToolPlanInputs::default() + }, + ) + .await; + + plan.assert_visible_lacks(&["tool_search"]); + assert_eq!(plan.exposure("plain_lookup"), ToolExposure::Direct); + let ToolSpec::Function(tool) = plan.visible_spec("plain_lookup") else { + panic!("expected visible plain dynamic tool"); + }; + assert_eq!( + (tool.name.as_str(), tool.defer_loading), + ("plain_lookup", None) + ); +} + #[tokio::test] async fn invalid_mcp_tools_are_not_registered() { let plan = probe_with( diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 90daf21963..3904c4a20b 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -18,6 +18,7 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; use core_test_support::apps_test_server::AppsTestServer; +use core_test_support::apps_test_server::configure_search_capable_model; use core_test_support::assert_regex_match; use core_test_support::responses; use core_test_support::responses::ResponseMock; @@ -2919,6 +2920,7 @@ async fn code_mode_can_call_hidden_dynamic_tools() -> Result<()> { let server = responses::start_mock_server().await; let mut builder = test_codex().with_config(move |config| { + configure_search_capable_model(config); let _ = config.features.enable(Feature::CodeMode); }); let base_test = builder.build(&server).await?; @@ -2948,8 +2950,8 @@ async fn code_mode_can_call_hidden_dynamic_tools() -> Result<()> { test.session_configured = new_thread.session_configured; let code = r#" -const tool = ALL_TOOLS.find(({ name }) => name === "codex_app_hidden_dynamic_tool"); -const out = await tools.codex_app_hidden_dynamic_tool({ city: "Paris" }); +const tool = ALL_TOOLS.find(({ name }) => name === "codex_app__hidden_dynamic_tool"); +const out = await tools.codex_app__hidden_dynamic_tool({ city: "Paris" }); text( JSON.stringify({ name: tool?.name ?? null, @@ -3054,7 +3056,7 @@ text( )?; assert_eq!( parsed.get("name"), - Some(&Value::String("codex_app_hidden_dynamic_tool".to_string())) + Some(&Value::String("codex_app__hidden_dynamic_tool".to_string())) ); assert_eq!( parsed.get("out"), @@ -3067,7 +3069,7 @@ text( .is_some_and(|description| { description.contains("A hidden dynamic tool.") && description.contains("declare const tools:") - && description.contains("codex_app_hidden_dynamic_tool(args:") + && description.contains("codex_app__hidden_dynamic_tool(args:") }) ); diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index d8779bfb05..a46ef93f81 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -24,6 +24,7 @@ use codex_protocol::protocol::RealtimeOutputModality; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; use codex_protocol::user_input::UserInput; +use core_test_support::apps_test_server::configure_search_capable_model; use core_test_support::context_snapshot; use core_test_support::context_snapshot::ContextSnapshotOptions; use core_test_support::context_snapshot::ContextSnapshotRenderMode; @@ -1036,7 +1037,9 @@ async fn remote_compact_filters_deferred_dynamic_tools() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; - let mut builder = test_codex().with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let mut builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(configure_search_capable_model); let mut test = builder.build(&server).await?; let hidden_tool = "hidden_dynamic_tool"; let visible_tool = "visible_dynamic_tool"; diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index e9ffeb62ef..3ddd71d72f 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -6,6 +6,7 @@ use codex_config::types::McpServerConfig; use codex_config::types::McpServerTransportConfig; use codex_features::Feature; use codex_login::CodexAuth; +use codex_models_manager::bundled_models_response; use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem; use codex_protocol::dynamic_tools::DynamicToolResponse; use codex_protocol::dynamic_tools::DynamicToolSpec; @@ -962,6 +963,74 @@ async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() - Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn deferred_dynamic_tool_is_direct_without_tool_search_support() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mock = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ]), + ) + .await; + + let dynamic_tool = DynamicToolSpec { + namespace: Some("codex_app".to_string()), + name: "automation_update".to_string(), + description: "Manage automations.".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false, + }), + defer_loading: true, + }; + + let mut builder = test_codex().with_config(|config| { + let mut model_catalog = bundled_models_response() + .unwrap_or_else(|err| panic!("bundled models.json should parse: {err}")); + let model = model_catalog + .models + .iter_mut() + .find(|model| model.slug == "gpt-5.4") + .expect("gpt-5.4 exists in bundled models.json"); + model.supports_search_tool = false; + config.model = Some("gpt-5.4".to_string()); + config.model_catalog = Some(model_catalog); + }); + let base_test = builder.build(&server).await?; + let new_thread = base_test + .thread_manager + .start_thread_with_tools( + base_test.config.clone(), + vec![dynamic_tool], + /*persist_extended_history*/ false, + ) + .await?; + let mut test = base_test; + test.codex = new_thread.thread; + test.session_configured = new_thread.session_configured; + + test.submit_turn("Use the automation tool").await?; + + let body = mock.single_request().body_json(); + assert!( + !tool_names(&body) + .iter() + .any(|name| name == TOOL_SEARCH_TOOL_NAME), + "tool_search should be absent for a model without tool-search support" + ); + let direct_tool = namespace_child_tool(&body, "codex_app", "automation_update") + .expect("deferred dynamic tool should be exposed directly"); + assert_eq!(direct_tool.get("defer_loading"), None); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn tool_search_indexes_only_enabled_non_app_mcp_tools() -> Result<()> { skip_if_no_network!(Ok(()));