From bc53d42fd98c3e7bc74ece9ac31ddbe894b7a942 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sat, 28 Mar 2026 13:37:00 -0700 Subject: [PATCH 1/3] codex-tools: extract tool spec models (#16047) ## Why This continues the `codex-tools` migration by moving another passive tool-definition layer out of `codex-core`. After `ResponsesApiTool` and the lower-level schema adapters moved into `codex-tools`, `core/src/client_common.rs` was still owning `ToolSpec` and the web-search request wire types even though they are serialized data models rather than runtime orchestration. Keeping those types in `codex-core` makes the crate boundary look smaller than it really is and leaves non-runtime tool-shape code coupled to core. ## What changed - moved `ToolSpec`, `ResponsesApiWebSearchFilters`, and `ResponsesApiWebSearchUserLocation` into `codex-rs/tools/src/tool_spec.rs` - added focused unit tests in `codex-rs/tools/src/tool_spec_tests.rs` for: - `ToolSpec::name()` - web-search config conversions - `ToolSpec` serialization for `web_search` and `tool_search` - kept `codex-rs/tools/src/lib.rs` exports-only by re-exporting the new module from `lib.rs` - reduced `core/src/client_common.rs` to a compatibility shim that re-exports the extracted tool-spec types for current core call sites - updated `core/src/tools/spec_tests.rs` to consume the extracted web-search types directly from `codex-tools` - updated `codex-rs/tools/README.md` so the crate contract reflects that `codex-tools` now owns the passive tool-spec request models in addition to the lower-level Responses API structs ## Test plan - `cargo test -p codex-tools` - `cargo test -p codex-core --lib tools::spec::` - `cargo test -p codex-core --lib client_common::` - `just fix -p codex-tools -p codex-core` - `just argument-comment-lint` ## References - #15923 - #15928 - #15944 - #15953 - #16031 --- codex-rs/core/src/client_common.rs | 100 +------------- codex-rs/core/src/tools/spec_tests.rs | 17 +-- codex-rs/tools/README.md | 3 + codex-rs/tools/src/lib.rs | 4 + codex-rs/tools/src/tool_spec.rs | 105 +++++++++++++++ codex-rs/tools/src/tool_spec_tests.rs | 183 ++++++++++++++++++++++++++ 6 files changed, 303 insertions(+), 109 deletions(-) create mode 100644 codex-rs/tools/src/tool_spec.rs create mode 100644 codex-rs/tools/src/tool_spec_tests.rs 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/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index b62407778f..60339b7811 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -1,4 +1,3 @@ -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; @@ -12,6 +11,9 @@ 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::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; @@ -106,14 +108,7 @@ 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, - } + tool.name() } // Avoid order-based assertions; compare via set containment instead. @@ -1205,10 +1200,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, } diff --git a/codex-rs/tools/README.md b/codex-rs/tools/README.md index ca73e8cf63..d8dabdcd72 100644 --- a/codex-rs/tools/README.md +++ b/codex-rs/tools/README.md @@ -11,10 +11,13 @@ schema and Responses API tool primitives that no longer need to live in - `JsonSchema` - `AdditionalProperties` - `ToolDefinition` +- `ToolSpec` - `ResponsesApiTool` - `FreeformTool` - `FreeformToolFormat` - `ToolSearchOutputTool` +- `ResponsesApiWebSearchFilters` +- `ResponsesApiWebSearchUserLocation` - `ResponsesApiNamespace` - `ResponsesApiNamespaceTool` - `parse_tool_input_schema()` diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index 92dc860d6c..05b527ba94 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,6 @@ 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::ResponsesApiWebSearchFilters; +pub use tool_spec::ResponsesApiWebSearchUserLocation; +pub use tool_spec::ToolSpec; diff --git a/codex-rs/tools/src/tool_spec.rs b/codex-rs/tools/src/tool_spec.rs new file mode 100644 index 0000000000..fc63223e1b --- /dev/null +++ b/codex-rs/tools/src/tool_spec.rs @@ -0,0 +1,105 @@ +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; + +/// 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, 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..33d31c1401 --- /dev/null +++ b/codex-rs/tools/src/tool_spec_tests.rs @@ -0,0 +1,183 @@ +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 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 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 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, + }, + }) + ); +} From ae8a3be9585d0918fe38fa199c963aeb80ee4f55 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sat, 28 Mar 2026 14:08:19 -0700 Subject: [PATCH 2/3] bazel: refresh the expired macOS SDK pin (#16128) ## Why macOS BuildBuddy started failing before target analysis because the Apple CDN object pinned in [`MODULE.bazel`](https://github.com/openai/codex/blob/fce0f76d577b5070f1e2b4a2abaa8350acfc38ff/MODULE.bazel#L28-L36) now returns `403 Forbidden`. The failure report that triggered this change was this [BuildBuddy invocation](https://app.buildbuddy.io/invocation/c57590e0-1bdb-4e19-a86f-74d4a7ded228). This repo uses `@llvm//extensions:osx.bzl` via `osx.from_archive(...)`, and that API does not discover a current SDK URL for us. It fetches exactly the `urls`, `sha256`, and `strip_prefix` we pin. Once Apple retires that `swcdn.apple.com` object, `@macos_sdk` stops resolving and every downstream macOS build fails during external repository fetch. This is the same basic failure mode we hit in [b9fa08ec619c96617a9ae2041c9ddb02d2c02434](https://github.com/openai/codex/commit/b9fa08ec619c96617a9ae2041c9ddb02d2c02434): the pin itself aged out. ## How I tracked it down 1. I started from the BuildBuddy error and copied the exact `swcdn.apple.com/.../CLTools_macOSNMOS_SDK.pkg` URL from the failure. 2. I reproduced the issue outside CI by opening that URL directly in a browser and by running `curl -I` against it locally. Both returned `403 Forbidden`, which ruled out BuildBuddy as the root cause. 3. I searched the repo for that URL and found it hardcoded in `MODULE.bazel`. 4. I inspected the `llvm` Bzlmod `osx` extension implementation to confirm that `osx.from_archive(...)` is just a literal fetch of the pinned archive metadata. There is no automatic fallback or catalog lookup behind it. 5. I queried Apple's software update catalogs to find the current Command Line Tools package for macOS 26.x. The useful catalog was: - `https://swscan.apple.com/content/catalogs/others/index-26-15-14-13-12-10.16-10.15-10.14-10.13-10.12-10.11-10.10-10.9-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog.gz` This is scriptable; it does not require opening a website in a browser. The catalog is a gzip-compressed plist served over HTTP, so the workflow is just: 1. fetch the catalog, 2. decompress it, 3. search or parse the plist for `CLTools_macOSNMOS_SDK.pkg` entries, 4. inspect the matching product metadata. The quick shell version I used was: ```shell curl -L \ | gzip -dc \ | rg -n -C 6 'CLTools_macOSNMOS_SDK\.pkg|PostDate|English\.dist' ``` That is enough to surface the current product id, package URL, post date, and the matching `.dist` file. If we want something less grep-driven next time, the same catalog can be parsed structurally. For example: ```python import gzip import plistlib import urllib.request url = "https://swscan.apple.com/content/catalogs/others/index-26-15-14-13-12-10.16-10.15-10.14-10.13-10.12-10.11-10.10-10.9-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog.gz" with urllib.request.urlopen(url) as resp: catalog = plistlib.loads(gzip.decompress(resp.read())) for product_id, product in catalog["Products"].items(): for package in product.get("Packages", []): package_url = package.get("URL", "") if package_url.endswith("CLTools_macOSNMOS_SDK.pkg"): print(product_id) print(product.get("PostDate")) print(package_url) print(product.get("Distributions", {}).get("English")) ``` In practice, `curl` was only the transport. The important part is that the catalog itself is a machine-readable plist, so this can be automated. 6. That catalog contains the newer `047-96692` Command Line Tools release, and its distribution file identifies it as [Command Line Tools for Xcode 26.4](https://swdist.apple.com/content/downloads/32/53/047-96692-A_OAHIHT53YB/ybtshxmrcju8m2qvw3w5elr4rajtg1x3y3/047-96692.English.dist). 7. I downloaded that package locally, computed its SHA-256, expanded it with `pkgutil --expand-full`, and verified that it contains `Payload/Library/Developer/CommandLineTools/SDKs/MacOSX26.4.sdk`, which is the correct new `strip_prefix` for this pin. The core debugging loop looked like this: ```shell curl -I rg 'swcdn\.apple\.com|osx\.from_archive' MODULE.bazel curl -L | gzip -dc | rg 'CLTools_macOSNMOS_SDK.pkg' pkgutil --expand-full CLTools_macOSNMOS_SDK.pkg expanded find expanded/Payload/Library/Developer/CommandLineTools/SDKs -maxdepth 1 -mindepth 1 ``` ## What changed - Updated `MODULE.bazel` to point `osx.from_archive(...)` at the currently live `047-96692` `CLTools_macOSNMOS_SDK.pkg` object. - Updated the pinned `sha256` to match that package. - Updated the `strip_prefix` from `MacOSX26.2.sdk` to `MacOSX26.4.sdk`. ## Verification - `bazel --output_user_root="$(mktemp -d /tmp/codex-bazel-sdk-fetch.XXXXXX)" build @macos_sdk//sysroot` ## Notes for next time As long as we pin raw `swcdn.apple.com` objects, this will likely happen again. When it does, the expected recovery path is: 1. Reproduce the `403` against the exact URL from CI. 2. Find the stale pin in `MODULE.bazel`. 3. Look up the current CLTools package in the relevant Apple software update catalog for that macOS major version. 4. Download the replacement package and refresh both `sha256` and `strip_prefix`. 5. Validate the new pin with a fresh `@macos_sdk` fetch, not just an incremental Bazel build. The important detail is that the non-`26` catalog did not surface the macOS 26.x SDK package here; the `index-26-15-14-...` catalog was the one that exposed the currently live replacement. --- MODULE.bazel | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/MODULE.bazel b/MODULE.bazel index 8163c0b018..e71f92ca77 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 = [ From 1826bc60d40455785e4b4643a88afffd0f43aa96 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sat, 28 Mar 2026 14:08:56 -0700 Subject: [PATCH 3/3] codex-tools: extract configured tool specs --- codex-rs/core/src/client.rs | 2 +- codex-rs/core/src/tools/registry.rs | 16 +------ codex-rs/core/src/tools/router.rs | 8 ++-- codex-rs/core/src/tools/spec.rs | 16 ------- codex-rs/core/src/tools/spec_tests.rs | 69 +++++---------------------- codex-rs/tools/README.md | 2 + codex-rs/tools/src/lib.rs | 2 + codex-rs/tools/src/tool_spec.rs | 36 ++++++++++++++ codex-rs/tools/src/tool_spec_tests.rs | 59 +++++++++++++++++++++++ 9 files changed, 118 insertions(+), 92 deletions(-) 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/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 60339b7811..2a7644eec5 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -4,13 +4,13 @@ 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; @@ -107,16 +107,12 @@ fn deferred_responses_api_tool_serializes_with_defer_loading() { ); } -fn tool_name(tool: &ToolSpec) -> &str { - tool.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); } @@ -136,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), @@ -157,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}")) } @@ -289,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); } @@ -318,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![ @@ -336,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. @@ -1679,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] @@ -1817,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![ @@ -2064,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) ); } @@ -2155,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" ); } @@ -3106,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 d8dabdcd72..66d342cdb4 100644 --- a/codex-rs/tools/README.md +++ b/codex-rs/tools/README.md @@ -12,6 +12,7 @@ schema and Responses API tool primitives that no longer need to live in - `AdditionalProperties` - `ToolDefinition` - `ToolSpec` +- `ConfiguredToolSpec` - `ResponsesApiTool` - `FreeformTool` - `FreeformToolFormat` @@ -23,6 +24,7 @@ schema and Responses API tool primitives that no longer need to live in - `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 05b527ba94..527424c276 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -25,6 +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 index fc63223e1b..f537388853 100644 --- a/codex-rs/tools/src/tool_spec.rs +++ b/codex-rs/tools/src/tool_spec.rs @@ -6,6 +6,7 @@ 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. @@ -60,6 +61,41 @@ impl ToolSpec { } } +#[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")] diff --git a/codex-rs/tools/src/tool_spec_tests.rs b/codex-rs/tools/src/tool_spec_tests.rs index 33d31c1401..c84ea94a36 100644 --- a/codex-rs/tools/src/tool_spec_tests.rs +++ b/codex-rs/tools/src/tool_spec_tests.rs @@ -1,3 +1,4 @@ +use super::ConfiguredToolSpec; use super::ResponsesApiWebSearchFilters; use super::ResponsesApiWebSearchUserLocation; use super::ToolSpec; @@ -6,6 +7,7 @@ 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; @@ -79,6 +81,29 @@ fn tool_spec_name_covers_all_variants() { ); } +#[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!( @@ -107,6 +132,40 @@ fn web_search_config_converts_to_responses_api_types() { ); } +#[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!(