core: bundle settings diff updates into one dev/user envelope (#12417)

## Summary
- bundle contextual prompt injection into at most one developer message
plus one contextual user message in both:
  - per-turn settings updates
  - initial context insertion
- preserve `<model_switch>` across compaction by rebuilding it through
canonical initial-context injection, instead of relying on
strip/reattach hacks
- centralize contextual user fragment detection in one shared definition
table and reuse it for parsing/compaction logic
- keep `AGENTS.md` in its natural serialized format:
  - `# AGENTS.md instructions for {dirname}`
  - `<INSTRUCTIONS>...</INSTRUCTIONS>`
- simplify related tests/helpers and accept the expected snapshot/layout
updates from bundled multi-part messages

## Why
The goal is to converge toward a simpler, more intentional prompt shape
where contextual updates are consistently represented as one developer
envelope plus one contextual user envelope, while keeping parsing and
compaction behavior aligned with that representation.

## Notable details
- the temporary `SettingsUpdateEnvelope` wrapper was removed; these
paths now return `Vec<ResponseItem>` directly
- local/remote compaction no longer rely on model-switch strip/restore
helpers
- contextual user detection is now driven by shared fragment definitions
instead of ad hoc matcher assembly
- AGENTS/user instructions are still the same logical context; only the
synthetic `<user_instructions>` wrapper was replaced by the natural
AGENTS text format

## Testing
- `just fmt`
- `cargo test -p codex-app-server
codex_message_processor::tests::extract_conversation_summary_prefers_plain_user_messages
-- --exact`
- `cargo test -p codex-core
compact::tests::collect_user_messages_filters_session_prefix_entries
--lib -- --exact`
- `cargo test -p codex-core --test all
'suite::compact::snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch'
-- --exact`
- `cargo test -p codex-core --test all
'suite::compact_remote::snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model_switch'
-- --exact`
- `cargo test -p codex-core --test all
'suite::client::includes_apps_guidance_as_developer_message_when_enabled'
-- --exact`
- `cargo test -p codex-core --test all
'suite::client::includes_developer_instructions_message_in_request' --
--exact`
- `cargo test -p codex-core --test all
'suite::client::includes_user_instructions_message_in_request' --
--exact`
- `cargo test -p codex-core --test all
'suite::client::resume_includes_initial_messages_and_sends_prior_items'
-- --exact`
- `cargo test -p codex-core --test all
'suite::review::review_input_isolated_from_parent_history' -- --exact`
- `cargo test -p codex-exec --test all
'suite::resume::exec_resume_last_respects_cwd_filter_and_all_flag' --
--exact`
- `cargo test -p core_test_support
context_snapshot::tests::full_text_mode_preserves_unredacted_text --
--exact`

## Notes
- I also ran several targeted `compact`, `compact_remote`,
`prompt_caching`, `model_visible_layout`, and `event_mapping` tests
while iterating on prompt-shape changes.
- I have not claimed a clean full-workspace `cargo test` from this
environment because local sandbox/resource conditions have previously
produced unrelated failures in large workspace runs.
This commit is contained in:
Charley Cunningham
2026-02-26 00:12:08 -08:00
committed by GitHub
Unverified
parent 28bfbb8f2b
commit 07aefffb1f
47 changed files with 966 additions and 813 deletions
+1 -1
View File
@@ -319,8 +319,8 @@ mod tests {
use crate::config::Config;
use crate::config::ConfigBuilder;
use crate::config_loader::LoaderOverrides;
use crate::contextual_user_message::SUBAGENT_NOTIFICATION_OPEN_TAG;
use crate::features::Feature;
use crate::session_prefix::SUBAGENT_NOTIFICATION_OPEN_TAG;
use assert_matches::assert_matches;
use codex_protocol::config_types::ModeKind;
use codex_protocol::models::ContentItem;
+92 -52
View File
@@ -1669,7 +1669,10 @@ impl Session {
match conversation_history {
InitialHistory::New => {
// Build and record initial items (user instructions + environment context)
let items = self.build_initial_context(&turn_context).await;
// TODO(ccunningham): Defer initial context insertion until the first real turn
// starts so it reflects the actual first-turn settings (permissions, etc.) and
// we do not emit model-visible "diff" updates before the first user message.
let items = self.build_initial_context(&turn_context, None).await;
self.record_conversation_items(&turn_context, &items).await;
{
let mut state = self.state.lock().await;
@@ -1773,7 +1776,7 @@ impl Session {
}
// Append the current session's initial context after the reconstructed history.
let initial_context = self.build_initial_context(&turn_context).await;
let initial_context = self.build_initial_context(&turn_context, None).await;
self.record_conversation_items(&turn_context, &initial_context)
.await;
{
@@ -2862,7 +2865,7 @@ impl Session {
} else {
let user_messages = collect_user_messages(history.raw_items());
let rebuilt = compact::build_compacted_history(
self.build_initial_context(turn_context).await,
self.build_initial_context(turn_context, None).await,
&user_messages,
&compacted.message,
);
@@ -2990,10 +2993,20 @@ impl Session {
pub(crate) async fn build_initial_context(
&self,
turn_context: &TurnContext,
previous_user_turn_model: Option<&str>,
) -> Vec<ResponseItem> {
let mut items = Vec::<ResponseItem>::with_capacity(4);
let mut developer_sections = Vec::<String>::with_capacity(8);
let mut contextual_user_sections = Vec::<String>::with_capacity(2);
let shell = self.user_shell();
items.push(
if let Some(model_switch_message) =
crate::context_manager::updates::build_model_instructions_update_item(
previous_user_turn_model,
turn_context,
)
{
developer_sections.push(model_switch_message.into_text());
}
developer_sections.push(
DeveloperInstructions::from_policy(
turn_context.sandbox_policy.get(),
turn_context.approval_policy.value(),
@@ -3001,17 +3014,17 @@ impl Session {
&turn_context.cwd,
turn_context.features.enabled(Feature::RequestPermissions),
)
.into(),
.into_text(),
);
if let Some(developer_instructions) = turn_context.developer_instructions.as_deref() {
items.push(DeveloperInstructions::new(developer_instructions.to_string()).into());
developer_sections.push(developer_instructions.to_string());
}
// Add developer instructions for memories.
if let Some(memory_prompt) =
build_memory_tool_developer_instructions(&turn_context.config.codex_home).await
&& turn_context.features.enabled(Feature::MemoryTool)
{
items.push(DeveloperInstructions::new(memory_prompt).into());
developer_sections.push(memory_prompt);
}
// Add developer instructions from collaboration_mode if they exist and are non-empty
let (collaboration_mode, base_instructions) = {
@@ -3024,7 +3037,7 @@ impl Session {
if let Some(collab_instructions) =
DeveloperInstructions::from_collaboration_mode(&collaboration_mode)
{
items.push(collab_instructions.into());
developer_sections.push(collab_instructions.into_text());
}
if self.features.enabled(Feature::Personality)
&& let Some(personality) = turn_context.personality
@@ -3039,34 +3052,46 @@ impl Session {
personality,
)
{
items.push(
DeveloperInstructions::personality_spec_message(personality_message).into(),
developer_sections.push(
DeveloperInstructions::personality_spec_message(personality_message)
.into_text(),
);
}
}
if turn_context.features.enabled(Feature::Apps) {
items.push(DeveloperInstructions::new(render_apps_section()).into());
developer_sections.push(render_apps_section());
}
if turn_context.features.enabled(Feature::CodexGitCommit)
&& let Some(commit_message_instruction) = commit_message_trailer_instruction(
turn_context.config.commit_attribution.as_deref(),
)
{
items.push(DeveloperInstructions::new(commit_message_instruction).into());
developer_sections.push(commit_message_instruction);
}
if let Some(user_instructions) = turn_context.user_instructions.as_deref() {
items.push(
contextual_user_sections.push(
UserInstructions {
text: user_instructions.to_string(),
directory: turn_context.cwd.to_string_lossy().into_owned(),
}
.into(),
.serialize_to_text(),
);
}
items.push(ResponseItem::from(EnvironmentContext::from_turn_context(
turn_context,
shell.as_ref(),
)));
contextual_user_sections.push(
EnvironmentContext::from_turn_context(turn_context, shell.as_ref()).serialize_to_xml(),
);
let mut items = Vec::with_capacity(2);
if let Some(developer_message) =
crate::context_manager::updates::build_developer_update_item(developer_sections)
{
items.push(developer_message);
}
if let Some(contextual_user_message) =
crate::context_manager::updates::build_contextual_user_message(contextual_user_sections)
{
items.push(contextual_user_message);
}
items
}
@@ -3111,22 +3136,8 @@ impl Session {
let reference_context_item = self.reference_context_item().await;
let should_inject_full_context = reference_context_item.is_none();
let context_items = if should_inject_full_context {
let mut initial_context = self.build_initial_context(turn_context).await;
// Full reinjection bypasses the settings-diff path, so add the model-switch
// instruction explicitly when needed. Keep it before the rest of full context so
// model-specific guidance is read first.
if let Some(model_switch_item) =
crate::context_manager::updates::build_model_instructions_update_item(
previous_user_turn_model,
turn_context,
)
{
// TODO(ccunningham): When a model switch changes the effective personality
// instructions, inject the updated personality spec alongside <model_switch>
// here so resume/model-switch paths can avoid forcing full reinjection.
initial_context.insert(0, model_switch_item);
}
initial_context
self.build_initial_context(turn_context, previous_user_turn_model)
.await
} else {
// Steady-state path: append only context diffs to minimize token overhead.
self.build_settings_update_items(
@@ -3516,9 +3527,9 @@ impl Session {
use_linux_sandbox_bwrap: turn_context.features.enabled(Feature::UseLinuxSandboxBwrap),
};
{
let mut cancel_guard = self.services.mcp_startup_cancellation_token.lock().await;
cancel_guard.cancel();
*cancel_guard = CancellationToken::new();
let mut guard = self.services.mcp_startup_cancellation_token.lock().await;
guard.cancel();
*guard = CancellationToken::new();
}
let (refreshed_manager, cancel_token) = McpConnectionManager::new(
&mcp_servers,
@@ -3532,11 +3543,11 @@ impl Session {
)
.await;
{
let mut cancel_guard = self.services.mcp_startup_cancellation_token.lock().await;
if cancel_guard.is_cancelled() {
let mut guard = self.services.mcp_startup_cancellation_token.lock().await;
if guard.is_cancelled() {
cancel_token.cancel();
}
*cancel_guard = cancel_token;
*guard = cancel_token;
}
let mut manager = self.services.mcp_connection_manager.write().await;
@@ -5011,6 +5022,7 @@ pub(crate) async fn run_turn(
&sess,
&turn_context,
InitialContextInjection::BeforeLastUserMessage,
previous_model.as_deref(),
)
.await
.is_err()
@@ -5134,7 +5146,13 @@ async fn run_pre_sampling_compact(
.unwrap_or(i64::MAX);
// Compact if the total usage tokens are greater than the auto compact limit
if total_usage_tokens >= auto_compact_limit {
run_auto_compact(sess, turn_context, InitialContextInjection::DoNotInject).await?;
run_auto_compact(
sess,
turn_context,
InitialContextInjection::DoNotInject,
None,
)
.await?;
}
Ok(())
}
@@ -5177,6 +5195,7 @@ async fn maybe_run_previous_model_inline_compact(
sess,
&previous_model_turn_context,
InitialContextInjection::DoNotInject,
None,
)
.await?;
return Ok(true);
@@ -5188,12 +5207,14 @@ async fn run_auto_compact(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
initial_context_injection: InitialContextInjection,
previous_user_turn_model: Option<&str>,
) -> CodexResult<()> {
if should_use_remote_compact_task(&turn_context.provider) {
run_inline_remote_auto_compact_task(
Arc::clone(sess),
Arc::clone(turn_context),
initial_context_injection,
previous_user_turn_model,
)
.await?;
} else {
@@ -5201,6 +5222,7 @@ async fn run_auto_compact(
Arc::clone(sess),
Arc::clone(turn_context),
initial_context_injection,
previous_user_turn_model,
)
.await?;
}
@@ -7438,7 +7460,7 @@ mod tests {
session
.record_context_updates_and_set_reference_context_item(&turn_context, None)
.await;
expected.extend(session.build_initial_context(&turn_context).await);
expected.extend(session.build_initial_context(&turn_context, None).await);
let history_after_seed = session.clone_history().await;
assert_eq!(expected, history_after_seed.raw_items());
@@ -7600,7 +7622,7 @@ mod tests {
let reconstruction_turn = session.new_default_turn().await;
expected.extend(
session
.build_initial_context(reconstruction_turn.as_ref())
.build_initial_context(reconstruction_turn.as_ref(), None)
.await,
);
let history = session.state.lock().await.clone_history();
@@ -7643,7 +7665,7 @@ mod tests {
async fn thread_rollback_drops_last_turn_from_history() {
let (sess, tc, rx) = make_session_and_context_with_rx().await;
let initial_context = sess.build_initial_context(tc.as_ref()).await;
let initial_context = sess.build_initial_context(tc.as_ref(), None).await;
sess.record_into_history(&initial_context, tc.as_ref())
.await;
@@ -7714,7 +7736,7 @@ mod tests {
async fn thread_rollback_clears_history_when_num_turns_exceeds_existing_turns() {
let (sess, tc, rx) = make_session_and_context_with_rx().await;
let initial_context = sess.build_initial_context(tc.as_ref()).await;
let initial_context = sess.build_initial_context(tc.as_ref(), None).await;
sess.record_into_history(&initial_context, tc.as_ref())
.await;
@@ -7742,7 +7764,7 @@ mod tests {
async fn thread_rollback_fails_when_turn_in_progress() {
let (sess, tc, rx) = make_session_and_context_with_rx().await;
let initial_context = sess.build_initial_context(tc.as_ref()).await;
let initial_context = sess.build_initial_context(tc.as_ref(), None).await;
sess.record_into_history(&initial_context, tc.as_ref())
.await;
@@ -7763,7 +7785,7 @@ mod tests {
async fn thread_rollback_fails_when_num_turns_is_zero() {
let (sess, tc, rx) = make_session_and_context_with_rx().await;
let initial_context = sess.build_initial_context(tc.as_ref()).await;
let initial_context = sess.build_initial_context(tc.as_ref(), None).await;
sess.record_into_history(&initial_context, tc.as_ref())
.await;
@@ -8787,7 +8809,7 @@ mod tests {
.record_context_updates_and_set_reference_context_item(&turn_context, None)
.await;
let history = session.clone_history().await;
let initial_context = session.build_initial_context(&turn_context).await;
let initial_context = session.build_initial_context(&turn_context, None).await;
assert_eq!(history.raw_items().to_vec(), initial_context);
let current_context = session.reference_context_item().await;
@@ -8831,10 +8853,28 @@ mod tests {
let history = session.clone_history().await;
let mut expected_history = vec![compacted_summary];
expected_history.extend(session.build_initial_context(&turn_context).await);
expected_history.extend(session.build_initial_context(&turn_context, None).await);
assert_eq!(history.raw_items().to_vec(), expected_history);
}
#[tokio::test]
async fn build_initial_context_prepends_model_switch_message() {
let (session, turn_context) = make_session_and_context().await;
let initial_context = session
.build_initial_context(&turn_context, Some("previous-regular-model"))
.await;
let ResponseItem::Message { role, content, .. } = &initial_context[0] else {
panic!("expected developer message");
};
assert_eq!(role, "developer");
let [ContentItem::InputText { text }, ..] = content.as_slice() else {
panic!("expected developer text");
};
assert!(text.contains("<model_switch>"));
}
#[tokio::test]
async fn run_user_shell_command_does_not_set_reference_context_item() {
let (session, _turn_context, rx) = make_session_and_context_with_rx().await;
@@ -9156,7 +9196,7 @@ mod tests {
let ContentItem::InputText { text } = content_item else {
return false;
};
text.contains(crate::session_prefix::TURN_ABORTED_OPEN_TAG)
text.contains(crate::contextual_user_message::TURN_ABORTED_OPEN_TAG)
})
}),
"expected a model-visible turn aborted marker in history after interrupt"
@@ -9230,7 +9270,7 @@ mod tests {
// personality_spec) matches reconstruction.
let reconstruction_turn = session.new_default_turn().await;
let mut initial_context = session
.build_initial_context(reconstruction_turn.as_ref())
.build_initial_context(reconstruction_turn.as_ref(), None)
.await;
// Ensure personality_spec is present when Personality is enabled, so expected matches
// what reconstruction produces (build_initial_context may omit it when baked into model).
+63 -155
View File
@@ -7,7 +7,6 @@ use crate::client_common::ResponseEvent;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::codex::get_last_assistant_message_from_turn;
use crate::context_manager::ContextManager;
use crate::error::CodexErr;
use crate::error::Result as CodexResult;
use crate::protocol::CompactedItem;
@@ -51,47 +50,11 @@ pub(crate) fn should_use_remote_compact_task(provider: &ModelProviderInfo) -> bo
provider.is_openai()
}
fn is_model_switch_developer_message(item: &ResponseItem) -> bool {
match item {
ResponseItem::Message { role, content, .. } if role == "developer" => {
matches!(
content.as_slice(),
[ContentItem::InputText { text }] if text.starts_with("<model_switch>\n")
)
}
_ => false,
}
}
pub(crate) fn extract_trailing_model_switch_update_for_compaction_request(
history: &mut ContextManager,
) -> Option<ResponseItem> {
let history_items = history.raw_items();
let last_user_turn_boundary_index = history_items
.iter()
.rposition(crate::context_manager::is_user_turn_boundary);
let model_switch_index = history_items
.iter()
.enumerate()
.rev()
.find_map(|(i, item)| {
let is_trailing = last_user_turn_boundary_index.is_none_or(|boundary| i > boundary);
if is_trailing && is_model_switch_developer_message(item) {
Some(i)
} else {
None
}
})?;
let mut replacement = history_items.to_vec();
let model_switch_item = replacement.remove(model_switch_index);
history.replace(replacement);
Some(model_switch_item)
}
pub(crate) async fn run_inline_auto_compact_task(
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
initial_context_injection: InitialContextInjection,
previous_user_turn_model: Option<&str>,
) -> CodexResult<()> {
let prompt = turn_context.compact_prompt().to_string();
let input = vec![UserInput::Text {
@@ -100,7 +63,14 @@ pub(crate) async fn run_inline_auto_compact_task(
text_elements: Vec::new(),
}];
run_compact_task_inner(sess, turn_context, input, initial_context_injection).await?;
run_compact_task_inner(
sess,
turn_context,
input,
initial_context_injection,
previous_user_turn_model,
)
.await?;
Ok(())
}
@@ -120,6 +90,7 @@ pub(crate) async fn run_compact_task(
turn_context,
input,
InitialContextInjection::DoNotInject,
None,
)
.await
}
@@ -129,6 +100,7 @@ async fn run_compact_task_inner(
turn_context: Arc<TurnContext>,
input: Vec<UserInput>,
initial_context_injection: InitialContextInjection,
previous_user_turn_model: Option<&str>,
) -> CodexResult<()> {
let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new());
sess.emit_turn_item_started(&turn_context, &compaction_item)
@@ -136,10 +108,6 @@ async fn run_compact_task_inner(
let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input);
let mut history = sess.clone_history().await;
// Keep compaction prompts in-distribution: if a model-switch update was injected at the
// tail of history (between turns), exclude it from the compaction request payload.
let stripped_model_switch_item =
extract_trailing_model_switch_update_for_compaction_request(&mut history);
history.record_items(
&[initial_input_for_turn.into()],
turn_context.truncation_policy,
@@ -240,15 +208,12 @@ async fn run_compact_task_inner(
initial_context_injection,
InitialContextInjection::BeforeLastUserMessage
) {
let initial_context = sess.build_initial_context(turn_context.as_ref()).await;
let initial_context = sess
.build_initial_context(turn_context.as_ref(), previous_user_turn_model)
.await;
new_history =
insert_initial_context_before_last_real_user_or_summary(new_history, initial_context);
}
// Reattach the stripped model-switch update only after successful compaction so the model
// still sees the switch instructions on the next real sampling request.
if let Some(model_switch_item) = stripped_model_switch_item {
new_history.push(model_switch_item);
}
let ghost_snapshots: Vec<ResponseItem> = history_items
.iter()
.filter(|item| matches!(item, ResponseItem::GhostSnapshot { .. }))
@@ -491,14 +456,18 @@ mod tests {
async fn process_compacted_history_with_test_session(
compacted_history: Vec<ResponseItem>,
previous_user_turn_model: Option<&str>,
) -> (Vec<ResponseItem>, Vec<ResponseItem>) {
let (session, turn_context) = crate::codex::make_session_and_context().await;
let initial_context = session.build_initial_context(&turn_context).await;
let initial_context = session
.build_initial_context(&turn_context, previous_user_turn_model)
.await;
let refreshed = crate::compact_remote::process_compacted_history(
&session,
&turn_context,
compacted_history,
InitialContextInjection::BeforeLastUserMessage,
previous_user_turn_model,
)
.await;
(refreshed, initial_context)
@@ -534,107 +503,6 @@ mod tests {
assert_eq!(None, joined);
}
#[test]
fn extract_trailing_model_switch_update_for_compaction_request_removes_trailing_item() {
let mut history = ContextManager::new();
history.replace(vec![
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "USER_MESSAGE".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "ASSISTANT_REPLY".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: "<model_switch>\nNEW_MODEL_INSTRUCTIONS".to_string(),
}],
end_turn: None,
phase: None,
},
]);
let model_switch_item =
extract_trailing_model_switch_update_for_compaction_request(&mut history);
assert_eq!(history.raw_items().len(), 2);
assert!(model_switch_item.is_some());
assert!(
history
.raw_items()
.iter()
.all(|item| !is_model_switch_developer_message(item))
);
}
#[test]
fn extract_trailing_model_switch_update_for_compaction_request_keeps_historical_item() {
let mut history = ContextManager::new();
history.replace(vec![
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "FIRST_USER_MESSAGE".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: "<model_switch>\nOLDER_MODEL_INSTRUCTIONS".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "ASSISTANT_REPLY".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "SECOND_USER_MESSAGE".to_string(),
}],
end_turn: None,
phase: None,
},
]);
let model_switch_item =
extract_trailing_model_switch_update_for_compaction_request(&mut history);
assert_eq!(history.raw_items().len(), 4);
assert!(model_switch_item.is_none());
assert!(
history
.raw_items()
.iter()
.any(is_model_switch_developer_message)
);
}
#[test]
fn collect_user_messages_extracts_user_text_only() {
let items = vec![
@@ -802,7 +670,7 @@ do things
},
];
let (refreshed, mut expected) =
process_compacted_history_with_test_session(compacted_history).await;
process_compacted_history_with_test_session(compacted_history, None).await;
expected.push(ResponseItem::Message {
id: None,
role: "user".to_string(),
@@ -827,7 +695,7 @@ do things
phase: None,
}];
let (refreshed, mut expected) =
process_compacted_history_with_test_session(compacted_history).await;
process_compacted_history_with_test_session(compacted_history, None).await;
expected.push(ResponseItem::Message {
id: None,
role: "user".to_string(),
@@ -903,7 +771,7 @@ keep me updated
},
];
let (refreshed, mut expected) =
process_compacted_history_with_test_session(compacted_history).await;
process_compacted_history_with_test_session(compacted_history, None).await;
expected.push(ResponseItem::Message {
id: None,
role: "user".to_string(),
@@ -949,7 +817,7 @@ keep me updated
];
let (refreshed, initial_context) =
process_compacted_history_with_test_session(compacted_history).await;
process_compacted_history_with_test_session(compacted_history, None).await;
let mut expected = vec![
ResponseItem::Message {
id: None,
@@ -983,6 +851,46 @@ keep me updated
assert_eq!(refreshed, expected);
}
#[tokio::test]
async fn process_compacted_history_reinjects_model_switch_message() {
let compacted_history = vec![ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "summary".to_string(),
}],
end_turn: None,
phase: None,
}];
let (refreshed, initial_context) = process_compacted_history_with_test_session(
compacted_history,
Some("previous-regular-model"),
)
.await;
let ResponseItem::Message { role, content, .. } = &initial_context[0] else {
panic!("expected developer message");
};
assert_eq!(role, "developer");
let [ContentItem::InputText { text }, ..] = content.as_slice() else {
panic!("expected developer text");
};
assert!(text.contains("<model_switch>"));
let mut expected = initial_context;
expected.push(ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "summary".to_string(),
}],
end_turn: None,
phase: None,
});
assert_eq!(refreshed, expected);
}
#[test]
fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() {
let compacted_history = vec![
+34 -19
View File
@@ -4,7 +4,6 @@ use crate::Prompt;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::compact::InitialContextInjection;
use crate::compact::extract_trailing_model_switch_update_for_compaction_request;
use crate::compact::insert_initial_context_before_last_real_user_or_summary;
use crate::context_manager::ContextManager;
use crate::context_manager::TotalTokenUsageBreakdown;
@@ -28,8 +27,15 @@ pub(crate) async fn run_inline_remote_auto_compact_task(
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
initial_context_injection: InitialContextInjection,
previous_user_turn_model: Option<&str>,
) -> CodexResult<()> {
run_remote_compact_task_inner(&sess, &turn_context, initial_context_injection).await?;
run_remote_compact_task_inner(
&sess,
&turn_context,
initial_context_injection,
previous_user_turn_model,
)
.await?;
Ok(())
}
@@ -44,16 +50,28 @@ pub(crate) async fn run_remote_compact_task(
});
sess.send_event(&turn_context, start_event).await;
run_remote_compact_task_inner(&sess, &turn_context, InitialContextInjection::DoNotInject).await
run_remote_compact_task_inner(
&sess,
&turn_context,
InitialContextInjection::DoNotInject,
None,
)
.await
}
async fn run_remote_compact_task_inner(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
initial_context_injection: InitialContextInjection,
previous_user_turn_model: Option<&str>,
) -> CodexResult<()> {
if let Err(err) =
run_remote_compact_task_inner_impl(sess, turn_context, initial_context_injection).await
if let Err(err) = run_remote_compact_task_inner_impl(
sess,
turn_context,
initial_context_injection,
previous_user_turn_model,
)
.await
{
let event = EventMsg::Error(
err.to_error_event(Some("Error running remote compact task".to_string())),
@@ -68,15 +86,12 @@ async fn run_remote_compact_task_inner_impl(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
initial_context_injection: InitialContextInjection,
previous_user_turn_model: Option<&str>,
) -> CodexResult<()> {
let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new());
sess.emit_turn_item_started(turn_context, &compaction_item)
.await;
let mut history = sess.clone_history().await;
// Keep compaction prompts in-distribution: if a model-switch update was injected at the
// tail of history (between turns), exclude it from the compaction request payload.
let stripped_model_switch_item =
extract_trailing_model_switch_update_for_compaction_request(&mut history);
let base_instructions = sess.get_base_instructions().await;
let deleted_items = trim_function_call_history_to_fit_context_window(
&mut history,
@@ -134,13 +149,9 @@ async fn run_remote_compact_task_inner_impl(
turn_context.as_ref(),
new_history,
initial_context_injection,
previous_user_turn_model,
)
.await;
// Reattach the stripped model-switch update only after successful compaction so the model
// still sees the switch instructions on the next real sampling request.
if let Some(model_switch_item) = stripped_model_switch_item {
new_history.push(model_switch_item);
}
if !ghost_snapshots.is_empty() {
new_history.extend(ghost_snapshots);
@@ -170,6 +181,7 @@ pub(crate) async fn process_compacted_history(
turn_context: &TurnContext,
mut compacted_history: Vec<ResponseItem>,
initial_context_injection: InitialContextInjection,
previous_user_turn_model: Option<&str>,
) -> Vec<ResponseItem> {
// Mid-turn compaction is the only path that must inject initial context above the last user
// message in the replacement history. Pre-turn compaction instead injects context after the
@@ -178,7 +190,8 @@ pub(crate) async fn process_compacted_history(
initial_context_injection,
InitialContextInjection::BeforeLastUserMessage
) {
sess.build_initial_context(turn_context).await
sess.build_initial_context(turn_context, previous_user_turn_model)
.await
} else {
Vec::new()
};
@@ -205,10 +218,12 @@ pub(crate) async fn process_compacted_history(
fn should_keep_compacted_history_item(item: &ResponseItem) -> bool {
match item {
ResponseItem::Message { role, .. } if role == "developer" => false,
ResponseItem::Message { role, .. } if role == "user" => matches!(
crate::event_mapping::parse_turn_item(item),
Some(TurnItem::UserMessage(_))
),
ResponseItem::Message { role, .. } if role == "user" => {
matches!(
crate::event_mapping::parse_turn_item(item),
Some(TurnItem::UserMessage(_))
)
}
ResponseItem::Message { role, .. } if role == "assistant" => true,
ResponseItem::Message { .. } => false,
ResponseItem::Compaction { .. } => true,
+2 -31
View File
@@ -1,14 +1,11 @@
use crate::codex::TurnContext;
use crate::context_manager::normalize;
use crate::instructions::SkillInstructions;
use crate::instructions::UserInstructions;
use crate::session_prefix::is_session_prefix;
use crate::event_mapping::is_contextual_user_message_content;
use crate::truncate::TruncationPolicy;
use crate::truncate::approx_token_count;
use crate::truncate::approx_tokens_from_byte_count_i64;
use crate::truncate::truncate_function_output_items_with_policy;
use crate::truncate::truncate_text;
use crate::user_shell_command::is_user_shell_command_text;
use codex_protocol::models::BaseInstructions;
use codex_protocol::models::ContentItem;
use codex_protocol::models::FunctionCallOutputBody;
@@ -554,33 +551,7 @@ pub(crate) fn is_user_turn_boundary(item: &ResponseItem) -> bool {
return false;
};
if role != "user" {
return false;
}
if UserInstructions::is_user_instructions(content)
|| SkillInstructions::is_skill_instructions(content)
{
return false;
}
for content_item in content {
match content_item {
ContentItem::InputText { text } => {
if is_session_prefix(text) || is_user_shell_command_text(text) {
return false;
}
}
ContentItem::OutputText { text } => {
if is_session_prefix(text) {
return false;
}
}
ContentItem::InputImage { .. } => {}
}
}
true
role == "user" && !is_contextual_user_message_content(content)
}
fn user_message_positions(items: &[ResponseItem]) -> Vec<usize> {
@@ -563,7 +563,6 @@ fn drop_last_n_user_turns_preserves_prefix() {
fn drop_last_n_user_turns_ignores_session_prefix_user_messages() {
let items = vec![
user_input_text_msg("<environment_context>ctx</environment_context>"),
user_input_text_msg("<user_instructions>do the thing</user_instructions>"),
user_input_text_msg(
"# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>",
),
@@ -586,7 +585,6 @@ fn drop_last_n_user_turns_ignores_session_prefix_user_messages() {
let expected_prefix_and_first_turn = vec![
user_input_text_msg("<environment_context>ctx</environment_context>"),
user_input_text_msg("<user_instructions>do the thing</user_instructions>"),
user_input_text_msg(
"# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>",
),
@@ -608,7 +606,6 @@ fn drop_last_n_user_turns_ignores_session_prefix_user_messages() {
let expected_prefix_only = vec![
user_input_text_msg("<environment_context>ctx</environment_context>"),
user_input_text_msg("<user_instructions>do the thing</user_instructions>"),
user_input_text_msg(
"# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>",
),
@@ -623,7 +620,6 @@ fn drop_last_n_user_turns_ignores_session_prefix_user_messages() {
let mut history = create_history_with_items(vec![
user_input_text_msg("<environment_context>ctx</environment_context>"),
user_input_text_msg("<user_instructions>do the thing</user_instructions>"),
user_input_text_msg(
"# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>",
),
@@ -644,7 +640,6 @@ fn drop_last_n_user_turns_ignores_session_prefix_user_messages() {
let mut history = create_history_with_items(vec![
user_input_text_msg("<environment_context>ctx</environment_context>"),
user_input_text_msg("<user_instructions>do the thing</user_instructions>"),
user_input_text_msg(
"# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>",
),
+65 -40
View File
@@ -4,6 +4,7 @@ use crate::features::Feature;
use crate::shell::Shell;
use codex_execpolicy::Policy;
use codex_protocol::config_types::Personality;
use codex_protocol::models::ContentItem;
use codex_protocol::models::DeveloperInstructions;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ModelInfo;
@@ -30,7 +31,7 @@ fn build_permissions_update_item(
previous: Option<&TurnContextItem>,
next: &TurnContext,
exec_policy: &Policy,
) -> Option<ResponseItem> {
) -> Option<DeveloperInstructions> {
let prev = previous?;
if prev.sandbox_policy == *next.sandbox_policy.get()
&& prev.approval_policy == next.approval_policy.value()
@@ -38,27 +39,26 @@ fn build_permissions_update_item(
return None;
}
Some(
DeveloperInstructions::from_policy(
next.sandbox_policy.get(),
next.approval_policy.value(),
exec_policy,
&next.cwd,
next.features.enabled(Feature::RequestPermissions),
)
.into(),
)
Some(DeveloperInstructions::from_policy(
next.sandbox_policy.get(),
next.approval_policy.value(),
exec_policy,
&next.cwd,
next.features.enabled(Feature::RequestPermissions),
))
}
fn build_collaboration_mode_update_item(
previous: Option<&TurnContextItem>,
next: &TurnContext,
) -> Option<ResponseItem> {
) -> Option<DeveloperInstructions> {
let prev = previous?;
if prev.collaboration_mode.as_ref() != Some(&next.collaboration_mode) {
// If the next mode has empty developer instructions, this returns None and we emit no
// update, so prior collaboration instructions remain in the prompt history.
Some(DeveloperInstructions::from_collaboration_mode(&next.collaboration_mode)?.into())
Some(DeveloperInstructions::from_collaboration_mode(
&next.collaboration_mode,
)?)
} else {
None
}
@@ -68,7 +68,7 @@ fn build_personality_update_item(
previous: Option<&TurnContextItem>,
next: &TurnContext,
personality_feature_enabled: bool,
) -> Option<ResponseItem> {
) -> Option<DeveloperInstructions> {
if !personality_feature_enabled {
return None;
}
@@ -82,8 +82,7 @@ fn build_personality_update_item(
{
let model_info = &next.model_info;
let personality_message = personality_message_for(model_info, personality);
personality_message
.map(|message| DeveloperInstructions::personality_spec_message(message).into())
personality_message.map(DeveloperInstructions::personality_spec_message)
} else {
None
}
@@ -103,7 +102,7 @@ pub(crate) fn personality_message_for(
pub(crate) fn build_model_instructions_update_item(
previous_user_turn_model: Option<&str>,
next: &TurnContext,
) -> Option<ResponseItem> {
) -> Option<DeveloperInstructions> {
let previous_model = previous_user_turn_model?;
if previous_model == next.model_info.slug {
return None;
@@ -114,7 +113,36 @@ pub(crate) fn build_model_instructions_update_item(
return None;
}
Some(DeveloperInstructions::model_switch_message(model_instructions).into())
Some(DeveloperInstructions::model_switch_message(
model_instructions,
))
}
pub(crate) fn build_developer_update_item(text_sections: Vec<String>) -> Option<ResponseItem> {
build_text_message("developer", text_sections)
}
pub(crate) fn build_contextual_user_message(text_sections: Vec<String>) -> Option<ResponseItem> {
build_text_message("user", text_sections)
}
fn build_text_message(role: &str, text_sections: Vec<String>) -> Option<ResponseItem> {
if text_sections.is_empty() {
return None;
}
let content = text_sections
.into_iter()
.map(|text| ContentItem::InputText { text })
.collect();
Some(ResponseItem::Message {
id: None,
role: role.to_string(),
content,
end_turn: None,
phase: None,
})
}
pub(crate) fn build_settings_update_items(
@@ -125,29 +153,26 @@ pub(crate) fn build_settings_update_items(
exec_policy: &Policy,
personality_feature_enabled: bool,
) -> Vec<ResponseItem> {
let mut update_items = Vec::new();
let contextual_user_message = build_environment_update_item(previous, next, shell);
let developer_update_sections = [
// Keep model-switch instructions first so model-specific guidance is read before
// any other context diffs on this turn.
build_model_instructions_update_item(previous_user_turn_model, next),
build_permissions_update_item(previous, next, exec_policy),
build_collaboration_mode_update_item(previous, next),
build_personality_update_item(previous, next, personality_feature_enabled),
]
.into_iter()
.flatten()
.map(DeveloperInstructions::into_text)
.collect();
// Keep model-switch instructions first so model-specific guidance is read before
// any other context diffs on this turn.
if let Some(model_instructions_item) =
build_model_instructions_update_item(previous_user_turn_model, next)
{
update_items.push(model_instructions_item);
let mut items = Vec::with_capacity(2);
if let Some(developer_message) = build_developer_update_item(developer_update_sections) {
items.push(developer_message);
}
if let Some(env_item) = build_environment_update_item(previous, next, shell) {
update_items.push(env_item);
if let Some(contextual_user_message) = contextual_user_message {
items.push(contextual_user_message);
}
if let Some(permissions_item) = build_permissions_update_item(previous, next, exec_policy) {
update_items.push(permissions_item);
}
if let Some(collaboration_mode_item) = build_collaboration_mode_update_item(previous, next) {
update_items.push(collaboration_mode_item);
}
if let Some(personality_item) =
build_personality_update_item(previous, next, personality_feature_enabled)
{
update_items.push(personality_item);
}
update_items
items
}
@@ -0,0 +1,139 @@
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG;
use codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG;
pub(crate) const AGENTS_MD_START_MARKER: &str = "# AGENTS.md instructions for ";
pub(crate) const AGENTS_MD_END_MARKER: &str = "</INSTRUCTIONS>";
pub(crate) const SKILL_OPEN_TAG: &str = "<skill>";
pub(crate) const SKILL_CLOSE_TAG: &str = "</skill>";
pub(crate) const USER_SHELL_COMMAND_OPEN_TAG: &str = "<user_shell_command>";
pub(crate) const USER_SHELL_COMMAND_CLOSE_TAG: &str = "</user_shell_command>";
pub(crate) const TURN_ABORTED_OPEN_TAG: &str = "<turn_aborted>";
pub(crate) const TURN_ABORTED_CLOSE_TAG: &str = "</turn_aborted>";
pub(crate) const SUBAGENT_NOTIFICATION_OPEN_TAG: &str = "<subagent_notification>";
pub(crate) const SUBAGENT_NOTIFICATION_CLOSE_TAG: &str = "</subagent_notification>";
#[derive(Clone, Copy)]
pub(crate) struct ContextualUserFragmentDefinition {
start_marker: &'static str,
end_marker: &'static str,
}
impl ContextualUserFragmentDefinition {
pub(crate) const fn new(start_marker: &'static str, end_marker: &'static str) -> Self {
Self {
start_marker,
end_marker,
}
}
pub(crate) fn matches_text(&self, text: &str) -> bool {
let trimmed = text.trim_start();
let starts_with_marker = trimmed
.get(..self.start_marker.len())
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(self.start_marker));
let trimmed = trimmed.trim_end();
let ends_with_marker = trimmed
.get(trimmed.len().saturating_sub(self.end_marker.len())..)
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(self.end_marker));
starts_with_marker && ends_with_marker
}
pub(crate) const fn start_marker(&self) -> &'static str {
self.start_marker
}
pub(crate) const fn end_marker(&self) -> &'static str {
self.end_marker
}
pub(crate) fn wrap(&self, body: String) -> String {
format!("{}\n{}\n{}", self.start_marker, body, self.end_marker)
}
pub(crate) fn into_message(self, text: String) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText { text }],
end_turn: None,
phase: None,
}
}
}
pub(crate) const AGENTS_MD_FRAGMENT: ContextualUserFragmentDefinition =
ContextualUserFragmentDefinition::new(AGENTS_MD_START_MARKER, AGENTS_MD_END_MARKER);
pub(crate) const ENVIRONMENT_CONTEXT_FRAGMENT: ContextualUserFragmentDefinition =
ContextualUserFragmentDefinition::new(
ENVIRONMENT_CONTEXT_OPEN_TAG,
ENVIRONMENT_CONTEXT_CLOSE_TAG,
);
pub(crate) const SKILL_FRAGMENT: ContextualUserFragmentDefinition =
ContextualUserFragmentDefinition::new(SKILL_OPEN_TAG, SKILL_CLOSE_TAG);
pub(crate) const USER_SHELL_COMMAND_FRAGMENT: ContextualUserFragmentDefinition =
ContextualUserFragmentDefinition::new(
USER_SHELL_COMMAND_OPEN_TAG,
USER_SHELL_COMMAND_CLOSE_TAG,
);
pub(crate) const TURN_ABORTED_FRAGMENT: ContextualUserFragmentDefinition =
ContextualUserFragmentDefinition::new(TURN_ABORTED_OPEN_TAG, TURN_ABORTED_CLOSE_TAG);
pub(crate) const SUBAGENT_NOTIFICATION_FRAGMENT: ContextualUserFragmentDefinition =
ContextualUserFragmentDefinition::new(
SUBAGENT_NOTIFICATION_OPEN_TAG,
SUBAGENT_NOTIFICATION_CLOSE_TAG,
);
const CONTEXTUAL_USER_FRAGMENTS: &[ContextualUserFragmentDefinition] = &[
AGENTS_MD_FRAGMENT,
ENVIRONMENT_CONTEXT_FRAGMENT,
SKILL_FRAGMENT,
USER_SHELL_COMMAND_FRAGMENT,
TURN_ABORTED_FRAGMENT,
SUBAGENT_NOTIFICATION_FRAGMENT,
];
pub(crate) fn is_contextual_user_fragment(content_item: &ContentItem) -> bool {
let ContentItem::InputText { text } = content_item else {
return false;
};
CONTEXTUAL_USER_FRAGMENTS
.iter()
.any(|definition| definition.matches_text(text))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_environment_context_fragment() {
assert!(is_contextual_user_fragment(&ContentItem::InputText {
text: "<environment_context>\n<cwd>/tmp</cwd>\n</environment_context>".to_string(),
}));
}
#[test]
fn detects_agents_instructions_fragment() {
assert!(is_contextual_user_fragment(&ContentItem::InputText {
text: "# AGENTS.md instructions for /tmp\n\n<INSTRUCTIONS>\nbody\n</INSTRUCTIONS>"
.to_string(),
}));
}
#[test]
fn detects_subagent_notification_fragment_case_insensitively() {
assert!(
SUBAGENT_NOTIFICATION_FRAGMENT
.matches_text("<SUBAGENT_NOTIFICATION>{}</subagent_notification>")
);
}
#[test]
fn ignores_regular_user_text() {
assert!(!is_contextual_user_fragment(&ContentItem::InputText {
text: "hello".to_string(),
}));
}
}
+4 -16
View File
@@ -1,9 +1,7 @@
use crate::codex::TurnContext;
use crate::contextual_user_message::ENVIRONMENT_CONTEXT_FRAGMENT;
use crate::shell::Shell;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG;
use codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG;
use codex_protocol::protocol::TurnContextItem;
use codex_protocol::protocol::TurnContextNetworkItem;
use serde::Deserialize;
@@ -40,7 +38,6 @@ impl EnvironmentContext {
let EnvironmentContext {
cwd,
network,
// should compare all fields except shell
shell: _,
} = other;
self.cwd == *cwd && self.network == *network
@@ -122,7 +119,7 @@ impl EnvironmentContext {
/// </environment_context>
/// ```
pub fn serialize_to_xml(self) -> String {
let mut lines = vec![ENVIRONMENT_CONTEXT_OPEN_TAG.to_string()];
let mut lines = Vec::new();
if let Some(cwd) = self.cwd {
lines.push(format!(" <cwd>{}</cwd>", cwd.to_string_lossy()));
}
@@ -145,22 +142,13 @@ impl EnvironmentContext {
// lines.push(" <network enabled=\"false\" />".to_string());
}
}
lines.push(ENVIRONMENT_CONTEXT_CLOSE_TAG.to_string());
lines.join("\n")
ENVIRONMENT_CONTEXT_FRAGMENT.wrap(lines.join("\n"))
}
}
impl From<EnvironmentContext> for ResponseItem {
fn from(ec: EnvironmentContext) -> Self {
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: ec.serialize_to_xml(),
}],
end_turn: None,
phase: None,
}
ENVIRONMENT_CONTEXT_FRAGMENT.into_message(ec.serialize_to_xml())
}
}
+23 -14
View File
@@ -18,16 +18,15 @@ use codex_protocol::user_input::UserInput;
use tracing::warn;
use uuid::Uuid;
use crate::instructions::SkillInstructions;
use crate::instructions::UserInstructions;
use crate::session_prefix::is_session_prefix;
use crate::user_shell_command::is_user_shell_command_text;
use crate::contextual_user_message::is_contextual_user_fragment;
use crate::web_search::web_search_action_detail;
pub(crate) fn is_contextual_user_message_content(message: &[ContentItem]) -> bool {
message.iter().any(is_contextual_user_fragment)
}
fn parse_user_message(message: &[ContentItem]) -> Option<UserMessageItem> {
if UserInstructions::is_user_instructions(message)
|| SkillInstructions::is_skill_instructions(message)
{
if is_contextual_user_message_content(message) {
return None;
}
@@ -44,9 +43,6 @@ fn parse_user_message(message: &[ContentItem]) -> Option<UserMessageItem> {
{
continue;
}
if is_session_prefix(text) || is_user_shell_command_text(text) {
return None;
}
content.push(UserInput::Text {
text: text.clone(),
// Model input content does not carry UI element ranges.
@@ -59,9 +55,6 @@ fn parse_user_message(message: &[ContentItem]) -> Option<UserMessageItem> {
});
}
ContentItem::OutputText { text } => {
if is_session_prefix(text) {
return None;
}
warn!("Output text in user message: {}", text);
}
}
@@ -299,7 +292,7 @@ mod tests {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "<user_instructions>test_text</user_instructions>".to_string(),
text: "# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>".to_string(),
}],
end_turn: None,
phase: None,
@@ -341,6 +334,22 @@ mod tests {
end_turn: None,
phase: None,
},
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![
ContentItem::InputText {
text: "<environment_context>ctx</environment_context>".to_string(),
},
ContentItem::InputText {
text:
"# AGENTS.md instructions for dir\n\n<INSTRUCTIONS>\nbody\n</INSTRUCTIONS>"
.to_string(),
},
],
end_turn: None,
phase: None,
},
];
for item in items {
-1
View File
@@ -1,6 +1,5 @@
mod user_instructions;
pub(crate) use user_instructions::SkillInstructions;
pub use user_instructions::USER_INSTRUCTIONS_OPEN_TAG_LEGACY;
pub use user_instructions::USER_INSTRUCTIONS_PREFIX;
pub(crate) use user_instructions::UserInstructions;
@@ -1,12 +1,12 @@
use serde::Deserialize;
use serde::Serialize;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
pub const USER_INSTRUCTIONS_OPEN_TAG_LEGACY: &str = "<user_instructions>";
use crate::contextual_user_message::AGENTS_MD_FRAGMENT;
use crate::contextual_user_message::SKILL_FRAGMENT;
pub const USER_INSTRUCTIONS_PREFIX: &str = "# AGENTS.md instructions for ";
pub const SKILL_INSTRUCTIONS_PREFIX: &str = "<skill";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename = "user_instructions", rename_all = "snake_case")]
@@ -16,31 +16,20 @@ pub(crate) struct UserInstructions {
}
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)
} else {
false
}
pub(crate) fn serialize_to_text(&self) -> String {
format!(
"{prefix}{directory}\n\n<INSTRUCTIONS>\n{contents}\n{suffix}",
prefix = AGENTS_MD_FRAGMENT.start_marker(),
directory = self.directory,
contents = self.text,
suffix = AGENTS_MD_FRAGMENT.end_marker(),
)
}
}
impl From<UserInstructions> for ResponseItem {
fn from(ui: UserInstructions) -> Self {
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: format!(
"{USER_INSTRUCTIONS_PREFIX}{directory}\n\n<INSTRUCTIONS>\n{contents}\n</INSTRUCTIONS>",
directory = ui.directory,
contents = ui.text
),
}],
end_turn: None,
phase: None,
}
AGENTS_MD_FRAGMENT.into_message(ui.serialize_to_text())
}
}
@@ -52,36 +41,21 @@ pub(crate) struct SkillInstructions {
pub contents: String,
}
impl SkillInstructions {
pub fn is_skill_instructions(message: &[ContentItem]) -> bool {
if let [ContentItem::InputText { text }] = message {
text.starts_with(SKILL_INSTRUCTIONS_PREFIX)
} else {
false
}
}
}
impl SkillInstructions {}
impl From<SkillInstructions> for ResponseItem {
fn from(si: SkillInstructions) -> Self {
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: format!(
"<skill>\n<name>{}</name>\n<path>{}</path>\n{}\n</skill>",
si.name, si.path, si.contents
),
}],
end_turn: None,
phase: None,
}
SKILL_FRAGMENT.into_message(SKILL_FRAGMENT.wrap(format!(
"<name>{}</name>\n<path>{}</path>\n{}",
si.name, si.path, si.contents
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use codex_protocol::models::ContentItem;
use pretty_assertions::assert_eq;
#[test]
@@ -110,21 +84,10 @@ mod tests {
#[test]
fn test_is_user_instructions() {
assert!(UserInstructions::is_user_instructions(
&[ContentItem::InputText {
text: "# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>".to_string(),
}]
assert!(AGENTS_MD_FRAGMENT.matches_text(
"# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>"
));
assert!(UserInstructions::is_user_instructions(&[
ContentItem::InputText {
text: "<user_instructions>test_text</user_instructions>".to_string(),
}
]));
assert!(!UserInstructions::is_user_instructions(&[
ContentItem::InputText {
text: "test_text".to_string(),
}
]));
assert!(!AGENTS_MD_FRAGMENT.matches_text("test_text"));
}
#[test]
@@ -154,16 +117,9 @@ mod tests {
#[test]
fn test_is_skill_instructions() {
assert!(SkillInstructions::is_skill_instructions(&[
ContentItem::InputText {
text: "<skill>\n<name>demo-skill</name>\n<path>skills/demo/SKILL.md</path>\nbody\n</skill>"
.to_string(),
}
]));
assert!(!SkillInstructions::is_skill_instructions(&[
ContentItem::InputText {
text: "regular text".to_string(),
}
]));
assert!(SKILL_FRAGMENT.matches_text(
"<skill>\n<name>demo-skill</name>\n<path>skills/demo/SKILL.md</path>\nbody\n</skill>"
));
assert!(!SKILL_FRAGMENT.matches_text("regular text"));
}
}
+1
View File
@@ -27,6 +27,7 @@ pub mod config;
pub mod config_loader;
pub mod connectors;
mod context_manager;
mod contextual_user_message;
pub mod custom_prompts;
pub mod env;
mod environment_context;
+1 -1
View File
@@ -193,7 +193,7 @@ mod tests {
#[tokio::test]
async fn ignores_session_prefix_messages_when_truncating_rollout_from_start() {
let (session, turn_context) = make_session_and_context().await;
let mut items = session.build_initial_context(&turn_context).await;
let mut items = session.build_initial_context(&turn_context, None).await;
items.push(user_msg("feature request"));
items.push(assistant_msg("ack"));
items.push(user_msg("second question"));
+4 -38
View File
@@ -1,28 +1,8 @@
use codex_protocol::protocol::AgentStatus;
/// Helpers for identifying model-visible "session prefix" messages.
///
/// A session prefix is a user-role message that carries configuration or state needed by
/// follow-up turns (e.g. `<environment_context>`, `<turn_aborted>`). These items are persisted in
/// history so the model can see them, but they are not user intent and must not create user-turn
/// boundaries.
pub(crate) const ENVIRONMENT_CONTEXT_OPEN_TAG: &str = "<environment_context>";
pub(crate) const TURN_ABORTED_OPEN_TAG: &str = "<turn_aborted>";
pub(crate) const SUBAGENT_NOTIFICATION_OPEN_TAG: &str = "<subagent_notification>";
pub(crate) const SUBAGENT_NOTIFICATION_CLOSE_TAG: &str = "</subagent_notification>";
fn starts_with_ascii_case_insensitive(text: &str, prefix: &str) -> bool {
text.get(..prefix.len())
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(prefix))
}
/// Returns true if `text` starts with a session prefix marker (case-insensitive).
pub(crate) fn is_session_prefix(text: &str) -> bool {
let trimmed = text.trim_start();
starts_with_ascii_case_insensitive(trimmed, ENVIRONMENT_CONTEXT_OPEN_TAG)
|| starts_with_ascii_case_insensitive(trimmed, TURN_ABORTED_OPEN_TAG)
|| starts_with_ascii_case_insensitive(trimmed, SUBAGENT_NOTIFICATION_OPEN_TAG)
}
/// Helpers for model-visible session state markers that are stored in user-role
/// messages but are not user intent.
use crate::contextual_user_message::SUBAGENT_NOTIFICATION_FRAGMENT;
pub(crate) fn format_subagent_notification_message(agent_id: &str, status: &AgentStatus) -> String {
let payload_json = serde_json::json!({
@@ -30,19 +10,5 @@ pub(crate) fn format_subagent_notification_message(agent_id: &str, status: &Agen
"status": status,
})
.to_string();
format!("{SUBAGENT_NOTIFICATION_OPEN_TAG}\n{payload_json}\n{SUBAGENT_NOTIFICATION_CLOSE_TAG}")
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn is_session_prefix_is_case_insensitive() {
assert_eq!(
is_session_prefix("<SUBAGENT_NOTIFICATION>{}</subagent_notification>"),
true
);
}
SUBAGENT_NOTIFICATION_FRAGMENT.wrap(payload_json)
}
+1 -1
View File
@@ -21,12 +21,12 @@ use tracing::warn;
use crate::AuthManager;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::contextual_user_message::TURN_ABORTED_OPEN_TAG;
use crate::models_manager::manager::ModelsManager;
use crate::protocol::EventMsg;
use crate::protocol::TurnAbortReason;
use crate::protocol::TurnAbortedEvent;
use crate::protocol::TurnCompleteEvent;
use crate::session_prefix::TURN_ABORTED_OPEN_TAG;
use crate::state::ActiveTurn;
use crate::state::RunningTask;
use crate::state::TaskKind;
+1 -1
View File
@@ -690,7 +690,7 @@ mod tests {
#[tokio::test]
async fn ignores_session_prefix_messages_when_truncating() {
let (session, turn_context) = make_session_and_context().await;
let mut items = session.build_initial_context(&turn_context).await;
let mut items = session.build_initial_context(&turn_context, None).await;
items.push(user_msg("feature request"));
items.push(assistant_msg("ack"));
items.push(user_msg("second question"));
+13 -24
View File
@@ -1,21 +1,12 @@
use std::time::Duration;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use crate::codex::TurnContext;
use crate::contextual_user_message::USER_SHELL_COMMAND_FRAGMENT;
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();
lowered.starts_with(USER_SHELL_COMMAND_OPEN)
}
fn format_duration_line(duration: Duration) -> String {
let duration_seconds = duration.as_secs_f64();
format!("Duration: {duration_seconds:.4} seconds")
@@ -48,7 +39,7 @@ pub fn format_user_shell_command_record(
turn_context: &TurnContext,
) -> String {
let body = format_user_shell_command_body(command, exec_output, turn_context);
format!("{USER_SHELL_COMMAND_OPEN}\n{body}\n{USER_SHELL_COMMAND_CLOSE}")
USER_SHELL_COMMAND_FRAGMENT.wrap(body)
}
pub fn user_shell_command_record_item(
@@ -56,15 +47,11 @@ pub fn user_shell_command_record_item(
exec_output: &ExecToolCallOutput,
turn_context: &TurnContext,
) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: format_user_shell_command_record(command, exec_output, turn_context),
}],
end_turn: None,
phase: None,
}
USER_SHELL_COMMAND_FRAGMENT.into_message(format_user_shell_command_record(
command,
exec_output,
turn_context,
))
}
#[cfg(test)]
@@ -72,14 +59,16 @@ mod tests {
use super::*;
use crate::codex::make_session_and_context;
use crate::exec::StreamOutput;
use codex_protocol::models::ContentItem;
use pretty_assertions::assert_eq;
#[test]
fn detects_user_shell_command_text_variants() {
assert!(is_user_shell_command_text(
"<user_shell_command>\necho hi\n</user_shell_command>"
));
assert!(!is_user_shell_command_text("echo hi"));
assert!(
USER_SHELL_COMMAND_FRAGMENT
.matches_text("<user_shell_command>\necho hi\n</user_shell_command>")
);
assert!(!USER_SHELL_COMMAND_FRAGMENT.matches_text("echo hi"));
}
#[tokio::test]