Add history and notes tools for token-budget sessions (#39827)

## Why

Token-budget sessions need a way to recover prior conversation context and preserve working state across context-window transitions.

## What changed

- Add direct-model `history` tools for listing windows and items, reading items, and searching conversation contents.
- Add direct-model `notes` tools for listing, reading, searching, appending, and writing persistent notes.
- Route tool calls through the configured Codex backend with trusted session and agent context, bounded request arguments, and truncation-aware output handling.
- Expose the extension when `features.token_budget.use_history_notes_history` is enabled with an OpenAI provider and Codex backend authentication.

## Testing

- Cover tool registration, configuration changes, provider and authentication requirements, backend request context, encrypted output preservation, and request and response limits.

GitOrigin-RevId: 43b259f01014ba3f30803dd7cd5634942407a9bb
This commit is contained in:
pmccrary-oai
2026-08-21 01:24:52 +00:00
committed by copyberry
parent f20b63e85c
commit daa48072f4
21 changed files with 1232 additions and 6 deletions
+24
View File
@@ -2015,6 +2015,7 @@ dependencies = [
"codex-git-utils",
"codex-goal-extension",
"codex-guardian-v2",
"codex-history-notes-extension",
"codex-home",
"codex-hooks",
"codex-http-client",
@@ -3329,6 +3330,29 @@ dependencies = [
"serde_json",
]
[[package]]
name = "codex-history-notes-extension"
version = "0.0.0"
dependencies = [
"codex-api",
"codex-client",
"codex-config",
"codex-core",
"codex-extension-api",
"codex-login",
"codex-model-provider",
"codex-model-provider-info",
"codex-protocol",
"codex-tools",
"codex-utils-output-truncation",
"http 1.4.0",
"pretty_assertions",
"serde_json",
"tempfile",
"tokio",
"wiremock",
]
[[package]]
name = "codex-home"
version = "0.0.0"
+2
View File
@@ -59,6 +59,7 @@ members = [
"ext/goal",
"ext/git-attribution",
"ext/guardian-v2",
"ext/history-notes",
"ext/image-generation",
"ext/items",
"ext/memories",
@@ -197,6 +198,7 @@ codex-extension-items = { path = "ext/items" }
codex-goal-extension = { path = "ext/goal" }
codex-git-attribution = { path = "ext/git-attribution" }
codex-guardian-v2 = { path = "ext/guardian-v2" }
codex-history-notes-extension = { path = "ext/history-notes" }
codex-image-generation-extension = { path = "ext/image-generation" }
codex-external-agent-migration = { path = "external-agent-migration" }
codex-experimental-api-macros = { path = "codex-experimental-api-macros" }
+1
View File
@@ -54,6 +54,7 @@ codex-guardian-v2 = { workspace = true }
codex-git-utils = { workspace = true }
codex-file-watcher = { workspace = true }
codex-hooks = { workspace = true }
codex-history-notes-extension = { workspace = true }
codex-http-client = { workspace = true }
codex-otel = { workspace = true }
codex-plugin = { workspace = true }
+1
View File
@@ -74,6 +74,7 @@ where
if let Some(queue_service) = queue_service {
codex_queue_extension::install(&mut builder, queue_service);
}
codex_history_notes_extension::install(&mut builder, auth_manager.clone());
if let Some(state_db) = state_db {
codex_goal_extension::install_with_backend(
&mut builder,
@@ -0,0 +1,86 @@
use std::time::Duration;
use anyhow::Result;
use app_test_support::ChatGptAuthFixture;
use app_test_support::MockResponsesConfig;
use app_test_support::TestAppServer;
use app_test_support::write_chatgpt_auth;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::UserInput;
use codex_config::types::AuthCredentialsStoreMode;
use core_test_support::responses;
use tempfile::TempDir;
use tokio::time::timeout;
#[tokio::test]
async fn app_server_registers_history_and_notes_tools_for_token_budget_threads() -> Result<()> {
let server = responses::start_mock_server().await;
let response_mock = responses::mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_assistant_message("msg-1", "done"),
responses::ev_completed("resp-1"),
]),
)
.await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&server.uri())
.with_model_provider("openai-custom")
.with_provider_name("OpenAI")
.with_provider_base_url(&format!("{}/backend-api/codex", server.uri()))
.with_provider_config("supports_websockets = false\nrequires_openai_auth = true")
.with_extra_config(
"[features.token_budget]\nenabled = true\nuse_history_notes_history = true",
)
.write(codex_home.path())?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("access-chatgpt"),
AuthCredentialsStoreMode::File,
)?;
let mut app_server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build_initialized()
.await?;
let thread = app_server
.start_thread(ThreadStartParams::default())
.await?
.thread;
timeout(
Duration::from_secs(10),
app_server.start_turn_and_wait_for_completion(TurnStartParams {
thread_id: thread.id,
input: vec![UserInput::Text {
text: "inspect history and notes".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
}),
)
.await??;
let request = response_mock.single_request();
for (namespace, tool_name) in [
("history", "list_windows"),
("history", "list_items"),
("history", "read_item"),
("history", "search_contents"),
("notes", "list_files_by_prefix"),
("notes", "read_file"),
("notes", "search_contents"),
("notes", "append_to_file"),
("notes", "write_file"),
] {
assert!(
request.tool_by_name(namespace, tool_name).is_some(),
"app-server should expose {namespace}.{tool_name} to the model"
);
}
Ok(())
}
@@ -36,6 +36,7 @@ mod feedback;
mod fs;
mod git_attribution;
mod guardian_v2;
mod history_notes_extension;
mod hooks_list;
mod host_skills;
mod imagegen_extension;
+4
View File
@@ -3377,6 +3377,10 @@
"format": "int64",
"minimum": 1.0,
"type": "integer"
},
"use_history_notes_history": {
"description": "Whether to expose the built-in history and notes extension.",
"type": "boolean"
}
},
"type": "object"
+2
View File
@@ -654,6 +654,7 @@ async fn load_config_resolves_token_budget_config() -> std::io::Result<()> {
r#"
[features.token_budget]
enabled = true
use_history_notes_history = true
reminder_threshold_tokens = 16000
reminder_message_template = "Custom reminder: {n_remaining} tokens."
guidance_message = "Preserve important state before compaction."
@@ -661,6 +662,7 @@ auto_compact_fallback_prompt = " Write notes immediately. "
auto_compact_fallback_buffer_tokens = 8000
"#,
TokenBudgetConfig {
use_history_notes_history: true,
reminder_threshold_tokens: Some(16_000),
reminder_message_template: "Custom reminder: {n_remaining} tokens.".to_string(),
guidance_message: Some("Preserve important state before compaction.".to_string()),
+6
View File
@@ -1087,6 +1087,7 @@ const AUTO_COMPACT_FALLBACK_PROMPT_MAX_BYTES: usize = 2000;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct TokenBudgetConfig {
pub use_history_notes_history: bool,
pub reminder_threshold_tokens: Option<i64>,
pub reminder_message_template: String,
pub guidance_message: Option<String>,
@@ -1179,6 +1180,7 @@ impl TokenBudgetConfig {
impl Default for TokenBudgetConfig {
fn default() -> Self {
Self {
use_history_notes_history: false,
reminder_threshold_tokens: None,
reminder_message_template: DEFAULT_TOKEN_BUDGET_REMINDER_MESSAGE_TEMPLATE.to_string(),
guidance_message: None,
@@ -2718,6 +2720,9 @@ fn resolve_token_budget_config(
}
let token_budget_config = token_budget_toml_config(config_toml.features.as_ref());
let use_history_notes_history = token_budget_config
.and_then(|config| config.use_history_notes_history)
.unwrap_or_default();
let reminder_threshold_tokens =
token_budget_config.and_then(|config| config.reminder_threshold_tokens);
let reminder_message_template = token_budget_config
@@ -2735,6 +2740,7 @@ fn resolve_token_budget_config(
token_budget_config.and_then(|config| config.auto_compact_fallback_buffer_tokens);
let token_budget = TokenBudgetConfig {
use_history_notes_history,
reminder_threshold_tokens,
reminder_message_template,
guidance_message,
+14 -5
View File
@@ -13,11 +13,16 @@ pub(super) fn has_explicit_settings(config: &Config) -> bool {
.get("features")
.and_then(|features| features.get("token_budget"))
.and_then(|token_budget| token_budget.as_table())
.is_some_and(|settings| settings.keys().any(|key| key != "enabled"))
|| config
.token_budget
.as_ref()
.is_some_and(|token_budget| token_budget != &TokenBudgetConfig::default())
.is_some_and(|settings| {
settings
.keys()
.any(|key| !matches!(key.as_str(), "enabled" | "use_history_notes_history"))
})
|| config.token_budget.as_ref().is_some_and(|token_budget| {
let mut settings = token_budget.clone();
settings.use_history_notes_history = false;
settings != TokenBudgetConfig::default()
})
}
pub(super) fn apply_model_defaults(config: &mut Config, model_info: &ModelInfo) {
@@ -34,6 +39,10 @@ pub(super) fn apply_model_defaults(config: &mut Config, model_info: &ModelInfo)
};
let token_budget = TokenBudgetConfig {
use_history_notes_history: config
.token_budget
.as_ref()
.is_some_and(|token_budget| token_budget.use_history_notes_history),
reminder_threshold_tokens: Some(model_defaults.reminder_threshold_tokens),
reminder_message_template: model_defaults.reminder_message_template.clone(),
guidance_message: Some(model_defaults.guidance_message.clone()),
+1 -1
View File
@@ -294,7 +294,7 @@ async fn token_budget_uses_model_message_defaults() -> Result<()> {
.with_pre_build_hook(|home| {
std::fs::write(
home.join("config.toml"),
"[features.token_budget]\nenabled = true\n",
"[features.token_budget]\nenabled = true\nuse_history_notes_history = true\n",
)
.expect("write token-budget configuration");
})
+6
View File
@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "history-notes",
crate_name = "codex_history_notes_extension",
)
+34
View File
@@ -0,0 +1,34 @@
[package]
edition.workspace = true
license.workspace = true
name = "codex-history-notes-extension"
version.workspace = true
[lib]
name = "codex_history_notes_extension"
path = "src/lib.rs"
doctest = false
[lints]
workspace = true
[dependencies]
codex-api = { workspace = true }
codex-client = { workspace = true }
codex-core = { workspace = true }
codex-extension-api = { workspace = true }
codex-login = { workspace = true }
codex-model-provider = { workspace = true }
codex-protocol = { workspace = true }
codex-tools = { workspace = true }
codex-utils-output-truncation = { workspace = true }
http = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
codex-config = { workspace = true }
codex-model-provider-info = { workspace = true }
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt"] }
wiremock = { workspace = true }
+71
View File
@@ -0,0 +1,71 @@
use std::time::Duration;
use codex_api::ReqwestTransport;
use codex_client::HttpTransport;
use codex_client::RequestBody;
use codex_login::default_client::create_client;
use codex_model_provider::SharedModelProvider;
use http::Method;
use serde_json::Value;
use serde_json::json;
const HISTORY_NOTES_BACKEND_TIMEOUT: Duration = Duration::from_secs(35);
#[derive(Clone)]
pub(crate) struct HistoryNotesBackend {
provider: SharedModelProvider,
}
impl HistoryNotesBackend {
pub(crate) fn new(provider: SharedModelProvider) -> Self {
Self { provider }
}
pub(crate) async fn call(
&self,
path: &str,
session_id: &str,
current_agent_name: &str,
mut arguments: Value,
) -> Result<Value, String> {
let Some(arguments_object) = arguments.as_object_mut() else {
return Err("History tool arguments must be a JSON object".to_string());
};
arguments_object.insert(
"context".to_string(),
json!({
"session_id": session_id,
"current_agent_name": current_agent_name,
}),
);
let provider =
self.provider.api_provider().await.map_err(|error| {
format!("History backend provider could not be resolved: {error}")
})?;
let auth = self
.provider
.api_auth()
.await
.map_err(|error| format!("History backend auth could not be resolved: {error}"))?;
let mut request = provider.build_request(Method::POST, path);
request.body = Some(RequestBody::Json(arguments));
request.timeout = Some(HISTORY_NOTES_BACKEND_TIMEOUT);
let request = auth
.apply_auth(request)
.await
.map_err(|error| format!("History backend auth failed: {error}"))?;
let response = ReqwestTransport::from_http_client(create_client())
.execute(request)
.await
.map_err(|error| format!("History backend request failed: {error}"))?;
serde_json::from_slice(&response.body)
.map_err(|error| format!("History backend returned invalid JSON: {error}"))
}
}
#[cfg(test)]
#[path = "backend_tests.rs"]
mod tests;
@@ -0,0 +1,75 @@
use codex_login::AuthHeaders;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_model_provider::create_model_provider;
use codex_model_provider_info::ModelProviderInfo;
use http::HeaderMap;
use http::HeaderValue;
use pretty_assertions::assert_eq;
use serde_json::json;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
use super::HistoryNotesBackend;
#[tokio::test]
async fn routes_through_codex_backend_and_injects_trusted_session_agent_context() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/backend-api/codex/alpha/notes/v2/read_file"))
.and(header("x-openai-actor-authorization", "actor-biscuit"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"encrypted_output": "enc_payload"
})))
.mount(&server)
.await;
let mut headers = HeaderMap::new();
headers.insert(
"x-openai-actor-authorization",
HeaderValue::from_static("actor-biscuit"),
);
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::Headers(AuthHeaders::new(headers)));
let provider = create_model_provider(
ModelProviderInfo::create_openai_provider(Some(format!(
"{}/backend-api/codex",
server.uri()
))),
Some(auth_manager),
);
let backend = HistoryNotesBackend::new(provider);
let response = backend
.call(
"alpha/notes/v2/read_file",
"session-123",
"/root/worker",
json!({
"path": "notes.md",
"context": {
"session_id": "spoofed-session",
"current_agent_name": "/root/spoofed",
}
}),
)
.await
.expect("History request should succeed");
assert_eq!(response, json!({"encrypted_output": "enc_payload"}));
let requests = server.received_requests().await.expect("recorded requests");
assert_eq!(requests.len(), 1);
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&requests[0].body).expect("JSON body"),
json!({
"path": "notes.md",
"context": {
"session_id": "session-123",
"current_agent_name": "/root/worker",
}
})
);
}
+118
View File
@@ -0,0 +1,118 @@
use std::sync::Arc;
use codex_core::config::Config;
use codex_extension_api::ConfigContributor;
use codex_extension_api::ExtensionData;
use codex_extension_api::ExtensionFuture;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::ThreadLifecycleContributor;
use codex_extension_api::ThreadStartInput;
use codex_extension_api::ToolCall;
use codex_extension_api::ToolContributor;
use codex_extension_api::ToolExecutor;
use codex_login::AuthManager;
use codex_model_provider::create_model_provider;
use codex_protocol::AgentPath;
use crate::backend::HistoryNotesBackend;
use crate::tools::HistoryNotesAction;
use crate::tools::HistoryNotesTool;
struct HistoryNotesExtension {
auth_manager: Arc<AuthManager>,
}
struct HistoryNotesExtensionConfig {
backend: HistoryNotesBackend,
}
struct HistoryNotesAgentIdentity {
agent_name: String,
}
impl HistoryNotesExtension {
fn update_config(&self, thread_store: &ExtensionData, config: &Config) {
if config
.token_budget
.as_ref()
.is_some_and(|token_budget| token_budget.use_history_notes_history)
&& config.model_provider.is_openai()
&& self.auth_manager.current_auth_uses_codex_backend()
{
thread_store.insert(HistoryNotesExtensionConfig {
backend: HistoryNotesBackend::new(create_model_provider(
config.model_provider.clone(),
Some(self.auth_manager.clone()),
)),
});
} else {
thread_store.remove::<HistoryNotesExtensionConfig>();
}
}
}
impl ThreadLifecycleContributor<Config> for HistoryNotesExtension {
fn on_thread_start<'a>(
&'a self,
input: ThreadStartInput<'a, Config>,
) -> ExtensionFuture<'a, ()> {
Box::pin(async move {
let agent_name = input
.session_source
.get_agent_path()
.unwrap_or_else(AgentPath::root)
.to_string();
input
.thread_store
.insert(HistoryNotesAgentIdentity { agent_name });
self.update_config(input.thread_store, input.config);
})
}
}
impl ConfigContributor<Config> for HistoryNotesExtension {
fn on_config_changed(
&self,
_session_store: &ExtensionData,
thread_store: &ExtensionData,
_previous_config: &Config,
new_config: &Config,
) {
self.update_config(thread_store, new_config);
}
}
impl ToolContributor for HistoryNotesExtension {
fn tools(
&self,
session_store: &ExtensionData,
thread_store: &ExtensionData,
) -> Vec<Arc<dyn ToolExecutor<ToolCall>>> {
let Some(config) = thread_store.get::<HistoryNotesExtensionConfig>() else {
return Vec::new();
};
let Some(identity) = thread_store.get::<HistoryNotesAgentIdentity>() else {
return Vec::new();
};
HistoryNotesAction::ALL
.into_iter()
.map(|action| {
Arc::new(HistoryNotesTool::new(
action,
config.backend.clone(),
session_store.level_id().to_string(),
identity.agent_name.clone(),
)) as Arc<dyn ToolExecutor<ToolCall>>
})
.collect()
}
}
/// Installs the standalone history and notes tools backed by the Codex backend.
pub fn install(registry: &mut ExtensionRegistryBuilder<Config>, auth_manager: Arc<AuthManager>) {
let extension = Arc::new(HistoryNotesExtension { auth_manager });
registry.thread_lifecycle_contributor(extension.clone());
registry.config_contributor(extension.clone());
registry.tool_contributor(extension);
}
+5
View File
@@ -0,0 +1,5 @@
mod backend;
mod extension;
mod tools;
pub use extension::install;
+450
View File
@@ -0,0 +1,450 @@
use codex_extension_api::FunctionCallError;
use codex_extension_api::ResponsesApiTool;
use codex_extension_api::ToolCall;
use codex_extension_api::ToolExecutor;
use codex_extension_api::ToolExecutorFuture;
use codex_extension_api::ToolName;
use codex_extension_api::ToolOutput;
use codex_extension_api::ToolPayload;
use codex_extension_api::ToolSpec;
use codex_extension_api::parse_tool_input_schema;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseInputItem;
use codex_tools::JsonToolOutput;
use codex_tools::ResponsesApiNamespace;
use codex_tools::ResponsesApiNamespaceTool;
use codex_tools::ToolExposure;
use codex_utils_output_truncation::TruncationPolicy;
use codex_utils_output_truncation::formatted_truncate_text;
use serde_json::Value;
use serde_json::json;
use crate::backend::HistoryNotesBackend;
const HISTORY_NAMESPACE: &str = "history";
const NOTES_NAMESPACE: &str = "notes";
const MAX_HISTORY_NOTES_RESULT_TOKENS: usize = 10_000;
const MAX_HISTORY_WINDOWS: u64 = 100;
const MAX_HISTORY_ITEMS: u64 = 20;
const MAX_HISTORY_CHARS_PER_ITEM: u64 = 2_000;
const MAX_HISTORY_READ_CHARS: u64 = 20_000;
const MAX_HISTORY_SEARCH_RESULTS: u64 = 20;
const MAX_NOTE_FILES: u64 = 100;
const MAX_NOTE_SEARCH_FILES: u64 = 20;
const MAX_NOTE_MATCHES_PER_FILE: u64 = 10;
const MAX_SEARCH_QUERY_CHARS: usize = 1_000;
const HISTORY_DESCRIPTION: &str = "Recover prior conversation after a context-window reset by listing, reading, and searching normalized history using agent names and full window IDs plus short item IDs allocated with the same unique-suffix mapping as [id: ...] that immediately follows an item's content. 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; relative agent paths such as child/notes/file resolve from the current agent. 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, listings, searches, and writes are strongly consistent. 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 {
HistoryListWindows,
HistoryListItems,
HistoryReadItem,
HistorySearchContents,
NotesListFilesByPrefix,
NotesReadFile,
NotesSearchContents,
NotesAppendToFile,
NotesWriteFile,
}
impl HistoryNotesAction {
pub(crate) const ALL: [Self; 9] = [
Self::HistoryListWindows,
Self::HistoryListItems,
Self::HistoryReadItem,
Self::HistorySearchContents,
Self::NotesListFilesByPrefix,
Self::NotesReadFile,
Self::NotesSearchContents,
Self::NotesAppendToFile,
Self::NotesWriteFile,
];
fn namespace(self) -> &'static str {
match self {
Self::HistoryListWindows
| Self::HistoryListItems
| Self::HistoryReadItem
| Self::HistorySearchContents => HISTORY_NAMESPACE,
Self::NotesListFilesByPrefix
| Self::NotesReadFile
| Self::NotesSearchContents
| Self::NotesAppendToFile
| Self::NotesWriteFile => NOTES_NAMESPACE,
}
}
fn name(self) -> &'static str {
match self {
Self::HistoryListWindows => "list_windows",
Self::HistoryListItems => "list_items",
Self::HistoryReadItem => "read_item",
Self::HistorySearchContents => "search_contents",
Self::NotesListFilesByPrefix => "list_files_by_prefix",
Self::NotesReadFile => "read_file",
Self::NotesSearchContents => "search_contents",
Self::NotesAppendToFile => "append_to_file",
Self::NotesWriteFile => "write_file",
}
}
fn endpoint(self) -> &'static str {
match self {
Self::HistoryListWindows => "alpha/history/v2/list_windows",
Self::HistoryListItems => "alpha/history/v2/list_items",
Self::HistoryReadItem => "alpha/history/v2/read_item",
Self::HistorySearchContents => "alpha/history/v2/search_contents",
Self::NotesListFilesByPrefix => "alpha/notes/v2/list_files_by_prefix",
Self::NotesReadFile => "alpha/notes/v2/read_file",
Self::NotesSearchContents => "alpha/notes/v2/search_contents",
Self::NotesAppendToFile => "alpha/notes/v2/append_to_file",
Self::NotesWriteFile => "alpha/notes/v2/write_file",
}
}
fn supports_parallel_tool_calls(self) -> bool {
!matches!(self, Self::NotesAppendToFile | Self::NotesWriteFile)
}
fn namespace_description(self) -> &'static str {
match self.namespace() {
HISTORY_NAMESPACE => HISTORY_DESCRIPTION,
NOTES_NAMESPACE => NOTES_DESCRIPTION,
_ => unreachable!("History actions use a known namespace"),
}
}
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."
}
}
}
fn parameters(self) -> Value {
match self {
Self::HistoryListWindows => json!({
"type": "object",
"properties": {
"limit": {"type": "integer", "minimum": 1, "maximum": MAX_HISTORY_WINDOWS, "description": "Maximum number of windows to return."},
"agent_name": {"type": ["string", "null"], "description": HISTORY_AGENT_NAME_DESCRIPTION},
"recent_first": {"type": "boolean", "description": "Whether to return the most recently created windows first."}
},
"additionalProperties": false
}),
Self::HistoryListItems => json!({
"type": "object",
"properties": {
"limit": {"type": "integer", "minimum": 1, "maximum": MAX_HISTORY_ITEMS, "description": "Maximum number of items to return."},
"recent_first": {"type": "boolean", "description": "Whether to return the most recently created items first."},
"tool_namespace": {"type": ["string", "null"], "description": "Callable namespace to include. When set, non-tool messages are excluded."},
"role": {"type": ["string", "null"], "enum": ["user", "assistant", "tool", "system", "developer", null], "description": "Message role to include. Null or omission includes all roles."},
"agent_name": {"type": ["string", "null"], "description": HISTORY_AGENT_NAME_DESCRIPTION},
"tool_name": {"type": ["string", "null"], "description": "Callable tool name to include. When set, non-tool messages are excluded."},
"window_id": {"type": ["string", "null"], "description": "Full window ID. Null or omission includes all windows."},
"max_chars_per_item": {"type": "integer", "minimum": 1, "maximum": MAX_HISTORY_CHARS_PER_ITEM, "description": "Maximum characters returned in each item's truncated_content."}
},
"additionalProperties": false
}),
Self::HistoryReadItem => json!({
"type": "object",
"properties": {
"agent_name": {"type": ["string", "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, "maximum": MAX_HISTORY_READ_CHARS, "description": "Maximum number of characters to return."},
"window_id": {"type": "string", "description": "Full window ID containing the item."}
},
"required": ["item_id", "window_id"],
"additionalProperties": false
}),
Self::HistorySearchContents => json!({
"type": "object",
"properties": {
"limit": {"type": "integer", "minimum": 1, "maximum": MAX_HISTORY_SEARCH_RESULTS, "description": "Maximum number of matching items to return."},
"query": {"type": "string", "maxLength": MAX_SEARCH_QUERY_CHARS, "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": {"type": ["string", "null"], "description": "Callable namespace to include. When set, non-tool messages are excluded."},
"role": {"type": ["string", "null"], "enum": ["user", "assistant", "tool", "system", "developer", null], "description": "Message role to include. Null or omission includes all roles."},
"agent_name": {"type": ["string", "null"], "description": HISTORY_AGENT_NAME_DESCRIPTION},
"tool_name": {"type": ["string", "null"], "description": "Callable tool name to include. When set, non-tool messages are excluded."},
"window_id": {"type": ["string", "null"], "description": "Full window ID. Null or omission includes all windows."}
},
"required": ["query"],
"additionalProperties": false
}),
Self::NotesListFilesByPrefix => json!({
"type": "object",
"properties": {
"prefix": {"type": ["string", "null"], "description": "Note path prefix to list."},
"max_results": {"type": "integer", "minimum": 1, "maximum": MAX_NOTE_FILES, "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."}
},
"additionalProperties": false
}),
Self::NotesReadFile => json!({
"type": "object",
"properties": {
"path": {"type": "string", "description": "Note file path to read."},
"start_line": {"type": ["integer", "null"], "description": "First line to return, inclusive and 1-based. Negative values count backward from the final line."},
"stop_line": {"type": ["integer", "null"], "description": "Last line to return, inclusive and 1-based. Negative values count backward from the final line."}
},
"required": ["path"],
"additionalProperties": false
}),
Self::NotesSearchContents => json!({
"type": "object",
"properties": {
"max_matches_per_file": {"type": "integer", "minimum": 1, "maximum": MAX_NOTE_MATCHES_PER_FILE, "description": "Maximum number of matching lines returned per file."},
"query": {"type": "string", "maxLength": MAX_SEARCH_QUERY_CHARS, "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, "maximum": MAX_NOTE_SEARCH_FILES, "description": "Maximum number of matching files returned."},
"path_prefix": {"type": ["string", "null"], "description": "Note path prefix to search."}
},
"required": ["query"],
"additionalProperties": false
}),
Self::NotesAppendToFile => json!({
"type": "object",
"properties": {
"text": {"type": "string", "description": "Text appended exactly as provided."},
"path": {"type": "string", "description": "Note file path to append to."}
},
"required": ["text", "path"],
"additionalProperties": false
}),
Self::NotesWriteFile => json!({
"type": "object",
"properties": {
"text": {"type": "string", "description": "Complete replacement text for the file."},
"path": {"type": "string", "description": "Note file path to create or replace."}
},
"required": ["text", "path"],
"additionalProperties": false
}),
}
}
fn validate_arguments(self, arguments: &Value) -> Result<(), FunctionCallError> {
let limits: &[(&str, u64)] = match self {
Self::HistoryListWindows => &[("limit", MAX_HISTORY_WINDOWS)],
Self::HistoryListItems => &[
("limit", MAX_HISTORY_ITEMS),
("max_chars_per_item", MAX_HISTORY_CHARS_PER_ITEM),
],
Self::HistoryReadItem => &[("limit_chars", MAX_HISTORY_READ_CHARS)],
Self::HistorySearchContents => &[("limit", MAX_HISTORY_SEARCH_RESULTS)],
Self::NotesListFilesByPrefix => &[("max_results", MAX_NOTE_FILES)],
Self::NotesSearchContents => &[
("max_files", MAX_NOTE_SEARCH_FILES),
("max_matches_per_file", MAX_NOTE_MATCHES_PER_FILE),
],
Self::NotesReadFile | Self::NotesAppendToFile | Self::NotesWriteFile => &[],
};
for (field, maximum) in limits {
if arguments
.get(*field)
.and_then(Value::as_u64)
.is_some_and(|value| value > *maximum)
{
return Err(FunctionCallError::RespondToModel(format!(
"History argument `{field}` exceeds the maximum of {maximum}"
)));
}
}
if matches!(
self,
Self::HistorySearchContents | Self::NotesSearchContents
) && arguments
.get("query")
.and_then(Value::as_str)
.is_some_and(|query| query.chars().count() > MAX_SEARCH_QUERY_CHARS)
{
return Err(FunctionCallError::RespondToModel(format!(
"History argument `query` exceeds the maximum of {MAX_SEARCH_QUERY_CHARS} characters"
)));
}
Ok(())
}
}
pub(crate) struct HistoryNotesTool {
action: HistoryNotesAction,
backend: HistoryNotesBackend,
session_id: String,
current_agent_name: String,
}
impl HistoryNotesTool {
pub(crate) fn new(
action: HistoryNotesAction,
backend: HistoryNotesBackend,
session_id: String,
current_agent_name: String,
) -> Self {
Self {
action,
backend,
session_id,
current_agent_name,
}
}
async fn handle_call(&self, call: ToolCall) -> Result<Box<dyn ToolOutput>, FunctionCallError> {
let arguments = call.function_arguments()?;
let arguments = if arguments.trim().is_empty() {
json!({})
} else {
serde_json::from_str(arguments)
.map_err(|error| FunctionCallError::RespondToModel(error.to_string()))?
};
self.action.validate_arguments(&arguments)?;
let result = self
.backend
.call(
self.action.endpoint(),
&self.session_id,
&self.current_agent_name,
arguments,
)
.await
.map_err(FunctionCallError::RespondToModel)?;
Ok(Box::new(HistoryNotesToolOutput::new(
result,
call.truncation_policy,
)?))
}
}
impl ToolExecutor<ToolCall> for HistoryNotesTool {
fn tool_name(&self) -> ToolName {
ToolName::namespaced(self.action.namespace(), self.action.name())
}
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,
})],
})
}
fn exposure(&self) -> ToolExposure {
ToolExposure::DirectModelOnly
}
fn supports_parallel_tool_calls(&self) -> bool {
self.action.supports_parallel_tool_calls()
}
fn handle(&self, call: ToolCall) -> ToolExecutorFuture<'_> {
Box::pin(self.handle_call(call))
}
}
struct HistoryNotesToolOutput {
result: Value,
truncation_policy: TruncationPolicy,
}
impl HistoryNotesToolOutput {
fn new(result: Value, truncation_policy: TruncationPolicy) -> Result<Self, FunctionCallError> {
let maximum_bytes = truncation_policy
.byte_budget()
.min(TruncationPolicy::Tokens(MAX_HISTORY_NOTES_RESULT_TOKENS).byte_budget());
if result
.get("encrypted_output")
.and_then(Value::as_str)
.is_some_and(|output| output.len() > maximum_bytes)
{
return Err(FunctionCallError::RespondToModel(format!(
"History returned an encrypted result larger than the {maximum_bytes}-byte tool-output limit; retry with narrower bounds"
)));
}
Ok(Self {
result,
truncation_policy,
})
}
}
impl ToolOutput for HistoryNotesToolOutput {
fn log_output(&self) -> String {
JsonToolOutput::new(self.result.clone()).log_output()
}
fn success_for_logging(&self) -> bool {
true
}
fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem {
let output = match self.result.get("encrypted_output").and_then(Value::as_str) {
Some(encrypted_content) => FunctionCallOutputPayload::from_content_items(vec![
FunctionCallOutputContentItem::EncryptedContent {
encrypted_content: encrypted_content.to_string(),
},
]),
None => FunctionCallOutputPayload::from_text(formatted_truncate_text(
&self.result.to_string(),
self.truncation_policy,
)),
};
ResponseInputItem::FunctionCallOutput {
call_id: call_id.to_string(),
output,
}
}
fn code_mode_result(&self, _payload: &ToolPayload) -> Value {
Value::String("History tools are unavailable in code mode.".to_string())
}
}
#[cfg(test)]
#[path = "tools_tests.rs"]
mod tests;
@@ -0,0 +1,83 @@
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::ResponseInputItem;
use codex_tools::FunctionCallError;
use codex_tools::ToolOutput;
use codex_tools::ToolPayload;
use codex_utils_output_truncation::TruncationPolicy;
use pretty_assertions::assert_eq;
use serde_json::json;
use super::HistoryNotesAction;
use super::HistoryNotesToolOutput;
#[test]
fn preserves_encrypted_history_output() {
let result = HistoryNotesToolOutput::new(
json!({"encrypted_output": "enc_payload"}),
TruncationPolicy::Bytes(1024),
)
.expect("bounded encrypted output should be accepted")
.to_response_item(
"call-1",
&ToolPayload::Function {
arguments: "{}".to_string(),
},
);
let ResponseInputItem::FunctionCallOutput { output, .. } = result else {
panic!("expected function-call output");
};
assert_eq!(
output.content_items(),
Some(
[FunctionCallOutputContentItem::EncryptedContent {
encrypted_content: "enc_payload".to_string(),
}]
.as_slice()
)
);
}
#[test]
fn rejects_encrypted_history_output_over_the_tool_limit() {
assert_eq!(
HistoryNotesToolOutput::new(
json!({"encrypted_output": "x".repeat(1025)}),
TruncationPolicy::Bytes(1024),
)
.err(),
Some(FunctionCallError::RespondToModel(
"History returned an encrypted result larger than the 1024-byte tool-output limit; retry with narrower bounds".to_string()
))
);
assert_eq!(
HistoryNotesToolOutput::new(
json!({"encrypted_output": "x".repeat(40_001)}),
TruncationPolicy::Tokens(20_000),
)
.err(),
Some(FunctionCallError::RespondToModel(
"History returned an encrypted result larger than the 40000-byte tool-output limit; retry with narrower bounds".to_string()
))
);
}
#[test]
fn rejects_history_request_limits_before_sending_them() {
assert_eq!(
HistoryNotesAction::HistoryListItems
.validate_arguments(&json!({"limit": 21, "max_chars_per_item": 2_000}))
.err(),
Some(FunctionCallError::RespondToModel(
"History argument `limit` exceeds the maximum of 20".to_string()
))
);
assert_eq!(
HistoryNotesAction::NotesSearchContents
.validate_arguments(&json!({"query": "x".repeat(1001)}))
.err(),
Some(FunctionCallError::RespondToModel(
"History argument `query` exceeds the maximum of 1000 characters".to_string()
))
);
}
@@ -0,0 +1,245 @@
use std::sync::Arc;
use codex_config::LoaderOverrides;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::config::TokenBudgetConfig;
use codex_extension_api::ConversationHistory;
use codex_extension_api::ExtensionData;
use codex_extension_api::ExtensionRegistry;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::NoopTurnItemEmitter;
use codex_extension_api::ThreadStartInput;
use codex_extension_api::ToolCall;
use codex_extension_api::ToolExecutor;
use codex_extension_api::ToolName;
use codex_extension_api::ToolPayload;
use codex_history_notes_extension::install;
use codex_login::AuthHeaders;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_model_provider_info::ModelProviderInfo;
use codex_protocol::AgentPath;
use codex_protocol::ThreadId;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::TruncationPolicy;
use http::HeaderMap;
use http::HeaderValue;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
type TestResult = Result<(), Box<dyn std::error::Error>>;
#[tokio::test]
async fn installed_extension_exposes_and_invokes_history_notes_tools() -> TestResult {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/backend-api/codex/alpha/notes/v2/read_file"))
.and(header("x-openai-actor-authorization", "actor-biscuit"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"encrypted_output": "enc_payload"
})))
.mount(&server)
.await;
let codex_home = TempDir::new()?;
let mut config = ConfigBuilder::default()
.loader_overrides(LoaderOverrides::without_managed_config_for_tests())
.codex_home(codex_home.path().to_path_buf())
.build()
.await?;
config.model_provider.base_url = Some(format!("{}/backend-api/codex", server.uri()));
config.token_budget = Some(TokenBudgetConfig {
use_history_notes_history: true,
..TokenBudgetConfig::default()
});
let mut headers = HeaderMap::new();
headers.insert(
"x-openai-actor-authorization",
HeaderValue::from_static("actor-biscuit"),
);
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::Headers(AuthHeaders::new(headers)));
let mut builder = ExtensionRegistryBuilder::<Config>::new();
install(&mut builder, auth_manager);
let registry = builder.build();
let session_store = ExtensionData::new("session-123");
let thread_store = ExtensionData::new("thread-123");
let session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: ThreadId::new(),
depth: 1,
agent_path: Some(AgentPath::root().join("worker").expect("agent path")),
agent_nickname: None,
agent_role: None,
});
for contributor in registry.thread_lifecycle_contributors() {
contributor
.on_thread_start(ThreadStartInput {
config: &config,
session_source: &session_source,
persistent_thread_state_available: true,
environments: &[],
mcp_resource_client: None,
extension_metrics: None,
session_store: &session_store,
thread_store: &thread_store,
})
.await;
}
let tools = exposed_tools(&registry, &session_store, &thread_store);
assert_eq!(
tools
.iter()
.map(|tool| tool.tool_name())
.collect::<Vec<_>>(),
vec![
ToolName::namespaced("history", "list_windows"),
ToolName::namespaced("history", "list_items"),
ToolName::namespaced("history", "read_item"),
ToolName::namespaced("history", "search_contents"),
ToolName::namespaced("notes", "list_files_by_prefix"),
ToolName::namespaced("notes", "read_file"),
ToolName::namespaced("notes", "search_contents"),
ToolName::namespaced("notes", "append_to_file"),
ToolName::namespaced("notes", "write_file"),
]
);
let read_file = tools
.iter()
.find(|tool| tool.tool_name() == ToolName::namespaced("notes", "read_file"))
.expect("notes.read_file should be exposed");
let call = tool_call(
ToolName::namespaced("notes", "read_file"),
json!({"path": "notes.md"}),
);
let output = read_file.handle(call.clone()).await?;
let ResponseInputItem::FunctionCallOutput { output, .. } =
output.to_response_item(&call.call_id, &call.payload)
else {
panic!("expected function-call output");
};
assert_eq!(
output.content_items(),
Some(
[FunctionCallOutputContentItem::EncryptedContent {
encrypted_content: "enc_payload".to_string(),
}]
.as_slice()
)
);
let requests = server.received_requests().await.expect("recorded requests");
assert_eq!(requests.len(), 1);
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&requests[0].body)?,
json!({
"path": "notes.md",
"context": {
"session_id": "session-123",
"current_agent_name": "/root/worker",
}
})
);
let mut disabled_config = config.clone();
disabled_config.token_budget = None;
for contributor in registry.config_contributors() {
contributor.on_config_changed(&session_store, &thread_store, &config, &disabled_config);
}
assert!(exposed_tools(&registry, &session_store, &thread_store).is_empty());
Ok(())
}
#[tokio::test]
async fn history_notes_require_an_openai_provider_and_codex_backend_auth() -> TestResult {
let codex_home = TempDir::new()?;
let mut config = ConfigBuilder::default()
.loader_overrides(LoaderOverrides::without_managed_config_for_tests())
.codex_home(codex_home.path().to_path_buf())
.build()
.await?;
config.token_budget = Some(TokenBudgetConfig {
use_history_notes_history: true,
..TokenBudgetConfig::default()
});
for (provider, auth) in [
(
config.model_provider.clone(),
CodexAuth::from_api_key("test-api-key"),
),
(
ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None),
CodexAuth::create_dummy_chatgpt_auth_for_testing(),
),
] {
config.model_provider = provider;
let mut builder = ExtensionRegistryBuilder::<Config>::new();
install(&mut builder, AuthManager::from_auth_for_testing(auth));
let registry = builder.build();
let session_store = ExtensionData::new("session-123");
let thread_store = ExtensionData::new("thread-123");
for contributor in registry.thread_lifecycle_contributors() {
contributor
.on_thread_start(ThreadStartInput {
config: &config,
session_source: &SessionSource::Cli,
persistent_thread_state_available: true,
environments: &[],
mcp_resource_client: None,
extension_metrics: None,
session_store: &session_store,
thread_store: &thread_store,
})
.await;
}
assert!(exposed_tools(&registry, &session_store, &thread_store).is_empty());
}
Ok(())
}
fn exposed_tools(
registry: &ExtensionRegistry<Config>,
session_store: &ExtensionData,
thread_store: &ExtensionData,
) -> Vec<Arc<dyn ToolExecutor<ToolCall>>> {
registry
.tool_contributors()
.iter()
.flat_map(|contributor| contributor.tools(session_store, thread_store))
.collect()
}
fn tool_call(tool_name: ToolName, arguments: serde_json::Value) -> ToolCall {
ToolCall {
turn_id: "turn-1".to_string(),
call_id: "call-read-file".to_string(),
tool_name,
model: "gpt-test".to_string(),
codex_turn_metadata: None,
truncation_policy: TruncationPolicy::Bytes(1024),
conversation_history: ConversationHistory::default(),
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
environments: Vec::new(),
payload: ToolPayload::Function {
arguments: arguments.to_string(),
},
}
}
+3
View File
@@ -289,6 +289,9 @@ impl FeatureConfig for MultiAgentV2ConfigToml {
pub struct TokenBudgetConfigToml {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
/// Whether to expose the built-in history and notes extension.
#[serde(skip_serializing_if = "Option::is_none")]
pub use_history_notes_history: Option<bool>,
/// Number of tokens remaining before auto-compaction when the wrap-up reminder is emitted.
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1))]