diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 3e0428c86d..6545698f0f 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -15,6 +15,8 @@ use codex_protocol::models::ResponseItem; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; use std::ops::Deref; +use std::path::PathBuf; +use tracing::warn; /// Transcript of thread history #[derive(Debug, Clone, Default)] @@ -277,8 +279,8 @@ impl ContextManager { output: truncated, } } - ResponseItem::Message { .. } - | ResponseItem::Reasoning { .. } + ResponseItem::Message { .. } => self.process_message_item(item), + ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::WebSearchCall { .. } @@ -288,6 +290,111 @@ impl ContextManager { | ResponseItem::Other => item.clone(), } } + + fn process_message_item(&self, item: &ResponseItem) -> ResponseItem { + let ResponseItem::Message { role, content, id } = item else { + return item.clone(); + }; + + if role != "user" { + return item.clone(); + } + + let Some(token_info) = self.token_info.as_ref() else { + return item.clone(); + }; + let Some(context_window) = token_info.model_context_window else { + return item.clone(); + }; + + let text = message_text(content); + if text.is_empty() { + return item.clone(); + } + + let remaining = context_window.saturating_sub(self.get_total_token_usage()); + let text_tokens = i64::try_from(approx_token_count(&text)).unwrap_or(i64::MAX); + if text_tokens <= remaining { + return item.clone(); + } + + let Some(path) = write_message_to_temp_file(&text) else { + return item.clone(); + }; + + let mut new_content = Vec::with_capacity(content.len().saturating_add(1)); + new_content.push(ContentItem::InputText { + text: format!( + "User input was too large for the remaining context window and was saved to {}.", + path.display() + ), + }); + + for item in content { + match item { + ContentItem::InputImage { image_url } => { + new_content.push(ContentItem::InputImage { + image_url: image_url.clone(), + }); + } + ContentItem::InputText { .. } if item.is_user_message_text() => { + new_content.push(item.clone()); + } + ContentItem::InputText { .. } | ContentItem::OutputText { .. } => {} + } + } + + ResponseItem::Message { + id: id.clone(), + role: role.clone(), + content: new_content, + } + } +} + +fn message_text(content: &[ContentItem]) -> String { + let mut pieces = Vec::new(); + for item in content { + match item { + ContentItem::InputText { text } | ContentItem::OutputText { text } => { + if !text.is_empty() { + pieces.push(text.as_str()); + } + } + ContentItem::InputImage { .. } => {} + } + } + + pieces.join("\n") +} + +fn write_message_to_temp_file(text: &str) -> Option { + let mut temp = match tempfile::Builder::new() + .prefix("codex-user-input-") + .suffix(".txt") + .tempfile() + { + Ok(temp) => temp, + Err(err) => { + warn!(error = %err, "failed to create temp file for user message"); + return None; + } + }; + + if let Err(err) = std::io::Write::write_all(&mut temp, text.as_bytes()) { + warn!(error = %err, "failed to write user message to temp file"); + return None; + } + + let (_, path) = match temp.keep() { + Ok(kept) => kept, + Err(err) => { + warn!(error = %err, "failed to persist user message temp file"); + return None; + } + }; + + Some(path) } /// API messages include every non-system item (user/assistant messages, reasoning, diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index eca2a1889e..342f7e7799 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -9,8 +9,11 @@ use codex_protocol::models::LocalShellExecAction; use codex_protocol::models::LocalShellStatus; use codex_protocol::models::ReasoningItemContent; use codex_protocol::models::ReasoningItemReasoningSummary; +use codex_protocol::protocol::TokenUsage; +use codex_protocol::protocol::TokenUsageInfo; use pretty_assertions::assert_eq; use regex_lite::Regex; +use std::fs; const EXEC_FORMAT_MAX_BYTES: usize = 10_000; const EXEC_FORMAT_MAX_TOKENS: usize = 2_500; @@ -81,6 +84,37 @@ fn truncate_exec_output(content: &str) -> String { truncate::truncate_text(content, TruncationPolicy::Tokens(EXEC_FORMAT_MAX_TOKENS)) } +#[test] +fn stores_oversized_user_message_in_temp_file() { + let mut history = ContextManager::new(); + history.set_token_info(Some(TokenUsageInfo { + total_token_usage: TokenUsage::default(), + last_token_usage: TokenUsage::default(), + model_context_window: Some(10), + })); + + let original = "x".repeat(80); + let item = user_input_text_msg(&original); + history.record_items([&item], TruncationPolicy::Tokens(10_000)); + + let [ResponseItem::Message { content, .. }] = history.raw_items() else { + panic!("expected single message item"); + }; + + let [ContentItem::InputText { text }] = content.as_slice() else { + panic!("expected single input text"); + }; + + let re = Regex::new(r"saved to (.+?)\. Use the read_file tool").expect("regex"); + let captures = re.captures(text).expect("capture temp path"); + let path = captures.get(1).expect("path capture").as_str(); + + let file_contents = fs::read_to_string(path).expect("read temp file"); + assert_eq!(file_contents, original); + + fs::remove_file(path).expect("cleanup temp file"); +} + #[test] fn filters_non_api_messages() { let mut h = ContextManager::default(); diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index c2bbd2d53c..86c6f66782 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -25,9 +25,11 @@ use core_test_support::skip_if_no_network; use core_test_support::stdio_server_bin; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; use std::collections::HashMap; +use std::fs; use std::time::Duration; // Verifies byte-truncation formatting for function error output (RespondToModel errors) @@ -241,6 +243,53 @@ async fn tool_call_output_exceeds_limit_truncated_chars_limit() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn oversized_user_message_is_written_to_temp_file() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex() + .with_model("test-gpt-5.1-codex") + .with_config(|config| { + config.model_context_window = Some(10); + }); + let test = builder.build(&server).await?; + + let _first = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + ) + .await; + let second = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), + ) + .await; + + test.submit_turn("seed").await?; + + let large_message = "x".repeat(4_000); + test.submit_turn(&large_message).await?; + + let user_texts = second.single_request().message_input_texts("user"); + let replaced = user_texts + .iter() + .find(|text| text.contains("User input was too large for the remaining context window")) + .unwrap_or_else(|| panic!("expected replacement message, got {user_texts:?}")); + + let captures = assert_regex_match( + r"saved to (.+?)\\. Use the read_file tool", + replaced.as_str(), + ); + let path = captures.get(1).expect("path capture").as_str(); + + let saved = fs::read_to_string(path).context("read saved user message")?; + assert_eq!(saved, large_message); + fs::remove_file(path).context("cleanup saved user message")?; + + Ok(()) +} + // Verifies that a standard tool call (shell_command) exceeding the model formatting // limits is truncated before being sent back to the model. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 04f54b1dcc..2d92b27a63 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -68,6 +68,49 @@ pub enum ContentItem { OutputText { text: String }, } +const ENVIRONMENT_CONTEXT_PREFIX: &str = ""; +const USER_INSTRUCTIONS_OPEN_TAG_LEGACY: &str = ""; +const USER_INSTRUCTIONS_PREFIX: &str = "# AGENTS.md instructions for "; +const SKILL_INSTRUCTIONS_PREFIX: &str = " bool { + let ContentItem::InputText { text } = self else { + return false; + }; + + if text.is_empty() { + return false; + } + + if is_local_image_open_tag_text(text) + || is_local_image_close_tag_text(text) + || is_image_open_tag_text(text) + || is_image_close_tag_text(text) + { + return false; + } + + let trimmed = text.trim_start(); + if trimmed.starts_with(USER_INSTRUCTIONS_PREFIX) + || trimmed.starts_with(USER_INSTRUCTIONS_OPEN_TAG_LEGACY) + || trimmed.starts_with(SKILL_INSTRUCTIONS_PREFIX) + { + return false; + } + + let lowered = trimmed.to_ascii_lowercase(); + if lowered.starts_with(ENVIRONMENT_CONTEXT_PREFIX) + || lowered.starts_with(USER_SHELL_COMMAND_OPEN) + { + return false; + } + + true + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ResponseItem { @@ -1187,4 +1230,47 @@ mod tests { Ok(()) } + + #[test] + fn input_text_detects_user_message_text() { + let user_text = ContentItem::InputText { + text: "hello world".to_string(), + }; + assert!(user_text.is_user_message_text()); + + let user_instructions = ContentItem::InputText { + text: format!("{USER_INSTRUCTIONS_PREFIX}dir\n\n\ntext\n"), + }; + assert!(!user_instructions.is_user_message_text()); + + let legacy_instructions = ContentItem::InputText { + text: "text".to_string(), + }; + assert!(!legacy_instructions.is_user_message_text()); + + let skill_instructions = ContentItem::InputText { + text: "\nx\ny\nbody\n".to_string(), + }; + assert!(!skill_instructions.is_user_message_text()); + + let session_prefix = ContentItem::InputText { + text: "\nfoo".to_string(), + }; + assert!(!session_prefix.is_user_message_text()); + + let shell_command = ContentItem::InputText { + text: "\ncmd\n".to_string(), + }; + assert!(!shell_command.is_user_message_text()); + + let image_open = ContentItem::InputText { + text: image_open_tag_text(), + }; + assert!(!image_open.is_user_message_text()); + + let image_close = ContentItem::InputText { + text: image_close_tag_text(), + }; + assert!(!image_close.is_user_message_text()); + } }