Handle oversized user input via temp file

This commit is contained in:
Ahmed Ibrahim
2026-01-13 00:14:42 -08:00
parent cbca43d57a
commit a2909e06cb
4 changed files with 278 additions and 2 deletions
+109 -2
View File
@@ -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<PathBuf> {
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,
@@ -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();
+49
View File
@@ -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)]
+86
View File
@@ -68,6 +68,49 @@ pub enum ContentItem {
OutputText { text: String },
}
const ENVIRONMENT_CONTEXT_PREFIX: &str = "<environment_context>";
const USER_INSTRUCTIONS_OPEN_TAG_LEGACY: &str = "<user_instructions>";
const USER_INSTRUCTIONS_PREFIX: &str = "# AGENTS.md instructions for ";
const SKILL_INSTRUCTIONS_PREFIX: &str = "<skill";
const USER_SHELL_COMMAND_OPEN: &str = "<user_shell_command>";
impl ContentItem {
pub fn is_user_message_text(&self) -> 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<INSTRUCTIONS>\ntext\n</INSTRUCTIONS>"),
};
assert!(!user_instructions.is_user_message_text());
let legacy_instructions = ContentItem::InputText {
text: "<user_instructions>text</user_instructions>".to_string(),
};
assert!(!legacy_instructions.is_user_message_text());
let skill_instructions = ContentItem::InputText {
text: "<skill>\n<name>x</name>\n<path>y</path>\nbody\n</skill>".to_string(),
};
assert!(!skill_instructions.is_user_message_text());
let session_prefix = ContentItem::InputText {
text: "<environment_context>\nfoo</environment_context>".to_string(),
};
assert!(!session_prefix.is_user_message_text());
let shell_command = ContentItem::InputText {
text: "<user_shell_command>\ncmd\n</user_shell_command>".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());
}
}