From 062439b2f855e7c41e2dfbfe370ccc966f095263 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Mon, 5 Oct 2026 19:57:20 +0000 Subject: [PATCH] Record base instructions in incremental tool history (#51188) ## What changed - Record nonempty base instructions as a developer message after tool declarations at the start of each incremental tool context window, and omit the request-level `instructions` field. - Preserve this prefix when rebuilding history after local or remote compaction, while keeping the compaction summary last. - Count recorded base instructions once when estimating context usage and trimming tool outputs. ## Testing Add regression coverage for instruction placement, omission of request-level instructions, compaction ordering, and token accounting. Exercise remote compaction trimming with both request-level and recorded base instructions. GitOrigin-RevId: 6a6fda92c5c6eb33852a77b38d34c028cf78b164 --- codex-rs/core/src/compact.rs | 58 +++++++++++++++---- codex-rs/core/src/compact_remote_v2.rs | 15 ++--- .../core/src/compact_remote_v2_attempt.rs | 10 +++- codex-rs/core/src/compact_tests.rs | 18 ++++-- .../core/src/context/base_instructions.rs | 21 ++++++- .../context/world_state/base_instructions.rs | 28 +++++++++ codex-rs/core/src/context/world_state/mod.rs | 14 +++++ codex-rs/core/src/context_manager/history.rs | 11 +++- .../core/src/context_manager/history_tests.rs | 18 ++++++ codex-rs/core/src/session/mod.rs | 40 ++++++++----- codex-rs/core/src/session/tests.rs | 29 +++++++--- .../core/src/session/tests/guardian_tests.rs | 17 +++--- codex-rs/core/src/session/turn.rs | 9 ++- codex-rs/core/src/session/world_state.rs | 2 + .../src/session/world_state_item_tests.rs | 12 +++- codex-rs/core/src/thread_manager_tests.rs | 5 +- .../src/thread_rollout_truncation_tests.rs | 3 +- .../tests/suite/compact_remote_trimming.rs | 22 ++++++- .../suite/scenarios_incremental_tools.rs | 10 ++++ ...ntal_tools__incremental_tool_removals.snap | 6 +- ..._incremental_tools__incremental_tools.snap | 12 ++-- 21 files changed, 285 insertions(+), 75 deletions(-) create mode 100644 codex-rs/core/src/context/world_state/base_instructions.rs diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 83f9774074..f4db71b688 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -6,10 +6,13 @@ use std::time::Instant; use crate::Prompt; use crate::client::ModelClientSession; use crate::client_common::ResponseEvent; +use crate::context::BaseInstructionsFragment; use crate::context::CompactionSummary; use crate::context::ContextualUserFragment; use crate::context::world_state::WorldState; use crate::context::world_state::WorldStateSnapshot; +use crate::context::world_state::split_prefix_updates; +use crate::context_manager::updates::merge_world_state_updates; use crate::hook_runtime::PostCompactHookOutcome; use crate::hook_runtime::PreCompactHookOutcome; use crate::hook_runtime::run_post_compact_hooks; @@ -40,6 +43,7 @@ use codex_protocol::error::Result as CodexResult; use codex_protocol::items::ContextCompactionItem; use codex_protocol::items::TurnItem; use codex_protocol::models::AgentMessageInputContent; +use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; use codex_protocol::models::ContentItemKind; use codex_protocol::models::ResponseInputItem; @@ -72,23 +76,40 @@ pub(crate) struct CompactedHistoryMetadata { pub(crate) reviewer_compaction_hash: Option, } -/// Render replacement context and its comparison baseline from the same captured step. -/// Initial context is installed only after compaction returns, above the last real user message -/// (or the summary when no user message remains), keeping the compaction summary last. -pub(crate) async fn build_compaction_initial_context( +/// Renders the window prefix and ordinary context with their comparison baseline. +pub(crate) async fn build_compaction_replacement_history( sess: &Session, step_context: &StepContext, world_state: &WorldState, + compacted_history: Vec, ) -> (Vec, WorldStateSnapshot) { - let (items, snapshot) = sess + let (updates, snapshot) = sess .build_initial_context_with_world_state(step_context, world_state) .await; + let (prefix, context) = split_prefix_updates(updates); + let context = merge_world_state_updates(context); ( - items.into_iter().map(ResponseItemEnvelope::new).collect(), + assemble_compaction_history(compacted_history, prefix, context), snapshot, ) } +fn assemble_compaction_history( + compacted_history: Vec, + prefix: Vec, + context: Vec, +) -> Vec { + let history = insert_initial_context_before_last_real_user_or_summary( + compacted_history, + context.into_iter().map(ResponseItemEnvelope::new).collect(), + ); + prefix + .into_iter() + .map(ResponseItemEnvelope::new) + .chain(history) + .collect() +} + pub(crate) async fn run_inline_auto_compact_task( sess: Arc, turn_context: Arc, @@ -269,9 +290,20 @@ async fn run_compact_task_inner_impl( .executed_tool_calls .attach_to_compaction_prompt(&mut turn_input); let turn_input_len = turn_input.len(); + let base_instructions = if turn_input + .iter() + .any(BaseInstructionsFragment::matches_item) + { + BaseInstructions { + text: String::new(), + provenance: None, + } + } else { + sess.get_prompt_base_instructions().await + }; let prompt = Prompt { input: turn_input, - base_instructions: sess.get_prompt_base_instructions().await, + base_instructions, cyber_access_program: turn_context.cyber_access_program, ..Default::default() }; @@ -359,11 +391,13 @@ async fn run_compact_task_inner_impl( } let (window_number, window_ids) = sess.advance_auto_compact_window().await; - let (initial_context, world_state_baseline) = - build_compaction_initial_context(sess.as_ref(), &replacement_step_context, &world_state) - .await; - new_history = - insert_initial_context_before_last_real_user_or_summary(new_history, initial_context); + let (new_history, world_state_baseline) = build_compaction_replacement_history( + sess.as_ref(), + &replacement_step_context, + &world_state, + new_history, + ) + .await; sess.replace_compacted_history( new_history, replacement_step_context.to_turn_context_item(), diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index e20b0a7f5c..6a061086b1 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -7,9 +7,8 @@ use crate::client_common::ResponseEvent; use crate::compact::CompactedHistoryMetadata; use crate::compact::CompactionAnalyticsAttempt; use crate::compact::CompactionAnalyticsDetails; -use crate::compact::build_compaction_initial_context; +use crate::compact::build_compaction_replacement_history; use crate::compact::compaction_status_from_result; -use crate::compact::insert_initial_context_before_last_real_user_or_summary; use crate::compact_model_fallback::record_model_fallback; use crate::compact_model_fallback::should_retry_with_current_model; use crate::compact_remote_history::HistoryItemGroup; @@ -313,11 +312,13 @@ async fn run_remote_compact_task_inner_impl( ); analytics_details.retained_image_count = Some(retained_images); let (new_window_number, new_window_ids) = sess.advance_auto_compact_window().await; - let (initial_context, world_state_baseline) = - build_compaction_initial_context(sess.as_ref(), replacement_step_context, &world_state) - .await; - let new_history = - insert_initial_context_before_last_real_user_or_summary(compacted_history, initial_context); + let (new_history, world_state_baseline) = build_compaction_replacement_history( + sess.as_ref(), + replacement_step_context, + &world_state, + compacted_history, + ) + .await; if let Some(trace_input_history) = trace_input_history.as_deref() { let replacement_history = new_history diff --git a/codex-rs/core/src/compact_remote_v2_attempt.rs b/codex-rs/core/src/compact_remote_v2_attempt.rs index ee4b3d900c..3ce092268b 100644 --- a/codex-rs/core/src/compact_remote_v2_attempt.rs +++ b/codex-rs/core/src/compact_remote_v2_attempt.rs @@ -14,6 +14,7 @@ use crate::session::session::Session; use crate::session::step_context::StepContext; use codex_history::CodexHarnessMetadata; use codex_protocol::error::Result as CodexResult; +use codex_protocol::models::BaseInstructions; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::TokenUsage; use codex_rollout_trace::CompactionTraceContext; @@ -42,7 +43,14 @@ pub(super) async fn run_remote_compact_v2_attempt( let turn_context = &step_context.turn; let mut history = sess.clone_history().await; let input_goal_ids = UserGoalUpdate::message_ids(history.raw_items()); - let base_instructions = sess.get_prompt_base_instructions().await; + let base_instructions = if step_context.uses_incremental_tools() { + BaseInstructions { + text: String::new(), + provenance: None, + } + } else { + sess.get_prompt_base_instructions().await + }; let (rewritten_outputs, estimated_deleted_tokens) = trim_function_call_history_to_fit_context_window( &mut history, diff --git a/codex-rs/core/src/compact_tests.rs b/codex-rs/core/src/compact_tests.rs index a407ffcb33..f57bd8853a 100644 --- a/codex-rs/core/src/compact_tests.rs +++ b/codex-rs/core/src/compact_tests.rs @@ -560,7 +560,10 @@ fn build_compacted_history_preserves_user_message_passthrough_metadata() { } #[test] -fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() { +fn assemble_compaction_history_keeps_prefix_first_and_summary_last() { + let prefix = ContextualUserFragment::into(crate::context::BaseInstructionsFragment( + "base instructions".to_string(), + )); let agent_completion = ResponseItem::AgentMessage { id: None, author: "child".to_string(), @@ -610,11 +613,13 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() internal_chat_message_metadata_passthrough: None, }]; - let refreshed = raw(insert_initial_context_before_last_real_user_or_summary( + let refreshed = raw(assemble_compaction_history( annotated(compacted_history), - annotated(initial_context), + vec![prefix.clone()], + initial_context, )); let expected = vec![ + prefix, ResponseItem::Message { id: None, role: "user".to_string(), @@ -657,7 +662,7 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() } #[test] -fn insert_initial_context_before_last_real_user_or_summary_keeps_compaction_last() { +fn assemble_compaction_history_keeps_compaction_last() { let agent_task = ResponseItem::AgentMessage { id: None, author: "parent".to_string(), @@ -683,9 +688,10 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_compaction_last internal_chat_message_metadata_passthrough: None, }]; - let refreshed = raw(insert_initial_context_before_last_real_user_or_summary( + let refreshed = raw(assemble_compaction_history( annotated(compacted_history), - annotated(initial_context), + Vec::new(), + initial_context, )); let expected = vec![ ResponseItem::Message { diff --git a/codex-rs/core/src/context/base_instructions.rs b/codex-rs/core/src/context/base_instructions.rs index c6898e7f29..d691f689cf 100644 --- a/codex-rs/core/src/context/base_instructions.rs +++ b/codex-rs/core/src/context/base_instructions.rs @@ -1,12 +1,31 @@ use super::ContextualUserFragment; use codex_protocol::models::ContentItemKind; +use codex_protocol::models::ResponseItem; #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct BaseInstructionsFragment(pub(crate) String); +impl BaseInstructionsFragment { + pub(crate) const KIND: &str = "model.base_instructions"; + + pub(crate) fn matches_item(item: &ResponseItem) -> bool { + let ResponseItem::Message { + internal_chat_message_metadata_passthrough: Some(metadata), + .. + } = item + else { + return false; + }; + metadata + .content_item_kinds + .as_ref() + .is_some_and(|kinds| kinds.iter().any(|kind| kind.0 == Self::KIND)) + } +} + impl ContextualUserFragment for BaseInstructionsFragment { fn content_kind(&self) -> ContentItemKind { - ContentItemKind("model.base_instructions".to_string()) + ContentItemKind(Self::KIND.to_string()) } fn role(&self) -> &'static str { diff --git a/codex-rs/core/src/context/world_state/base_instructions.rs b/codex-rs/core/src/context/world_state/base_instructions.rs new file mode 100644 index 0000000000..ac79a45425 --- /dev/null +++ b/codex-rs/core/src/context/world_state/base_instructions.rs @@ -0,0 +1,28 @@ +//! Records fixed base instructions alongside tool declarations at the start of each window. + +use super::PreviousSectionState; +use super::SectionTransition; +use super::WorldStateHash; +use super::WorldStateSection; +use super::WorldStateUpdate; +use crate::context::BaseInstructionsFragment; + +pub(crate) struct BaseInstructionsState(pub(crate) String); + +impl WorldStateSection for BaseInstructionsState { + const ID: &'static str = "base_instructions"; + type Snapshot = WorldStateHash; + + fn render_diff( + &self, + previous: PreviousSectionState<'_, Self::Snapshot>, + ) -> SectionTransition { + let fragment = BaseInstructionsFragment(self.0.clone()); + let hash = WorldStateHash::from_fragment(&fragment); + let updates = WorldStateUpdate::optional_prefix_boxed_fragment( + (matches!(previous, PreviousSectionState::Absent) && !self.0.is_empty()) + .then(|| Box::new(fragment) as _), + ); + (Some(hash), updates) + } +} diff --git a/codex-rs/core/src/context/world_state/mod.rs b/codex-rs/core/src/context/world_state/mod.rs index 798933e593..6a5d22db80 100644 --- a/codex-rs/core/src/context/world_state/mod.rs +++ b/codex-rs/core/src/context/world_state/mod.rs @@ -1,5 +1,6 @@ mod agents_md; mod apps_instructions; +mod base_instructions; mod collaboration_mode; mod compact_permissions; mod context_window_guidance; @@ -39,6 +40,7 @@ use std::fmt; pub(crate) use agents_md::AgentsMdState; pub(crate) use apps_instructions::AppsInstructionsState; +pub(crate) use base_instructions::BaseInstructionsState; pub(crate) use collaboration_mode::CollaborationModeState; pub(crate) use compact_permissions::CompactPermissionsState; pub(crate) use context_window_guidance::ContextWindowGuidanceState; @@ -105,6 +107,18 @@ impl WorldStateUpdate { .collect() } + pub(crate) fn optional_prefix_boxed_fragment( + fragment: Option>, + ) -> Vec { + fragment + .into_iter() + .map(|fragment| Self { + placement: Placement::Prefix, + content: WorldStateUpdateContent::Fragment(fragment), + }) + .collect() + } + pub(crate) fn standalone(mut self) -> Self { self.placement = Placement::Standalone; self diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 1ecb4173f8..1c32e5e2b9 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -12,6 +12,7 @@ #[path = "history_user_authorization.rs"] mod user_authorization; +use crate::context::BaseInstructionsFragment; use crate::context::ContextualUserFragment; use crate::context::ModelSwitchInstructions; use crate::context::is_guardian_context_message; @@ -653,8 +654,14 @@ impl ContextManager { &self, base_instructions: &BaseInstructions, ) -> Option { - let base_tokens = - i64::try_from(approx_token_count(&base_instructions.text)).unwrap_or(i64::MAX); + // Incremental windows already account for their recorded base instructions below. + let has_recorded_instructions = + self.raw_items().any(BaseInstructionsFragment::matches_item); + let base_tokens = if has_recorded_instructions { + 0 + } else { + i64::try_from(approx_token_count(&base_instructions.text)).unwrap_or(i64::MAX) + }; let items_tokens = self .items diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 52d417afaa..9e2696e7a0 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -1498,6 +1498,24 @@ fn estimate_token_count_with_base_instructions_uses_provided_text() { assert_eq!(long_estimate - short_estimate, expected_delta); } +#[test] +fn estimate_token_count_counts_recorded_base_instructions_once() { + use crate::context::ContextualUserFragment; + + let base = BaseInstructions { + text: "base instructions ".repeat(100), + provenance: None, + }; + let item = + ContextualUserFragment::into(crate::context::BaseInstructionsFragment(base.text.clone())); + let expected = estimate_item_token_count(&item); + let history = create_history_with_items(vec![item]); + assert_eq!( + history.estimate_token_count_with_base_instructions(&base), + Some(expected) + ); +} + #[test] fn remove_first_item_removes_matching_output_for_function_call() { let items = vec![ diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 7b14f934c9..853f047d47 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -33,6 +33,7 @@ use crate::context::RecommendedPluginsInstructions; use crate::context::world_state::Placement; use crate::context::world_state::WorldState; use crate::context::world_state::WorldStateSnapshot; +use crate::context::world_state::WorldStateUpdate; use crate::context::world_state::WorldStateUpdateContent; use crate::context::world_state::split_prefix_updates; use crate::current_time::TimeProvider; @@ -4182,13 +4183,12 @@ impl Session { } /// `step_context` and `world_state` must come from the same captured step. - /// If more callers need this pair, bundle them into a captured-context struct - /// so callers cannot mix settings and WorldState from different steps. + /// Returns rendered items with placement preserved for the caller's history layout. pub(crate) async fn build_initial_context_with_world_state( &self, step_context: &StepContext, world_state: &WorldState, - ) -> (Vec, WorldStateSnapshot) { + ) -> (Vec, WorldStateSnapshot) { let turn_context = step_context.turn.as_ref(); let mut developer_sections = Vec::::with_capacity(8); let mut contextual_user_sections = Vec::::with_capacity(2); @@ -4331,8 +4331,9 @@ impl Session { .render_fragment(), ); } - let (world_state_snapshot, updates) = world_state.render_full(); - let (mut items, context) = split_prefix_updates(updates); + let (snapshot, updates) = world_state.render_full(); + let (mut prefix, context) = split_prefix_updates(updates); + let mut items = Vec::with_capacity(4); let mut context = context.into_iter(); // Keep the initial-context grouping within each fragment run, but never across an item. loop { @@ -4445,10 +4446,18 @@ impl Session { } } // New context windows and compaction install these items directly into replacement history. - for item in &mut items { + for item in prefix.iter_mut().chain(&mut items) { item.set_turn_id_if_missing(&turn_context.sub_id); } - (items, world_state_snapshot) + let updates = prefix + .into_iter() + .map(WorldStateUpdate::prefix_item) + .chain(items.into_iter().map(|item| WorldStateUpdate { + placement: Placement::Standalone, + content: WorldStateUpdateContent::Item(Box::new(item)), + })) + .collect(); + (updates, snapshot) } #[tracing::instrument(level = "trace", skip_all, fields(item_count = items.len()))] @@ -4543,14 +4552,15 @@ impl Session { state.start_new_context_window() }; let (window_number, window_ids) = window; - let (context_items, world_state_snapshot) = self + let (context_updates, world_state_snapshot) = self .build_initial_context_with_world_state(step_context, world_state.as_ref()) .await; - let context_items = context_items - .into_iter() - .map(ResponseItemEnvelope::new) - .chain(retained_client_developer_messages) - .collect(); + let context_items = + crate::context_manager::updates::merge_world_state_updates(context_updates) + .into_iter() + .map(ResponseItemEnvelope::new) + .chain(retained_client_developer_messages) + .collect(); let turn_context_item = step_context.to_turn_context_item(); self.replace_compacted_history( context_items, @@ -4605,9 +4615,11 @@ impl Session { let world_state = Arc::new(self.build_world_state_for_step(step_context).await?); // Full initial context resets the baseline; later turns persist only its changes. let (mut context_items, world_state_item) = if should_inject_full_context { - let (context_items, snapshot) = self + let (context_updates, snapshot) = self .build_initial_context_with_world_state(step_context, world_state.as_ref()) .await; + let context_items = + crate::context_manager::updates::merge_world_state_updates(context_updates); self.state .lock() .await diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 8e13dbf102..7eb2063a94 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -6654,10 +6654,10 @@ async fn build_initial_context( .build_world_state_for_step(&step_context) .await .expect("world state should build"); - session + let (updates, _) = session .build_initial_context_with_world_state(&step_context, &world_state) - .await - .0 + .await; + crate::context_manager::updates::merge_world_state_updates(updates) } pub(crate) async fn build_world_state_from_turn_context( @@ -10568,6 +10568,8 @@ async fn build_initial_context_reuses_in_flight_recommendation_prewarm() { assert!(futures::poll!(initial_context.as_mut()).is_pending()); let (_, (initial_context, _)) = tokio::join!(prewarm, initial_context); + let initial_context = + crate::context_manager::updates::merge_world_state_updates(initial_context); assert_eq!( developer_input_texts(&initial_context) .into_iter() @@ -11298,8 +11300,13 @@ async fn build_initial_context_uses_retained_step_after_model_change() { .await .unwrap(); let world_a = Arc::new(session.build_world_state_for_step(&step_a).await.unwrap()); - let (initial_a, _) = - crate::compact::build_compaction_initial_context(&session, &step_a, &world_a).await; + let (initial_a, _) = crate::compact::build_compaction_replacement_history( + &session, + &step_a, + &world_a, + Vec::new(), + ) + .await; let mut selected_b = step_a.settings.selected().clone(); selected_b.collaboration_mode.settings.model = "model-b".to_string(); @@ -11328,15 +11335,21 @@ async fn build_initial_context_uses_retained_step_after_model_change() { .build_initial_context_with_world_state(&step_b, &world_b) .await .0; + let initial_b = crate::context_manager::updates::merge_world_state_updates(initial_b); let turn_contributions_b = session.build_turn_context_contribution_items(&step_b).await; - let (restored_a, restored_snapshot) = - crate::compact::build_compaction_initial_context(&session, &step_a, &world_a).await; + let (restored_a, restored_snapshot) = crate::compact::build_compaction_replacement_history( + &session, + &step_a, + &world_a, + Vec::new(), + ) + .await; assert_eq!(restored_a, initial_a); assert_eq!(restored_snapshot, world_a.render_full().0); let initial_a = initial_a .into_iter() - .map(ResponseItemEnvelope::into_item) + .map(|item| item.item) .collect::>(); let a_text = developer_input_texts(&initial_a).join("\n"); let b_text = developer_input_texts(&initial_b).join("\n"); diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 6eee4d3939..8e616d38fd 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -1117,20 +1117,23 @@ async fn compaction_initial_context_preserves_separate_guardian_developer_messag .await .expect("world state should build"), ); - let (refreshed, _) = - crate::compact::build_compaction_initial_context(&session, &step_context, &world_state) - .await; + let (refreshed, _) = crate::compact::build_compaction_replacement_history( + &session, + &step_context, + &world_state, + Vec::new(), + ) + .await; let developer_messages = refreshed .iter() - .filter_map(|envelope| match &envelope.item { + .map(|item| &item.item) + .filter_map(|item| match item { ResponseItem::Message { role, content, .. } if role == "developer" => { crate::content_items_to_text(content).map(|text| { ( text, - envelope - .item - .executed_tool_call_metadata() + item.executed_tool_call_metadata() .and_then(|metadata| metadata.content_item_kinds.clone()), ) }) diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index d92917472c..c4e3dfc9cf 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1583,7 +1583,14 @@ pub(crate) fn build_prompt( step_context.tool_router.model_visible_specs() }, parallel_tool_calls: true, - base_instructions, + base_instructions: if step_context.uses_incremental_tools() { + BaseInstructions { + text: String::new(), + provenance: None, + } + } else { + base_instructions + }, output_schema: turn_context.final_output_json_schema.clone(), output_schema_strict: !crate::guardian::is_basic_session_source( &turn_context.session_source, diff --git a/codex-rs/core/src/session/world_state.rs b/codex-rs/core/src/session/world_state.rs index 109fe1cee8..1d73601e89 100644 --- a/codex-rs/core/src/session/world_state.rs +++ b/codex-rs/core/src/session/world_state.rs @@ -6,6 +6,7 @@ use crate::connectors; use crate::context::TokenBudgetContext; use crate::context::world_state::AgentsMdState; use crate::context::world_state::AppsInstructionsState; +use crate::context::world_state::BaseInstructionsState; use crate::context::world_state::CollaborationModeState; use crate::context::world_state::CompactPermissionsState; use crate::context::world_state::ContextWindowGuidanceState; @@ -122,6 +123,7 @@ impl Session { let specs = step_context.tool_router.model_visible_specs(); let definitions = codex_tools::create_tools_json_for_responses_lite(&specs)?; world_state.add_section(TopLevelToolsState::new(definitions)?); + world_state.add_section(BaseInstructionsState(base_instructions)); } world_state.add_section(ModelInstructionsState::new( &model_info.slug, diff --git a/codex-rs/core/src/session/world_state_item_tests.rs b/codex-rs/core/src/session/world_state_item_tests.rs index 418bf78f8a..d253e64543 100644 --- a/codex-rs/core/src/session/world_state_item_tests.rs +++ b/codex-rs/core/src/session/world_state_item_tests.rs @@ -13,6 +13,8 @@ use crate::context::world_state::WorldState; use crate::context::world_state::WorldStateSection; use crate::context::world_state::WorldStateUpdate; use crate::context::world_state::WorldStateUpdateContent; +use crate::context::world_state::split_prefix_updates; +use crate::context_manager::updates::merge_world_state_updates; use codex_protocol::models::ResponseItem; use core_test_support::responses::strip_metadata_from_items; use pretty_assertions::assert_eq; @@ -85,14 +87,20 @@ async fn initial_context_preserves_world_state_items_and_snapshot() { let step_context = StepContext::for_test(Arc::new(turn_context)); let mut world_state = WorldState::default(); world_state.add_section(ItemSection); - let (items, snapshot) = session + let (updates, snapshot) = session .build_initial_context_with_world_state(&step_context, &world_state) .await; + let (prefix, context) = split_prefix_updates(updates); assert_eq!( - strip_metadata_from_items(&items), + strip_metadata_from_items(&prefix), strip_metadata_from_items(&[ additional_tools("example"), ContextualUserFragment::into(BaseInstructionsFragment("base instructions".to_string())), + ]), + ); + assert_eq!( + strip_metadata_from_items(&merge_world_state_updates(context)), + strip_metadata_from_items(&[ ContextualUserFragment::into(DeveloperInstructions::new("before item")), ContextualUserFragment::into(DeveloperInstructions::new("separate before item")), ContextualUserFragment::into(UserInstructions { diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index e0074648bd..5570407abb 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -839,10 +839,11 @@ async fn ignores_session_prefix_messages_when_truncating() { let turn_context = Arc::new(turn_context); let world_state = build_world_state_from_turn_context(&session, &turn_context).await; let step_context = StepContext::for_test(turn_context); - let mut items = session + let updates = session .build_initial_context_with_world_state(&step_context, &world_state) .await .0; + let mut items = crate::context_manager::updates::merge_world_state_updates(updates); items.push(user_msg("feature request")); items.push(assistant_msg("ack")); items.push(user_msg("second question")); @@ -1459,6 +1460,8 @@ async fn spawn_internal_session_preserves_parent_lineage_without_forking_history .build_initial_context_with_world_state(&reviewer_step, &reviewer_world_state) .await .0; + let reviewer_context = + crate::context_manager::updates::merge_world_state_updates(reviewer_context); assert!( !serde_json::to_string(&reviewer_context) .expect("reviewer context should serialize") diff --git a/codex-rs/core/src/thread_rollout_truncation_tests.rs b/codex-rs/core/src/thread_rollout_truncation_tests.rs index b92c252f01..68ec36cc48 100644 --- a/codex-rs/core/src/thread_rollout_truncation_tests.rs +++ b/codex-rs/core/src/thread_rollout_truncation_tests.rs @@ -367,10 +367,11 @@ async fn ignores_session_prefix_messages_when_truncating_rollout_from_start() { let turn_context = Arc::new(turn_context); let world_state = build_world_state_from_turn_context(&session, &turn_context).await; let step_context = StepContext::for_test(turn_context); - let mut items = session + let updates = session .build_initial_context_with_world_state(&step_context, &world_state) .await .0; + let mut items = crate::context_manager::updates::merge_world_state_updates(updates); items.push(user_msg("feature request")); items.push(assistant_msg("ack")); items.push(user_msg("second question")); diff --git a/codex-rs/core/tests/suite/compact_remote_trimming.rs b/codex-rs/core/tests/suite/compact_remote_trimming.rs index 912bf9f6a1..7c1fac6a7e 100644 --- a/codex-rs/core/tests/suite/compact_remote_trimming.rs +++ b/codex-rs/core/tests/suite/compact_remote_trimming.rs @@ -3,6 +3,7 @@ use super::*; use core_test_support::apps_test_server::configure_search_capable_model; use pretty_assertions::assert_eq; +use test_case::test_case; fn compact_response() -> String { sse(vec![ @@ -141,15 +142,21 @@ async fn remote_compact_v2_trims_tool_search_output_to_empty_tools_array() -> Re Ok(()) } +#[test_case(false; "request_instructions")] +#[test_case(true; "recorded_instructions")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn remote_compact_v2_trim_estimate_uses_session_base_instructions() -> Result<()> { +async fn remote_compact_v2_trim_estimate_uses_session_base_instructions( + incremental_tools: bool, +) -> Result<()> { skip_if_no_network!(Ok(())); let short_instructions = "session base instructions"; - let long_instructions = format!("{short_instructions} {}", "x".repeat(24_000)); + let medium_instructions = format!("{short_instructions} {}", "x".repeat(16_000)); + let long_instructions = format!("{short_instructions} {}", "x".repeat(40_000)); let trailing_output = "x".repeat(12_000); for (instructions, expected_output) in [ (short_instructions, trailing_output.as_str()), + (medium_instructions.as_str(), trailing_output.as_str()), ( long_instructions.as_str(), CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE, @@ -158,11 +165,20 @@ async fn remote_compact_v2_trim_estimate_uses_session_base_instructions() -> Res let harness = TestCodexHarness::with_builder( test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_model_info_override("gpt-5.4", move |model| { + model.use_responses_lite = incremental_tools; + }) .with_config({ let instructions = instructions.to_string(); move |config| { - config.model_context_window = Some(8_000); + config.model_context_window = Some(12_000); config.base_instructions = Some(instructions); + if incremental_tools { + config + .features + .enable(Feature::IncrementalTools) + .expect("enable incremental tools"); + } } }), ) diff --git a/codex-rs/core/tests/suite/scenarios_incremental_tools.rs b/codex-rs/core/tests/suite/scenarios_incremental_tools.rs index 0d20b4101f..236edfc5f9 100644 --- a/codex-rs/core/tests/suite/scenarios_incremental_tools.rs +++ b/codex-rs/core/tests/suite/scenarios_incremental_tools.rs @@ -126,6 +126,16 @@ async fn incremental_tools_append_changed_catalog_without_rewriting_history( .iter() .all(|request| request.body_json().get("tools").is_none()) ); + for request in &requests { + let input = request.input(); + assert_eq!(input[0]["type"], "additional_tools"); + assert_eq!(input[1]["role"], "developer"); + assert_eq!( + input[1]["content"][0]["text"], + "Use the available tools to help the user." + ); + assert!(request.body_json().get("instructions").is_none()); + } let initial = requests[0].inputs_of_type("additional_tools"); assert_eq!(initial.len(), 1); assert_eq!( diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools__incremental_tool_removals.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools__incremental_tool_removals.snap index e531f7f5bf..b885e40dd7 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools__incremental_tool_removals.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools__incremental_tool_removals.snap @@ -16,9 +16,7 @@ Settings: text: {"verbosity":"low"} tool_choice: "auto" -- request 1 (turn) -- -00:message/developer: - -01:additional_tools/developer (3; hash=7748a53142f449b7): +00:additional_tools/developer (3; hash=7748a53142f449b7): - namespace/functions; hash=124fee69a69ed6b2 - function/exec_command - function/write_stdin @@ -37,6 +35,8 @@ Settings: - namespace/mcp__echo_service: Echo service instructions.; hash=a5144e5d70d01799 - function/cwd - function/echo +01:message/developer: + 02:message/developer: 03:message/user: diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools__incremental_tools.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools__incremental_tools.snap index 0e344e1cd7..64702ea68e 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools__incremental_tools.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools__incremental_tools.snap @@ -16,9 +16,7 @@ Settings: text: {"verbosity":"low"} tool_choice: "auto" -- request 1 (turn) -- -00:message/developer: - -01:additional_tools/developer (2; hash=49f981f03ae5c5ce): +00:additional_tools/developer (2; hash=49f981f03ae5c5ce): - namespace/functions; hash=cc6e8cf0e5405f3a - custom/exec - function/wait @@ -28,6 +26,8 @@ Settings: - custom/apply_patch - function/view_image - tool_search: # Tool discovery Searches over deferred tool metadata with BM25 and exposes matching tools for t...; args=[limit, query]; hash=400cd6ac61d524a9 +01:message/developer: + 02:message/developer: 03:message/user: @@ -42,9 +42,7 @@ Settings: Settings: relative to window 1 model: "updated-tools-model" -- request 3 (turn) -- -00:message/developer: - -01:additional_tools/developer (2; hash=49f981f03ae5c5ce): +00:additional_tools/developer (2; hash=49f981f03ae5c5ce): - namespace/functions; hash=cc6e8cf0e5405f3a - custom/exec - function/wait @@ -54,6 +52,8 @@ Settings: relative to window 1 - custom/apply_patch - function/view_image - tool_search: # Tool discovery Searches over deferred tool metadata with BM25 and exposes matching tools for t...; args=[limit, query]; hash=400cd6ac61d524a9 +01:message/developer: + 02:message/developer: 03:message/user: