mirror of
https://github.com/openai/codex.git
synced 2026-10-23 18:08:45 +00:00
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:
Generated
+2
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
+72
@@ -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.
|
||||
@@ -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()
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
+48
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user