From e103bbc06f2f516afe47eb9b1d8d172128912023 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 23 Oct 2026 00:36:00 +0000 Subject: [PATCH] Apply model catalog overrides to history, notes, and context tools (#56313) ## What changed - Read optional tool descriptions and parameter schemas for `history`, `notes`, `new_context`, and `get_context_remaining` from the acting model's catalog messages. - Support catalog namespace descriptions for history and notes, preserving explicit empty and whitespace descriptions. - Share catalog override handling across native tools and MCP resource helpers. Keep bundled schemas when overrides are invalid, preserve encrypted parameters, and leave tool identity and transport fields unchanged. ## Testing Add coverage for sparse catalog serialization, schema fallback and encryption preservation, per-step tool definitions, and context tool updates when switching models. Extend outgoing-request tests to verify history and notes overrides while retaining history image attachments. GitOrigin-RevId: 1adac8b5009f95c91adba491f20a4e2e2dc91475 --- codex-rs/Cargo.lock | 2 + codex-rs/core/src/session/mod.rs | 8 ++ codex-rs/core/src/tools/catalog_parameters.rs | 14 --- .../core/src/tools/code_mode/wait_spec.rs | 2 +- .../tools/handlers/get_context_remaining.rs | 16 +++- .../handlers/get_context_remaining_spec.rs | 9 +- .../src/tools/handlers/mcp_resource_spec.rs | 26 +---- .../tools/handlers/mcp_resource_spec_tests.rs | 39 -------- .../src/tools/handlers/new_context_window.rs | 16 +++- .../tools/handlers/new_context_window_spec.rs | 9 +- .../handlers/request_user_input_async.rs | 2 +- codex-rs/core/src/tools/mod.rs | 1 - codex-rs/core/src/tools/multi_agent_tool.rs | 2 +- codex-rs/core/src/tools/spec_plan.rs | 26 ++++- codex-rs/core/src/tools/spec_plan_tests.rs | 2 + codex-rs/core/tests/suite/scenarios.rs | 3 + .../scenarios_context_management_tools.rs | 94 +++++++++++++++++++ ...ol_definitions_follow_captured_models.snap | 72 ++++++++++++++ codex-rs/ext/history-notes/Cargo.toml | 1 + codex-rs/ext/history-notes/src/extension.rs | 37 ++++++++ codex-rs/ext/history-notes/src/tools.rs | 76 ++++++++++++--- codex-rs/ext/history-notes/src/tools_tests.rs | 26 +++++ .../tests/history_notes_extension.rs | 33 +++++++ .../history-notes/tests/image_model_input.rs | 85 ++++++++++++++++- ...y_images_reach_the_next_model_request.snap | 48 ++++++++++ codex-rs/prompts/src/model_messages.rs | 21 +++++ codex-rs/protocol/src/openai_models.rs | 17 +++- .../protocol/src/openai_models/memento_v2.rs | 52 ++++++++++ .../src/openai_models/memento_v2_tests.rs | 18 ++++ codex-rs/tools/Cargo.toml | 1 + codex-rs/tools/src/catalog_tool_message.rs | 49 ++++++++++ .../tools/src/catalog_tool_message_tests.rs | 85 +++++++++++++++++ codex-rs/tools/src/json_schema.rs | 13 ++- codex-rs/tools/src/json_schema_tests.rs | 28 ++++++ codex-rs/tools/src/lib.rs | 3 + 35 files changed, 822 insertions(+), 114 deletions(-) delete mode 100644 codex-rs/core/src/tools/catalog_parameters.rs create mode 100644 codex-rs/core/tests/suite/scenarios_context_management_tools.rs create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__scenarios__context_management_tools__catalog_context_tool_definitions_follow_captured_models.snap create mode 100644 codex-rs/ext/history-notes/tests/snapshots/image_model_input__history_images_reach_the_next_model_request.snap create mode 100644 codex-rs/protocol/src/openai_models/memento_v2.rs create mode 100644 codex-rs/protocol/src/openai_models/memento_v2_tests.rs create mode 100644 codex-rs/tools/src/catalog_tool_message.rs create mode 100644 codex-rs/tools/src/catalog_tool_message_tests.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index bfc383e4d7..6bf43c6b6a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3683,6 +3683,7 @@ dependencies = [ "codex-utils-output-truncation", "core_test_support", "http 1.4.0", + "insta", "pretty_assertions", "serde_json", "tempfile", @@ -4910,6 +4911,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.18", + "tracing", "urlencoding", ] diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index afe840d5ad..1824f43aad 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -104,6 +104,7 @@ use codex_otel::auth_storage::AuthStorageOriginator; use codex_otel::current_span_trace_id; use codex_otel::current_span_w3c_trace_context; use codex_otel::set_parent_from_w3c_trace_context; +use codex_prompts::ResolvedModelMessages; use codex_prompts::render_model_instructions; use codex_protocol::ResponseUsageMetadata; use codex_protocol::SessionId; @@ -4064,6 +4065,13 @@ impl Session { { extension_data.insert(messages.clone()); } + let model_messages = ResolvedModelMessages::from_model(&settings.model_info); + if let Some(messages) = model_messages.history() { + extension_data.insert(messages.clone()); + } + if let Some(messages) = model_messages.notes() { + extension_data.insert(messages.clone()); + } let (mcp, prepared_recommendations) = tokio::join!( // MCP refresh can be large; keep it off the sampling request's stack. Box::pin(self.mcp_runtime_for_step( diff --git a/codex-rs/core/src/tools/catalog_parameters.rs b/codex-rs/core/src/tools/catalog_parameters.rs deleted file mode 100644 index 9c36c70abe..0000000000 --- a/codex-rs/core/src/tools/catalog_parameters.rs +++ /dev/null @@ -1,14 +0,0 @@ -//! Parses catalog parameter overrides using the harness's supported JSON Schema subset. -//! Rejected schemas leave fallback and transport-specific requirements to the tool owner. - -use codex_tools::JsonSchema; -use serde_json::Value; - -pub(super) fn parse(parameters: &str) -> Result { - let parameters: Value = - serde_json::from_str(parameters).map_err(|_| "schema is not valid JSON")?; - if !parameters.is_object() || parameters["type"] != "object" { - return Err("schema must declare an object type"); - } - serde_json::from_value(parameters).map_err(|_| "schema uses unsupported JSON Schema structures") -} diff --git a/codex-rs/core/src/tools/code_mode/wait_spec.rs b/codex-rs/core/src/tools/code_mode/wait_spec.rs index 01b713e515..b1ec01f177 100644 --- a/codex-rs/core/src/tools/code_mode/wait_spec.rs +++ b/codex-rs/core/src/tools/code_mode/wait_spec.rs @@ -44,7 +44,7 @@ pub(crate) fn create_wait_tool( strict: false, parameters: parameters_override .and_then( - |parameters| match crate::tools::catalog_parameters::parse(parameters) { + |parameters| match codex_tools::parse_catalog_tool_parameters(parameters) { Ok(parameters) => Some(parameters), Err(reason) => { tracing::warn!( diff --git a/codex-rs/core/src/tools/handlers/get_context_remaining.rs b/codex-rs/core/src/tools/handlers/get_context_remaining.rs index 4ef46b2a70..cdde397204 100644 --- a/codex-rs/core/src/tools/handlers/get_context_remaining.rs +++ b/codex-rs/core/src/tools/handlers/get_context_remaining.rs @@ -6,11 +6,11 @@ use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; use crate::tools::context::boxed_tool_output; use crate::tools::handlers::get_context_remaining_spec::GET_CONTEXT_REMAINING_TOOL_NAME; -use crate::tools::handlers::get_context_remaining_spec::create_get_context_remaining_tool; use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseInputItem; +use codex_protocol::openai_models::ToolMessage; use codex_tools::ToolName; use codex_tools::ToolSpec; use serde_json::Value as JsonValue; @@ -57,7 +57,17 @@ impl ToolOutput for GetContextRemainingOutput { } } -pub struct GetContextRemainingHandler; +pub struct GetContextRemainingHandler { + spec: ToolSpec, +} + +impl GetContextRemainingHandler { + pub(crate) fn new(messages: Option<&ToolMessage>) -> Self { + Self { + spec: super::get_context_remaining_spec::create_get_context_remaining_tool(messages), + } + } +} impl ToolExecutor for GetContextRemainingHandler { fn tool_name(&self) -> ToolName { @@ -69,7 +79,7 @@ impl ToolExecutor for GetContextRemainingHandler { } fn spec(&self) -> ToolSpec { - create_get_context_remaining_tool() + self.spec.clone() } fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a> diff --git a/codex-rs/core/src/tools/handlers/get_context_remaining_spec.rs b/codex-rs/core/src/tools/handlers/get_context_remaining_spec.rs index 102dfbd138..de63217574 100644 --- a/codex-rs/core/src/tools/handlers/get_context_remaining_spec.rs +++ b/codex-rs/core/src/tools/handlers/get_context_remaining_spec.rs @@ -1,21 +1,24 @@ +use codex_protocol::openai_models::ToolMessage; use codex_tools::JsonSchema; use codex_tools::ResponsesApiTool; use codex_tools::ToolSpec; +use codex_tools::with_catalog_tool_message; use serde_json::Value; use serde_json::json; use std::collections::BTreeMap; pub(crate) const GET_CONTEXT_REMAINING_TOOL_NAME: &str = "get_context_remaining"; -pub fn create_get_context_remaining_tool() -> ToolSpec { - ToolSpec::Function(ResponsesApiTool { +pub fn create_get_context_remaining_tool(messages: Option<&ToolMessage>) -> ToolSpec { + let tool = ResponsesApiTool { name: GET_CONTEXT_REMAINING_TOOL_NAME.to_string(), description: "Get the remaining tokens in the current context window.".to_string(), strict: false, defer_loading: None, parameters: JsonSchema::object(BTreeMap::new(), /*required*/ None, Some(false.into())), output_schema: Some(get_context_remaining_output_schema().into()), - }) + }; + ToolSpec::Function(with_catalog_tool_message(tool, messages)) } fn get_context_remaining_output_schema() -> Value { diff --git a/codex-rs/core/src/tools/handlers/mcp_resource_spec.rs b/codex-rs/core/src/tools/handlers/mcp_resource_spec.rs index 4c6d41f06b..86b550fbbd 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource_spec.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource_spec.rs @@ -2,6 +2,7 @@ use codex_protocol::openai_models::ToolMessage; use codex_tools::JsonSchema; use codex_tools::ResponsesApiTool; use codex_tools::ToolSpec; +use codex_tools::with_catalog_tool_message; use std::collections::BTreeMap; pub fn create_list_mcp_resources_tool(messages: Option<&ToolMessage>) -> ToolSpec { @@ -29,7 +30,7 @@ pub fn create_list_mcp_resources_tool(messages: Option<&ToolMessage>) -> ToolSpe parameters: JsonSchema::object(properties, /*required*/ None, Some(false.into())), output_schema: None, }; - with_model_messages(tool, messages) + ToolSpec::Function(with_catalog_tool_message(tool, messages)) } pub fn create_list_mcp_resource_templates_tool(messages: Option<&ToolMessage>) -> ToolSpec { @@ -58,7 +59,7 @@ pub fn create_list_mcp_resource_templates_tool(messages: Option<&ToolMessage>) - parameters: JsonSchema::object(properties, /*required*/ None, Some(false.into())), output_schema: None, }; - with_model_messages(tool, messages) + ToolSpec::Function(with_catalog_tool_message(tool, messages)) } pub fn create_read_mcp_resource_tool(messages: Option<&ToolMessage>) -> ToolSpec { @@ -93,26 +94,7 @@ pub fn create_read_mcp_resource_tool(messages: Option<&ToolMessage>) -> ToolSpec ), output_schema: None, }; - with_model_messages(tool, messages) -} - -fn with_model_messages(mut tool: ResponsesApiTool, messages: Option<&ToolMessage>) -> ToolSpec { - if let Some(messages) = messages { - if let Some(description) = &messages.description { - tool.description.clone_from(description); - } - if let Some(parameters) = &messages.parameters { - match crate::tools::catalog_parameters::parse(parameters) { - Ok(parameters) => tool.parameters = parameters, - Err(reason) => tracing::warn!( - tool = %tool.name, - reason, - "Invalid catalog tool parameters; using bundled parameters" - ), - } - } - } - ToolSpec::Function(tool) + ToolSpec::Function(with_catalog_tool_message(tool, messages)) } #[cfg(test)] diff --git a/codex-rs/core/src/tools/handlers/mcp_resource_spec_tests.rs b/codex-rs/core/src/tools/handlers/mcp_resource_spec_tests.rs index ec6839e4e8..638e0173b5 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource_spec_tests.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource_spec_tests.rs @@ -94,42 +94,3 @@ fn read_mcp_resource_tool_matches_expected_spec() { }) ); } - -#[test] -fn catalog_fields_override_independently_and_invalid_parameters_fall_back() { - let parameters = r#"{"type":"object","properties":{"server":{"type":"string","description":"Catalog server guidance."}},"additionalProperties":false}"#; - for create_tool in [ - create_list_mcp_resources_tool, - create_list_mcp_resource_templates_tool, - create_read_mcp_resource_tool, - ] { - for (description, schema, valid_schema) in [ - (None, None, false), - (Some(""), None, false), - (None, Some(parameters), true), - ( - Some(" Catalog {{literal}} guidance.\n"), - Some(parameters), - true, - ), - (Some("Catalog guidance."), Some("invalid JSON"), false), - (None, Some(r#"{"type":"string"}"#), false), - ] { - let messages = ToolMessage { - description: description.map(str::to_owned), - parameters: schema.map(str::to_owned), - }; - let mut expected = create_tool(/*messages*/ None); - let ToolSpec::Function(tool) = &mut expected else { - panic!("expected a function tool"); - }; - if let Some(description) = description { - tool.description = description.to_owned(); - } - if valid_schema { - tool.parameters = serde_json::from_str(parameters).unwrap(); - } - assert_eq!(create_tool(Some(&messages)), expected); - } - } -} diff --git a/codex-rs/core/src/tools/handlers/new_context_window.rs b/codex-rs/core/src/tools/handlers/new_context_window.rs index 4b11300b54..227aad0b58 100644 --- a/codex-rs/core/src/tools/handlers/new_context_window.rs +++ b/codex-rs/core/src/tools/handlers/new_context_window.rs @@ -5,16 +5,26 @@ use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::context::boxed_tool_output; use crate::tools::handlers::new_context_window_spec::NEW_CONTEXT_WINDOW_TOOL_NAME; -use crate::tools::handlers::new_context_window_spec::create_new_context_window_tool; use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; +use codex_protocol::openai_models::ToolMessage; use codex_tools::ToolName; use codex_tools::ToolSpec; pub(crate) const NEW_CONTEXT_WINDOW_MESSAGE: &str = "A new context window will start without summarizing conversation history."; -pub struct NewContextWindowHandler; +pub struct NewContextWindowHandler { + spec: ToolSpec, +} + +impl NewContextWindowHandler { + pub(crate) fn new(messages: Option<&ToolMessage>) -> Self { + Self { + spec: super::new_context_window_spec::create_new_context_window_tool(messages), + } + } +} impl ToolExecutor for NewContextWindowHandler { fn tool_name(&self) -> ToolName { @@ -22,7 +32,7 @@ impl ToolExecutor for NewContextWindowHandler { } fn spec(&self) -> ToolSpec { - create_new_context_window_tool() + self.spec.clone() } fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a> diff --git a/codex-rs/core/src/tools/handlers/new_context_window_spec.rs b/codex-rs/core/src/tools/handlers/new_context_window_spec.rs index d2bde1f137..c5ac4bc88a 100644 --- a/codex-rs/core/src/tools/handlers/new_context_window_spec.rs +++ b/codex-rs/core/src/tools/handlers/new_context_window_spec.rs @@ -1,17 +1,20 @@ +use codex_protocol::openai_models::ToolMessage; use codex_tools::JsonSchema; use codex_tools::ResponsesApiTool; use codex_tools::ToolSpec; +use codex_tools::with_catalog_tool_message; use std::collections::BTreeMap; pub(crate) const NEW_CONTEXT_WINDOW_TOOL_NAME: &str = "new_context"; -pub fn create_new_context_window_tool() -> ToolSpec { - ToolSpec::Function(ResponsesApiTool { +pub fn create_new_context_window_tool(messages: Option<&ToolMessage>) -> ToolSpec { + let tool = ResponsesApiTool { name: NEW_CONTEXT_WINDOW_TOOL_NAME.to_string(), description: "Start a new context window. Does not clear, reset, or otherwise affect environment state.".to_string(), strict: false, defer_loading: None, parameters: JsonSchema::object(BTreeMap::new(), /*required*/ None, Some(false.into())), output_schema: None, - }) + }; + ToolSpec::Function(with_catalog_tool_message(tool, messages)) } diff --git a/codex-rs/core/src/tools/handlers/request_user_input_async.rs b/codex-rs/core/src/tools/handlers/request_user_input_async.rs index da153dcd59..a98d43ebb9 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input_async.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input_async.rs @@ -66,7 +66,7 @@ impl ToolExecutor for RequestUserInputAsyncHandler { /*additional_properties*/ Some(false.into()), ); if let Some(parameters_override) = &self.parameters { - match crate::tools::catalog_parameters::parse(parameters_override) { + match codex_tools::parse_catalog_tool_parameters(parameters_override) { Ok(schema) => parameters = schema, Err(reason) => tracing::warn!( tool = TOOL_NAME, diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index 8f4041420c..73473f41d1 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -1,6 +1,5 @@ mod approvals; pub(crate) mod call_trace; -mod catalog_parameters; pub(crate) mod code_mode; pub(crate) mod context; mod control_tool_analytics; diff --git a/codex-rs/core/src/tools/multi_agent_tool.rs b/codex-rs/core/src/tools/multi_agent_tool.rs index 0e44fd92b2..2b271af752 100644 --- a/codex-rs/core/src/tools/multi_agent_tool.rs +++ b/codex-rs/core/src/tools/multi_agent_tool.rs @@ -25,7 +25,7 @@ pub(super) fn multi_agent_v2_handler( parameters_override: Option<&str>, ) -> Arc { let parameters_override = parameters_override.map(|parameters| -> Result { - let mut parameters = super::catalog_parameters::parse(parameters)?; + let mut parameters = codex_tools::parse_catalog_tool_parameters(parameters)?; if let ToolSpec::Function(tool) = handler.spec() && let Some(properties) = tool.parameters.properties { diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 24b60f3ff2..192deff7e1 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -652,7 +652,11 @@ fn build_model_visible_specs( } specs.extend(hosted_specs); - merge_into_namespaces(specs) + let mcp_namespaces = registry + .mcp_namespaces() + .map(|(_, namespace)| namespace) + .collect(); + merge_into_namespaces(specs, &mcp_namespaces) } fn spec_for_model_request( @@ -1101,7 +1105,7 @@ fn register_code_mode_executors( } #[instrument(level = "trace", skip_all, fields(tool_spec_count = specs.len()))] -fn merge_into_namespaces(specs: Vec) -> Vec { +fn merge_into_namespaces(specs: Vec, mcp_namespaces: &HashSet<&str>) -> Vec { let mut merged_specs = Vec::with_capacity(specs.len()); let mut namespace_indices = BTreeMap::::new(); for spec in specs { @@ -1144,7 +1148,10 @@ fn merge_into_namespaces(specs: Vec) -> Vec { left_name.cmp(right_name) }); - if namespace.description.trim().is_empty() { + // Native tools resolve their own defaults, including intentional empty descriptions. + if namespace.description.trim().is_empty() + && mcp_namespaces.contains(namespace.name.as_str()) + { namespace.description = default_namespace_description(&namespace.name); } } @@ -1358,8 +1365,17 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, registry: &mut Tool } if features.enabled(Feature::TokenBudget) { - registry.add_with_exposure(NewContextWindowHandler, ToolExposure::DirectModelOnly); - registry.add(GetContextRemainingHandler); + let model_messages = ResolvedModelMessages::from_model(context.model_info); + let messages = model_messages.context_management(); + registry.add_with_exposure( + NewContextWindowHandler::new( + messages.and_then(|messages| messages.new_context.as_ref()), + ), + ToolExposure::DirectModelOnly, + ); + registry.add(GetContextRemainingHandler::new( + messages.and_then(|messages| messages.get_context_remaining.as_ref()), + )); } let current_time_reminder_enabled = features.enabled(Feature::CurrentTimeReminder); diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 1a8a066187..1ecd9f77f1 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -2060,6 +2060,8 @@ async fn relaxed_tool_collisions_preserve_first_nonempty_namespace_description() Some("Second namespace description."), "Second namespace description.", ), + (None, None, "Tools in the shared namespace."), + (Some(""), Some(" \t\n"), "Tools in the shared namespace."), ] { let runtime = |name, description: Option<&str>| { let mut tool = mcp_tool("shared", "shared", name); diff --git a/codex-rs/core/tests/suite/scenarios.rs b/codex-rs/core/tests/suite/scenarios.rs index 51e89ca463..bd71eddee7 100644 --- a/codex-rs/core/tests/suite/scenarios.rs +++ b/codex-rs/core/tests/suite/scenarios.rs @@ -110,6 +110,9 @@ mod incremental_tools_resume; #[path = "scenarios_model_instructions.rs"] mod model_instructions; +#[path = "scenarios_context_management_tools.rs"] +mod context_management_tools; + #[path = "scenarios_code_mode_settled_helpers_tests.rs"] mod code_mode_settled_helpers; diff --git a/codex-rs/core/tests/suite/scenarios_context_management_tools.rs b/codex-rs/core/tests/suite/scenarios_context_management_tools.rs new file mode 100644 index 0000000000..5402d5df38 --- /dev/null +++ b/codex-rs/core/tests/suite/scenarios_context_management_tools.rs @@ -0,0 +1,94 @@ +//! Outgoing context-tool definitions follow the captured acting model. + +use super::*; +use codex_protocol::openai_models::ContextManagementToolMessages; +use codex_protocol::openai_models::ModelInfo; +use pretty_assertions::assert_eq; + +fn configure_tools(model: &mut ModelInfo, description: &str) { + let message = ToolMessage { + description: Some(description.to_string()), + parameters: Some(r#"{"type":"object","properties":{},"additionalProperties":false,"description":"Catalog arguments."}"#.to_string()), + }; + model + .model_messages + .get_or_insert_default() + .tools + .get_or_insert_default() + .context_management = Some(ContextManagementToolMessages { + new_context: Some(message.clone()), + get_context_remaining: Some(message), + }); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn catalog_context_tool_definitions_follow_captured_models() -> Result<()> { + skip_if_no_network!(Ok(())); + let server = start_mock_server().await; + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ev_completed("first")]), + sse(vec![ev_completed("second")]), + ], + ) + .await; + let test = test_codex() + .with_model_info_override("gpt-5.4", |model| { + model.use_responses_lite = false; + model.tool_mode = Some(ToolMode::Direct); + configure_tools(model, "Catalog context guidance."); + }) + .with_config(|config| { + config + .features + .enable(Feature::TokenBudget) + .expect("enable token budget"); + let catalog = config.model_catalog.as_mut().expect("model catalog"); + let mut updated = catalog + .models + .iter() + .find(|model| model.slug == "gpt-5.4") + .unwrap() + .clone(); + updated.slug = "context-tools-updated".to_string(); + configure_tools(&mut updated, ""); + catalog.models.push(updated); + }) + .build_with_auto_env(&server) + .await?; + test.submit_text_turn("Begin work.").await?; + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { + model: Some("context-tools-updated".to_string()), + ..Default::default() + }, + ) + .await?; + test.submit_text_turn("Continue with updated tools.") + .await?; + let requests = mock.requests(); + assert_eq!(requests.len(), 2); + for (request, description) in requests.iter().zip(["Catalog context guidance.", ""]) { + let body = request.body_json(); + for name in ["new_context", "get_context_remaining"] { + let tool = body["tools"] + .as_array() + .unwrap() + .iter() + .find(|tool| tool["name"] == name) + .unwrap(); + assert_eq!(tool["description"], description); + assert_eq!(tool["parameters"]["description"], "Catalog arguments."); + } + } + insta::assert_snapshot!(context_snapshot::format_request_history_snapshot( + "Context tool descriptions and parameters use the captured model; selecting a model with explicit empty descriptions preserves empty text and prior history.", + &requests, + &ContextSnapshotOptions::default() + .rewrite_known_segments() + .include_request_settings(), + )); + Ok(()) +} diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__context_management_tools__catalog_context_tool_definitions_follow_captured_models.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__context_management_tools__catalog_context_tool_definitions_follow_captured_models.snap new file mode 100644 index 0000000000..2985ef461d --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__context_management_tools__catalog_context_tool_definitions_follow_captured_models.snap @@ -0,0 +1,72 @@ +--- +source: core/tests/suite/scenarios_context_management_tools.rs +expression: "context_snapshot::format_request_history_snapshot(\"Context tool descriptions and parameters use the captured model; selecting a model with explicit empty descriptions preserves empty text and prior history.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)" +--- +Scenario: Context tool descriptions and parameters use the captured model; selecting a model with explicit empty descriptions preserves empty text and prior history. + +## Window 1 +Settings: + include: ["reasoning.encrypted_content"] + model: "gpt-5.4" + parallel_tool_calls: true + prompt_cache_key: "" + reasoning: {"effort":"medium"} + store: false + stream: true + text: {"verbosity":"low"} + tool_choice: "auto" + tools (9; hash=2f5d124739fef213): + - function/exec_command: Runs a command in a PTY, returning output or a session ID for ongoing interaction.; args=[cmd, justification, login, max_output_tokens, prefix_rule, sandbox_permissions, shell, tty, workdir, yield_time_ms]; hash=c04108e51a8548c5 + - function/write_stdin: Writes characters to an existing unified exec session and returns recent output.; args=[chars, max_output_tokens, session_id, yield_time_ms]; hash=7fb62d8a3e2df4e2 + - function/request_user_input: Request user input for one to three short questions and wait for the response. This tool is only ...; args=[questions]; hash=64a729b25c424fc6 + - function/new_context: Catalog context guidance.; args=[]; hash=8d300f346fb11d84 + - function/get_context_remaining: Catalog context guidance.; args=[]; hash=802b1cf4a796b439 + - custom/apply_patch: The `apply_patch` tool can be used to edit files. This is a FREEFORM tool, so do not wrap the pat...; hash=dea9f12b0111a4ba + - function/view_image: View a local image file from the filesystem when visual inspection is needed. Use this for images...; args=[detail, path]; hash=3b766d5bdec5d50e + - tool_search: # Tool discovery Searches over deferred tool metadata with BM25 and exposes matching tools for t...; args=[limit, query]; hash=400cd6ac61d524a9 + - web_search; hash=f103e327c6870a46 +-- request 1 (turn) -- +00:message/developer: + +01:message/developer: + +02:message/developer[3]: + [01] + [02] + Agent name: /root + First context window id: + Current context window id: + + [03] +03:message/user: + +04:message/user: + Begin work. + +## Window 2 (after request 1) +Settings: relative to window 1 + model: "context-tools-updated" + tools (9; hash=bc76e2d214a3ede5): + ~ function/new_context; args=[]; hash=b9263827b0adbb78 + ~ function/get_context_remaining; args=[]; hash=3e96d66806c56bdd +-- request 2 (turn) -- +00:message/developer: + +01:message/developer: + +02:message/developer[3]: + [01] + [02] + Agent name: /root + First context window id: + Current context window id: + + [03] +03:message/user: + +04:message/user: + Begin work. +05:message/developer: + +06:message/user: + Continue with updated tools. diff --git a/codex-rs/ext/history-notes/Cargo.toml b/codex-rs/ext/history-notes/Cargo.toml index df2f272945..11e93ea96b 100644 --- a/codex-rs/ext/history-notes/Cargo.toml +++ b/codex-rs/ext/history-notes/Cargo.toml @@ -30,6 +30,7 @@ serde_json = { workspace = true } codex-config = { workspace = true } codex-model-provider-info = { workspace = true } core_test_support = { workspace = true } +insta = { workspace = true } pretty_assertions = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/codex-rs/ext/history-notes/src/extension.rs b/codex-rs/ext/history-notes/src/extension.rs index ff425ec8fa..8af60180d4 100644 --- a/codex-rs/ext/history-notes/src/extension.rs +++ b/codex-rs/ext/history-notes/src/extension.rs @@ -20,6 +20,8 @@ use codex_extension_api::ToolExecutor; use codex_login::AuthManager; use codex_model_provider::create_model_provider; use codex_protocol::AgentPath; +use codex_protocol::openai_models::HistoryToolMessages; +use codex_protocol::openai_models::NotesToolMessages; use codex_utils_output_truncation::TruncationPolicy; use serde_json::json; @@ -160,6 +162,39 @@ impl ToolContributor for HistoryNotesExtension { &self, session_store: &ExtensionData, thread_store: &ExtensionData, + ) -> Vec ToolExecutor>>> { + self.tools_with_messages( + session_store, + thread_store, + /*history_messages*/ None, + /*notes_messages*/ None, + ) + } + + fn tools_for_step( + &self, + session_store: &ExtensionData, + thread_store: &ExtensionData, + step_store: &ExtensionData, + ) -> Vec ToolExecutor>>> { + let history_messages = step_store.get::(); + let notes_messages = step_store.get::(); + self.tools_with_messages( + session_store, + thread_store, + history_messages.as_deref(), + notes_messages.as_deref(), + ) + } +} + +impl HistoryNotesExtension { + fn tools_with_messages( + &self, + session_store: &ExtensionData, + thread_store: &ExtensionData, + history_messages: Option<&HistoryToolMessages>, + notes_messages: Option<&NotesToolMessages>, ) -> Vec ToolExecutor>>> { let Some(config) = thread_store.get::() else { return Vec::new(); @@ -176,6 +211,8 @@ impl ToolContributor for HistoryNotesExtension { config.backend.clone(), session_store.level_id().to_string(), identity.agent_name.clone(), + history_messages, + notes_messages, )) as Arc ToolExecutor>> }) .collect() diff --git a/codex-rs/ext/history-notes/src/tools.rs b/codex-rs/ext/history-notes/src/tools.rs index 114ee2ece8..17773befb1 100644 --- a/codex-rs/ext/history-notes/src/tools.rs +++ b/codex-rs/ext/history-notes/src/tools.rs @@ -13,10 +13,13 @@ use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ImageReference; use codex_protocol::models::ResponseInputItem; +use codex_protocol::openai_models::HistoryToolMessages; +use codex_protocol::openai_models::NotesToolMessages; use codex_tools::JsonToolOutput; use codex_tools::ResponsesApiNamespace; use codex_tools::ResponsesApiNamespaceTool; use codex_tools::ToolExposure; +use codex_tools::with_catalog_tool_message; use serde_json::Value; use serde_json::json; @@ -235,6 +238,60 @@ impl HistoryNotesAction { }), } } + + fn spec( + self, + history_messages: Option<&HistoryToolMessages>, + notes_messages: Option<&NotesToolMessages>, + ) -> ToolSpec { + let (namespace_description, tool_message) = match self { + Self::HistoryListWindows => history_messages + .map(|messages| (&messages.namespace_description, &messages.list_windows)), + Self::HistoryListItems => history_messages + .map(|messages| (&messages.namespace_description, &messages.list_items)), + Self::HistoryReadItem => history_messages + .map(|messages| (&messages.namespace_description, &messages.read_item)), + Self::HistorySearchContents => history_messages + .map(|messages| (&messages.namespace_description, &messages.search_contents)), + Self::NotesListFilesByPrefix => notes_messages.map(|messages| { + ( + &messages.namespace_description, + &messages.list_files_by_prefix, + ) + }), + Self::NotesReadFile => notes_messages + .map(|messages| (&messages.namespace_description, &messages.read_file)), + Self::NotesSearchContents => notes_messages + .map(|messages| (&messages.namespace_description, &messages.search_contents)), + Self::NotesAppendToFile => notes_messages + .map(|messages| (&messages.namespace_description, &messages.append_to_file)), + Self::NotesWriteFile => notes_messages + .map(|messages| (&messages.namespace_description, &messages.write_file)), + } + .map_or((None, None), |(namespace_description, tool_message)| { + (namespace_description.as_deref(), tool_message.as_ref()) + }); + let tool = with_catalog_tool_message( + ResponsesApiTool { + name: self.name().to_string(), + description: self.description().to_string(), + strict: false, + parameters: parse_tool_input_schema(&self.parameters()).unwrap_or_else(|error| { + panic!("History tool input schema should parse: {error}") + }), + output_schema: None, + defer_loading: None, + }, + tool_message, + ); + ToolSpec::Namespace(ResponsesApiNamespace { + name: self.namespace().to_string(), + description: namespace_description + .unwrap_or(self.namespace_description()) + .to_string(), + tools: vec![ResponsesApiNamespaceTool::Function(tool)], + }) + } } pub(crate) struct HistoryNotesTool { @@ -242,6 +299,7 @@ pub(crate) struct HistoryNotesTool { backend: HistoryNotesBackend, session_id: String, current_agent_name: String, + spec: ToolSpec, } impl HistoryNotesTool { @@ -250,12 +308,15 @@ impl HistoryNotesTool { backend: HistoryNotesBackend, session_id: String, current_agent_name: String, + history_messages: Option<&HistoryToolMessages>, + notes_messages: Option<&NotesToolMessages>, ) -> Self { Self { action, backend, session_id, current_agent_name, + spec: action.spec(history_messages, notes_messages), } } @@ -292,20 +353,7 @@ impl<'call> ToolExecutor> for HistoryNotesTool { } fn spec(&self) -> ToolSpec { - ToolSpec::Namespace(ResponsesApiNamespace { - name: self.action.namespace().to_string(), - description: self.action.namespace_description().to_string(), - tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool { - name: self.action.name().to_string(), - description: self.action.description().to_string(), - strict: false, - parameters: parse_tool_input_schema(&self.action.parameters()).unwrap_or_else( - |error| panic!("History tool input schema should parse: {error}"), - ), - output_schema: None, - defer_loading: None, - })], - }) + self.spec.clone() } fn exposure(&self) -> ToolExposure { diff --git a/codex-rs/ext/history-notes/src/tools_tests.rs b/codex-rs/ext/history-notes/src/tools_tests.rs index 9ec63f6621..55d021ba3b 100644 --- a/codex-rs/ext/history-notes/src/tools_tests.rs +++ b/codex-rs/ext/history-notes/src/tools_tests.rs @@ -153,3 +153,29 @@ fn rejects_malformed_attachments_instead_of_silently_dropping_them() { assert_eq!(message, "History backend returned invalid image content."); } } + +#[test] +fn sparse_catalog_messages_preserve_defaults_and_explicit_empty_descriptions() { + let action = super::HistoryNotesAction::NotesReadFile; + let mut expected = action.spec(/*history_messages*/ None, /*notes_messages*/ None); + let codex_tools::ToolSpec::Namespace(namespace) = &mut expected else { + panic!("expected namespace"); + }; + namespace.description.clear(); + let codex_tools::ResponsesApiNamespaceTool::Function(tool) = &mut namespace.tools[0] else { + panic!("expected function"); + }; + tool.description.clear(); + let messages = codex_protocol::openai_models::NotesToolMessages { + namespace_description: Some(String::new()), + read_file: Some(codex_protocol::openai_models::ToolMessage { + description: Some(String::new()), + parameters: Some(String::new()), + }), + ..Default::default() + }; + assert_eq!( + action.spec(/*history_messages*/ None, Some(&messages)), + expected + ); +} diff --git a/codex-rs/ext/history-notes/tests/history_notes_extension.rs b/codex-rs/ext/history-notes/tests/history_notes_extension.rs index 143eabe01f..e5db8b2ad2 100644 --- a/codex-rs/ext/history-notes/tests/history_notes_extension.rs +++ b/codex-rs/ext/history-notes/tests/history_notes_extension.rs @@ -29,6 +29,8 @@ use codex_protocol::AgentPath; use codex_protocol::ThreadId; use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::ResponseInputItem; +use codex_protocol::openai_models::NotesToolMessages; +use codex_protocol::openai_models::ToolMessage; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TruncationPolicy; @@ -136,6 +138,37 @@ async fn installed_extension_exposes_and_invokes_history_notes_tools() -> TestRe ] ); + // Existing executors retain the catalog snapshot selected for their step. + let step_store = ExtensionData::new("step-1"); + let mut messages = NotesToolMessages { + read_file: Some(ToolMessage { + description: Some("Catalog read instructions".to_string()), + ..Default::default() + }), + ..Default::default() + }; + step_store.insert(messages.clone()); + let catalog_tools = + registry.tool_contributors()[0].tools_for_step(&session_store, &thread_store, &step_store); + let captured_tool = catalog_tools + .iter() + .find(|tool| tool.tool_name() == ToolName::namespaced("notes", "read_file")) + .expect("notes.read_file should be exposed"); + let captured_spec = captured_tool.spec(); + messages.read_file.as_mut().expect("read_file").description = Some(String::new()); + step_store.insert(messages); + let next_tools = + registry.tool_contributors()[0].tools_for_step(&session_store, &thread_store, &step_store); + assert_ne!( + next_tools + .iter() + .find(|tool| tool.tool_name() == ToolName::namespaced("notes", "read_file")) + .expect("notes.read_file should be exposed") + .spec(), + captured_spec + ); + assert_eq!(captured_tool.spec(), captured_spec); + let read_file = tools .iter() .find(|tool| tool.tool_name() == ToolName::namespaced("notes", "read_file")) diff --git a/codex-rs/ext/history-notes/tests/image_model_input.rs b/codex-rs/ext/history-notes/tests/image_model_input.rs index be78b52a47..1f3105450b 100644 --- a/codex-rs/ext/history-notes/tests/image_model_input.rs +++ b/codex-rs/ext/history-notes/tests/image_model_input.rs @@ -1,5 +1,6 @@ -//! Verify history attachments survive native preparation into the next model request. +//! Verify captured catalog definitions and history attachments reach outgoing model requests. +use std::collections::BTreeMap; use std::sync::Arc; use codex_core::config::Config; @@ -9,6 +10,8 @@ use codex_history_notes_extension::install; use codex_login::AuthHeaders; use codex_login::AuthManager; use codex_login::CodexAuth; +use core_test_support::context_snapshot; +use core_test_support::context_snapshot::ContextSnapshotOptions; use core_test_support::responses; use core_test_support::test_codex::test_codex; use http::HeaderMap; @@ -22,6 +25,25 @@ use wiremock::matchers::path; const PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII="; +const NATIVE_TOOLS: [(&str, &str, &[&str]); 2] = [ + ( + "history", + "", + &["list_windows", "list_items", "read_item", "search_contents"], + ), + ( + "notes", + " ", + &[ + "list_files_by_prefix", + "read_file", + "search_contents", + "append_to_file", + "write_file", + ], + ), +]; + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn history_images_reach_the_next_model_request() -> Result<(), Box> { let server = responses::start_mock_server().await; @@ -62,10 +84,27 @@ async fn history_images_reach_the_next_model_request() -> Result<(), Box Result<(), Box>(), + tool_names + .iter() + .map(|tool| (*tool, format!("Catalog {name}.{tool}"))) + .collect(), + ); + if name == "history" { + assert_eq!( + tools + .iter() + .find(|tool| tool["name"] == "read_item") + .unwrap()["parameters"], + parameters + ); + } + } + } assert_eq!( requests[1].function_call_output("read-history")["output"], json!([ @@ -90,5 +164,12 @@ async fn history_images_reach_the_next_model_request() -> Result<(), Box" + reasoning: {"effort":"medium"} + store: false + stream: true + text: {"verbosity":"low"} + tool_choice: "auto" + tools (9; hash=33554259377e0d47): + - function/exec_command: Runs a command in a PTY, returning output or a session ID for ongoing interaction.; args=[cmd, justification, login, max_output_tokens, prefix_rule, sandbox_permissions, shell, tty, workdir, yield_time_ms]; hash=c04108e51a8548c5 + - function/write_stdin: Writes characters to an existing unified exec session and returns recent output.; args=[chars, max_output_tokens, session_id, yield_time_ms]; hash=7fb62d8a3e2df4e2 + - function/request_user_input: Request user input for one to three short questions and wait for the response. This tool is only ...; args=[questions]; hash=64a729b25c424fc6 + - custom/apply_patch: The `apply_patch` tool can be used to edit files. This is a FREEFORM tool, so do not wrap the pat...; hash=dea9f12b0111a4ba + - function/view_image: View a local image file from the filesystem when visual inspection is needed. Use this for images...; args=[detail, path]; hash=3b766d5bdec5d50e + - namespace/history; hash=48578cec2ffd3727 + - function/list_items + - function/list_windows + - function/read_item + - function/search_contents + - namespace/notes: ; hash=1984d9e2f2d6c4f0 + - function/append_to_file + - function/list_files_by_prefix + - function/read_file + - function/search_contents + - function/write_file + - tool_search: # Tool discovery Searches over deferred tool metadata with BM25 and exposes matching tools for t...; args=[limit, query]; hash=400cd6ac61d524a9 + - web_search; hash=f103e327c6870a46 +-- request 1 (turn) -- +00:message/developer: + +01:message/developer: + +02:message/user: + +03:message/user: + Read the image from history. +-- request 2 (turn) -- +04:function_call/history.read_item:{"item_id":"item","window_id":"window"} +05:function_call_output: | | | diff --git a/codex-rs/prompts/src/model_messages.rs b/codex-rs/prompts/src/model_messages.rs index 26d3266e16..c3f7e43c3f 100644 --- a/codex-rs/prompts/src/model_messages.rs +++ b/codex-rs/prompts/src/model_messages.rs @@ -144,6 +144,27 @@ impl<'a> ResolvedModelMessages<'a> { }) } + /// Selects native history definitions; missing fields delegate to the tool owner. + pub fn history(self) -> Option<&'a codex_protocol::openai_models::HistoryToolMessages> { + self.catalog_messages?.tools.as_ref()?.history.as_ref() + } + + /// Selects native notes definitions; missing fields delegate to the tool owner. + pub fn notes(self) -> Option<&'a codex_protocol::openai_models::NotesToolMessages> { + self.catalog_messages?.tools.as_ref()?.notes.as_ref() + } + + /// Selects context-window control definitions independently of runtime tool names. + pub fn context_management( + self, + ) -> Option<&'a codex_protocol::openai_models::ContextManagementToolMessages> { + self.catalog_messages? + .tools + .as_ref()? + .context_management + .as_ref() + } + /// Resolves optional confirmation policies, leaving missing values to actor defaults. pub fn confirmation_policies(&self) -> Option<&'a ConfirmationPolicies> { self.catalog_messages diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 797c3f881b..0b3a3d0e86 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -35,6 +35,11 @@ use crate::protocol::MultiAgentVersion; mod access_programs; mod incremental_tools; pub use incremental_tools::IncrementalToolMessages; +#[path = "openai_models/memento_v2.rs"] +mod memento_v2; +pub use memento_v2::ContextManagementToolMessages; +pub use memento_v2::HistoryToolMessages; +pub use memento_v2::NotesToolMessages; #[path = "openai_models/guardian.rs"] mod guardian; pub use guardian::GuardianModelPolicy; @@ -585,6 +590,15 @@ pub struct ConfirmationPolicies { /// Model-owned tool messages and description prefixes. #[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] pub struct ToolMessages { + /// Native Memento V2 history tool definitions, independent of runtime namespace aliases. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub history: Option, + /// Native Memento V2 notes tool definitions. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub notes: Option, + /// Memento V2 context-window control tool definitions. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context_management: Option, /// Optional wording for incremental catalog notices. #[serde(default, skip_serializing_if = "Option::is_none")] pub incremental_tools: Option, @@ -634,7 +648,8 @@ pub struct ToolMessage { #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, /// Complete JSON Schema encoded as a string. Consumed by Multi-Agent V2 tools, Code Mode wait, - /// request_user_input_async (the send_user_message_async catalog key), and MCP resource helpers. + /// request_user_input_async (the send_user_message_async catalog key), MCP resource helpers, + /// and Memento V2 tools. /// Uses the harness's supported schema subset; unrecognized keywords are ignored. /// Missing, null, invalid or unsupported structures, or a root without `type: "object"` /// retains the harness parameters. Schema semantics must remain API-compatible. diff --git a/codex-rs/protocol/src/openai_models/memento_v2.rs b/codex-rs/protocol/src/openai_models/memento_v2.rs new file mode 100644 index 0000000000..0c803e91a9 --- /dev/null +++ b/codex-rs/protocol/src/openai_models/memento_v2.rs @@ -0,0 +1,52 @@ +//! Sparse Memento V2 tool definitions. Runtime data remains with consumers. + +use super::ToolMessage; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +/// Model-owned history namespace and public tool definitions. +#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] +pub struct HistoryToolMessages { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub namespace_description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub list_windows: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub list_items: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub read_item: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub search_contents: Option, +} + +/// Model-owned notes namespace and public tool definitions. +#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] +pub struct NotesToolMessages { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub namespace_description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub list_files_by_prefix: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub read_file: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub search_contents: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub append_to_file: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub write_file: Option, +} + +/// Model-owned context-window control tool definitions. +#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] +pub struct ContextManagementToolMessages { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub new_context: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub get_context_remaining: Option, +} + +#[cfg(test)] +#[path = "memento_v2_tests.rs"] +mod tests; diff --git a/codex-rs/protocol/src/openai_models/memento_v2_tests.rs b/codex-rs/protocol/src/openai_models/memento_v2_tests.rs new file mode 100644 index 0000000000..aab2a77ba9 --- /dev/null +++ b/codex-rs/protocol/src/openai_models/memento_v2_tests.rs @@ -0,0 +1,18 @@ +use crate::openai_models::ModelMessages; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[test] +fn memento_catalog_round_trip_preserves_sparse_tools_and_explicit_empty() { + for tools in [ + json!({}), + json!({ + "history": {"namespace_description": "", "read_item": {"description": "", "parameters": "not JSON"}}, + "notes": {"read_file": {"parameters": "{}"}}, + "context_management": {"new_context": {"description": "catalog"}} + }), + ] { + let messages: ModelMessages = serde_json::from_value(json!({"tools": tools})).unwrap(); + assert_eq!(serde_json::to_value(messages.tools).unwrap(), tools); + } +} diff --git a/codex-rs/tools/Cargo.toml b/codex-rs/tools/Cargo.toml index b943f38be2..aa378ff33d 100644 --- a/codex-rs/tools/Cargo.toml +++ b/codex-rs/tools/Cargo.toml @@ -29,6 +29,7 @@ rmcp = { workspace = true, default-features = false, features = [ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true, features = ["raw_value"] } thiserror = { workspace = true } +tracing = { workspace = true } urlencoding = { workspace = true } [dev-dependencies] diff --git a/codex-rs/tools/src/catalog_tool_message.rs b/codex-rs/tools/src/catalog_tool_message.rs new file mode 100644 index 0000000000..492f8f5c98 --- /dev/null +++ b/codex-rs/tools/src/catalog_tool_message.rs @@ -0,0 +1,49 @@ +//! Applies sparse catalog overrides while preserving bundled tool transport requirements. +//! Invalid parameters retain the bundled schema; other tool fields remain harness-owned. + +use crate::ResponsesApiTool; +use crate::parse_catalog_tool_parameters; +use codex_protocol::openai_models::ToolMessage; + +/// Apply catalog descriptions and parameters without changing the tool's identity or transport. +pub fn with_catalog_tool_message( + mut tool: ResponsesApiTool, + messages: Option<&ToolMessage>, +) -> ResponsesApiTool { + let Some(messages) = messages else { + return tool; + }; + if let Some(description) = &messages.description { + tool.description.clone_from(description); + } + if let Some(parameters) = &messages.parameters { + let parameters = parse_catalog_tool_parameters(parameters).and_then(|mut parameters| { + if let Some(properties) = &tool.parameters.properties { + for (name, bundled_property) in properties.iter() { + if bundled_property.encrypted == Some(true) { + let property = parameters + .properties + .as_mut() + .and_then(|properties| properties.get_mut(name)) + .ok_or("schema omits an encrypted parameter")?; + property.encrypted = Some(true); + } + } + } + Ok(parameters) + }); + match parameters { + Ok(parameters) => tool.parameters = parameters, + Err(reason) => tracing::warn!( + tool = %tool.name, + reason, + "Invalid catalog tool parameters; using bundled parameters" + ), + } + } + tool +} + +#[cfg(test)] +#[path = "catalog_tool_message_tests.rs"] +mod tests; diff --git a/codex-rs/tools/src/catalog_tool_message_tests.rs b/codex-rs/tools/src/catalog_tool_message_tests.rs new file mode 100644 index 0000000000..5c6278a64a --- /dev/null +++ b/codex-rs/tools/src/catalog_tool_message_tests.rs @@ -0,0 +1,85 @@ +use super::*; +use crate::JsonSchema; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[test] +fn catalog_overrides_preserve_tool_fields_and_encryption_with_independent_fallbacks() { + let bundled = ResponsesApiTool { + name: "example".to_string(), + description: "Bundled guidance.".to_string(), + strict: true, + defer_loading: Some(true), + parameters: serde_json::from_value(json!({ + "type": "object", + "properties": { + "text": {"type": "string", "encrypted": true}, + "title": {"type": "string", "encrypted": true}, + "path": {"type": "string"} + } + })) + .unwrap(), + output_schema: Some(json!({"type": "string"}).into()), + }; + assert_eq!( + with_catalog_tool_message(bundled.clone(), /*messages*/ None), + bundled, + ); + + let mut catalog = json!({ + "type": "object", + "properties": { + "text": {"type": "string", "description": "Catalog guidance.", "encrypted": false}, + "title": {"type": "string"}, + "path": {"type": "string"} + } + }); + let parameters = catalog.to_string(); + catalog["properties"]["text"]["encrypted"] = json!(true); + catalog["properties"]["title"]["encrypted"] = json!(true); + let expected_parameters: JsonSchema = serde_json::from_value(catalog).unwrap(); + + for (description, schema, expected_schema) in [ + (None, None, None), + (Some(""), None, None), + (None, Some(parameters.as_str()), Some(&expected_parameters)), + ( + Some(" Catalog {{literal}} guidance.\n"), + Some(parameters.as_str()), + Some(&expected_parameters), + ), + (Some("Catalog guidance."), Some("invalid JSON"), None), + (None, Some(r#"{"type":"string"}"#), None), + ( + None, + Some(r#"{"type":"object","properties":{"text":{"type":"unsupported"}}}"#), + None, + ), + ( + Some(""), + Some(r#"{"type":"object","properties":{"title":{"type":"string"}}}"#), + None, + ), + ( + Some("Catalog guidance."), + Some(r#"{"type":"object","properties":{"text":{"type":"string"}}}"#), + None, + ), + ] { + let messages = ToolMessage { + description: description.map(str::to_owned), + parameters: schema.map(str::to_owned), + }; + let mut expected = bundled.clone(); + if let Some(description) = description { + expected.description = description.to_owned(); + } + if let Some(parameters) = expected_schema { + expected.parameters = parameters.clone(); + } + assert_eq!( + with_catalog_tool_message(bundled.clone(), Some(&messages)), + expected, + ); + } +} diff --git a/codex-rs/tools/src/json_schema.rs b/codex-rs/tools/src/json_schema.rs index 0d70b1712d..d4e6990fc6 100644 --- a/codex-rs/tools/src/json_schema.rs +++ b/codex-rs/tools/src/json_schema.rs @@ -1,4 +1,4 @@ -//! Normalize tool schemas and prune unreachable definitions before applying the size policy. +//! Parse catalog overrides and normalize external tool schemas with their size policy. mod compaction; mod traversal; @@ -22,6 +22,17 @@ const DEFINITION_TABLE_KEYS: [&str; 2] = ["$defs", "definitions"]; const SCHEMA_CHILD_KEYS: [&str; 4] = ["items", "anyOf", "oneOf", "allOf"]; const COMPOSITION_SCHEMA_KEYS: [&str; 3] = ["anyOf", "oneOf", "allOf"]; +/// Parse catalog parameters without normalization or compaction. +/// Consumers own fallback and transport-specific requirements. +pub fn parse_catalog_tool_parameters(parameters: &str) -> Result { + let parameters: JsonValue = + serde_json::from_str(parameters).map_err(|_| "schema is not valid JSON")?; + if !parameters.is_object() || parameters["type"] != "object" { + return Err("schema must declare an object type"); + } + serde_json::from_value(parameters).map_err(|_| "schema uses unsupported JSON Schema structures") +} + /// Parse the tool `input_schema` or return an error for invalid schema. pub fn parse_tool_input_schema(input_schema: &JsonValue) -> Result { parse_tool_input_schema_with_max_bytes(input_schema, DEFAULT_COMPACT_TOOL_SCHEMA_BYTES) diff --git a/codex-rs/tools/src/json_schema_tests.rs b/codex-rs/tools/src/json_schema_tests.rs index 768ae2deee..b6321afb16 100644 --- a/codex-rs/tools/src/json_schema_tests.rs +++ b/codex-rs/tools/src/json_schema_tests.rs @@ -2,11 +2,39 @@ use super::AdditionalProperties; use super::JsonSchema; use super::JsonSchemaPrimitiveType; use super::JsonSchemaType; +use super::parse_catalog_tool_parameters; use super::parse_tool_input_schema; use super::parse_tool_input_schema_without_compaction; use pretty_assertions::assert_eq; use std::collections::BTreeMap; +#[test] +fn catalog_parameters_preserve_schema_without_normalization() { + let parameters = serde_json::json!({ + "type": "object", + "properties": {"paths": {"type": "array"}}, + "$defs": {"unused": {"type": "string"}} + }); + let parsed = parse_catalog_tool_parameters(¶meters.to_string()).expect("catalog schema"); + assert_eq!(serde_json::to_value(parsed).unwrap(), parameters); +} + +#[test] +fn catalog_parameters_reject_invalid_shapes() { + for (parameters, reason) in [ + ("", "schema is not valid JSON"), + ("null", "schema must declare an object type"), + ("{}", "schema must declare an object type"), + (r#"{"type":"array"}"#, "schema must declare an object type"), + ( + r#"{"type":"object","properties":{"text":{"type":"unsupported"}}}"#, + "schema uses unsupported JSON Schema structures", + ), + ] { + assert_eq!(parse_catalog_tool_parameters(parameters), Err(reason)); + } +} + // Tests in this section exercise normalization transforms that mutate badly // formed JSON for consumption by the Responses API. diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index a89697a2e9..86616597af 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -1,6 +1,7 @@ //! Shared tool definitions and Responses API tool primitives that can live //! outside `codex-core`. +mod catalog_tool_message; mod code_mode; mod dynamic_tool; mod function_call_error; @@ -23,6 +24,7 @@ mod tool_payload; mod tool_search; mod tool_spec; +pub use catalog_tool_message::with_catalog_tool_message; pub use code_mode::augment_tool_spec_for_code_mode; pub use code_mode::code_mode_name_for_tool_name; pub use code_mode::collect_code_mode_exec_prompt_tool_definitions; @@ -41,6 +43,7 @@ pub use json_schema::AdditionalProperties; pub use json_schema::JsonSchema; pub use json_schema::JsonSchemaPrimitiveType; pub use json_schema::JsonSchemaType; +pub use json_schema::parse_catalog_tool_parameters; pub use json_schema::parse_tool_input_schema; pub use json_schema::parse_tool_input_schema_without_compaction; pub use mcp_tool::mcp_call_tool_result_output_schema;