From f9451bde2471eb2230828012c58b890bfe4d93b1 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 26 Mar 2026 20:55:10 -0700 Subject: [PATCH] codex-tools: extract dynamic tool adapters --- codex-rs/Cargo.lock | 1 + codex-rs/core/src/tools/spec.rs | 52 +++++++-------- codex-rs/core/src/tools/spec_tests.rs | 39 +++++++++++ codex-rs/tools/Cargo.toml | 1 + codex-rs/tools/README.md | 3 +- codex-rs/tools/src/dynamic_tool.rs | 24 +++++++ codex-rs/tools/src/dynamic_tool_tests.rs | 68 ++++++++++++++++++++ codex-rs/tools/src/lib.rs | 5 +- codex-rs/tools/src/mcp_tool.rs | 20 ++---- codex-rs/tools/src/mcp_tool_tests.rs | 21 +++--- codex-rs/tools/src/parsed_tool_definition.rs | 12 ++++ 11 files changed, 196 insertions(+), 50 deletions(-) create mode 100644 codex-rs/tools/src/dynamic_tool.rs create mode 100644 codex-rs/tools/src/dynamic_tool_tests.rs create mode 100644 codex-rs/tools/src/parsed_tool_definition.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index c5846fbe198..67d43f14538 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2632,6 +2632,7 @@ dependencies = [ name = "codex-tools" version = "0.0.0" dependencies = [ + "codex-protocol", "pretty_assertions", "rmcp", "serde", diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index ea85eaacdde..b64e27d6105 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -46,8 +46,9 @@ use codex_protocol::openai_models::WebSearchToolType; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_tools::ParsedToolDefinition; +use codex_tools::parse_dynamic_tool; use codex_tools::parse_mcp_tool; -pub use codex_tools::parse_tool_input_schema; use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; use serde::Serialize; @@ -2366,16 +2367,10 @@ pub(crate) fn mcp_tool_to_openai_tool( fully_qualified_name: String, tool: rmcp::model::Tool, ) -> Result { - let parsed_tool = parse_mcp_tool(&tool)?; - - Ok(ResponsesApiTool { - name: fully_qualified_name, - description: parsed_tool.description, - strict: false, - defer_loading: None, - parameters: parsed_tool.input_schema, - output_schema: Some(parsed_tool.output_schema), - }) + Ok(parsed_tool_to_openai_tool( + fully_qualified_name, + parse_mcp_tool(&tool)?, + )) } pub(crate) fn mcp_tool_to_deferred_openai_tool( @@ -2384,29 +2379,34 @@ pub(crate) fn mcp_tool_to_deferred_openai_tool( ) -> Result { let parsed_tool = parse_mcp_tool(&tool)?; - Ok(ResponsesApiTool { + Ok(parsed_tool_to_openai_tool( name, - description: parsed_tool.description, - strict: false, - defer_loading: Some(true), - parameters: parsed_tool.input_schema, - output_schema: None, - }) + ParsedToolDefinition { + output_schema: None, + defer_loading: true, + ..parsed_tool + }, + )) } fn dynamic_tool_to_openai_tool( tool: &DynamicToolSpec, ) -> Result { - let input_schema = parse_tool_input_schema(&tool.input_schema)?; + Ok(parsed_tool_to_openai_tool( + tool.name.clone(), + parse_dynamic_tool(tool)?, + )) +} - Ok(ResponsesApiTool { - name: tool.name.clone(), - description: tool.description.clone(), +fn parsed_tool_to_openai_tool(name: String, parsed_tool: ParsedToolDefinition) -> ResponsesApiTool { + ResponsesApiTool { + name, + description: parsed_tool.description, strict: false, - defer_loading: None, - parameters: input_schema, - output_schema: None, - }) + defer_loading: parsed_tool.defer_loading.then_some(true), + parameters: parsed_tool.input_schema, + output_schema: parsed_tool.output_schema, + } } /// Builds the tool registry builder while collecting tool specs for later serialization. diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index 5b9016ec6a5..69b910f64ba 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -8,6 +8,7 @@ use crate::tools::ToolRouter; use crate::tools::registry::ConfiguredToolSpec; use crate::tools::router::ToolRouterParams; use codex_app_server_protocol::AppInfo; +use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelsResponse; @@ -126,6 +127,44 @@ fn deferred_responses_api_tool_serializes_with_defer_loading() { ); } +#[test] +fn dynamic_tool_preserves_defer_loading() { + let tool = DynamicToolSpec { + name: "lookup_order".to_string(), + description: "Look up an order".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "order_id": {"type": "string"} + }, + "required": ["order_id"], + "additionalProperties": false, + }), + defer_loading: true, + }; + + let openai_tool = dynamic_tool_to_openai_tool(&tool).expect("convert dynamic tool"); + + assert_eq!( + openai_tool, + ResponsesApiTool { + name: "lookup_order".to_string(), + description: "Look up an order".to_string(), + strict: false, + defer_loading: Some(true), + parameters: JsonSchema::Object { + properties: BTreeMap::from([( + "order_id".to_string(), + JsonSchema::String { description: None }, + )]), + required: Some(vec!["order_id".to_string()]), + additional_properties: Some(false.into()), + }, + output_schema: None, + } + ); +} + fn tool_name(tool: &ToolSpec) -> &str { match tool { ToolSpec::Function(ResponsesApiTool { name, .. }) => name, diff --git a/codex-rs/tools/Cargo.toml b/codex-rs/tools/Cargo.toml index d695336fae7..1dc9b1c2faf 100644 --- a/codex-rs/tools/Cargo.toml +++ b/codex-rs/tools/Cargo.toml @@ -8,6 +8,7 @@ version.workspace = true workspace = true [dependencies] +codex-protocol = { workspace = true } rmcp = { workspace = true, default-features = false, features = [ "base64", "macros", diff --git a/codex-rs/tools/README.md b/codex-rs/tools/README.md index b8780fe7b13..ad2a3e05961 100644 --- a/codex-rs/tools/README.md +++ b/codex-rs/tools/README.md @@ -9,8 +9,9 @@ schema primitives that no longer need to live in `core/src/tools/spec.rs`: - `JsonSchema` - `AdditionalProperties` +- `ParsedToolDefinition` - `parse_tool_input_schema()` -- `ParsedMcpTool` +- `parse_dynamic_tool()` - `parse_mcp_tool()` - `mcp_call_tool_result_output_schema()` diff --git a/codex-rs/tools/src/dynamic_tool.rs b/codex-rs/tools/src/dynamic_tool.rs new file mode 100644 index 00000000000..851fecf6fba --- /dev/null +++ b/codex-rs/tools/src/dynamic_tool.rs @@ -0,0 +1,24 @@ +use crate::ParsedToolDefinition; +use crate::parse_tool_input_schema; +use codex_protocol::dynamic_tools::DynamicToolSpec; + +pub fn parse_dynamic_tool( + tool: &DynamicToolSpec, +) -> Result { + let DynamicToolSpec { + name: _, + description, + input_schema, + defer_loading, + } = tool; + Ok(ParsedToolDefinition { + description: description.clone(), + input_schema: parse_tool_input_schema(input_schema)?, + output_schema: None, + defer_loading: *defer_loading, + }) +} + +#[cfg(test)] +#[path = "dynamic_tool_tests.rs"] +mod tests; diff --git a/codex-rs/tools/src/dynamic_tool_tests.rs b/codex-rs/tools/src/dynamic_tool_tests.rs new file mode 100644 index 00000000000..568400bb06f --- /dev/null +++ b/codex-rs/tools/src/dynamic_tool_tests.rs @@ -0,0 +1,68 @@ +use super::parse_dynamic_tool; +use crate::JsonSchema; +use crate::ParsedToolDefinition; +use codex_protocol::dynamic_tools::DynamicToolSpec; +use pretty_assertions::assert_eq; +use std::collections::BTreeMap; + +#[test] +fn parse_dynamic_tool_sanitizes_input_schema() { + let tool = DynamicToolSpec { + name: "lookup_ticket".to_string(), + description: "Fetch a ticket".to_string(), + input_schema: serde_json::json!({ + "properties": { + "id": { + "description": "Ticket identifier" + } + } + }), + defer_loading: false, + }; + + assert_eq!( + parse_dynamic_tool(&tool).expect("parse dynamic tool"), + ParsedToolDefinition { + description: "Fetch a ticket".to_string(), + input_schema: JsonSchema::Object { + properties: BTreeMap::from([( + "id".to_string(), + JsonSchema::String { + description: Some("Ticket identifier".to_string()), + }, + )]), + required: None, + additional_properties: None, + }, + output_schema: None, + defer_loading: false, + } + ); +} + +#[test] +fn parse_dynamic_tool_preserves_defer_loading() { + let tool = DynamicToolSpec { + name: "lookup_ticket".to_string(), + description: "Fetch a ticket".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": {} + }), + defer_loading: true, + }; + + assert_eq!( + parse_dynamic_tool(&tool).expect("parse dynamic tool"), + ParsedToolDefinition { + description: "Fetch a ticket".to_string(), + input_schema: JsonSchema::Object { + properties: BTreeMap::new(), + required: None, + additional_properties: None, + }, + output_schema: None, + defer_loading: true, + } + ); +} diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index e719d3e23fe..517d7057680 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -1,11 +1,14 @@ //! Shared tool-schema parsing primitives that can live outside `codex-core`. +mod dynamic_tool; mod json_schema; mod mcp_tool; +mod parsed_tool_definition; +pub use dynamic_tool::parse_dynamic_tool; pub use json_schema::AdditionalProperties; pub use json_schema::JsonSchema; pub use json_schema::parse_tool_input_schema; -pub use mcp_tool::ParsedMcpTool; pub use mcp_tool::mcp_call_tool_result_output_schema; pub use mcp_tool::parse_mcp_tool; +pub use parsed_tool_definition::ParsedToolDefinition; diff --git a/codex-rs/tools/src/mcp_tool.rs b/codex-rs/tools/src/mcp_tool.rs index 99294a0e673..0fda8677a7b 100644 --- a/codex-rs/tools/src/mcp_tool.rs +++ b/codex-rs/tools/src/mcp_tool.rs @@ -1,18 +1,9 @@ -use crate::JsonSchema; +use crate::ParsedToolDefinition; use crate::parse_tool_input_schema; use serde_json::Value as JsonValue; use serde_json::json; -/// Parsed MCP tool metadata and schemas that can be adapted into a higher-level -/// tool spec by downstream crates. -#[derive(Debug, PartialEq)] -pub struct ParsedMcpTool { - pub description: String, - pub input_schema: JsonSchema, - pub output_schema: JsonValue, -} - -pub fn parse_mcp_tool(tool: &rmcp::model::Tool) -> Result { +pub fn parse_mcp_tool(tool: &rmcp::model::Tool) -> Result { let mut serialized_input_schema = serde_json::Value::Object(tool.input_schema.as_ref().clone()); // OpenAI models mandate the "properties" field in the schema. Some MCP @@ -34,10 +25,13 @@ pub fn parse_mcp_tool(tool: &rmcp::model::Tool) -> Result, + pub defer_loading: bool, +}