diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 9fb283ffb..0ea08497c 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -377,7 +377,8 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::WindowsSandboxLevel; -use codex_protocol::dynamic_tools::DynamicToolSpec as CoreDynamicToolSpec; +use codex_protocol::dynamic_tools::DynamicToolFunctionSpec; +use codex_protocol::dynamic_tools::group_dynamic_tools_by_namespace; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; #[cfg(test)] diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index a5881c66c..88fbac596 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -1077,22 +1077,29 @@ impl ThreadRequestProcessor { .default_environment_selections(&config.cwd) }); let dynamic_tools = dynamic_tools.unwrap_or_default(); + // Count callable tools before grouping changes the outer list length. + let core_dynamic_tool_count = dynamic_tools.len(); let core_dynamic_tools = if dynamic_tools.is_empty() { Vec::new() } else { validate_dynamic_tools(&dynamic_tools).map_err(invalid_request)?; - dynamic_tools + // Normalize the flat app-server input into core's function and namespace types. + let tools = dynamic_tools .into_iter() - .map(|tool| CoreDynamicToolSpec { - namespace: tool.namespace, - name: tool.name, - description: tool.description, - input_schema: tool.input_schema, - defer_loading: tool.defer_loading, + .map(|tool| { + ( + tool.namespace, + DynamicToolFunctionSpec { + name: tool.name, + description: tool.description, + input_schema: tool.input_schema, + defer_loading: tool.defer_loading, + }, + ) }) - .collect() + .collect(); + group_dynamic_tools_by_namespace(tools) }; - let core_dynamic_tool_count = core_dynamic_tools.len(); let mut thread_extension_init = ExtensionDataInit::new(); if !selected_capability_roots.is_empty() { thread_extension_init.insert(selected_capability_roots); 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 79c5b801b..96f7f12bf 100644 --- a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs +++ b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs @@ -42,9 +42,8 @@ const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60); #[cfg(not(any(target_os = "macos", windows)))] const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); -/// Ensures dynamic tool specs are serialized into the model request payload. #[tokio::test] -async fn thread_start_injects_dynamic_tools_into_model_requests() -> Result<()> { +async fn thread_start_normalizes_legacy_dynamic_tools_into_model_request() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; let server = create_mock_responses_server_sequence_unchecked(responses).await; @@ -54,29 +53,45 @@ async fn thread_start_injects_dynamic_tools_into_model_requests() -> Result<()> let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - // Use a minimal JSON schema so we can assert the tool payload round-trips. - let input_schema = json!({ + let visible_schema = json!({ "type": "object", "properties": { - "city": { "type": "string" } + "ticket_id": { "type": "string" } }, - "required": ["city"], + "required": ["ticket_id"], "additionalProperties": false, }); - let dynamic_tool = DynamicToolSpec { - namespace: None, - name: "demo_tool".to_string(), - description: "Demo dynamic tool".to_string(), - input_schema: input_schema.clone(), - defer_loading: 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()]), - ..Default::default() - }) + .send_raw_request( + "thread/start", + Some(json!({ + "dynamicTools": [ + { + "name": "lookup_ticket", + "description": "Look up a ticket", + "inputSchema": visible_schema, + }, + { + "namespace": "legacy_app", + "name": "lookup_status", + "description": "Look up a ticket status", + "inputSchema": visible_schema, + "exposeToContext": true + }, + { + "namespace": "legacy_app", + "name": "update_ticket", + "description": "Update a ticket", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "exposeToContext": false + } + ] + })), + ) .await?; let thread_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, @@ -85,13 +100,12 @@ async fn thread_start_injects_dynamic_tools_into_model_requests() -> Result<()> .await??; let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; - // Start a turn so a model request is issued. let turn_req = mcp .send_turn_start_request(TurnStartParams { - thread_id: thread.id.clone(), + thread_id: thread.id, client_user_message_id: None, input: vec![V2UserInput::Text { - text: "Hello".to_string(), + text: "Look up the ticket".to_string(), text_elements: Vec::new(), }], ..Default::default() @@ -103,26 +117,42 @@ async fn thread_start_injects_dynamic_tools_into_model_requests() -> Result<()> ) .await??; let _turn: TurnStartResponse = to_response::(turn_resp)?; - timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_notification_message("turn/completed"), ) .await??; - // Inspect the captured model request to assert the tool spec made it through. let bodies = responses_bodies(&server).await?; - 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 function = + find_tool(&bodies[0], "lookup_ticket").context("expected normalized legacy function")?; assert_eq!( - tool.get("description"), - Some(&Value::String(dynamic_tool.description.clone())) + function, + &json!({ + "type": "function", + "name": "lookup_ticket", + "description": "Look up a ticket", + "strict": false, + "parameters": visible_schema, + }) + ); + let namespace = + find_tool(&bodies[0], "legacy_app").context("expected normalized legacy namespace")?; + assert_eq!( + namespace, + &json!({ + "type": "namespace", + "name": "legacy_app", + "description": "Tools in the legacy_app namespace.", + "tools": [{ + "type": "function", + "name": "lookup_status", + "description": "Look up a ticket status", + "strict": false, + "parameters": visible_schema, + }], + }) ); - assert_eq!(tool.get("parameters"), Some(&input_schema)); Ok(()) } diff --git a/codex-rs/core-api/src/lib.rs b/codex-rs/core-api/src/lib.rs index 6c36258a0..9b2626543 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -70,6 +70,9 @@ pub use codex_protocol::config_types::AutoCompactTokenLimitScope; pub use codex_protocol::config_types::CollaborationModeMask; pub use codex_protocol::config_types::ShellEnvironmentPolicy; pub use codex_protocol::config_types::WebSearchMode; +pub use codex_protocol::dynamic_tools::DynamicToolFunctionSpec; +pub use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec; +pub use codex_protocol::dynamic_tools::DynamicToolNamespaceTool; pub use codex_protocol::dynamic_tools::DynamicToolSpec; pub use codex_protocol::error::Result as CodexResult; pub use codex_protocol::models::PermissionProfile; diff --git a/codex-rs/core/src/tools/handlers/dynamic.rs b/codex-rs/core/src/tools/handlers/dynamic.rs index 62eef88c3..401278576 100644 --- a/codex-rs/core/src/tools/handlers/dynamic.rs +++ b/codex-rs/core/src/tools/handlers/dynamic.rs @@ -11,8 +11,9 @@ use crate::tools::registry::ToolExecutor; use crate::tools::registry::ToolExposure; use crate::turn_timing::now_unix_timestamp_ms; use codex_protocol::dynamic_tools::DynamicToolCallRequest; +use codex_protocol::dynamic_tools::DynamicToolFunctionSpec; +use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec; use codex_protocol::dynamic_tools::DynamicToolResponse; -use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::protocol::DynamicToolCallResponseEvent; use codex_protocol::protocol::EventMsg; @@ -36,13 +37,34 @@ pub struct DynamicToolHandler { } impl DynamicToolHandler { - pub fn new(tool: &DynamicToolSpec) -> Option { - let tool_name = ToolName::new(tool.namespace.clone(), tool.name.clone()); + pub fn new(tool: &DynamicToolFunctionSpec) -> Option { + Self::from_parts(tool, /*namespace*/ None) + } + + pub fn new_in_namespace( + namespace: &DynamicToolNamespaceSpec, + tool: &DynamicToolFunctionSpec, + ) -> Option { + Self::from_parts(tool, Some(namespace)) + } + + fn from_parts( + tool: &DynamicToolFunctionSpec, + namespace: Option<&DynamicToolNamespaceSpec>, + ) -> Option { + let tool_name = ToolName::new( + namespace.map(|namespace| namespace.name.clone()), + tool.name.clone(), + ); let output_tool = dynamic_tool_to_responses_api_tool(tool).ok()?; - let spec = match tool.namespace.as_ref() { + let spec = match namespace { Some(namespace) => ToolSpec::Namespace(ResponsesApiNamespace { - name: namespace.clone(), - description: default_namespace_description(namespace), + name: namespace.name.clone(), + description: if namespace.description.trim().is_empty() { + default_namespace_description(&namespace.name) + } else { + namespace.description.clone() + }, tools: vec![ResponsesApiNamespaceTool::Function(output_tool)], }), None => ToolSpec::Function(output_tool), diff --git a/codex-rs/core/src/tools/handlers/tool_search.rs b/codex-rs/core/src/tools/handlers/tool_search.rs index 9254f4bee..32c55a578 100644 --- a/codex-rs/core/src/tools/handlers/tool_search.rs +++ b/codex-rs/core/src/tools/handlers/tool_search.rs @@ -144,7 +144,8 @@ mod tests { use crate::tools::handlers::DynamicToolHandler; use crate::tools::handlers::McpHandler; use codex_mcp::ToolInfo; - use codex_protocol::dynamic_tools::DynamicToolSpec; + use codex_protocol::dynamic_tools::DynamicToolFunctionSpec; + use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec; use codex_tools::ResponsesApiNamespace; use codex_tools::ResponsesApiNamespaceTool; use codex_tools::ResponsesApiTool; @@ -154,8 +155,12 @@ mod tests { #[test] fn mixed_search_results_coalesce_mcp_namespaces() { - let dynamic_tools = [DynamicToolSpec { - namespace: Some("codex_app".to_string()), + let dynamic_namespace = DynamicToolNamespaceSpec { + name: "codex_app".to_string(), + description: "Tools in the codex_app namespace.".to_string(), + tools: Vec::new(), + }; + let dynamic_tools = [DynamicToolFunctionSpec { name: "automation_update".to_string(), description: "Create, update, view, or delete recurring automations.".to_string(), input_schema: serde_json::json!({ @@ -182,7 +187,7 @@ mod tests { }) .collect::>(); search_infos.extend(dynamic_tools.iter().map(|tool| { - DynamicToolHandler::new(tool) + DynamicToolHandler::new_in_namespace(&dynamic_namespace, tool) .expect("dynamic tool should convert") .search_info() .expect("dynamic handler should return search info") diff --git a/codex-rs/core/src/tools/router_tests.rs b/codex-rs/core/src/tools/router_tests.rs index 4024cb61f..c85c15a50 100644 --- a/codex-rs/core/src/tools/router_tests.rs +++ b/codex-rs/core/src/tools/router_tests.rs @@ -10,6 +10,9 @@ use codex_extension_api::ExtensionRegistryBuilder; use codex_extension_api::ResponsesApiTool; use codex_extension_api::ToolCall as ExtensionToolCall; use codex_extension_api::ToolExecutor; +use codex_protocol::dynamic_tools::DynamicToolFunctionSpec; +use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec; +use codex_protocol::dynamic_tools::DynamicToolNamespaceTool; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputBody; @@ -249,30 +252,32 @@ async fn specs_filter_deferred_dynamic_tools() -> anyhow::Result<()> { let (_, turn) = make_session_and_context().await; let hidden_tool = "hidden_dynamic_tool"; let visible_tool = "visible_dynamic_tool"; - let dynamic_tools = vec![ - DynamicToolSpec { - namespace: Some("codex_app".to_string()), - name: hidden_tool.to_string(), - description: "Hidden until discovered.".to_string(), - input_schema: json!({ - "type": "object", - "properties": {}, - "additionalProperties": false, + let dynamic_tools = vec![DynamicToolSpec::Namespace(DynamicToolNamespaceSpec { + name: "codex_app".to_string(), + description: "Codex app tools.".to_string(), + tools: vec![ + DynamicToolNamespaceTool::Function(DynamicToolFunctionSpec { + name: hidden_tool.to_string(), + description: "Hidden until discovered.".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false, + }), + defer_loading: true, }), - defer_loading: true, - }, - DynamicToolSpec { - namespace: Some("codex_app".to_string()), - name: visible_tool.to_string(), - description: "Visible immediately.".to_string(), - input_schema: json!({ - "type": "object", - "properties": {}, - "additionalProperties": false, + DynamicToolNamespaceTool::Function(DynamicToolFunctionSpec { + name: visible_tool.to_string(), + description: "Visible immediately.".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false, + }), + defer_loading: false, }), - defer_loading: false, - }, - ]; + ], + })]; let router = ToolRouter::from_turn_context( &turn, diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 76c1e4389..f096cddd8 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -59,6 +59,7 @@ use codex_features::Feature; use codex_login::AuthManager; use codex_mcp::ToolInfo; use codex_protocol::config_types::WebSearchMode; +use codex_protocol::dynamic_tools::DynamicToolNamespaceTool; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::InputModality; @@ -813,16 +814,34 @@ fn add_mcp_runtime_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut } fn add_dynamic_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { - for tool in context.dynamic_tools { - let Some(handler) = DynamicToolHandler::new(tool) else { - tracing::error!( - "Failed to convert dynamic tool {:?} to OpenAI tool", - tool.name - ); - continue; - }; - - planned_tools.add(handler); + for spec in context.dynamic_tools { + match spec { + DynamicToolSpec::Function(tool) => { + let Some(handler) = DynamicToolHandler::new(tool) else { + tracing::error!( + "Failed to convert dynamic tool {:?} to OpenAI tool", + tool.name + ); + continue; + }; + planned_tools.add(handler); + } + DynamicToolSpec::Namespace(namespace) => { + for tool in &namespace.tools { + let DynamicToolNamespaceTool::Function(tool) = tool; + let Some(handler) = DynamicToolHandler::new_in_namespace(namespace, tool) + else { + tracing::error!( + "Failed to convert dynamic tool {:?}.{:?} to OpenAI tool", + namespace.name, + tool.name + ); + continue; + }; + planned_tools.add(handler); + } + } + } } } diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index d7cc7b78a..0cdc56785 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -384,8 +384,7 @@ fn invalid_mcp_tool(server: &str, namespace: &str, name: &str) -> ToolInfo { } fn dynamic_tool(namespace: Option<&str>, name: &str, defer_loading: bool) -> DynamicToolSpec { - DynamicToolSpec { - namespace: namespace.map(str::to_string), + let function = codex_protocol::dynamic_tools::DynamicToolFunctionSpec { name: name.to_string(), description: format!("{name} dynamic tool"), input_schema: json!({ @@ -394,6 +393,18 @@ fn dynamic_tool(namespace: Option<&str>, name: &str, defer_loading: bool) -> Dyn "additionalProperties": false, }), defer_loading, + }; + match namespace { + Some(namespace) => { + DynamicToolSpec::Namespace(codex_protocol::dynamic_tools::DynamicToolNamespaceSpec { + name: namespace.to_string(), + description: format!("{namespace} dynamic tools"), + tools: vec![ + codex_protocol::dynamic_tools::DynamicToolNamespaceTool::Function(function), + ], + }) + } + None => DynamicToolSpec::Function(function), } } diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 0a697d6df..670b0fa1a 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -12,6 +12,9 @@ use codex_login::CodexAuth; use codex_models_manager::bundled_models_response; use codex_protocol::config_types::WebSearchMode; use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem; +use codex_protocol::dynamic_tools::DynamicToolFunctionSpec; +use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec; +use codex_protocol::dynamic_tools::DynamicToolNamespaceTool; use codex_protocol::dynamic_tools::DynamicToolResponse; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::PermissionProfile; @@ -3286,6 +3289,7 @@ text(JSON.stringify(tool)); serde_json::json!({ "name": "mcp__rmcp__echo", "description": concat!( + "Use these tools to exercise the rmcp test server.\n\n", "Echo back the provided message and include environment data.\n\n", "exec tool declaration:\n", "```ts\n", @@ -3312,20 +3316,25 @@ async fn code_mode_can_call_hidden_dynamic_tools() -> Result<()> { .thread_manager .start_thread_with_tools( base_test.config.clone(), - vec![DynamicToolSpec { - namespace: Some("codex_app".to_string()), - name: "hidden_dynamic_tool".to_string(), - description: "A hidden dynamic tool.".to_string(), - input_schema: serde_json::json!({ - "type": "object", - "properties": { - "city": { "type": "string" } - }, - "required": ["city"], - "additionalProperties": false, - }), - defer_loading: true, - }], + vec![DynamicToolSpec::Namespace(DynamicToolNamespaceSpec { + name: "codex_app".to_string(), + description: "Codex app tools.".to_string(), + tools: vec![DynamicToolNamespaceTool::Function( + DynamicToolFunctionSpec { + name: "hidden_dynamic_tool".to_string(), + description: "A hidden dynamic tool.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "city": { "type": "string" } + }, + "required": ["city"], + "additionalProperties": false, + }), + defer_loading: true, + }, + )], + })], ) .await?; let mut test = base_test; @@ -3333,8 +3342,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, @@ -3441,7 +3450,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"), @@ -3452,9 +3461,10 @@ text( .get("description") .and_then(Value::as_str) .is_some_and(|description| { - description.contains("A hidden dynamic tool.") + description.contains("Codex app tools.") + && 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:") }) ); @@ -3475,17 +3485,22 @@ async fn code_mode_excludes_configured_nested_tool_namespaces() -> Result<()> { .thread_manager .start_thread_with_tools( base_test.config.clone(), - vec![DynamicToolSpec { - namespace: Some("excluded".to_string()), - name: "lookup".to_string(), - description: "An excluded dynamic tool.".to_string(), - input_schema: serde_json::json!({ - "type": "object", - "properties": {}, - "additionalProperties": false, - }), - defer_loading: false, - }], + vec![DynamicToolSpec::Namespace(DynamicToolNamespaceSpec { + name: "excluded".to_string(), + description: "Excluded tools.".to_string(), + tools: vec![DynamicToolNamespaceTool::Function( + DynamicToolFunctionSpec { + name: "lookup".to_string(), + description: "An excluded dynamic tool.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": {}, + "additionalProperties": false, + }), + defer_loading: false, + }, + )], + })], ) .await?; let mut test = base_test; diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 781592875..6bca81bfd 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -8,6 +8,9 @@ use codex_core::compact::SUMMARY_PREFIX; use codex_features::Feature; use codex_login::CodexAuth; use codex_protocol::config_types::ServiceTier; +use codex_protocol::dynamic_tools::DynamicToolFunctionSpec; +use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec; +use codex_protocol::dynamic_tools::DynamicToolNamespaceTool; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::items::TurnItem; use codex_protocol::models::ContentItem; @@ -1147,22 +1150,24 @@ async fn remote_compact_filters_deferred_dynamic_tools() -> Result<()> { "properties": {}, "additionalProperties": false, }); - let dynamic_tools = vec![ - DynamicToolSpec { - namespace: Some("codex_app".to_string()), - name: hidden_tool.to_string(), - description: "Hidden until discovered.".to_string(), - input_schema: input_schema.clone(), - defer_loading: true, - }, - DynamicToolSpec { - namespace: Some("codex_app".to_string()), - name: visible_tool.to_string(), - description: "Visible immediately.".to_string(), - input_schema, - defer_loading: false, - }, - ]; + let dynamic_tools = vec![DynamicToolSpec::Namespace(DynamicToolNamespaceSpec { + name: "codex_app".to_string(), + description: "Codex app tools.".to_string(), + tools: vec![ + DynamicToolNamespaceTool::Function(DynamicToolFunctionSpec { + name: hidden_tool.to_string(), + description: "Hidden until discovered.".to_string(), + input_schema: input_schema.clone(), + defer_loading: true, + }), + DynamicToolNamespaceTool::Function(DynamicToolFunctionSpec { + name: visible_tool.to_string(), + description: "Visible immediately.".to_string(), + input_schema, + defer_loading: false, + }), + ], + })]; let new_thread = test .thread_manager .start_thread_with_tools(test.config.clone(), dynamic_tools) @@ -1784,13 +1789,18 @@ async fn remote_compact_trims_tool_search_output_to_empty_tools_array() -> Resul "required": ["mode"], "additionalProperties": false, }); - let dynamic_tool = DynamicToolSpec { - namespace: Some("codex_app".to_string()), - name: tool_name.to_string(), - description: tool_description, - input_schema, - defer_loading: true, - }; + let dynamic_tool = DynamicToolSpec::Namespace(DynamicToolNamespaceSpec { + name: "codex_app".to_string(), + description: "Codex app tools.".to_string(), + tools: vec![DynamicToolNamespaceTool::Function( + DynamicToolFunctionSpec { + name: tool_name.to_string(), + description: tool_description, + input_schema, + defer_loading: true, + }, + )], + }); let mut builder = test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 764805631..f2360a730 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -7,6 +7,9 @@ use codex_config::types::McpServerTransportConfig; use codex_features::Feature; use codex_login::CodexAuth; use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem; +use codex_protocol::dynamic_tools::DynamicToolFunctionSpec; +use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec; +use codex_protocol::dynamic_tools::DynamicToolNamespaceTool; use codex_protocol::dynamic_tools::DynamicToolResponse; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::FunctionCallOutputPayload; @@ -916,13 +919,18 @@ async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() - "required": ["mode"], "additionalProperties": false, }); - let dynamic_tool = DynamicToolSpec { - namespace: Some("codex_app".to_string()), - name: tool_name.to_string(), - description: tool_description.to_string(), - input_schema: input_schema.clone(), - defer_loading: true, - }; + let dynamic_tool = DynamicToolSpec::Namespace(DynamicToolNamespaceSpec { + name: "codex_app".to_string(), + description: "Automation tools.".to_string(), + tools: vec![DynamicToolNamespaceTool::Function( + DynamicToolFunctionSpec { + name: tool_name.to_string(), + description: tool_description.to_string(), + input_schema: input_schema.clone(), + defer_loading: true, + }, + )], + }); let mut builder = test_codex().with_config(configure_search_capable_model); let base_test = builder.build(&server).await?; @@ -998,7 +1006,7 @@ async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() - vec![json!({ "type": "namespace", "name": "codex_app", - "description": "Tools in the codex_app namespace.", + "description": "Automation tools.", "tools": [{ "type": "function", "name": tool_name, @@ -1535,21 +1543,27 @@ async fn tool_search_matches_dynamic_tools_by_name_description_namespace_and_sch ) .await; - let dynamic_tool = DynamicToolSpec { - namespace: Some("orbit_ops".to_string()), - name: "quasar_ping_beacon".to_string(), - description: "Trigger the saffron metronome workflow for reminder follow-ups.".to_string(), - input_schema: json!({ - "type": "object", - "properties": { - "chrono_spec": { "type": "string" }, - "targetThreadId": { "type": "string" }, + let dynamic_tool = DynamicToolSpec::Namespace(DynamicToolNamespaceSpec { + name: "orbit_ops".to_string(), + description: "Orbital reminder operations.".to_string(), + tools: vec![DynamicToolNamespaceTool::Function( + DynamicToolFunctionSpec { + name: "quasar_ping_beacon".to_string(), + description: "Trigger the saffron metronome workflow for reminder follow-ups." + .to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "chrono_spec": { "type": "string" }, + "targetThreadId": { "type": "string" }, + }, + "required": ["chrono_spec"], + "additionalProperties": false, + }), + defer_loading: true, }, - "required": ["chrono_spec"], - "additionalProperties": false, - }), - defer_loading: true, - }; + )], + }); let mut builder = test_codex().with_config(configure_search_capable_model); let base_test = builder.build(&server).await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index b4809e8a3..eae82de9e 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -7,6 +7,9 @@ use codex_features::Feature; use codex_login::CodexAuth; use codex_protocol::ThreadId; use codex_protocol::config_types::WebSearchMode; +use codex_protocol::dynamic_tools::DynamicToolFunctionSpec; +use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec; +use codex_protocol::dynamic_tools::DynamicToolNamespaceTool; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; @@ -117,18 +120,28 @@ async fn resume_restores_dynamic_tools_from_rollout_with_sqlite_enabled() -> Res ) .await; - let dynamic_tool = DynamicToolSpec { - namespace: None, - name: "resume_lookup".to_string(), - description: "Look up a value after resume.".to_string(), - input_schema: json!({ - "type": "object", - "properties": { "query": { "type": "string" } }, - "required": ["query"], - "additionalProperties": false, - }), - defer_loading: false, - }; + let namespace = "resume_tools"; + let namespace_description = "Tools available after resume."; + let tool_name = "resume_lookup"; + let tool_description = "Look up a value after resume."; + let input_schema = json!({ + "type": "object", + "properties": { "query": { "type": "string" } }, + "required": ["query"], + "additionalProperties": false, + }); + let dynamic_tool = DynamicToolSpec::Namespace(DynamicToolNamespaceSpec { + name: namespace.to_string(), + description: namespace_description.to_string(), + tools: vec![DynamicToolNamespaceTool::Function( + DynamicToolFunctionSpec { + name: tool_name.to_string(), + description: tool_description.to_string(), + input_schema: input_schema.clone(), + defer_loading: false, + }, + )], + }); let mut builder = test_codex().with_config(|config| { config .features @@ -138,7 +151,7 @@ async fn resume_restores_dynamic_tools_from_rollout_with_sqlite_enabled() -> Res let base_test = builder.build(&server).await?; let started = base_test .thread_manager - .start_thread_with_tools(base_test.config.clone(), vec![dynamic_tool.clone()]) + .start_thread_with_tools(base_test.config.clone(), vec![dynamic_tool]) .await?; let rollout_path = started .session_configured @@ -182,17 +195,144 @@ async fn resume_restores_dynamic_tools_from_rollout_with_sqlite_enabled() -> Res .get("tools") .and_then(serde_json::Value::as_array) .expect("resumed request tools"); - let restored_tool = tools + let restored_namespace = tools .iter() - .find(|tool| tool.get("name") == Some(&json!(dynamic_tool.name.as_str()))) - .expect("dynamic tool should be restored from rollout metadata"); + .find(|tool| tool.get("name") == Some(&json!(namespace))) + .expect("dynamic tool namespace should be restored from rollout metadata"); assert_eq!( - restored_tool.get("description"), - Some(&json!(dynamic_tool.description.as_str())) + restored_namespace, + &json!({ + "type": "namespace", + "name": namespace, + "description": namespace_description, + "tools": [{ + "type": "function", + "name": tool_name, + "description": tool_description, + "strict": false, + "parameters": input_schema, + }], + }) ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn resume_restores_legacy_dynamic_tools_from_rollout_with_sqlite_enabled() -> Result<()> { + let server = start_mock_server().await; + let mock = mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + responses::sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), + ], + ) + .await; + + let namespace = "resume_tools"; + let tool_name = "resume_lookup"; + let tool_description = "Look up a value after resume."; + let input_schema = json!({ + "type": "object", + "properties": { "query": { "type": "string" } }, + "required": ["query"], + "additionalProperties": false, + }); + let mut builder = test_codex().with_config(|config| { + config + .features + .enable(Feature::Sqlite) + .expect("test config should allow feature update"); + }); + let base_test = builder.build(&server).await?; + let started = base_test + .thread_manager + .start_thread_with_tools(base_test.config.clone(), Vec::new()) + .await?; + let rollout_path = started + .session_configured + .rollout_path + .clone() + .expect("rollout path"); + + started + .thread + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "persist this thread".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + wait_for_event(&started.thread, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + started.thread.submit(Op::Shutdown).await?; + wait_for_event(&started.thread, |event| { + matches!(event, EventMsg::ShutdownComplete) + }) + .await; + + let mut rollout_lines = fs::read_to_string(&rollout_path)? + .lines() + .map(serde_json::from_str::) + .collect::>>()?; + rollout_lines.first_mut().expect("session metadata line")["payload"]["dynamic_tools"] = json!([{ + "namespace": namespace, + "name": tool_name, + "description": tool_description, + "inputSchema": input_schema, + "exposeToContext": true, + }]); + let rollout = rollout_lines + .iter() + .map(serde_json::to_string) + .collect::>>()? + .join("\n"); + fs::write(&rollout_path, format!("{rollout}\n"))?; + + let mut resume_builder = test_codex().with_config(|config| { + config + .features + .enable(Feature::Sqlite) + .expect("test config should allow feature update"); + }); + let resumed = resume_builder + .resume(&server, base_test.home.clone(), rollout_path) + .await?; + resumed.submit_turn("use the restored tool").await?; + + let requests = mock.requests(); + assert_eq!(requests.len(), 2); + let resumed_body = requests[1].body_json(); + let tools = resumed_body + .get("tools") + .and_then(serde_json::Value::as_array) + .expect("resumed request tools"); + let restored_namespace = tools + .iter() + .find(|tool| tool.get("name") == Some(&json!(namespace))) + .expect("dynamic tool namespace should be restored from rollout metadata"); assert_eq!( - restored_tool.get("parameters"), - Some(&dynamic_tool.input_schema) + restored_namespace, + &json!({ + "type": "namespace", + "name": namespace, + "description": "Tools in the resume_tools namespace.", + "tools": [{ + "type": "function", + "name": tool_name, + "description": tool_description, + "strict": false, + "parameters": input_schema, + }], + }) ); Ok(()) diff --git a/codex-rs/protocol/src/dynamic_tools.rs b/codex-rs/protocol/src/dynamic_tools.rs index da2ef6e02..27405f58b 100644 --- a/codex-rs/protocol/src/dynamic_tools.rs +++ b/codex-rs/protocol/src/dynamic_tools.rs @@ -2,21 +2,46 @@ use schemars::JsonSchema; use serde::Deserialize; use serde::Deserializer; use serde::Serialize; +use serde::de::Error as _; use serde_json::Value as JsonValue; +use std::collections::HashMap; use ts_rs::TS; -#[derive(Debug, Clone, Serialize, PartialEq, JsonSchema, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type", export_to = "v2/")] +pub enum DynamicToolSpec { + Function(DynamicToolFunctionSpec), + Namespace(DynamicToolNamespaceSpec), +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] -pub struct DynamicToolSpec { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub namespace: Option, +#[ts(export_to = "v2/")] +pub struct DynamicToolFunctionSpec { pub name: String, pub description: String, pub input_schema: JsonValue, - #[serde(default)] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub defer_loading: bool, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct DynamicToolNamespaceSpec { + pub name: String, + pub description: String, + pub tools: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type", export_to = "v2/")] +pub enum DynamicToolNamespaceTool { + Function(DynamicToolFunctionSpec), +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct DynamicToolCallRequest { @@ -47,9 +72,11 @@ pub enum DynamicToolCallOutputContentItem { InputImage { image_url: String }, } +/// Former flat `SessionMeta` shape, including the old `exposeToContext` flag. +/// Kept so new builds can resume sessions written before explicit namespaces. #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct DynamicToolSpecDe { +struct LegacyDynamicToolSpec { namespace: Option, name: String, description: String, @@ -58,84 +85,84 @@ struct DynamicToolSpecDe { expose_to_context: Option, } -impl<'de> Deserialize<'de> for DynamicToolSpec { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let DynamicToolSpecDe { - namespace, - name, - description, - input_schema, - defer_loading, - expose_to_context, - } = DynamicToolSpecDe::deserialize(deserializer)?; - - Ok(Self { - namespace, - name, - description, - input_schema, - defer_loading: defer_loading - .unwrap_or_else(|| expose_to_context.map(|visible| !visible).unwrap_or(false)), - }) +pub fn normalize_dynamic_tool_specs( + values: Vec, +) -> Result, serde_json::Error> { + let has_legacy_format = values.iter().any(|value| { + value.get("namespace").is_some() + || value.get("exposeToContext").is_some() + || value.get("type").is_none() + }); + let has_canonical_namespace = values + .iter() + .any(|value| value.get("type").and_then(JsonValue::as_str) == Some("namespace")); + if has_legacy_format && has_canonical_namespace { + return Err(serde_json::Error::custom( + "dynamic tools must use either canonical or legacy format consistently", + )); + } + if !has_legacy_format { + return values.into_iter().map(serde_json::from_value).collect(); } -} -#[cfg(test)] -mod tests { - use super::DynamicToolSpec; - use pretty_assertions::assert_eq; - use serde_json::json; - - #[test] - fn dynamic_tool_spec_deserializes_defer_loading() { - let value = json!({ - "name": "lookup_ticket", - "description": "Fetch a ticket", - "inputSchema": { - "type": "object", - "properties": { - "id": { "type": "string" } - } - }, - "deferLoading": true, - }); - - let actual: DynamicToolSpec = serde_json::from_value(value).expect("deserialize"); - - assert_eq!( - actual, - DynamicToolSpec { - namespace: None, - name: "lookup_ticket".to_string(), - description: "Fetch a ticket".to_string(), - input_schema: json!({ - "type": "object", - "properties": { - "id": { "type": "string" } - } + let tools = values + .into_iter() + .map(|value| { + let tool: LegacyDynamicToolSpec = serde_json::from_value(value)?; + let function = DynamicToolFunctionSpec { + name: tool.name, + description: tool.description, + input_schema: tool.input_schema, + defer_loading: tool.defer_loading.unwrap_or_else(|| { + tool.expose_to_context + .map(|visible| !visible) + .unwrap_or(false) }), - defer_loading: true, - } - ); - } - - #[test] - fn dynamic_tool_spec_legacy_expose_to_context_inverts_to_defer_loading() { - let value = json!({ - "name": "lookup_ticket", - "description": "Fetch a ticket", - "inputSchema": { - "type": "object", - "properties": {} - }, - "exposeToContext": false, - }); - - let actual: DynamicToolSpec = serde_json::from_value(value).expect("deserialize"); - - assert!(actual.defer_loading); - } + }; + Ok((tool.namespace, function)) + }) + .collect::, serde_json::Error>>()?; + Ok(group_dynamic_tools_by_namespace(tools)) +} + +pub fn group_dynamic_tools_by_namespace( + tools: Vec<(Option, DynamicToolFunctionSpec)>, +) -> Vec { + let mut grouped_tools = Vec::with_capacity(tools.len()); + let mut namespace_indices = HashMap::::new(); + for (namespace, function) in tools { + let Some(namespace) = namespace else { + grouped_tools.push(DynamicToolSpec::Function(function)); + continue; + }; + let function = DynamicToolNamespaceTool::Function(function); + if let Some(index) = namespace_indices.get(&namespace).copied() { + let DynamicToolSpec::Namespace(namespace) = &mut grouped_tools[index] else { + unreachable!("namespace index must point to a namespace"); + }; + namespace.tools.push(function); + continue; + } + namespace_indices.insert(namespace.clone(), grouped_tools.len()); + grouped_tools.push(DynamicToolSpec::Namespace(DynamicToolNamespaceSpec { + name: namespace, + description: String::new(), + tools: vec![function], + })); + } + grouped_tools +} + +pub fn deserialize_dynamic_tool_specs<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + let Some(values) = Option::>::deserialize(deserializer)? else { + return Ok(None); + }; + normalize_dynamic_tool_specs(values) + .map(Some) + .map_err(D::Error::custom) } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index b41a02b88..92b8194b4 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2872,7 +2872,11 @@ pub struct SessionMeta { /// but may be missing for older sessions. If not present, fall back to rendering the base_instructions /// from ModelsManager. pub base_instructions: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + default, + deserialize_with = "crate::dynamic_tools::deserialize_dynamic_tool_specs", + skip_serializing_if = "Option::is_none" + )] pub dynamic_tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub memory_mode: Option, @@ -4122,6 +4126,59 @@ mod tests { Ok(()) } + #[test] + fn session_meta_normalizes_legacy_dynamic_tools() -> Result<()> { + let mut value = serde_json::to_value(SessionMeta::default())?; + value["dynamic_tools"] = json!([ + { + "namespace": "legacy_app", + "name": "lookup_ticket", + "description": "Look up a ticket", + "inputSchema": {"type": "object", "properties": {}}, + "exposeToContext": false + }, + { + "namespace": "legacy_app", + "name": "update_ticket", + "description": "Update a ticket", + "inputSchema": {"type": "object", "properties": {}}, + "deferLoading": false, + "exposeToContext": false + } + ]); + + let meta: SessionMeta = serde_json::from_value(value)?; + + assert_eq!( + meta.dynamic_tools, + Some(vec![DynamicToolSpec::Namespace( + crate::dynamic_tools::DynamicToolNamespaceSpec { + name: "legacy_app".to_string(), + description: String::new(), + tools: vec![ + crate::dynamic_tools::DynamicToolNamespaceTool::Function( + crate::dynamic_tools::DynamicToolFunctionSpec { + name: "lookup_ticket".to_string(), + description: "Look up a ticket".to_string(), + input_schema: json!({"type": "object", "properties": {}}), + defer_loading: true, + }, + ), + crate::dynamic_tools::DynamicToolNamespaceTool::Function( + crate::dynamic_tools::DynamicToolFunctionSpec { + name: "update_ticket".to_string(), + description: "Update a ticket".to_string(), + input_schema: json!({"type": "object", "properties": {}}), + defer_loading: false, + }, + ), + ], + }, + )]) + ); + Ok(()) + } + fn sorted_writable_roots(roots: Vec) -> Vec<(PathBuf, Vec)> { let mut sorted_roots: Vec<(PathBuf, Vec)> = roots .into_iter() diff --git a/codex-rs/tools/src/code_mode.rs b/codex-rs/tools/src/code_mode.rs index 4876486f6..ae1848948 100644 --- a/codex-rs/tools/src/code_mode.rs +++ b/codex-rs/tools/src/code_mode.rs @@ -63,7 +63,19 @@ pub fn collect_code_mode_tool_definitions<'a>( ) -> Vec { let mut tool_definitions = specs .into_iter() - .flat_map(code_mode_tool_definitions_for_spec) + .flat_map(|spec| { + let mut definitions = code_mode_tool_definitions_for_spec(spec); + if let ToolSpec::Namespace(namespace) = spec { + let namespace_description = namespace.description.trim(); + if !namespace_description.is_empty() { + for definition in &mut definitions { + definition.description = + format!("{namespace_description}\n\n{}", definition.description); + } + } + } + definitions + }) .filter(|definition| codex_code_mode::is_code_mode_nested_tool(&definition.name)) .map(codex_code_mode::augment_tool_definition) .collect::>(); diff --git a/codex-rs/tools/src/dynamic_tool.rs b/codex-rs/tools/src/dynamic_tool.rs index af74a3c1e..192a730b4 100644 --- a/codex-rs/tools/src/dynamic_tool.rs +++ b/codex-rs/tools/src/dynamic_tool.rs @@ -1,8 +1,10 @@ use crate::ToolDefinition; use crate::parse_tool_input_schema; -use codex_protocol::dynamic_tools::DynamicToolSpec; +use codex_protocol::dynamic_tools::DynamicToolFunctionSpec; -pub fn parse_dynamic_tool(tool: &DynamicToolSpec) -> Result { +pub fn parse_dynamic_tool( + tool: &DynamicToolFunctionSpec, +) -> Result { Ok(ToolDefinition { name: tool.name.clone(), description: tool.description.clone(), diff --git a/codex-rs/tools/src/dynamic_tool_tests.rs b/codex-rs/tools/src/dynamic_tool_tests.rs index 284ce4853..9b0764797 100644 --- a/codex-rs/tools/src/dynamic_tool_tests.rs +++ b/codex-rs/tools/src/dynamic_tool_tests.rs @@ -1,14 +1,13 @@ use super::parse_dynamic_tool; use crate::JsonSchema; use crate::ToolDefinition; -use codex_protocol::dynamic_tools::DynamicToolSpec; +use codex_protocol::dynamic_tools::DynamicToolFunctionSpec; use pretty_assertions::assert_eq; use std::collections::BTreeMap; #[test] fn parse_dynamic_tool_sanitizes_input_schema() { - let tool = DynamicToolSpec { - namespace: None, + let tool = DynamicToolFunctionSpec { name: "lookup_ticket".to_string(), description: "Fetch a ticket".to_string(), input_schema: serde_json::json!({ @@ -39,8 +38,7 @@ fn parse_dynamic_tool_sanitizes_input_schema() { #[test] fn parse_dynamic_tool_preserves_defer_loading() { - let tool = DynamicToolSpec { - namespace: None, + let tool = DynamicToolFunctionSpec { name: "lookup_ticket".to_string(), description: "Fetch a ticket".to_string(), input_schema: serde_json::json!({ diff --git a/codex-rs/tools/src/responses_api.rs b/codex-rs/tools/src/responses_api.rs index 013c491cf..9911ad0ef 100644 --- a/codex-rs/tools/src/responses_api.rs +++ b/codex-rs/tools/src/responses_api.rs @@ -3,7 +3,7 @@ use crate::ToolDefinition; use crate::ToolName; use crate::parse_dynamic_tool; use crate::parse_mcp_tool; -use codex_protocol::dynamic_tools::DynamicToolSpec; +use codex_protocol::dynamic_tools::DynamicToolFunctionSpec; use serde::Deserialize; use serde::Serialize; use serde_json::Value; @@ -67,7 +67,7 @@ pub enum ResponsesApiNamespaceTool { } pub fn dynamic_tool_to_responses_api_tool( - tool: &DynamicToolSpec, + tool: &DynamicToolFunctionSpec, ) -> Result { Ok(tool_definition_to_responses_api_tool(parse_dynamic_tool( tool, diff --git a/codex-rs/tools/src/responses_api_tests.rs b/codex-rs/tools/src/responses_api_tests.rs index e549d9969..fc8dbff55 100644 --- a/codex-rs/tools/src/responses_api_tests.rs +++ b/codex-rs/tools/src/responses_api_tests.rs @@ -8,7 +8,7 @@ use super::tool_definition_to_responses_api_tool; use crate::JsonSchema; use crate::ToolDefinition; use crate::ToolName; -use codex_protocol::dynamic_tools::DynamicToolSpec; +use codex_protocol::dynamic_tools::DynamicToolFunctionSpec; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::BTreeMap; @@ -50,8 +50,7 @@ fn tool_definition_to_responses_api_tool_omits_false_defer_loading() { #[test] fn dynamic_tool_to_responses_api_tool_preserves_defer_loading() { - let tool = DynamicToolSpec { - namespace: None, + let tool = DynamicToolFunctionSpec { name: "lookup_order".to_string(), description: "Look up an order".to_string(), input_schema: json!({