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
This commit is contained in:
rhan-oai
2026-10-23 00:46:28 +00:00
committed by copyberry
parent 5b792948f9
commit e103bbc06f
35 changed files with 822 additions and 114 deletions
+2
View File
@@ -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",
]
+8
View File
@@ -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(
@@ -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<JsonSchema, &'static str> {
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")
}
@@ -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!(
@@ -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<ToolInvocation> for GetContextRemainingHandler {
fn tool_name(&self) -> ToolName {
@@ -69,7 +79,7 @@ impl ToolExecutor<ToolInvocation> 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>
@@ -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 {
@@ -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)]
@@ -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);
}
}
}
@@ -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<ToolInvocation> for NewContextWindowHandler {
fn tool_name(&self) -> ToolName {
@@ -22,7 +32,7 @@ impl ToolExecutor<ToolInvocation> 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>
@@ -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))
}
@@ -66,7 +66,7 @@ impl ToolExecutor<ToolInvocation> 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,
-1
View File
@@ -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;
+1 -1
View File
@@ -25,7 +25,7 @@ pub(super) fn multi_agent_v2_handler(
parameters_override: Option<&str>,
) -> Arc<dyn CoreToolRuntime> {
let parameters_override = parameters_override.map(|parameters| -> Result<JsonSchema, &str> {
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
{
+21 -5
View File
@@ -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<ToolSpec>) -> Vec<ToolSpec> {
fn merge_into_namespaces(specs: Vec<ToolSpec>, mcp_namespaces: &HashSet<&str>) -> Vec<ToolSpec> {
let mut merged_specs = Vec::with_capacity(specs.len());
let mut namespace_indices = BTreeMap::<String, usize>::new();
for spec in specs {
@@ -1144,7 +1148,10 @@ fn merge_into_namespaces(specs: Vec<ToolSpec>) -> Vec<ToolSpec> {
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);
@@ -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);
+3
View File
@@ -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;
@@ -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(())
}
@@ -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: "<PROMPT_CACHE_KEY 1>"
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:
<MODEL_INSTRUCTIONS>
01:message/developer:
<PERMISSIONS_INSTRUCTIONS>
02:message/developer[3]:
[01] <context_window>
[02]
Agent name: /root
First context window id: <UUID 1>
Current context window id: <UUID 1>
[03] </context_window>
03:message/user:
<ENVIRONMENT_CONTEXT>
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:
<MODEL_INSTRUCTIONS>
01:message/developer:
<PERMISSIONS_INSTRUCTIONS>
02:message/developer[3]:
[01] <context_window>
[02]
Agent name: /root
First context window id: <UUID 1>
Current context window id: <UUID 1>
[03] </context_window>
03:message/user:
<ENVIRONMENT_CONTEXT>
04:message/user:
Begin work.
05:message/developer:
<MODEL_SWITCH>
06:message/user:
Continue with updated tools.
+1
View File
@@ -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"] }
@@ -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<Arc<dyn for<'call> ToolExecutor<ToolCall<'call>>>> {
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<Arc<dyn for<'call> ToolExecutor<ToolCall<'call>>>> {
let history_messages = step_store.get::<HistoryToolMessages>();
let notes_messages = step_store.get::<NotesToolMessages>();
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<Arc<dyn for<'call> ToolExecutor<ToolCall<'call>>>> {
let Some(config) = thread_store.get::<HistoryNotesExtensionConfig>() 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<dyn for<'call> ToolExecutor<ToolCall<'call>>>
})
.collect()
+62 -14
View File
@@ -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<ToolCall<'call>> 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 {
@@ -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
);
}
@@ -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"))
@@ -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<dyn std::error::Error>> {
let server = responses::start_mock_server().await;
@@ -62,10 +84,27 @@ async fn history_images_reach_the_next_model_request() -> Result<(), Box<dyn std
&mut extensions,
AuthManager::from_auth_for_testing(auth.clone()),
);
let parameters = json!({
"type": "object", "description": "Catalog read parameters",
"properties": {"window_id": {"type": "string"}, "item_id": {"type": "string"}},
"required": ["window_id", "item_id"]
});
let catalog_parameters = parameters.clone();
let test = test_codex()
.with_auth(auth)
.with_model_info_override("gpt-5.5", |model_info| {
.with_model_info_override("gpt-5.5", move |model_info| {
model_info.supports_experimental_context = true;
let mut messages = json!({});
for (namespace, description, names) in NATIVE_TOOLS {
messages[namespace] = json!({"namespace_description": description});
for name in names {
messages[namespace][*name] =
json!({"description": format!("Catalog {namespace}.{name}")});
}
}
messages["history"]["read_item"]["parameters"] = json!(catalog_parameters.to_string());
model_info.model_messages.get_or_insert_default().tools =
Some(serde_json::from_value(messages).expect("native catalog messages"));
})
.with_extensions(Arc::new(extensions.build()))
.with_config(|config| {
@@ -81,6 +120,41 @@ async fn history_images_reach_the_next_model_request() -> Result<(), Box<dyn std
.await?;
let requests = requests.requests();
assert_eq!(requests.len(), 2);
for request in &requests {
let body = request.body_json();
for (name, description, tool_names) in NATIVE_TOOLS {
let namespace = body["tools"]
.as_array()
.expect("tools")
.iter()
.find(|tool| tool["name"] == name)
.expect("native namespace");
assert_eq!(namespace["description"], description);
let tools = namespace["tools"].as_array().expect("namespace tools");
assert_eq!(
tools
.iter()
.map(|tool| (
tool["name"].as_str().unwrap(),
tool["description"].as_str().unwrap().to_string(),
))
.collect::<BTreeMap<_, _>>(),
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<dyn std
{"type": "input_image", "image_url": format!("data:image/png;base64,{PNG}"), "detail": "auto"}
])
);
insta::assert_snapshot!(context_snapshot::format_request_history_snapshot(
"History/Notes catalog definitions preserve empty and whitespace namespace descriptions, and history images reach the next model request.",
&requests,
&ContextSnapshotOptions::default()
.rewrite_known_segments()
.include_request_settings(),
));
Ok(())
}
@@ -0,0 +1,48 @@
---
source: ext/history-notes/tests/image_model_input.rs
expression: "context_snapshot::format_request_history_snapshot(\"History/Notes catalog definitions preserve empty and whitespace namespace descriptions, and history images reach the next model request.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)"
---
Scenario: History/Notes catalog definitions preserve empty and whitespace namespace descriptions, and history images reach the next model request.
## Window 1
Settings:
include: ["reasoning.encrypted_content"]
model: "gpt-5.5"
parallel_tool_calls: true
prompt_cache_key: "<PROMPT_CACHE_KEY 1>"
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:
<MODEL_INSTRUCTIONS>
01:message/developer:
<PERMISSIONS_INSTRUCTIONS>
02:message/user:
<ENVIRONMENT_CONTEXT>
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:<encrypted_content> | <input_image> | <input_image> | <input_image>
+21
View File
@@ -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
+16 -1
View File
@@ -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<HistoryToolMessages>,
/// Native Memento V2 notes tool definitions.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notes: Option<NotesToolMessages>,
/// Memento V2 context-window control tool definitions.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_management: Option<ContextManagementToolMessages>,
/// Optional wording for incremental catalog notices.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub incremental_tools: Option<IncrementalToolMessages>,
@@ -634,7 +648,8 @@ pub struct ToolMessage {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// 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.
@@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub list_windows: Option<ToolMessage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub list_items: Option<ToolMessage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub read_item: Option<ToolMessage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub search_contents: Option<ToolMessage>,
}
/// 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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub list_files_by_prefix: Option<ToolMessage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub read_file: Option<ToolMessage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub search_contents: Option<ToolMessage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub append_to_file: Option<ToolMessage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub write_file: Option<ToolMessage>,
}
/// 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<ToolMessage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub get_context_remaining: Option<ToolMessage>,
}
#[cfg(test)]
#[path = "memento_v2_tests.rs"]
mod tests;
@@ -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);
}
}
+1
View File
@@ -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]
@@ -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;
@@ -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,
);
}
}
+12 -1
View File
@@ -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<JsonSchema, &'static str> {
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<JsonSchema, serde_json::Error> {
parse_tool_input_schema_with_max_bytes(input_schema, DEFAULT_COMPACT_TOOL_SCHEMA_BYTES)
+28
View File
@@ -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(&parameters.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.
+3
View File
@@ -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;