mirror of
https://github.com/openai/codex.git
synced 2026-11-03 19:53:25 +00:00
Handle oversized user input via temp file
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user