Compare commits

...

14 Commits

Author SHA1 Message Date
Ahmed Ibrahim
ff80f8dfc2 tests 2026-01-20 00:19:00 -08:00
Ahmed Ibrahim
5c21cd3a6f fix 2026-01-20 00:07:13 -08:00
Ahmed Ibrahim
cbf2f5ea9a Merge branch 'main' into feature/large-user-message-tempfile 2026-01-19 23:54:47 -08:00
Ahmed Ibrahim
46a7a98055 fix 2026-01-19 11:30:38 -08:00
Ahmed Ibrahim
eccb9863cf feedback 2026-01-19 11:12:13 -08:00
Ahmed Ibrahim
91c4c57b1f feedback 2026-01-19 11:09:22 -08:00
Ahmed Ibrahim
1816c4c83c offload 2026-01-19 10:50:32 -08:00
Ahmed Ibrahim
735b70970c fix 2026-01-19 09:54:05 -08:00
Ahmed Ibrahim
fc0d710b25 fix 2026-01-19 09:51:25 -08:00
Ahmed Ibrahim
8c03179f7d fix 2026-01-19 09:49:08 -08:00
Ahmed Ibrahim
bb275ebbfb Merge branch 'main' into feature/large-user-message-tempfile 2026-01-19 09:34:06 -08:00
Ahmed Ibrahim
e90eeee6b2 path 2026-01-14 09:44:10 -08:00
Ahmed Ibrahim
b594def567 Fix tests for oversized user input temp file and auto compact 2026-01-13 01:44:19 -08:00
Ahmed Ibrahim
a2909e06cb Handle oversized user input via temp file 2026-01-13 00:14:42 -08:00
16 changed files with 558 additions and 47 deletions

View File

@@ -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 }

View File

@@ -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 {

View File

@@ -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(&notice)).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,

View File

@@ -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 {

View File

@@ -1,5 +1,6 @@
mod history;
mod normalize;
mod offload;
pub(crate) use history::ContextManager;
pub(crate) use history::is_user_turn_boundary;

View 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)
}
}

View File

@@ -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;

View File

@@ -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
}

View File

@@ -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);
}
}

View File

@@ -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();

View File

@@ -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![

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,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)]

View 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 = ">";

View File

@@ -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;

View File

@@ -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());
}
}

View File

@@ -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