From 160b7ce0d3921f1db29f6c1d80d545890f04272f Mon Sep 17 00:00:00 2001 From: Channing Conger Date: Mon, 2 Mar 2026 21:34:28 +0000 Subject: [PATCH] Add new `visibility` flag to DyanmicToolSpecs --- .../schema/json/ClientRequest.json | 4 + .../codex_app_server_protocol.schemas.json | 4 + .../schema/json/v2/ThreadStartParams.json | 4 + .../schema/typescript/v2/DynamicToolSpec.ts | 2 +- .../app-server-protocol/src/protocol/v2.rs | 26 +++++++ codex-rs/app-server/README.md | 2 + .../app-server/src/codex_message_processor.rs | 3 + .../tests/suite/v2/dynamic_tools.rs | 37 ++++++--- codex-rs/core/src/tools/js_repl/mod.rs | 1 + codex-rs/core/src/tools/spec.rs | 75 ++++++++++++++++--- codex-rs/core/tests/suite/sqlite_state.rs | 2 + codex-rs/protocol/src/dynamic_tools.rs | 33 ++++++++ ...019_thread_dynamic_tools_model_visible.sql | 2 + codex-rs/state/src/runtime/threads.rs | 9 ++- 14 files changed, 180 insertions(+), 24 deletions(-) create mode 100644 codex-rs/state/migrations/0019_thread_dynamic_tools_model_visible.sql diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 03e42bed45..62798cee37 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -344,6 +344,10 @@ "type": "string" }, "inputSchema": true, + "modelVisible": { + "default": true, + "type": "boolean" + }, "name": { "type": "string" } diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index edee815e08..751fef23b5 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -9166,6 +9166,10 @@ "type": "string" }, "inputSchema": true, + "modelVisible": { + "default": true, + "type": "boolean" + }, "name": { "type": "string" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json index 5f2dda11a1..0ef8eeadbd 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -49,6 +49,10 @@ "type": "string" }, "inputSchema": true, + "modelVisible": { + "default": true, + "type": "boolean" + }, "name": { "type": "string" } diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/DynamicToolSpec.ts b/codex-rs/app-server-protocol/schema/typescript/v2/DynamicToolSpec.ts index 8b39793f3f..a921559ce6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/DynamicToolSpec.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/DynamicToolSpec.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { JsonValue } from "../serde_json/JsonValue"; -export type DynamicToolSpec = { name: string, description: string, inputSchema: JsonValue, }; +export type DynamicToolSpec = { name: string, description: string, inputSchema: JsonValue, modelVisible: boolean, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index bde0e17fa3..d63711f9ee 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -383,6 +383,12 @@ pub struct DynamicToolSpec { pub name: String, pub description: String, pub input_schema: JsonValue, + #[serde(default = "default_model_visible")] + pub model_visible: bool, +} + +fn default_model_visible() -> bool { + true } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -4256,6 +4262,26 @@ mod tests { assert_eq!(back_to_v2, v2_policy); } + #[test] + fn dynamic_tool_spec_defaults_model_visible_when_omitted() { + let spec: DynamicToolSpec = serde_json::from_value(json!({ + "name": "demo_tool", + "description": "Demo dynamic tool", + "inputSchema": { "type": "object" } + })) + .expect("dynamic tool spec should deserialize"); + + assert_eq!( + spec, + DynamicToolSpec { + name: "demo_tool".to_string(), + description: "Demo dynamic tool".to_string(), + input_schema: json!({ "type": "object" }), + model_visible: true, + } + ); + } + #[test] fn sandbox_policy_round_trips_workspace_write_read_only_access() { let readable_root = test_absolute_path(); diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 64d0625c8d..2a5192306d 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -736,6 +736,8 @@ When the client responds to `item/tool/requestUserInput`, the server emits `serv `dynamicTools` on `thread/start` and the corresponding `item/tool/call` request/response flow are experimental APIs. To enable them, set `initialize.params.capabilities.experimentalApi = true`. +Each `dynamicTools` entry may include `modelVisible`. When omitted, it defaults to `true`. Set `modelVisible: false` to keep the tool callable at runtime without advertising it in the model's `tools` list. + When a dynamic tool is invoked during a turn, the server sends an `item/tool/call` JSON-RPC request to the client: ```json diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index e65f7e888f..347b52dd21 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -2159,6 +2159,7 @@ impl CodexMessageProcessor { name: tool.name, description: tool.description, input_schema: tool.input_schema, + model_visible: tool.model_visible, }) .collect() }; @@ -7958,6 +7959,7 @@ mod tests { name: "my_tool".to_string(), description: "test".to_string(), input_schema: json!({"type": "null"}), + model_visible: true, }]; let err = validate_dynamic_tools(&tools).expect_err("invalid schema"); assert!(err.contains("my_tool"), "unexpected error: {err}"); @@ -7970,6 +7972,7 @@ mod tests { description: "test".to_string(), // Missing `type` is common; core sanitizes these to a supported schema. input_schema: json!({"properties": {}}), + model_visible: true, }]; validate_dynamic_tools(&tools).expect("valid schema"); } diff --git a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs index 52a597f66b..a68f4a5279 100644 --- a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs +++ b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs @@ -36,9 +36,9 @@ use wiremock::MockServer; const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); -/// Ensures dynamic tool specs are serialized into the model request payload. +/// Ensures only model-visible dynamic tools are serialized into the model request payload. #[tokio::test] -async fn thread_start_injects_dynamic_tools_into_model_requests() -> Result<()> { +async fn thread_start_only_injects_model_visible_dynamic_tools_into_model_requests() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; let server = create_mock_responses_server_sequence_unchecked(responses).await; @@ -57,16 +57,26 @@ async fn thread_start_injects_dynamic_tools_into_model_requests() -> Result<()> "required": ["city"], "additionalProperties": false, }); - let dynamic_tool = DynamicToolSpec { - name: "demo_tool".to_string(), - description: "Demo dynamic tool".to_string(), + let visible_dynamic_tool = DynamicToolSpec { + name: "visible_demo_tool".to_string(), + description: "Visible dynamic tool".to_string(), input_schema: input_schema.clone(), + model_visible: true, + }; + let hidden_dynamic_tool = DynamicToolSpec { + name: "hidden_demo_tool".to_string(), + description: "Hidden dynamic tool".to_string(), + input_schema: input_schema.clone(), + model_visible: false, }; // Thread start injects dynamic tools into the thread's tool registry. let thread_req = mcp .send_thread_start_request(ThreadStartParams { - dynamic_tools: Some(vec![dynamic_tool.clone()]), + dynamic_tools: Some(vec![ + visible_dynamic_tool.clone(), + hidden_dynamic_tool.clone(), + ]), ..Default::default() }) .await?; @@ -106,19 +116,20 @@ async fn thread_start_injects_dynamic_tools_into_model_requests() -> Result<()> let body = bodies .first() .context("expected at least one responses request")?; - let tool = find_tool(body, &dynamic_tool.name) - .context("expected dynamic tool to be injected into request")?; + let tool = find_tool(body, &visible_dynamic_tool.name) + .context("expected visible dynamic tool to be injected into request")?; assert_eq!( tool.get("description"), - Some(&Value::String(dynamic_tool.description.clone())) + Some(&Value::String(visible_dynamic_tool.description.clone())) ); assert_eq!(tool.get("parameters"), Some(&input_schema)); + assert!(find_tool(body, &hidden_dynamic_tool.name).is_none()); Ok(()) } -/// Exercises the full dynamic tool call path (server request, client response, model output). +/// Exercises the full dynamic tool call path for a hidden dynamic tool. #[tokio::test] async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Result<()> { let call_id = "dyn-call-1"; @@ -154,6 +165,7 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res "required": ["city"], "additionalProperties": false, }), + model_visible: false, }; let thread_req = mcp @@ -281,6 +293,10 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res .iter() .find_map(|body| function_call_output_payload(body, call_id)) .context("expected function_call_output in follow-up request")?; + let initial_request_body = bodies + .first() + .context("expected initial responses request body")?; + assert!(find_tool(initial_request_body, tool_name).is_none()); let expected_payload = FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputText { text: "dynamic-ok".to_string(), @@ -326,6 +342,7 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<( "required": ["city"], "additionalProperties": false, }), + model_visible: true, }; let thread_req = mcp diff --git a/codex-rs/core/src/tools/js_repl/mod.rs b/codex-rs/core/src/tools/js_repl/mod.rs index a234fd0eb8..7e626cbb27 100644 --- a/codex-rs/core/src/tools/js_repl/mod.rs +++ b/codex-rs/core/src/tools/js_repl/mod.rs @@ -2446,6 +2446,7 @@ console.log(out.output?.body?.text ?? ""); "properties": {}, "additionalProperties": false }), + model_visible: true, }]) .await; if !turn diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 76730d0890..28b345fe67 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -1865,16 +1865,19 @@ pub(crate) fn build_specs( if !dynamic_tools.is_empty() { for tool in dynamic_tools { - match dynamic_tool_to_openai_tool(tool) { - Ok(converted_tool) => { - builder.push_spec(ToolSpec::Function(converted_tool)); - builder.register_handler(tool.name.clone(), dynamic_tool_handler.clone()); - } - Err(e) => { - tracing::error!( - "Failed to convert dynamic tool {:?} to OpenAI tool: {e:?}", - tool.name - ); + builder.register_handler(tool.name.clone(), dynamic_tool_handler.clone()); + + if tool.model_visible { + match dynamic_tool_to_openai_tool(tool) { + Ok(converted_tool) => { + builder.push_spec(ToolSpec::Function(converted_tool)); + } + Err(e) => { + tracing::error!( + "Failed to convert dynamic tool {:?} to OpenAI tool: {e:?}", + tool.name + ); + } } } } @@ -2798,6 +2801,58 @@ mod tests { assert_eq!(mcp_names, expected); } + #[test] + fn hidden_dynamic_tools_register_handlers_without_prompt_specs() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("o3", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + + let dynamic_tools = vec![ + DynamicToolSpec { + name: "visible_tool".to_string(), + description: "Visible dynamic tool".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + model_visible: true, + }, + DynamicToolSpec { + name: "hidden_tool".to_string(), + description: "Hidden dynamic tool".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + model_visible: false, + }, + ]; + + let (tools, registry) = build_specs(&tools_config, None, None, &dynamic_tools).build(); + + assert!( + tools + .iter() + .any(|tool| tool_name(&tool.spec) == "visible_tool") + ); + assert!( + !tools + .iter() + .any(|tool| tool_name(&tool.spec) == "hidden_tool") + ); + assert!(registry.handler("visible_tool").is_some()); + assert!(registry.handler("hidden_tool").is_some()); + } + #[test] fn search_tool_description_includes_only_codex_apps_connector_names() { let config = test_config(); diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index 113e837b30..d54161cf5e 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -94,6 +94,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> { "required": ["city"], "properties": { "city": { "type": "string" } } }), + model_visible: true, }, DynamicToolSpec { name: "weather_lookup".to_string(), @@ -103,6 +104,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> { "required": ["zip"], "properties": { "zip": { "type": "string" } } }), + model_visible: false, }, ]; let dynamic_tools_for_hook = dynamic_tools.clone(); diff --git a/codex-rs/protocol/src/dynamic_tools.rs b/codex-rs/protocol/src/dynamic_tools.rs index 8b5405f307..b3e0b2119d 100644 --- a/codex-rs/protocol/src/dynamic_tools.rs +++ b/codex-rs/protocol/src/dynamic_tools.rs @@ -4,12 +4,18 @@ use serde::Serialize; use serde_json::Value as JsonValue; use ts_rs::TS; +fn default_model_visible() -> bool { + true +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct DynamicToolSpec { pub name: String, pub description: String, pub input_schema: JsonValue, + #[serde(default = "default_model_visible")] + pub model_visible: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] @@ -37,3 +43,30 @@ pub enum DynamicToolCallOutputContentItem { #[serde(rename_all = "camelCase")] InputImage { image_url: String }, } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use serde_json::json; + + #[test] + fn dynamic_tool_spec_defaults_model_visible_when_omitted() { + let spec: DynamicToolSpec = serde_json::from_value(json!({ + "name": "demo_tool", + "description": "Demo dynamic tool", + "inputSchema": { "type": "object" } + })) + .expect("dynamic tool spec should deserialize"); + + assert_eq!( + spec, + DynamicToolSpec { + name: "demo_tool".to_string(), + description: "Demo dynamic tool".to_string(), + input_schema: json!({ "type": "object" }), + model_visible: true, + } + ); + } +} diff --git a/codex-rs/state/migrations/0019_thread_dynamic_tools_model_visible.sql b/codex-rs/state/migrations/0019_thread_dynamic_tools_model_visible.sql new file mode 100644 index 0000000000..7037c216d0 --- /dev/null +++ b/codex-rs/state/migrations/0019_thread_dynamic_tools_model_visible.sql @@ -0,0 +1,2 @@ +ALTER TABLE thread_dynamic_tools +ADD COLUMN model_visible BOOLEAN NOT NULL DEFAULT 1; diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index e02cfb3e4a..02c59fb319 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -42,7 +42,7 @@ WHERE id = ? ) -> anyhow::Result>> { let rows = sqlx::query( r#" -SELECT name, description, input_schema +SELECT name, description, input_schema, model_visible FROM thread_dynamic_tools WHERE thread_id = ? ORDER BY position ASC @@ -62,6 +62,7 @@ ORDER BY position ASC name: row.try_get("name")?, description: row.try_get("description")?, input_schema, + model_visible: row.try_get("model_visible")?, }); } Ok(Some(tools)) @@ -304,8 +305,9 @@ INSERT INTO thread_dynamic_tools ( position, name, description, - input_schema -) VALUES (?, ?, ?, ?, ?) + input_schema, + model_visible +) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(thread_id, position) DO NOTHING "#, ) @@ -314,6 +316,7 @@ ON CONFLICT(thread_id, position) DO NOTHING .bind(tool.name.as_str()) .bind(tool.description.as_str()) .bind(input_schema) + .bind(tool.model_visible) .execute(&mut *tx) .await?; }