mirror of
https://github.com/openai/codex.git
synced 2026-03-07 23:23:20 +00:00
Compare commits
14 Commits
fix/notify
...
feature/la
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff80f8dfc2 | ||
|
|
5c21cd3a6f | ||
|
|
cbf2f5ea9a | ||
|
|
46a7a98055 | ||
|
|
eccb9863cf | ||
|
|
91c4c57b1f | ||
|
|
1816c4c83c | ||
|
|
735b70970c | ||
|
|
fc0d710b25 | ||
|
|
8c03179f7d | ||
|
|
bb275ebbfb | ||
|
|
e90eeee6b2 | ||
|
|
b594def567 | ||
|
|
a2909e06cb |
@@ -93,7 +93,7 @@ tracing = { workspace = true, features = ["log"] }
|
||||
tree-sitter = { workspace = true }
|
||||
tree-sitter-bash = { workspace = true }
|
||||
url = { workspace = true }
|
||||
uuid = { workspace = true, features = ["serde", "v4", "v5"] }
|
||||
uuid = { workspace = true, features = ["serde", "v4", "v5", "v7"] }
|
||||
which = { workspace = true }
|
||||
wildmatch = { workspace = true }
|
||||
|
||||
|
||||
@@ -480,6 +480,10 @@ pub(crate) struct SessionConfiguration {
|
||||
}
|
||||
|
||||
impl SessionConfiguration {
|
||||
pub(crate) fn codex_home(&self) -> &PathBuf {
|
||||
&self.original_config_do_not_use.codex_home
|
||||
}
|
||||
|
||||
pub(crate) fn apply(&self, updates: &SessionSettingsUpdate) -> ConstraintResult<Self> {
|
||||
let mut next_configuration = self.clone();
|
||||
if let Some(collaboration_mode) = updates.collaboration_mode.clone() {
|
||||
@@ -826,7 +830,7 @@ impl Session {
|
||||
|
||||
async fn get_total_token_usage(&self) -> i64 {
|
||||
let state = self.state.lock().await;
|
||||
state.get_total_token_usage(state.server_reasoning_included())
|
||||
state.get_total_token_usage()
|
||||
}
|
||||
|
||||
pub(crate) async fn get_base_instructions(&self) -> BaseInstructions {
|
||||
@@ -1432,7 +1436,7 @@ impl Session {
|
||||
turn_context: &TurnContext,
|
||||
rollout_items: &[RolloutItem],
|
||||
) -> Vec<ResponseItem> {
|
||||
let mut history = ContextManager::new();
|
||||
let mut history = ContextManager::new(&self.codex_home().await);
|
||||
for item in rollout_items {
|
||||
match item {
|
||||
RolloutItem::ResponseItem(response_item) => {
|
||||
@@ -1656,6 +1660,15 @@ impl Session {
|
||||
self.send_event(turn_context, event).await;
|
||||
}
|
||||
|
||||
pub(crate) async fn codex_home(&self) -> PathBuf {
|
||||
self.state
|
||||
.lock()
|
||||
.await
|
||||
.session_configuration
|
||||
.codex_home()
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub(crate) async fn set_total_tokens_full(&self, turn_context: &TurnContext) {
|
||||
if let Some(context_window) = turn_context.client.get_model_context_window() {
|
||||
let mut state = self.state.lock().await;
|
||||
@@ -4476,7 +4489,8 @@ mod tests {
|
||||
turn_context: &TurnContext,
|
||||
) -> (Vec<RolloutItem>, Vec<ResponseItem>) {
|
||||
let mut rollout_items = Vec::new();
|
||||
let mut live_history = ContextManager::new();
|
||||
let codex_home = session.codex_home().await;
|
||||
let mut live_history = ContextManager::new(&codex_home);
|
||||
|
||||
let initial_context = session.build_initial_context(turn_context).await;
|
||||
for item in &initial_context {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::codex::TurnContext;
|
||||
use crate::context_manager::normalize;
|
||||
use crate::context_manager::offload::ContextOffloader;
|
||||
use crate::instructions::SkillInstructions;
|
||||
use crate::instructions::UserInstructions;
|
||||
use crate::truncate::TruncationPolicy;
|
||||
@@ -15,6 +16,8 @@ use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::TokenUsage;
|
||||
use codex_protocol::protocol::TokenUsageInfo;
|
||||
use std::ops::Deref;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Transcript of thread history
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@@ -22,13 +25,17 @@ pub(crate) struct ContextManager {
|
||||
/// The oldest items are at the beginning of the vector.
|
||||
items: Vec<ResponseItem>,
|
||||
token_info: Option<TokenUsageInfo>,
|
||||
server_reasoning_included: bool,
|
||||
codex_home: PathBuf,
|
||||
}
|
||||
|
||||
impl ContextManager {
|
||||
pub(crate) fn new() -> Self {
|
||||
pub(crate) fn new(codex_home: &Path) -> Self {
|
||||
Self {
|
||||
items: Vec::new(),
|
||||
token_info: TokenUsageInfo::new_or_append(&None, &None, None),
|
||||
server_reasoning_included: false,
|
||||
codex_home: codex_home.to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +56,14 @@ impl ContextManager {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_server_reasoning_included(&mut self, included: bool) {
|
||||
self.server_reasoning_included = included;
|
||||
}
|
||||
|
||||
pub(crate) fn total_token_usage(&self) -> i64 {
|
||||
self.get_total_token_usage(self.server_reasoning_included)
|
||||
}
|
||||
|
||||
/// `items` is ordered from oldest to newest.
|
||||
pub(crate) fn record_items<I>(&mut self, items: I, policy: TruncationPolicy)
|
||||
where
|
||||
@@ -289,8 +304,8 @@ impl ContextManager {
|
||||
output: truncated,
|
||||
}
|
||||
}
|
||||
ResponseItem::Message { .. }
|
||||
| ResponseItem::Reasoning { .. }
|
||||
ResponseItem::Message { .. } => self.process_message_item(item, policy),
|
||||
ResponseItem::Reasoning { .. }
|
||||
| ResponseItem::LocalShellCall { .. }
|
||||
| ResponseItem::FunctionCall { .. }
|
||||
| ResponseItem::WebSearchCall { .. }
|
||||
@@ -300,6 +315,90 @@ impl ContextManager {
|
||||
| ResponseItem::Other => item.clone(),
|
||||
}
|
||||
}
|
||||
fn process_message_item(&self, item: &ResponseItem, policy: TruncationPolicy) -> 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 mut remaining = context_window
|
||||
.saturating_sub(self.get_total_token_usage(self.server_reasoning_included));
|
||||
let offloader = ContextOffloader::new(&self.codex_home);
|
||||
let mut new_content = Vec::with_capacity(content.len().saturating_add(1));
|
||||
for item in content {
|
||||
match item {
|
||||
ContentItem::InputText { text } if item.is_user_message_text() => {
|
||||
let (items, used_tokens) =
|
||||
process_user_text_item(text, remaining, policy, &offloader);
|
||||
new_content.extend(items);
|
||||
remaining = remaining.saturating_sub(used_tokens);
|
||||
}
|
||||
ContentItem::InputText { .. }
|
||||
| ContentItem::OutputText { .. }
|
||||
| ContentItem::InputImage { .. } => {
|
||||
new_content.push(item.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ResponseItem::Message {
|
||||
id: id.clone(),
|
||||
role: role.clone(),
|
||||
content: new_content,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn process_user_text_item(
|
||||
text: &str,
|
||||
remaining: i64,
|
||||
policy: TruncationPolicy,
|
||||
offloader: &ContextOffloader,
|
||||
) -> (Vec<ContentItem>, i64) {
|
||||
if text.is_empty() {
|
||||
return (Vec::new(), 0);
|
||||
}
|
||||
|
||||
let text_tokens = i64::try_from(approx_token_count(text)).unwrap_or(i64::MAX);
|
||||
if text_tokens <= remaining {
|
||||
return (
|
||||
vec![ContentItem::InputText {
|
||||
text: text.to_string(),
|
||||
}],
|
||||
text_tokens,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(offloaded) = offloader.write_user_message(text) {
|
||||
let notice = format!(
|
||||
"User input was too large for the remaining context window and was saved to {}. Use read_file to load it.",
|
||||
offloaded.display()
|
||||
);
|
||||
let notice_tokens = i64::try_from(approx_token_count(¬ice)).unwrap_or(i64::MAX);
|
||||
(vec![ContentItem::InputText { text: notice }], notice_tokens)
|
||||
} else {
|
||||
let truncated = truncate_text(text, policy);
|
||||
let used_tokens = i64::try_from(approx_token_count(&truncated)).unwrap_or(i64::MAX);
|
||||
(build_truncated_text_item(truncated), used_tokens)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_truncated_text_item(truncated: String) -> Vec<ContentItem> {
|
||||
if truncated.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![ContentItem::InputText { text: truncated }]
|
||||
}
|
||||
}
|
||||
|
||||
/// API messages include every non-system item (user/assistant messages, reasoning,
|
||||
|
||||
@@ -10,8 +10,13 @@ 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;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::tempdir;
|
||||
|
||||
const EXEC_FORMAT_MAX_BYTES: usize = 10_000;
|
||||
const EXEC_FORMAT_MAX_TOKENS: usize = 2_500;
|
||||
@@ -27,7 +32,8 @@ fn assistant_msg(text: &str) -> ResponseItem {
|
||||
}
|
||||
|
||||
fn create_history_with_items(items: Vec<ResponseItem>) -> ContextManager {
|
||||
let mut h = ContextManager::new();
|
||||
let codex_home = PathBuf::from("/tmp");
|
||||
let mut h = ContextManager::new(&codex_home);
|
||||
// Use a generous but fixed token budget; tests only rely on truncation
|
||||
// behavior, not on a specific model's token limit.
|
||||
h.record_items(items.iter(), TruncationPolicy::Tokens(10_000));
|
||||
@@ -82,6 +88,208 @@ fn truncate_exec_output(content: &str) -> String {
|
||||
truncate::truncate_text(content, TruncationPolicy::Tokens(EXEC_FORMAT_MAX_TOKENS))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stores_oversized_user_message_in_user_message_dir() {
|
||||
let temp_dir = tempdir().expect("create temp dir");
|
||||
let codex_home = temp_dir.path().to_path_buf();
|
||||
let mut history = ContextManager::new(&codex_home);
|
||||
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 placeholder = content
|
||||
.iter()
|
||||
.find_map(|item| match item {
|
||||
ContentItem::InputText { text }
|
||||
if text.contains("User input was too large for the remaining context window") =>
|
||||
{
|
||||
Some(text.as_str())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("expected placeholder text, got {content:?}"));
|
||||
|
||||
let path = placeholder
|
||||
.split("saved to ")
|
||||
.nth(1)
|
||||
.and_then(|tail| tail.split(". Use").next())
|
||||
.expect("capture temp path");
|
||||
let path = PathBuf::from(path);
|
||||
|
||||
assert!(
|
||||
!content.iter().any(|item| matches!(
|
||||
item,
|
||||
ContentItem::InputText { text } if text == &original
|
||||
)),
|
||||
"original user text should not remain in the message content"
|
||||
);
|
||||
|
||||
assert!(
|
||||
path.exists(),
|
||||
"expected saved user message at {}, placeholder: {placeholder}",
|
||||
path.display()
|
||||
);
|
||||
|
||||
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 offloads_each_user_input_item_independently() {
|
||||
let temp_dir = tempdir().expect("create temp dir");
|
||||
let codex_home = temp_dir.path().to_path_buf();
|
||||
let mut history = ContextManager::new(&codex_home);
|
||||
history.set_token_info(Some(TokenUsageInfo {
|
||||
total_token_usage: TokenUsage::default(),
|
||||
last_token_usage: TokenUsage::default(),
|
||||
model_context_window: Some(10),
|
||||
}));
|
||||
|
||||
let small = "hi";
|
||||
let large = "x".repeat(80);
|
||||
let item = ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![
|
||||
ContentItem::InputText {
|
||||
text: small.to_string(),
|
||||
},
|
||||
ContentItem::InputText {
|
||||
text: large.clone(),
|
||||
},
|
||||
],
|
||||
};
|
||||
history.record_items([&item], TruncationPolicy::Tokens(10_000));
|
||||
|
||||
let [ResponseItem::Message { content, .. }] = history.raw_items() else {
|
||||
panic!("expected single message item");
|
||||
};
|
||||
|
||||
assert!(
|
||||
content.iter().any(|item| matches!(
|
||||
item,
|
||||
ContentItem::InputText { text } if text == small
|
||||
)),
|
||||
"expected small user input to remain in message content"
|
||||
);
|
||||
|
||||
assert!(
|
||||
!content.iter().any(|item| matches!(
|
||||
item,
|
||||
ContentItem::InputText { text } if text == &large
|
||||
)),
|
||||
"expected large user input to be removed from message content"
|
||||
);
|
||||
|
||||
let placeholder = content
|
||||
.iter()
|
||||
.find_map(|item| match item {
|
||||
ContentItem::InputText { text }
|
||||
if text.contains("User input was too large for the remaining context window") =>
|
||||
{
|
||||
Some(text.as_str())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("expected placeholder text, got {content:?}"));
|
||||
|
||||
let path = placeholder
|
||||
.split("saved to ")
|
||||
.nth(1)
|
||||
.and_then(|tail| tail.split(". Use").next())
|
||||
.expect("capture temp path");
|
||||
let path = PathBuf::from(path);
|
||||
|
||||
let file_contents = fs::read_to_string(&path).expect("read temp file");
|
||||
assert_eq!(file_contents, large);
|
||||
|
||||
fs::remove_file(path).expect("cleanup temp file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrements_remaining_for_multiple_user_text_items() {
|
||||
let temp_dir = tempdir().expect("create temp dir");
|
||||
let codex_home = temp_dir.path().to_path_buf();
|
||||
let mut history = ContextManager::new(&codex_home);
|
||||
history.set_token_info(Some(TokenUsageInfo {
|
||||
total_token_usage: TokenUsage::default(),
|
||||
last_token_usage: TokenUsage::default(),
|
||||
model_context_window: Some(10),
|
||||
}));
|
||||
|
||||
let first = "a".repeat(36);
|
||||
let second = "b".repeat(36);
|
||||
let item = ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![
|
||||
ContentItem::InputText {
|
||||
text: first.clone(),
|
||||
},
|
||||
ContentItem::InputText {
|
||||
text: second.clone(),
|
||||
},
|
||||
],
|
||||
};
|
||||
history.record_items([&item], TruncationPolicy::Tokens(10_000));
|
||||
|
||||
let [ResponseItem::Message { content, .. }] = history.raw_items() else {
|
||||
panic!("expected single message item");
|
||||
};
|
||||
|
||||
assert!(
|
||||
content.iter().any(|item| matches!(
|
||||
item,
|
||||
ContentItem::InputText { text } if text == &first
|
||||
)),
|
||||
"expected first user input to remain in message content"
|
||||
);
|
||||
|
||||
assert!(
|
||||
!content.iter().any(|item| matches!(
|
||||
item,
|
||||
ContentItem::InputText { text } if text == &second
|
||||
)),
|
||||
"expected second user input to be removed from message content"
|
||||
);
|
||||
|
||||
let placeholder = content
|
||||
.iter()
|
||||
.find_map(|item| match item {
|
||||
ContentItem::InputText { text }
|
||||
if text.contains("User input was too large for the remaining context window") =>
|
||||
{
|
||||
Some(text.as_str())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("expected placeholder text, got {content:?}"));
|
||||
|
||||
let path = placeholder
|
||||
.split("saved to ")
|
||||
.nth(1)
|
||||
.and_then(|tail| tail.split(". Use").next())
|
||||
.expect("capture temp path");
|
||||
let path = PathBuf::from(path);
|
||||
|
||||
let file_contents = fs::read_to_string(&path).expect("read temp file");
|
||||
assert_eq!(file_contents, second);
|
||||
|
||||
fs::remove_file(path).expect("cleanup temp file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_non_api_messages() {
|
||||
let mut h = ContextManager::default();
|
||||
@@ -462,7 +670,8 @@ fn normalization_retains_local_shell_outputs() {
|
||||
|
||||
#[test]
|
||||
fn record_items_truncates_function_call_output_content() {
|
||||
let mut history = ContextManager::new();
|
||||
let codex_home = PathBuf::from("/tmp");
|
||||
let mut history = ContextManager::new(&codex_home);
|
||||
// Any reasonably small token budget works; the test only cares that
|
||||
// truncation happens and the marker is present.
|
||||
let policy = TruncationPolicy::Tokens(1_000);
|
||||
@@ -500,7 +709,8 @@ fn record_items_truncates_function_call_output_content() {
|
||||
|
||||
#[test]
|
||||
fn record_items_truncates_custom_tool_call_output_content() {
|
||||
let mut history = ContextManager::new();
|
||||
let codex_home = PathBuf::from("/tmp");
|
||||
let mut history = ContextManager::new(&codex_home);
|
||||
let policy = TruncationPolicy::Tokens(1_000);
|
||||
let line = "custom output that is very long\n";
|
||||
let long_output = line.repeat(2_500);
|
||||
@@ -530,7 +740,8 @@ fn record_items_truncates_custom_tool_call_output_content() {
|
||||
|
||||
#[test]
|
||||
fn record_items_respects_custom_token_limit() {
|
||||
let mut history = ContextManager::new();
|
||||
let codex_home = PathBuf::from("/tmp");
|
||||
let mut history = ContextManager::new(&codex_home);
|
||||
let policy = TruncationPolicy::Tokens(10);
|
||||
let long_output = "tokenized content repeated many times ".repeat(200);
|
||||
let item = ResponseItem::FunctionCallOutput {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
mod history;
|
||||
mod normalize;
|
||||
mod offload;
|
||||
|
||||
pub(crate) use history::ContextManager;
|
||||
pub(crate) use history::is_user_turn_boundary;
|
||||
|
||||
38
codex-rs/core/src/context_manager/offload.rs
Normal file
38
codex-rs/core/src/context_manager/offload.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ContextOffloader {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl ContextOffloader {
|
||||
pub(crate) fn new(codex_home: &Path) -> Self {
|
||||
Self {
|
||||
root: codex_home.join("context"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn write_user_message(&self, text: &str) -> Option<PathBuf> {
|
||||
self.write_text("usermsgs", "user-message", text)
|
||||
}
|
||||
|
||||
fn write_text(&self, dir_name: &str, file_prefix: &str, text: &str) -> Option<PathBuf> {
|
||||
let dir = self.root.join(dir_name);
|
||||
if let Err(err) = std::fs::create_dir_all(&dir) {
|
||||
warn!(error = %err, "failed to create offload directory");
|
||||
return None;
|
||||
}
|
||||
|
||||
let id = Uuid::now_v7();
|
||||
let path = dir.join(format!("{file_prefix}-{id}.txt"));
|
||||
if let Err(err) = std::fs::write(&path, text.as_bytes()) {
|
||||
warn!(error = %err, "failed to write offloaded content");
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(path)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
mod user_instructions;
|
||||
|
||||
pub(crate) use user_instructions::SkillInstructions;
|
||||
pub use user_instructions::USER_INSTRUCTIONS_OPEN_TAG_LEGACY;
|
||||
pub use user_instructions::USER_INSTRUCTIONS_OPEN_TAG;
|
||||
pub use user_instructions::USER_INSTRUCTIONS_PREFIX;
|
||||
pub(crate) use user_instructions::UserInstructions;
|
||||
|
||||
@@ -4,9 +4,9 @@ use serde::Serialize;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
pub const USER_INSTRUCTIONS_OPEN_TAG_LEGACY: &str = "<user_instructions>";
|
||||
pub const USER_INSTRUCTIONS_PREFIX: &str = "# AGENTS.md instructions for ";
|
||||
pub const SKILL_INSTRUCTIONS_PREFIX: &str = "<skill";
|
||||
pub use codex_protocol::instruction_markers::SKILL_INSTRUCTIONS_PREFIX;
|
||||
pub use codex_protocol::instruction_markers::USER_INSTRUCTIONS_OPEN_TAG;
|
||||
pub use codex_protocol::instruction_markers::USER_INSTRUCTIONS_PREFIX;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename = "user_instructions", rename_all = "snake_case")]
|
||||
@@ -19,7 +19,7 @@ impl UserInstructions {
|
||||
pub fn is_user_instructions(message: &[ContentItem]) -> bool {
|
||||
if let [ContentItem::InputText { text }] = message {
|
||||
text.starts_with(USER_INSTRUCTIONS_PREFIX)
|
||||
|| text.starts_with(USER_INSTRUCTIONS_OPEN_TAG_LEGACY)
|
||||
|| text.starts_with(USER_INSTRUCTIONS_OPEN_TAG)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
@@ -14,18 +14,16 @@ pub(crate) struct SessionState {
|
||||
pub(crate) session_configuration: SessionConfiguration,
|
||||
pub(crate) history: ContextManager,
|
||||
pub(crate) latest_rate_limits: Option<RateLimitSnapshot>,
|
||||
pub(crate) server_reasoning_included: bool,
|
||||
}
|
||||
|
||||
impl SessionState {
|
||||
/// Create a new session state mirroring previous `State::default()` semantics.
|
||||
pub(crate) fn new(session_configuration: SessionConfiguration) -> Self {
|
||||
let history = ContextManager::new();
|
||||
let history = ContextManager::new(session_configuration.codex_home());
|
||||
Self {
|
||||
session_configuration,
|
||||
history,
|
||||
latest_rate_limits: None,
|
||||
server_reasoning_included: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,17 +78,12 @@ impl SessionState {
|
||||
self.history.set_token_usage_full(context_window);
|
||||
}
|
||||
|
||||
pub(crate) fn get_total_token_usage(&self, server_reasoning_included: bool) -> i64 {
|
||||
self.history
|
||||
.get_total_token_usage(server_reasoning_included)
|
||||
pub(crate) fn get_total_token_usage(&self) -> i64 {
|
||||
self.history.total_token_usage()
|
||||
}
|
||||
|
||||
pub(crate) fn set_server_reasoning_included(&mut self, included: bool) {
|
||||
self.server_reasoning_included = included;
|
||||
}
|
||||
|
||||
pub(crate) fn server_reasoning_included(&self) -> bool {
|
||||
self.server_reasoning_included
|
||||
self.history.set_server_reasoning_included(included);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use std::time::Duration;
|
||||
|
||||
pub use codex_protocol::instruction_markers::USER_SHELL_COMMAND_CLOSE;
|
||||
pub use codex_protocol::instruction_markers::USER_SHELL_COMMAND_OPEN;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
@@ -7,9 +9,6 @@ use crate::codex::TurnContext;
|
||||
use crate::exec::ExecToolCallOutput;
|
||||
use crate::tools::format_exec_output_str;
|
||||
|
||||
pub const USER_SHELL_COMMAND_OPEN: &str = "<user_shell_command>";
|
||||
pub const USER_SHELL_COMMAND_CLOSE: &str = "</user_shell_command>";
|
||||
|
||||
pub fn is_user_shell_command_text(text: &str) -> bool {
|
||||
let trimmed = text.trim_start();
|
||||
let lowered = trimmed.to_ascii_lowercase();
|
||||
|
||||
@@ -471,6 +471,9 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() {
|
||||
let codex = test_codex()
|
||||
.with_config(move |config| {
|
||||
config.model_provider.name = non_openai_provider_name;
|
||||
set_test_compact_prompt(config);
|
||||
config.model_auto_compact_token_limit = Some(200_000);
|
||||
config.model_context_window = Some(1_500_000);
|
||||
})
|
||||
.build(&server)
|
||||
.await
|
||||
@@ -1042,6 +1045,7 @@ async fn auto_compact_runs_after_token_limit_hit() {
|
||||
let home = TempDir::new().unwrap();
|
||||
let mut config = load_default_config_for_test(&home).await;
|
||||
config.model_provider = model_provider;
|
||||
config.model_context_window = Some(1_000_000);
|
||||
set_test_compact_prompt(&mut config);
|
||||
config.model_auto_compact_token_limit = Some(200_000);
|
||||
let thread_manager = ThreadManager::with_models_provider(
|
||||
@@ -1919,9 +1923,9 @@ async fn auto_compact_triggers_after_function_call_over_95_percent_usage() {
|
||||
|
||||
let server = start_mock_server().await;
|
||||
|
||||
let context_window = 100;
|
||||
let limit = context_window * 90 / 100;
|
||||
let over_limit_tokens = context_window * 95 / 100 + 1;
|
||||
let context_window = 200;
|
||||
let limit = context_window / 2;
|
||||
let over_limit_tokens = limit + 20;
|
||||
let follow_up_user = "FOLLOW_UP_AFTER_LIMIT";
|
||||
|
||||
let first_turn = sse(vec![
|
||||
|
||||
@@ -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,57 @@ 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_user_message_dir() -> 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 path = replaced
|
||||
.split("saved to ")
|
||||
.nth(1)
|
||||
.and_then(|tail| tail.split(". Use").next())
|
||||
.context("extract saved path")?;
|
||||
|
||||
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")?;
|
||||
assert!(
|
||||
!user_texts.iter().any(|text| text == &large_message),
|
||||
"expected large user message to be removed from request"
|
||||
);
|
||||
|
||||
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)]
|
||||
|
||||
14
codex-rs/protocol/src/instruction_markers.rs
Normal file
14
codex-rs/protocol/src/instruction_markers.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
pub const USER_INSTRUCTIONS_PREFIX: &str = "# AGENTS.md instructions for ";
|
||||
pub const USER_INSTRUCTIONS_OPEN_TAG: &str = "<user_instructions>";
|
||||
pub const USER_INSTRUCTIONS_CLOSE_TAG: &str = "</user_instructions>";
|
||||
pub const ENVIRONMENT_CONTEXT_OPEN_TAG: &str = "<environment_context>";
|
||||
pub const ENVIRONMENT_CONTEXT_CLOSE_TAG: &str = "</environment_context>";
|
||||
pub const COLLABORATION_MODE_OPEN_TAG: &str = "<collaboration_mode>";
|
||||
pub const COLLABORATION_MODE_CLOSE_TAG: &str = "</collaboration_mode>";
|
||||
pub const SKILL_INSTRUCTIONS_PREFIX: &str = "<skill";
|
||||
pub const USER_SHELL_COMMAND_OPEN: &str = "<user_shell_command>";
|
||||
pub const USER_SHELL_COMMAND_CLOSE: &str = "</user_shell_command>";
|
||||
pub const IMAGE_OPEN_TAG: &str = "<image>";
|
||||
pub const IMAGE_CLOSE_TAG: &str = "</image>";
|
||||
pub const LOCAL_IMAGE_OPEN_TAG_PREFIX: &str = "<image name=";
|
||||
pub const LOCAL_IMAGE_OPEN_TAG_SUFFIX: &str = ">";
|
||||
@@ -4,6 +4,7 @@ pub use thread_id::ThreadId;
|
||||
pub mod approvals;
|
||||
pub mod config_types;
|
||||
pub mod custom_prompts;
|
||||
pub mod instruction_markers;
|
||||
pub mod items;
|
||||
pub mod message_history;
|
||||
pub mod models;
|
||||
|
||||
@@ -12,9 +12,13 @@ use ts_rs::TS;
|
||||
|
||||
use crate::config_types::CollaborationMode;
|
||||
use crate::config_types::SandboxMode;
|
||||
use crate::instruction_markers::COLLABORATION_MODE_CLOSE_TAG;
|
||||
use crate::instruction_markers::COLLABORATION_MODE_OPEN_TAG;
|
||||
use crate::instruction_markers::IMAGE_CLOSE_TAG;
|
||||
use crate::instruction_markers::IMAGE_OPEN_TAG;
|
||||
use crate::instruction_markers::LOCAL_IMAGE_OPEN_TAG_PREFIX;
|
||||
use crate::instruction_markers::LOCAL_IMAGE_OPEN_TAG_SUFFIX;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::COLLABORATION_MODE_CLOSE_TAG;
|
||||
use crate::protocol::COLLABORATION_MODE_OPEN_TAG;
|
||||
use crate::protocol::NetworkAccess;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::protocol::WritableRoot;
|
||||
@@ -71,6 +75,49 @@ pub enum ContentItem {
|
||||
OutputText { text: String },
|
||||
}
|
||||
|
||||
use crate::instruction_markers::ENVIRONMENT_CONTEXT_OPEN_TAG;
|
||||
use crate::instruction_markers::SKILL_INSTRUCTIONS_PREFIX;
|
||||
use crate::instruction_markers::USER_INSTRUCTIONS_OPEN_TAG;
|
||||
use crate::instruction_markers::USER_INSTRUCTIONS_PREFIX;
|
||||
use crate::instruction_markers::USER_SHELL_COMMAND_OPEN;
|
||||
|
||||
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)
|
||||
|| trimmed.starts_with(SKILL_INSTRUCTIONS_PREFIX)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let lowered = trimmed.to_ascii_lowercase();
|
||||
if lowered.starts_with(ENVIRONMENT_CONTEXT_OPEN_TAG)
|
||||
|| 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 {
|
||||
@@ -380,10 +427,6 @@ fn local_image_error_placeholder(
|
||||
|
||||
pub const VIEW_IMAGE_TOOL_NAME: &str = "view_image";
|
||||
|
||||
const IMAGE_OPEN_TAG: &str = "<image>";
|
||||
const IMAGE_CLOSE_TAG: &str = "</image>";
|
||||
const LOCAL_IMAGE_OPEN_TAG_PREFIX: &str = "<image name=";
|
||||
const LOCAL_IMAGE_OPEN_TAG_SUFFIX: &str = ">";
|
||||
const LOCAL_IMAGE_CLOSE_TAG: &str = IMAGE_CLOSE_TAG;
|
||||
|
||||
pub fn image_open_tag_text() -> String {
|
||||
@@ -1226,4 +1269,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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,12 @@ use crate::approvals::ElicitationRequestEvent;
|
||||
use crate::config_types::CollaborationMode;
|
||||
use crate::config_types::ReasoningSummary as ReasoningSummaryConfig;
|
||||
use crate::custom_prompts::CustomPrompt;
|
||||
pub use crate::instruction_markers::COLLABORATION_MODE_CLOSE_TAG;
|
||||
pub use crate::instruction_markers::COLLABORATION_MODE_OPEN_TAG;
|
||||
pub use crate::instruction_markers::ENVIRONMENT_CONTEXT_CLOSE_TAG;
|
||||
pub use crate::instruction_markers::ENVIRONMENT_CONTEXT_OPEN_TAG;
|
||||
pub use crate::instruction_markers::USER_INSTRUCTIONS_CLOSE_TAG;
|
||||
pub use crate::instruction_markers::USER_INSTRUCTIONS_OPEN_TAG;
|
||||
use crate::items::TurnItem;
|
||||
use crate::message_history::HistoryEntry;
|
||||
use crate::models::BaseInstructions;
|
||||
@@ -48,14 +54,6 @@ pub use crate::approvals::ExecApprovalRequestEvent;
|
||||
pub use crate::approvals::ExecPolicyAmendment;
|
||||
pub use crate::request_user_input::RequestUserInputEvent;
|
||||
|
||||
/// Open/close tags for special user-input blocks. Used across crates to avoid
|
||||
/// duplicated hardcoded strings.
|
||||
pub const USER_INSTRUCTIONS_OPEN_TAG: &str = "<user_instructions>";
|
||||
pub const USER_INSTRUCTIONS_CLOSE_TAG: &str = "</user_instructions>";
|
||||
pub const ENVIRONMENT_CONTEXT_OPEN_TAG: &str = "<environment_context>";
|
||||
pub const ENVIRONMENT_CONTEXT_CLOSE_TAG: &str = "</environment_context>";
|
||||
pub const COLLABORATION_MODE_OPEN_TAG: &str = "<collaboration_mode>";
|
||||
pub const COLLABORATION_MODE_CLOSE_TAG: &str = "</collaboration_mode>";
|
||||
pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:";
|
||||
|
||||
/// Submission Queue Entry - requests from user
|
||||
|
||||
Reference in New Issue
Block a user