diff --git a/MODULE.bazel b/MODULE.bazel index 146710c7fd..f739352120 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -27,11 +27,11 @@ register_toolchains("@llvm//toolchain:all") osx = use_extension("@llvm//extensions:osx.bzl", "osx") osx.from_archive( - sha256 = "6a4922f89487a96d7054ec6ca5065bfddd9f1d017c74d82f1d79cecf7feb8228", - strip_prefix = "Payload/Library/Developer/CommandLineTools/SDKs/MacOSX26.2.sdk", + sha256 = "1bde70c0b1c2ab89ff454acbebf6741390d7b7eb149ca2a3ca24cc9203a408b7", + strip_prefix = "Payload/Library/Developer/CommandLineTools/SDKs/MacOSX26.4.sdk", type = "pkg", urls = [ - "https://swcdn.apple.com/content/downloads/26/44/047-81934-A_28TPKM5SD1/ps6pk6dk4x02vgfa5qsctq6tgf23t5f0w2/CLTools_macOSNMOS_SDK.pkg", + "https://swcdn.apple.com/content/downloads/32/53/047-96692-A_OAHIHT53YB/ybtshxmrcju8m2qvw3w5elr4rajtg1x3y3/CLTools_macOSNMOS_SDK.pkg", ], ) osx.frameworks(names = [ diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index e38ff3a562..af5e3007b4 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -72,6 +72,7 @@ use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::W3cTraceContext; +use codex_tools::create_tools_json_for_responses_api; use eventsource_stream::Event; use eventsource_stream::EventStreamError; use futures::StreamExt; @@ -107,7 +108,6 @@ use crate::response_debug_context::extract_response_debug_context; use crate::response_debug_context::extract_response_debug_context_from_api_error; use crate::response_debug_context::telemetry_api_error_message; use crate::response_debug_context::telemetry_transport_error_message; -use crate::tools::spec::create_tools_json_for_responses_api; use crate::util::FeedbackRequestTags; use crate::util::emit_feedback_auth_recovery_tags; use crate::util::emit_feedback_request_tags_with_auth_env; diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 50a523a26e..908dec56c6 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -1,10 +1,10 @@ -use crate::client_common::tools::ToolSpec; use crate::config::types::Personality; use crate::error::Result; pub use codex_api::common::ResponseEvent; use codex_protocol::models::BaseInstructions; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::ResponseItem; +use codex_tools::ToolSpec; use futures::Stream; use serde::Deserialize; use serde_json::Value; @@ -157,107 +157,11 @@ fn strip_total_output_header(output: &str) -> Option<(&str, u32)> { } pub(crate) mod tools { - use codex_protocol::config_types::WebSearchContextSize; - use codex_protocol::config_types::WebSearchFilters as ConfigWebSearchFilters; - use codex_protocol::config_types::WebSearchUserLocation as ConfigWebSearchUserLocation; - use codex_protocol::config_types::WebSearchUserLocationType; pub(crate) use codex_tools::FreeformTool; pub(crate) use codex_tools::FreeformToolFormat; - use codex_tools::JsonSchema; pub(crate) use codex_tools::ResponsesApiTool; pub(crate) use codex_tools::ToolSearchOutputTool; - use serde::Serialize; - - /// When serialized as JSON, this produces a valid "Tool" in the OpenAI - /// Responses API. - #[derive(Debug, Clone, Serialize, PartialEq)] - #[serde(tag = "type")] - pub(crate) enum ToolSpec { - #[serde(rename = "function")] - Function(ResponsesApiTool), - #[serde(rename = "tool_search")] - ToolSearch { - execution: String, - description: String, - parameters: JsonSchema, - }, - #[serde(rename = "local_shell")] - LocalShell {}, - #[serde(rename = "image_generation")] - ImageGeneration { output_format: String }, - // TODO: Understand why we get an error on web_search although the API docs say it's supported. - // https://platform.openai.com/docs/guides/tools-web-search?api-mode=responses#:~:text=%7B%20type%3A%20%22web_search%22%20%7D%2C - // The `external_web_access` field determines whether the web search is over cached or live content. - // https://platform.openai.com/docs/guides/tools-web-search#live-internet-access - #[serde(rename = "web_search")] - WebSearch { - #[serde(skip_serializing_if = "Option::is_none")] - external_web_access: Option, - #[serde(skip_serializing_if = "Option::is_none")] - filters: Option, - #[serde(skip_serializing_if = "Option::is_none")] - user_location: Option, - #[serde(skip_serializing_if = "Option::is_none")] - search_context_size: Option, - #[serde(skip_serializing_if = "Option::is_none")] - search_content_types: Option>, - }, - #[serde(rename = "custom")] - Freeform(FreeformTool), - } - - impl ToolSpec { - pub(crate) fn name(&self) -> &str { - match self { - ToolSpec::Function(tool) => tool.name.as_str(), - ToolSpec::ToolSearch { .. } => "tool_search", - ToolSpec::LocalShell {} => "local_shell", - ToolSpec::ImageGeneration { .. } => "image_generation", - ToolSpec::WebSearch { .. } => "web_search", - ToolSpec::Freeform(tool) => tool.name.as_str(), - } - } - } - - #[derive(Debug, Clone, Serialize, PartialEq)] - pub(crate) struct ResponsesApiWebSearchFilters { - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) allowed_domains: Option>, - } - - impl From for ResponsesApiWebSearchFilters { - fn from(filters: ConfigWebSearchFilters) -> Self { - Self { - allowed_domains: filters.allowed_domains, - } - } - } - - #[derive(Debug, Clone, Serialize, PartialEq)] - pub(crate) struct ResponsesApiWebSearchUserLocation { - #[serde(rename = "type")] - pub(crate) r#type: WebSearchUserLocationType, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) country: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) region: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) city: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) timezone: Option, - } - - impl From for ResponsesApiWebSearchUserLocation { - fn from(user_location: ConfigWebSearchUserLocation) -> Self { - Self { - r#type: user_location.r#type, - country: user_location.country, - region: user_location.region, - city: user_location.city, - timezone: user_location.timezone, - } - } - } + pub(crate) use codex_tools::ToolSpec; } pub struct ResponseStream { diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index aa7f401099..0e77caac72 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -24,6 +24,7 @@ use codex_hooks::HookToolInput; use codex_hooks::HookToolInputLocalShell; use codex_hooks::HookToolKind; use codex_protocol::models::ResponseInputItem; +use codex_tools::ConfiguredToolSpec; use codex_utils_readiness::Readiness; use serde_json::Value; use tracing::warn; @@ -436,21 +437,6 @@ impl ToolRegistry { } } -#[derive(Debug, Clone)] -pub struct ConfiguredToolSpec { - pub spec: ToolSpec, - pub supports_parallel_tool_calls: bool, -} - -impl ConfiguredToolSpec { - pub fn new(spec: ToolSpec, supports_parallel_tool_calls: bool) -> Self { - Self { - spec, - supports_parallel_tool_calls, - } - } -} - pub struct ToolRegistryBuilder { handlers: HashMap>, specs: Vec, diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index 345f7ce06f..1145e3a8f2 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -9,7 +9,6 @@ use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::discoverable::DiscoverableTool; use crate::tools::registry::AnyToolResult; -use crate::tools::registry::ConfiguredToolSpec; use crate::tools::registry::ToolRegistry; use crate::tools::spec::ToolsConfig; use crate::tools::spec::build_specs_with_discoverable_tools; @@ -18,6 +17,7 @@ use codex_protocol::models::LocalShellAction; use codex_protocol::models::ResponseItem; use codex_protocol::models::SearchToolCallParams; use codex_protocol::models::ShellToolCallParams; +use codex_tools::ConfiguredToolSpec; use rmcp::model::Tool; use std::collections::HashMap; use std::sync::Arc; @@ -66,7 +66,7 @@ impl ToolRouter { specs .iter() .filter_map(|configured_tool| { - if !codex_code_mode::is_code_mode_nested_tool(configured_tool.spec.name()) { + if !codex_code_mode::is_code_mode_nested_tool(configured_tool.name()) { Some(configured_tool.spec.clone()) } else { None @@ -101,7 +101,7 @@ impl ToolRouter { pub fn find_spec(&self, tool_name: &str) -> Option { self.specs .iter() - .find(|config| config.spec.name() == tool_name) + .find(|config| config.name() == tool_name) .map(|config| config.spec.clone()) } @@ -109,7 +109,7 @@ impl ToolRouter { self.specs .iter() .filter(|config| config.supports_parallel_tool_calls) - .any(|config| config.spec.name() == tool_name) + .any(|config| config.name() == tool_name) } #[instrument(level = "trace", skip_all, err)] diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 2a0704262c..d5b4b29216 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -2351,22 +2351,6 @@ pub(crate) struct ApplyPatchToolArgs { pub(crate) input: String, } -/// Returns JSON values that are compatible with Function Calling in the -/// Responses API: -/// https://platform.openai.com/docs/guides/function-calling?api-mode=responses -pub fn create_tools_json_for_responses_api( - tools: &[ToolSpec], -) -> crate::error::Result> { - let mut tools_json = Vec::new(); - - for tool in tools { - let json = serde_json::to_value(tool)?; - tools_json.push(json); - } - - Ok(tools_json) -} - fn push_tool_spec( builder: &mut ToolRegistryBuilder, spec: ToolSpec, diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index b62407778f..2a7644eec5 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -1,17 +1,19 @@ -use crate::client_common::tools::FreeformTool; use crate::config::test_config; use crate::models_manager::manager::ModelsManager; use crate::models_manager::model_info::with_config_overrides; use crate::shell::Shell; use crate::shell::ShellType; use crate::tools::ToolRouter; -use crate::tools::registry::ConfiguredToolSpec; use crate::tools::router::ToolRouterParams; use codex_app_server_protocol::AppInfo; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelsResponse; use codex_tools::AdditionalProperties; +use codex_tools::ConfiguredToolSpec; +use codex_tools::FreeformTool; +use codex_tools::ResponsesApiWebSearchFilters; +use codex_tools::ResponsesApiWebSearchUserLocation; use codex_tools::mcp_tool_to_deferred_responses_api_tool; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; @@ -105,23 +107,12 @@ fn deferred_responses_api_tool_serializes_with_defer_loading() { ); } -fn tool_name(tool: &ToolSpec) -> &str { - match tool { - ToolSpec::Function(ResponsesApiTool { name, .. }) => name, - ToolSpec::ToolSearch { .. } => "tool_search", - ToolSpec::LocalShell {} => "local_shell", - ToolSpec::ImageGeneration { .. } => "image_generation", - ToolSpec::WebSearch { .. } => "web_search", - ToolSpec::Freeform(FreeformTool { name, .. }) => name, - } -} - // Avoid order-based assertions; compare via set containment instead. fn assert_contains_tool_names(tools: &[ConfiguredToolSpec], expected_subset: &[&str]) { use std::collections::HashSet; let mut names = HashSet::new(); let mut duplicates = Vec::new(); - for name in tools.iter().map(|t| tool_name(&t.spec)) { + for name in tools.iter().map(ConfiguredToolSpec::name) { if !names.insert(name) { duplicates.push(name); } @@ -141,7 +132,7 @@ fn assert_contains_tool_names(tools: &[ConfiguredToolSpec], expected_subset: &[& fn assert_lacks_tool_name(tools: &[ConfiguredToolSpec], expected_absent: &str) { let names = tools .iter() - .map(|tool| tool_name(&tool.spec)) + .map(ConfiguredToolSpec::name) .collect::>(); assert!( !names.contains(&expected_absent), @@ -162,7 +153,7 @@ fn shell_tool_name(config: &ToolsConfig) -> Option<&'static str> { fn find_tool<'a>(tools: &'a [ConfiguredToolSpec], expected_name: &str) -> &'a ConfiguredToolSpec { tools .iter() - .find(|tool| tool_name(&tool.spec) == expected_name) + .find(|tool| tool.name() == expected_name) .unwrap_or_else(|| panic!("expected tool {expected_name}")) } @@ -294,7 +285,7 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() { let mut actual: BTreeMap = BTreeMap::from([]); let mut duplicate_names = Vec::new(); for t in &tools { - let name = tool_name(&t.spec).to_string(); + let name = t.name().to_string(); if actual.insert(name.clone(), t.spec.clone()).is_some() { duplicate_names.push(name); } @@ -323,7 +314,7 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() { }, create_view_image_tool(config.can_request_original_image_detail), ] { - expected.insert(tool_name(&spec).to_string(), spec); + expected.insert(spec.name().to_string(), spec); } let collab_specs = if config.multi_agent_v2 { vec![ @@ -341,16 +332,16 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() { ] }; for spec in collab_specs { - expected.insert(tool_name(&spec).to_string(), spec); + expected.insert(spec.name().to_string(), spec); } if !config.multi_agent_v2 { let spec = create_resume_agent_tool(); - expected.insert(tool_name(&spec).to_string(), spec); + expected.insert(spec.name().to_string(), spec); } if config.exec_permission_approvals_enabled { let spec = create_request_permissions_tool(); - expected.insert(tool_name(&spec).to_string(), spec); + expected.insert(spec.name().to_string(), spec); } // Exact name set match — this is the only test allowed to fail when tools change. @@ -1205,10 +1196,10 @@ fn web_search_config_is_forwarded_to_tool_spec() { external_web_access: Some(true), filters: web_search_config .filters - .map(crate::client_common::tools::ResponsesApiWebSearchFilters::from), + .map(ResponsesApiWebSearchFilters::from), user_location: web_search_config .user_location - .map(crate::client_common::tools::ResponsesApiWebSearchUserLocation::from), + .map(ResponsesApiWebSearchUserLocation::from), search_context_size: web_search_config.search_context_size, search_content_types: None, } @@ -1684,11 +1675,7 @@ fn test_test_model_info_includes_sync_tool() { ) .build(); - assert!( - tools - .iter() - .any(|tool| tool_name(&tool.spec) == "test_sync_tool") - ); + assert!(tools.iter().any(|tool| tool.name() == "test_sync_tool")); } #[test] @@ -1822,7 +1809,7 @@ fn test_build_specs_mcp_tools_sorted_by_name() { // Only assert that the MCP tools themselves are sorted by fully-qualified name. let mcp_names: Vec<_> = tools .iter() - .map(|t| tool_name(&t.spec).to_string()) + .map(|t| t.name().to_string()) .filter(|n| n.starts_with("test_server/")) .collect(); let expected = vec![ @@ -2069,7 +2056,7 @@ fn tool_suggest_is_not_registered_without_feature_flag() { assert!( !tools .iter() - .any(|tool| tool_name(&tool.spec) == TOOL_SUGGEST_TOOL_NAME) + .any(|tool| tool.name() == TOOL_SUGGEST_TOOL_NAME) ); } @@ -2160,7 +2147,7 @@ fn tool_suggest_requires_apps_and_plugins_features() { assert!( !tools .iter() - .any(|tool| tool_name(&tool.spec) == TOOL_SUGGEST_TOOL_NAME), + .any(|tool| tool.name() == TOOL_SUGGEST_TOOL_NAME), "tool_suggest should be absent when {disabled_feature:?} is disabled" ); } @@ -3111,38 +3098,3 @@ fn code_mode_exec_description_omits_nested_tool_details_when_not_code_mode_only( assert!(!description.contains("### `update_plan` (`update_plan`)")); assert!(!description.contains("### `view_image` (`view_image`)")); } - -#[test] -fn chat_tools_include_top_level_name() { - let properties = - BTreeMap::from([("foo".to_string(), JsonSchema::String { description: None })]); - let tools = vec![ToolSpec::Function(ResponsesApiTool { - name: "demo".to_string(), - description: "A demo tool".to_string(), - strict: false, - defer_loading: None, - parameters: JsonSchema::Object { - properties, - required: None, - additional_properties: None, - }, - output_schema: None, - })]; - - let responses_json = create_tools_json_for_responses_api(&tools).unwrap(); - assert_eq!( - responses_json, - vec![json!({ - "type": "function", - "name": "demo", - "description": "A demo tool", - "strict": false, - "parameters": { - "type": "object", - "properties": { - "foo": { "type": "string" } - }, - }, - })] - ); -} diff --git a/codex-rs/tools/README.md b/codex-rs/tools/README.md index ca73e8cf63..66d342cdb4 100644 --- a/codex-rs/tools/README.md +++ b/codex-rs/tools/README.md @@ -11,15 +11,20 @@ schema and Responses API tool primitives that no longer need to live in - `JsonSchema` - `AdditionalProperties` - `ToolDefinition` +- `ToolSpec` +- `ConfiguredToolSpec` - `ResponsesApiTool` - `FreeformTool` - `FreeformToolFormat` - `ToolSearchOutputTool` +- `ResponsesApiWebSearchFilters` +- `ResponsesApiWebSearchUserLocation` - `ResponsesApiNamespace` - `ResponsesApiNamespaceTool` - `parse_tool_input_schema()` - `parse_dynamic_tool()` - `parse_mcp_tool()` +- `create_tools_json_for_responses_api()` - `mcp_call_tool_result_output_schema()` - `tool_definition_to_responses_api_tool()` - `dynamic_tool_to_responses_api_tool()` diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index 92dc860d6c..527424c276 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -6,6 +6,7 @@ mod json_schema; mod mcp_tool; mod responses_api; mod tool_definition; +mod tool_spec; pub use dynamic_tool::parse_dynamic_tool; pub use json_schema::AdditionalProperties; @@ -24,3 +25,8 @@ pub use responses_api::mcp_tool_to_deferred_responses_api_tool; pub use responses_api::mcp_tool_to_responses_api_tool; pub use responses_api::tool_definition_to_responses_api_tool; pub use tool_definition::ToolDefinition; +pub use tool_spec::ConfiguredToolSpec; +pub use tool_spec::ResponsesApiWebSearchFilters; +pub use tool_spec::ResponsesApiWebSearchUserLocation; +pub use tool_spec::ToolSpec; +pub use tool_spec::create_tools_json_for_responses_api; diff --git a/codex-rs/tools/src/tool_spec.rs b/codex-rs/tools/src/tool_spec.rs new file mode 100644 index 0000000000..f537388853 --- /dev/null +++ b/codex-rs/tools/src/tool_spec.rs @@ -0,0 +1,141 @@ +use crate::FreeformTool; +use crate::JsonSchema; +use crate::ResponsesApiTool; +use codex_protocol::config_types::WebSearchContextSize; +use codex_protocol::config_types::WebSearchFilters as ConfigWebSearchFilters; +use codex_protocol::config_types::WebSearchUserLocation as ConfigWebSearchUserLocation; +use codex_protocol::config_types::WebSearchUserLocationType; +use serde::Serialize; +use serde_json::Value; + +/// When serialized as JSON, this produces a valid "Tool" in the OpenAI +/// Responses API. +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(tag = "type")] +pub enum ToolSpec { + #[serde(rename = "function")] + Function(ResponsesApiTool), + #[serde(rename = "tool_search")] + ToolSearch { + execution: String, + description: String, + parameters: JsonSchema, + }, + #[serde(rename = "local_shell")] + LocalShell {}, + #[serde(rename = "image_generation")] + ImageGeneration { output_format: String }, + // TODO: Understand why we get an error on web_search although the API docs + // say it's supported. + // https://platform.openai.com/docs/guides/tools-web-search?api-mode=responses#:~:text=%7B%20type%3A%20%22web_search%22%20%7D%2C + // The `external_web_access` field determines whether the web search is over + // cached or live content. + // https://platform.openai.com/docs/guides/tools-web-search#live-internet-access + #[serde(rename = "web_search")] + WebSearch { + #[serde(skip_serializing_if = "Option::is_none")] + external_web_access: Option, + #[serde(skip_serializing_if = "Option::is_none")] + filters: Option, + #[serde(skip_serializing_if = "Option::is_none")] + user_location: Option, + #[serde(skip_serializing_if = "Option::is_none")] + search_context_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + search_content_types: Option>, + }, + #[serde(rename = "custom")] + Freeform(FreeformTool), +} + +impl ToolSpec { + pub fn name(&self) -> &str { + match self { + ToolSpec::Function(tool) => tool.name.as_str(), + ToolSpec::ToolSearch { .. } => "tool_search", + ToolSpec::LocalShell {} => "local_shell", + ToolSpec::ImageGeneration { .. } => "image_generation", + ToolSpec::WebSearch { .. } => "web_search", + ToolSpec::Freeform(tool) => tool.name.as_str(), + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ConfiguredToolSpec { + pub spec: ToolSpec, + pub supports_parallel_tool_calls: bool, +} + +impl ConfiguredToolSpec { + pub fn new(spec: ToolSpec, supports_parallel_tool_calls: bool) -> Self { + Self { + spec, + supports_parallel_tool_calls, + } + } + + pub fn name(&self) -> &str { + self.spec.name() + } +} + +/// Returns JSON values that are compatible with Function Calling in the +/// Responses API: +/// https://platform.openai.com/docs/guides/function-calling?api-mode=responses +pub fn create_tools_json_for_responses_api( + tools: &[ToolSpec], +) -> Result, serde_json::Error> { + let mut tools_json = Vec::new(); + + for tool in tools { + let json = serde_json::to_value(tool)?; + tools_json.push(json); + } + + Ok(tools_json) +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct ResponsesApiWebSearchFilters { + #[serde(skip_serializing_if = "Option::is_none")] + pub allowed_domains: Option>, +} + +impl From for ResponsesApiWebSearchFilters { + fn from(filters: ConfigWebSearchFilters) -> Self { + Self { + allowed_domains: filters.allowed_domains, + } + } +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct ResponsesApiWebSearchUserLocation { + #[serde(rename = "type")] + pub r#type: WebSearchUserLocationType, + #[serde(skip_serializing_if = "Option::is_none")] + pub country: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub region: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub city: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timezone: Option, +} + +impl From for ResponsesApiWebSearchUserLocation { + fn from(user_location: ConfigWebSearchUserLocation) -> Self { + Self { + r#type: user_location.r#type, + country: user_location.country, + region: user_location.region, + city: user_location.city, + timezone: user_location.timezone, + } + } +} + +#[cfg(test)] +#[path = "tool_spec_tests.rs"] +mod tests; diff --git a/codex-rs/tools/src/tool_spec_tests.rs b/codex-rs/tools/src/tool_spec_tests.rs new file mode 100644 index 0000000000..c84ea94a36 --- /dev/null +++ b/codex-rs/tools/src/tool_spec_tests.rs @@ -0,0 +1,242 @@ +use super::ConfiguredToolSpec; +use super::ResponsesApiWebSearchFilters; +use super::ResponsesApiWebSearchUserLocation; +use super::ToolSpec; +use crate::AdditionalProperties; +use crate::FreeformTool; +use crate::FreeformToolFormat; +use crate::JsonSchema; +use crate::ResponsesApiTool; +use crate::create_tools_json_for_responses_api; +use codex_protocol::config_types::WebSearchContextSize; +use codex_protocol::config_types::WebSearchFilters as ConfigWebSearchFilters; +use codex_protocol::config_types::WebSearchUserLocation as ConfigWebSearchUserLocation; +use codex_protocol::config_types::WebSearchUserLocationType; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::collections::BTreeMap; + +#[test] +fn tool_spec_name_covers_all_variants() { + assert_eq!( + ToolSpec::Function(ResponsesApiTool { + name: "lookup_order".to_string(), + description: "Look up an order".to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::Object { + properties: BTreeMap::new(), + required: None, + additional_properties: None, + }, + output_schema: None, + }) + .name(), + "lookup_order" + ); + assert_eq!( + ToolSpec::ToolSearch { + execution: "sync".to_string(), + description: "Search for tools".to_string(), + parameters: JsonSchema::Object { + properties: BTreeMap::new(), + required: None, + additional_properties: None, + }, + } + .name(), + "tool_search" + ); + assert_eq!(ToolSpec::LocalShell {}.name(), "local_shell"); + assert_eq!( + ToolSpec::ImageGeneration { + output_format: "png".to_string(), + } + .name(), + "image_generation" + ); + assert_eq!( + ToolSpec::WebSearch { + external_web_access: Some(true), + filters: None, + user_location: None, + search_context_size: None, + search_content_types: None, + } + .name(), + "web_search" + ); + assert_eq!( + ToolSpec::Freeform(FreeformTool { + name: "exec".to_string(), + description: "Run a command".to_string(), + format: FreeformToolFormat { + r#type: "grammar".to_string(), + syntax: "lark".to_string(), + definition: "start: \"exec\"".to_string(), + }, + }) + .name(), + "exec" + ); +} + +#[test] +fn configured_tool_spec_name_delegates_to_tool_spec() { + assert_eq!( + ConfiguredToolSpec::new( + ToolSpec::Function(ResponsesApiTool { + name: "lookup_order".to_string(), + description: "Look up an order".to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::Object { + properties: BTreeMap::new(), + required: None, + additional_properties: None, + }, + output_schema: None, + }), + /*supports_parallel_tool_calls*/ true, + ) + .name(), + "lookup_order" + ); +} + +#[test] +fn web_search_config_converts_to_responses_api_types() { + assert_eq!( + ResponsesApiWebSearchFilters::from(ConfigWebSearchFilters { + allowed_domains: Some(vec!["example.com".to_string()]), + }), + ResponsesApiWebSearchFilters { + allowed_domains: Some(vec!["example.com".to_string()]), + } + ); + assert_eq!( + ResponsesApiWebSearchUserLocation::from(ConfigWebSearchUserLocation { + r#type: WebSearchUserLocationType::Approximate, + country: Some("US".to_string()), + region: Some("California".to_string()), + city: Some("San Francisco".to_string()), + timezone: Some("America/Los_Angeles".to_string()), + }), + ResponsesApiWebSearchUserLocation { + r#type: WebSearchUserLocationType::Approximate, + country: Some("US".to_string()), + region: Some("California".to_string()), + city: Some("San Francisco".to_string()), + timezone: Some("America/Los_Angeles".to_string()), + } + ); +} + +#[test] +fn create_tools_json_for_responses_api_includes_top_level_name() { + assert_eq!( + create_tools_json_for_responses_api(&[ToolSpec::Function(ResponsesApiTool { + name: "demo".to_string(), + description: "A demo tool".to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::Object { + properties: BTreeMap::from([( + "foo".to_string(), + JsonSchema::String { description: None }, + )]), + required: None, + additional_properties: None, + }, + output_schema: None, + })]) + .expect("serialize tools"), + vec![json!({ + "type": "function", + "name": "demo", + "description": "A demo tool", + "strict": false, + "parameters": { + "type": "object", + "properties": { + "foo": { "type": "string" } + }, + }, + })] + ); +} + +#[test] +fn web_search_tool_spec_serializes_expected_wire_shape() { + assert_eq!( + serde_json::to_value(ToolSpec::WebSearch { + external_web_access: Some(true), + filters: Some(ResponsesApiWebSearchFilters { + allowed_domains: Some(vec!["example.com".to_string()]), + }), + user_location: Some(ResponsesApiWebSearchUserLocation { + r#type: WebSearchUserLocationType::Approximate, + country: Some("US".to_string()), + region: Some("California".to_string()), + city: Some("San Francisco".to_string()), + timezone: Some("America/Los_Angeles".to_string()), + }), + search_context_size: Some(WebSearchContextSize::High), + search_content_types: Some(vec!["text".to_string(), "image".to_string()]), + }) + .expect("serialize web_search"), + json!({ + "type": "web_search", + "external_web_access": true, + "filters": { + "allowed_domains": ["example.com"], + }, + "user_location": { + "type": "approximate", + "country": "US", + "region": "California", + "city": "San Francisco", + "timezone": "America/Los_Angeles", + }, + "search_context_size": "high", + "search_content_types": ["text", "image"], + }) + ); +} + +#[test] +fn tool_search_tool_spec_serializes_expected_wire_shape() { + assert_eq!( + serde_json::to_value(ToolSpec::ToolSearch { + execution: "sync".to_string(), + description: "Search app tools".to_string(), + parameters: JsonSchema::Object { + properties: BTreeMap::from([( + "query".to_string(), + JsonSchema::String { + description: Some("Tool search query".to_string()), + }, + )]), + required: Some(vec!["query".to_string()]), + additional_properties: Some(AdditionalProperties::Boolean(false)), + }, + }) + .expect("serialize tool_search"), + json!({ + "type": "tool_search", + "execution": "sync", + "description": "Search app tools", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Tool search query", + } + }, + "required": ["query"], + "additionalProperties": false, + }, + }) + ); +}