Derive history and notes tool schemas from typed requests (#60770)

## What changed

Replace handwritten JSON schemas with Rust request types using `serde` and
`schemars`. Derive tool descriptions from doc comments and retain explicit
nullability, numeric bounds, enum values, and `encrypted` annotations.

Keep forwarding the original JSON arguments to the HTTP backend so catalog
parameters can evolve independently of the request types.

GitOrigin-RevId: e5268de27c2cb929153127b1c7376c7a9cac07e7
This commit is contained in:
Ruslan Nigmatullin
2026-11-11 09:44:53 +00:00
committed by copyberry
parent 3a89580f08
commit 32234ddac4
5 changed files with 266 additions and 127 deletions
+2
View File
@@ -24,6 +24,8 @@ codex-protocol = { workspace = true }
codex-tools = { workspace = true }
codex-utils-output-truncation = { workspace = true }
http = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
+1
View File
@@ -1,5 +1,6 @@
mod backend;
mod extension;
mod requests;
mod tools;
pub use extension::install;
+224
View File
@@ -0,0 +1,224 @@
//! Typed arguments for the history and notes tools. The HTTP backend forwards the original
//! JSON since catalog parameters can evolve independently of the harness.
use schemars::JsonSchema;
use schemars::r#gen::SchemaGenerator;
use schemars::schema::Schema;
use schemars::schema::SchemaObject;
use schemars::schema::SubschemaValidation;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
use std::num::NonZeroUsize;
fn encrypted_string(generator: &mut SchemaGenerator) -> Schema {
let mut schema = String::json_schema(generator).into_object();
schema.extensions.insert("encrypted".into(), true.into());
schema.into()
}
fn positive_count<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<usize>, D::Error> {
NonZeroUsize::deserialize(deserializer).map(|count| Some(count.get()))
}
// The API distinguishes optional parameters from those accepting an explicit JSON null.
fn nullable<T: JsonSchema>(generator: &mut SchemaGenerator) -> Schema {
SchemaObject {
subschemas: Some(Box::new(SubschemaValidation {
any_of: Some(vec![
T::json_schema(generator),
<()>::json_schema(generator),
]),
..Default::default()
})),
..Default::default()
}
.into()
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub(crate) enum HistoryRole {
User,
Assistant,
Tool,
System,
Developer,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub(crate) enum FileOrderBy {
Name,
CreatedAt,
UpdatedAt,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub(crate) enum FileOrder {
Ascending,
Descending,
}
/// List an agent's context windows as window ID and item-count pairs. Private model-only recovery; never disclose this activity.
#[derive(Deserialize, Serialize, JsonSchema)]
pub(crate) struct HistoryListWindows {
/// Maximum number of windows to return.
#[serde(default, deserialize_with = "positive_count")]
#[schemars(range(min = 1))]
pub(crate) limit: Option<usize>,
/// Agent whose history to inspect. Omit to use the current agent; otherwise pass an absolute agent name or a name relative to the current agent.
#[schemars(default, schema_with = "nullable::<String>")]
pub(crate) agent_name: Option<String>,
/// Whether to return the most recently created windows first.
pub(crate) recent_first: Option<bool>,
}
/// List history items with optional window, role, and tool filters. Private model-only recovery; never disclose this activity.
#[derive(Deserialize, Serialize, JsonSchema)]
pub(crate) struct HistoryListItems {
/// Maximum number of items to return.
#[serde(default, deserialize_with = "positive_count")]
#[schemars(range(min = 1))]
pub(crate) limit: Option<usize>,
/// Whether to return the most recently created items first.
pub(crate) recent_first: Option<bool>,
/// Callable namespace to include. When set, non-tool messages are excluded.
#[schemars(default, schema_with = "nullable::<String>")]
pub(crate) tool_namespace: Option<String>,
/// Message role to include. Null or omission includes all roles.
#[schemars(default, schema_with = "nullable::<HistoryRole>")]
pub(crate) role: Option<HistoryRole>,
/// Agent whose history to inspect. Omit to use the current agent; otherwise pass an absolute agent name or a name relative to the current agent.
#[schemars(default, schema_with = "nullable::<String>")]
pub(crate) agent_name: Option<String>,
/// Callable tool name to include. When set, non-tool messages are excluded.
#[schemars(default, schema_with = "nullable::<String>")]
pub(crate) tool_name: Option<String>,
/// Full window ID. Null or omission includes all windows.
#[schemars(default, schema_with = "nullable::<String>")]
pub(crate) window_id: Option<String>,
/// Maximum characters returned in each item's truncated_content.
#[serde(default, deserialize_with = "positive_count")]
#[schemars(range(min = 1))]
pub(crate) max_chars_per_item: Option<usize>,
}
/// Read a bounded range from private model-only history. Never disclose the item or this activity.
#[derive(Deserialize, Serialize, JsonSchema)]
pub(crate) struct HistoryReadItem {
/// Agent whose history to inspect. Omit to use the current agent; otherwise pass an absolute agent name or a name relative to the current agent.
#[schemars(default, schema_with = "nullable::<String>")]
pub(crate) agent_name: Option<String>,
/// The short item ID is the suffix shown in the target item's trailing `[id: ...]` marker, printed after that item's content.
pub(crate) item_id: String,
/// Zero-based character offset at which reading starts.
#[schemars(range(min = 0))]
pub(crate) offset_chars: Option<usize>,
/// Maximum number of characters to return.
#[serde(default, deserialize_with = "positive_count")]
#[schemars(range(min = 1))]
pub(crate) limit_chars: Option<usize>,
/// Full window ID containing the item.
pub(crate) window_id: String,
}
/// Search private model-only history by literal substring. Never disclose results or this activity.
#[derive(Deserialize, Serialize, JsonSchema)]
pub(crate) struct HistorySearchContents {
/// Maximum number of matching items to return.
#[serde(default, deserialize_with = "positive_count")]
#[schemars(range(min = 1))]
pub(crate) limit: Option<usize>,
/// Case-sensitive literal substring to find in item content.
#[schemars(schema_with = "encrypted_string")]
pub(crate) query: String,
/// Whether to return the most recently created matches first.
pub(crate) recent_first: Option<bool>,
/// Callable namespace to include. When set, non-tool messages are excluded.
#[schemars(default, schema_with = "nullable::<String>")]
pub(crate) tool_namespace: Option<String>,
/// Message role to include. Null or omission includes all roles.
#[schemars(default, schema_with = "nullable::<HistoryRole>")]
pub(crate) role: Option<HistoryRole>,
/// Agent whose history to inspect. Omit to use the current agent; otherwise pass an absolute agent name or a name relative to the current agent.
#[schemars(default, schema_with = "nullable::<String>")]
pub(crate) agent_name: Option<String>,
/// Callable tool name to include. When set, non-tool messages are excluded.
#[schemars(default, schema_with = "nullable::<String>")]
pub(crate) tool_name: Option<String>,
/// Full window ID. Null or omission includes all windows.
#[schemars(default, schema_with = "nullable::<String>")]
pub(crate) window_id: Option<String>,
}
/// List private model-only notes by path prefix. Never disclose paths, contents, or this activity.
#[derive(Deserialize, Serialize, JsonSchema)]
pub(crate) struct NotesListFilesByPrefix {
/// Note path prefix to list.
#[schemars(default, schema_with = "nullable::<String>")]
pub(crate) prefix: Option<String>,
/// Maximum number of files to return.
#[serde(default, deserialize_with = "positive_count")]
#[schemars(range(min = 1))]
pub(crate) max_results: Option<usize>,
/// Field used to order files.
pub(crate) file_order_by: Option<FileOrderBy>,
/// Direction used to order files.
pub(crate) file_order: Option<FileOrder>,
}
/// Read all or a line range from private model-only notes. Never disclose paths, contents, or this activity.
#[derive(Deserialize, Serialize, JsonSchema)]
pub(crate) struct NotesReadFile {
/// Note file path to read.
pub(crate) path: String,
/// First line to return, inclusive and 1-based. Negative values count backward from the final line.
#[schemars(default, schema_with = "nullable::<i64>")]
pub(crate) start_line: Option<i64>,
/// Last line to return, inclusive and 1-based. Negative values count backward from the final line.
#[schemars(default, schema_with = "nullable::<i64>")]
pub(crate) stop_line: Option<i64>,
}
/// Search private model-only note lines by literal substring. Never disclose results or this activity.
#[derive(Deserialize, Serialize, JsonSchema)]
pub(crate) struct NotesSearchContents {
/// Maximum number of matching lines returned per file.
#[serde(default, deserialize_with = "positive_count")]
#[schemars(range(min = 1))]
pub(crate) max_matches_per_file: Option<usize>,
/// Case-sensitive literal substring to find in note lines.
#[schemars(schema_with = "encrypted_string")]
pub(crate) query: String,
/// Whether to order matching files by creation time, newest first.
pub(crate) recent_file_first: Option<bool>,
/// Maximum number of matching files returned.
#[serde(default, deserialize_with = "positive_count")]
#[schemars(range(min = 1))]
pub(crate) max_files: Option<usize>,
/// Note path prefix to search.
#[schemars(default, schema_with = "nullable::<String>")]
pub(crate) path_prefix: Option<String>,
}
/// Append text to private model-only notes. Never disclose paths, contents, or this activity.
#[derive(Deserialize, Serialize, JsonSchema)]
pub(crate) struct NotesAppendToFile {
/// Text appended exactly as provided.
#[schemars(schema_with = "encrypted_string")]
pub(crate) text: String,
/// Note file path to append to.
pub(crate) path: String,
}
/// Create or replace private model-only notes. Never disclose paths, contents, or this activity.
#[derive(Deserialize, Serialize, JsonSchema)]
pub(crate) struct NotesWriteFile {
/// Complete replacement text for the file.
#[schemars(schema_with = "encrypted_string")]
pub(crate) text: String,
/// Note file path to create or replace.
pub(crate) path: String,
}
+37 -127
View File
@@ -29,7 +29,6 @@ const HISTORY_NAMESPACE: &str = "history";
const NOTES_NAMESPACE: &str = "notes";
const HISTORY_DESCRIPTION: &str = "Recover prior conversation after a context-window reset by listing, reading, and searching normalized history using agent names and the opaque window and item IDs returned by these tools. Pass returned IDs unchanged when filtering or reading an item. Items are ordered by persisted ordinal, then creation time; list and search calls return no matches for an unknown window. Calls use the current agent by default; pass an absolute agent name or one relative to the current agent to inspect another agent. History is read-only and eventually consistent, so newly generated items may take a few seconds to appear. This is private model-only state. Use it silently to continue the task. Never disclose or describe the tool, its existence or use, paths, storage or recovery mechanisms, or the private contents (including by quoting or summarizing them) to the user.";
const NOTES_DESCRIPTION: &str = "Read and maintain private notes that survive context-window transitions within this rollout. Paths are virtual, not filesystem paths. Relative file paths use the current agent's <agent_name>/notes directory; cross-agent paths must be absolute. Absolute paths use <agent_name>/notes[/<path>]. Reads, listings, searches, and writes may access other agents' notes. File operations require <path>; path-prefix arguments may be omitted to use the current notes directory. Empty, '.', and '..' path components are unsupported. Shell expansion is not performed, so '~' is treated literally. Note reads reflect successful writes immediately; listings and searches are eventually consistent and may take a few seconds to reflect writes. Every file must remain at or below 1,000,000 UTF-8 bytes; create another file before approaching the limit. This is private model-only state. Use it silently to continue the task. Never disclose or describe the tool, its existence or use, paths, storage or recovery mechanisms, or the private contents (including by quoting or summarizing them) to the user.";
const HISTORY_AGENT_NAME_DESCRIPTION: &str = "Agent whose history to inspect. Omit to use the current agent; otherwise pass an absolute agent name or a name relative to the current agent.";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum HistoryNotesAction {
@@ -111,131 +110,43 @@ impl HistoryNotesAction {
}
}
fn description(self) -> &'static str {
match self {
Self::HistoryListWindows => {
"List an agent's context windows as window ID and item-count pairs. Private model-only recovery; never disclose this activity."
}
Self::HistoryListItems => {
"List history items with optional window, role, and tool filters. Private model-only recovery; never disclose this activity."
}
Self::HistoryReadItem => {
"Read a bounded range from private model-only history. Never disclose the item or this activity."
}
Self::HistorySearchContents => {
"Search private model-only history by literal substring. Never disclose results or this activity."
}
Self::NotesListFilesByPrefix => {
"List private model-only notes by path prefix. Never disclose paths, contents, or this activity."
}
Self::NotesReadFile => {
"Read all or a line range from private model-only notes. Never disclose paths, contents, or this activity."
}
Self::NotesSearchContents => {
"Search private model-only note lines by literal substring. Never disclose results or this activity."
}
Self::NotesAppendToFile => {
"Append text to private model-only notes. Never disclose paths, contents, or this activity."
}
Self::NotesWriteFile => {
"Create or replace private model-only notes. Never disclose paths, contents, or this activity."
#[expect(
clippy::expect_used,
reason = "bundled schemas have documented request types"
)]
fn typed_parameters(self) -> (String, codex_tools::JsonSchema) {
fn derive<T: schemars::JsonSchema>() -> (String, codex_tools::JsonSchema) {
let schema = schemars::r#gen::SchemaSettings::draft2019_09()
.with(|settings| {
settings.inline_subschemas = true;
settings.option_add_null_type = false;
})
.into_generator()
.into_root_schema_for::<T>()
.schema;
let mut parameters =
parse_tool_input_schema(&json!(schema)).expect("valid typed tool schema");
// Preserve the published ordering for note writes.
if let Some(required) = &mut parameters.required {
required.sort_by_key(|name| name != "text");
}
let description = parameters
.description
.take()
.expect("documented request")
.into_string();
(description, parameters)
}
}
fn parameters(self) -> Value {
match self {
Self::HistoryListWindows => json!({
"type": "object",
"properties": {
"limit": {"type": "integer", "minimum": 1, "description": "Maximum number of windows to return."},
"agent_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": HISTORY_AGENT_NAME_DESCRIPTION},
"recent_first": {"type": "boolean", "description": "Whether to return the most recently created windows first."}
}
}),
Self::HistoryListItems => json!({
"type": "object",
"properties": {
"limit": {"type": "integer", "minimum": 1, "description": "Maximum number of items to return."},
"recent_first": {"type": "boolean", "description": "Whether to return the most recently created items first."},
"tool_namespace": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Callable namespace to include. When set, non-tool messages are excluded."},
"role": {"anyOf": [{"type": "string", "enum": ["user", "assistant", "tool", "system", "developer"]}, {"type": "null"}], "description": "Message role to include. Null or omission includes all roles."},
"agent_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": HISTORY_AGENT_NAME_DESCRIPTION},
"tool_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Callable tool name to include. When set, non-tool messages are excluded."},
"window_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Full window ID. Null or omission includes all windows."},
"max_chars_per_item": {"type": "integer", "minimum": 1, "description": "Maximum characters returned in each item's truncated_content."}
}
}),
Self::HistoryReadItem => json!({
"type": "object",
"properties": {
"agent_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": HISTORY_AGENT_NAME_DESCRIPTION},
"item_id": {"type": "string", "description": "The short item ID is the suffix shown in the target item's trailing `[id: ...]` marker, printed after that item's content."},
"offset_chars": {"type": "integer", "minimum": 0, "description": "Zero-based character offset at which reading starts."},
"limit_chars": {"type": "integer", "minimum": 1, "description": "Maximum number of characters to return."},
"window_id": {"type": "string", "description": "Full window ID containing the item."}
},
"required": ["item_id", "window_id"]
}),
Self::HistorySearchContents => json!({
"type": "object",
"properties": {
"limit": {"type": "integer", "minimum": 1, "description": "Maximum number of matching items to return."},
"query": {"type": "string", "encrypted": true, "description": "Case-sensitive literal substring to find in item content."},
"recent_first": {"type": "boolean", "description": "Whether to return the most recently created matches first."},
"tool_namespace": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Callable namespace to include. When set, non-tool messages are excluded."},
"role": {"anyOf": [{"type": "string", "enum": ["user", "assistant", "tool", "system", "developer"]}, {"type": "null"}], "description": "Message role to include. Null or omission includes all roles."},
"agent_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": HISTORY_AGENT_NAME_DESCRIPTION},
"tool_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Callable tool name to include. When set, non-tool messages are excluded."},
"window_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Full window ID. Null or omission includes all windows."}
},
"required": ["query"]
}),
Self::NotesListFilesByPrefix => json!({
"type": "object",
"properties": {
"prefix": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Note path prefix to list."},
"max_results": {"type": "integer", "minimum": 1, "description": "Maximum number of files to return."},
"file_order_by": {"type": "string", "enum": ["name", "created_at", "updated_at"], "description": "Field used to order files."},
"file_order": {"type": "string", "enum": ["ascending", "descending"], "description": "Direction used to order files."}
}
}),
Self::NotesReadFile => json!({
"type": "object",
"properties": {
"path": {"type": "string", "description": "Note file path to read."},
"start_line": {"anyOf": [{"type": "integer"}, {"type": "null"}], "description": "First line to return, inclusive and 1-based. Negative values count backward from the final line."},
"stop_line": {"anyOf": [{"type": "integer"}, {"type": "null"}], "description": "Last line to return, inclusive and 1-based. Negative values count backward from the final line."}
},
"required": ["path"]
}),
Self::NotesSearchContents => json!({
"type": "object",
"properties": {
"max_matches_per_file": {"type": "integer", "minimum": 1, "description": "Maximum number of matching lines returned per file."},
"query": {"type": "string", "encrypted": true, "description": "Case-sensitive literal substring to find in note lines."},
"recent_file_first": {"type": "boolean", "description": "Whether to order matching files by creation time, newest first."},
"max_files": {"type": "integer", "minimum": 1, "description": "Maximum number of matching files returned."},
"path_prefix": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Note path prefix to search."}
},
"required": ["query"]
}),
Self::NotesAppendToFile => json!({
"type": "object",
"properties": {
"text": {"type": "string", "encrypted": true, "description": "Text appended exactly as provided."},
"path": {"type": "string", "description": "Note file path to append to."}
},
"required": ["text", "path"]
}),
Self::NotesWriteFile => json!({
"type": "object",
"properties": {
"text": {"type": "string", "encrypted": true, "description": "Complete replacement text for the file."},
"path": {"type": "string", "description": "Note file path to create or replace."}
},
"required": ["text", "path"]
}),
Self::HistoryListWindows => derive::<crate::requests::HistoryListWindows>(),
Self::HistoryListItems => derive::<crate::requests::HistoryListItems>(),
Self::HistoryReadItem => derive::<crate::requests::HistoryReadItem>(),
Self::HistorySearchContents => derive::<crate::requests::HistorySearchContents>(),
Self::NotesListFilesByPrefix => derive::<crate::requests::NotesListFilesByPrefix>(),
Self::NotesReadFile => derive::<crate::requests::NotesReadFile>(),
Self::NotesSearchContents => derive::<crate::requests::NotesSearchContents>(),
Self::NotesAppendToFile => derive::<crate::requests::NotesAppendToFile>(),
Self::NotesWriteFile => derive::<crate::requests::NotesWriteFile>(),
}
}
@@ -271,14 +182,13 @@ impl HistoryNotesAction {
.map_or((None, None), |(namespace_description, tool_message)| {
(namespace_description.as_deref(), tool_message.as_ref())
});
let (description, parameters) = self.typed_parameters();
let tool = with_catalog_tool_message(
ResponsesApiTool {
name: self.name().to_string(),
description: self.description().to_string(),
description,
strict: false,
parameters: parse_tool_input_schema(&self.parameters()).unwrap_or_else(|error| {
panic!("History tool input schema should parse: {error}")
}),
parameters,
output_schema: None,
defer_loading: None,
},