use crate::context_manager::normalize;use crate::event_mapping::has_non_contextual_dev_message_content;use crate::event_mapping::is_contextual_dev_message_content;use crate::event_mapping::is_contextual_user_message_content;use crate::session::turn_context::TurnContext;use base64::Engine;use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;use codex_protocol::models::BaseInstructions;use codex_protocol::models::ContentItem;use codex_protocol::models::FunctionCallOutputBody;use codex_protocol::models::FunctionCallOutputContentItem;use codex_protocol::models::FunctionCallOutputPayload;use codex_protocol::models::ImageDetail;use codex_protocol::models::ResponseItem;use codex_protocol::openai_models::InputModality;use codex_protocol::protocol::InterAgentCommunication;use codex_protocol::protocol::TokenUsage;use codex_protocol::protocol::TokenUsageInfo;use codex_protocol::protocol::TurnContextItem;use codex_utils_cache::BlockingLruCache;use codex_utils_cache::sha1_digest;use codex_utils_output_truncation::TruncationPolicy;use codex_utils_output_truncation::approx_bytes_for_tokens;use codex_utils_output_truncation::approx_token_count;use codex_utils_output_truncation::approx_tokens_from_byte_count_i64;use codex_utils_output_truncation::truncate_function_output_items_with_policy;use codex_utils_output_truncation::truncate_text;use std::num::NonZeroUsize;use std::ops::Deref;use std::sync::LazyLock;/// Transcript of thread history#[derive(Debug, Clone, Default)]pub(crate) struct ContextManager { /// The oldest items are at the beginning of the vector. items: Vec<ResponseItem>, /// Bumped whenever history is rewritten, such as compaction or rollback. history_version: u64, token_info: Option<TokenUsageInfo>, /// Reference context snapshot used for diffing and producing model-visible /// settings update items. /// /// This is the baseline for the next regular model turn, and may already /// match the current turn after context updates are persisted. /// /// When this is `None`, settings diffing treats the next turn as having no /// baseline and emits a full reinjection of context state. Rollback may /// also clear this when it trims a mixed initial-context developer bundle /// whose non-diff fragments no longer exist in the surviving history. reference_context_item: Option<TurnContextItem>,}impl ContextManager { pub(crate) fn new() -> Self { Self { items: Vec::new(), history_version: 0, token_info: TokenUsageInfo::new_or_append( &None, &None, /*model_context_window*/ None, ), reference_context_item: None, } } pub(crate) fn token_info(&self) -> Option<TokenUsageInfo> { self.token_info.clone() } pub(crate) fn set_token_info(&mut self, info: Option<TokenUsageInfo>) { self.token_info = info; } pub(crate) fn set_reference_context_item(&mut self, item: Option<TurnContextItem>) { self.reference_context_item = item; } pub(crate) fn reference_context_item(&self) -> Option<TurnContextItem> { self.reference_context_item.clone() } pub(crate) fn set_token_usage_full(&mut self, context_window: i64) { match &mut self.token_info { Some(info) => info.fill_to_context_window(context_window), None => { self.token_info = Some(TokenUsageInfo::full_context_window(context_window)); } } } /// `items` is ordered from oldest to newest. pub(crate) fn record_items<I>(&mut self, items: I, policy: TruncationPolicy) where I: IntoIterator, I::Item: std::ops::Deref<Target = ResponseItem>, { for item in items { let item_ref = item.deref(); if !is_api_message(item_ref) { continue; } let processed = self.process_item(item_ref, policy); self.items.push(processed); } } /// Returns the history prepared for sending to the model. This applies a proper /// normalization and drops un-suited items. When `input_modalities` does not /// include `InputModality::Image`, images are stripped from messages and tool /// outputs. pub(crate) fn for_prompt(mut self, input_modalities: &[InputModality]) -> Vec<ResponseItem> { self.normalize_history(input_modalities); self.items } /// Returns raw items in the history. pub(crate) fn raw_items(&self) -> &[ResponseItem] { &self.items } /// Returns raw items in the history and consumes the snapshot. pub(crate) fn into_raw_items(self) -> Vec<ResponseItem> { self.items } pub(crate) fn history_version(&self) -> u64 { self.history_version } // Estimate token usage using byte-based heuristics from the truncation helpers. // This is a coarse lower bound, not a tokenizer-accurate count. pub(crate) fn estimate_token_count(&self, turn_context: &TurnContext) -> Option<i64> { let model_info = &turn_context.model_info; let personality = turn_context.personality.or(turn_context.config.personality); let base_instructions = BaseInstructions { text: model_info.get_model_instructions(personality), }; self.estimate_token_count_with_base_instructions(&base_instructions) } pub(crate) fn estimate_token_count_with_base_instructions( &self, base_instructions: &BaseInstructions, ) -> Option<i64> { let base_tokens = i64::try_from(approx_token_count(&base_instructions.text)).unwrap_or(i64::MAX); let items_tokens = self .items .iter() .map(estimate_item_token_count) .fold(0i64, i64::saturating_add); Some(base_tokens.saturating_add(items_tokens)) } pub(crate) fn remove_first_item(&mut self) { if !self.items.is_empty() { // Remove the oldest item (front of the list). Items are ordered from // oldest → newest, so index 0 is the first entry recorded. let removed = self.items.remove(0); // If the removed item participates in a call/output pair, also remove // its corresponding counterpart to keep the invariants intact without // running a full normalization pass. normalize::remove_corresponding_for(&mut self.items, &removed); } } pub(crate) fn replace(&mut self, items: Vec<ResponseItem>) { self.items = items; self.history_version = self.history_version.saturating_add(1); } /// Replace image content in the last turn if it originated from a tool output. /// Returns true when a tool image was replaced, false otherwise. pub(crate) fn replace_last_turn_images(&mut self, placeholder: &str) -> bool { let Some(index) = self.items.iter().rposition(|item| { matches!(item, ResponseItem::FunctionCallOutput { .. }) || is_user_turn_boundary(item) }) else { return false; }; match &mut self.items[index] { ResponseItem::FunctionCallOutput { output, .. } => { let Some(content_items) = output.content_items_mut() else { return false; }; let mut replaced = false; let placeholder = placeholder.to_string(); for item in content_items.iter_mut() { if matches!(item, FunctionCallOutputContentItem::InputImage { .. }) { *item = FunctionCallOutputContentItem::InputText { text: placeholder.clone(), }; replaced = true; } } if replaced { self.history_version = self.history_version.saturating_add(1); } replaced } ResponseItem::Message { .. } => false, _ => false, } } /// Drop the last `num_turns` instruction turns from this history. /// /// Instruction turns are history messages that should behave like a new prompt boundary: /// ordinary user messages and structured assistant inter-agent instructions. /// /// This mirrors thread-rollback semantics: /// - `num_turns == 0` is a no-op /// - if there are no user turns, this is a no-op /// - if `num_turns` exceeds the number of user turns, all user turns are dropped while /// preserving any items that occurred before the first user message. /// /// If rollback trims a pre-turn developer message that mixes contextual fragments with /// persistent developer text from `build_initial_context`, this also clears /// `reference_context_item`. The surviving history no longer contains the full bundle that /// established the prior baseline, so future turns must fall back to full reinjection instead /// of diffing against stale state. pub(crate) fn drop_last_n_user_turns(&mut self, num_turns: u32) { if num_turns == 0 { return; } let snapshot = self.items.clone(); let user_positions = user_message_positions(&snapshot); let Some(&first_instruction_turn_idx) = user_positions.first() else { self.replace(snapshot); return; }; let n_from_end = usize::try_from(num_turns).unwrap_or(usize::MAX); let mut cut_idx = if n_from_end >= user_positions.len() { first_instruction_turn_idx } else { user_positions[user_positions.len() - n_from_end] }; cut_idx = self.trim_pre_turn_context_updates(&snapshot, first_instruction_turn_idx, cut_idx); self.replace(snapshot[..cut_idx].to_vec()); } pub(crate) fn update_token_info( &mut self, usage: &TokenUsage, model_context_window: Option<i64>, ) { self.token_info = TokenUsageInfo::new_or_append( &self.token_info, &Some(usage.clone()), model_context_window, ); } fn get_non_last_reasoning_items_tokens(&self) -> i64 { // Get reasoning items excluding all the ones after the last instruction boundary. let Some(last_user_index) = self.items.iter().rposition(is_user_turn_boundary) else { return 0; }; self.items .iter() .take(last_user_index) .filter(|item| { matches!( item, ResponseItem::Reasoning { encrypted_content: Some(_), .. } ) }) .map(estimate_item_token_count) .fold(0i64, i64::saturating_add) } // These are local items added after the most recent model-emitted item. // They are not reflected in `last_token_usage.total_tokens`. fn items_after_last_model_generated_item(&self) -> &[ResponseItem] { let start = self .items .iter() .rposition(is_model_generated_item) .map_or(self.items.len(), |index| index.saturating_add(1)); &self.items[start..] } /// When true, the server already accounted for past reasoning tokens and /// the client should not re-estimate them. pub(crate) fn get_total_token_usage(&self, server_reasoning_included: bool) -> i64 { let last_tokens = self .token_info .as_ref() .map(|info| info.last_token_usage.total_tokens) .unwrap_or(0); let items_after_last_model_generated_tokens = self .items_after_last_model_generated_item() .iter() .map(estimate_item_token_count) .fold(0i64, i64::saturating_add); if server_reasoning_included { last_tokens.saturating_add(items_after_last_model_generated_tokens) } else { last_tokens .saturating_add(self.get_non_last_reasoning_items_tokens()) .saturating_add(items_after_last_model_generated_tokens) } } pub(crate) fn estimated_tokens_after_last_model_generated_item(&self) -> i64 { self.items_after_last_model_generated_item() .iter() .map(estimate_item_token_count) .fold(0i64, i64::saturating_add) } /// This function enforces a couple of invariants on the in-memory history: /// 1. every call (function/custom) has a corresponding output entry /// 2. every output has a corresponding call entry /// 3. when images are unsupported, image content is stripped from messages and tool outputs fn normalize_history(&mut self, input_modalities: &[InputModality]) { // all function/tool calls must have a corresponding output normalize::ensure_call_outputs_present(&mut self.items); // all outputs must have a corresponding function/tool call normalize::remove_orphan_outputs(&mut self.items); // strip images when model does not support them normalize::strip_images_when_unsupported(input_modalities, &mut self.items); } fn process_item(&self, item: &ResponseItem, policy: TruncationPolicy) -> ResponseItem { let policy_with_serialization_budget = policy * 1.2; match item { ResponseItem::FunctionCallOutput { call_id, output, metadata, } => ResponseItem::FunctionCallOutput { call_id: call_id.clone(), output: truncate_function_output_payload(output, policy_with_serialization_budget), metadata: metadata.clone(), }, ResponseItem::CustomToolCallOutput { call_id, name, output, metadata, } => ResponseItem::CustomToolCallOutput { call_id: call_id.clone(), name: name.clone(), output: truncate_function_output_payload(output, policy_with_serialization_budget), metadata: metadata.clone(), }, ResponseItem::Message { .. } | ResponseItem::AgentMessage { .. } | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::ToolSearchCall { .. } | ResponseItem::ToolSearchOutput { .. } | ResponseItem::WebSearchCall { .. } | ResponseItem::ImageGenerationCall { .. } | ResponseItem::CustomToolCall { .. } | ResponseItem::Compaction { .. } | ResponseItem::CompactionTrigger { .. } | ResponseItem::ContextCompaction { .. } | ResponseItem::Other => item.clone(), } } /// Walk backward from a rollback cut and trim contiguous pre-turn context-update items. /// /// Returns the adjusted cut index after removing contextual developer/user items immediately /// above the rolled-back turn boundary. /// /// `first_instruction_turn_idx` is the earliest rollback-eligible instruction-turn boundary /// in `snapshot`; the trim walk never crosses it so any session-prefix items that predate the /// first real turn survive rollback. /// /// `cut_idx` is the tentative slice boundary after dropping the requested number of /// instruction turns, before stripping contextual pre-turn items that sit immediately above /// that boundary. /// /// If any trimmed developer message was a mixed `build_initial_context` bundle containing both /// rollback-trimmable contextual fragments and persistent developer text, this also clears the /// stored `reference_context_item` baseline so the next real turn falls back to full /// reinjection. fn trim_pre_turn_context_updates( &mut self, snapshot: &[ResponseItem], first_instruction_turn_idx: usize, mut cut_idx: usize, ) -> usize { while cut_idx > first_instruction_turn_idx { match &snapshot[cut_idx - 1] { ResponseItem::Message { role, content, .. } if role == "developer" && is_contextual_dev_message_content(content) => { if has_non_contextual_dev_message_content(content) { // Mixed `build_initial_context` bundles are not reconstructible from // steady-state diffs once trimmed, so the next real turn must fully // reinject context instead of diffing against a stale baseline. self.reference_context_item = None; } cut_idx -= 1; } ResponseItem::Message { role, content, .. } if role == "user" && is_contextual_user_message_content(content) => { cut_idx -= 1; } _ => break, } } cut_idx }}pub(crate) fn truncate_function_output_payload( output: &FunctionCallOutputPayload, policy: TruncationPolicy,) -> FunctionCallOutputPayload { let body = match &output.body { FunctionCallOutputBody::Text(content) => { FunctionCallOutputBody::Text(truncate_text(content, policy)) } FunctionCallOutputBody::ContentItems(items) => FunctionCallOutputBody::ContentItems( truncate_function_output_items_with_policy(items, policy), ), }; FunctionCallOutputPayload { body, success: output.success, }}/// API messages include every non-system item (user/assistant messages, reasoning,/// tool calls, tool outputs, shell calls, web-search calls, and image-generation/// calls).fn is_api_message(message: &ResponseItem) -> bool { match message { ResponseItem::Message { role, .. } => role.as_str() != "system", ResponseItem::AgentMessage { .. } | ResponseItem::FunctionCallOutput { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::ToolSearchCall { .. } | ResponseItem::ToolSearchOutput { .. } | ResponseItem::CustomToolCall { .. } | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::Reasoning { .. } | ResponseItem::WebSearchCall { .. } | ResponseItem::ImageGenerationCall { .. } | ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true, ResponseItem::CompactionTrigger { .. } => false, ResponseItem::Other => false, }}fn estimate_reasoning_length(encoded_len: usize) -> usize { encoded_len .saturating_mul(3) .checked_div(4) .unwrap_or(0) .saturating_sub(650)}fn estimate_encrypted_function_output_length(encoded_len: usize) -> usize { encoded_len.saturating_mul(9).div_ceil(16)}fn estimate_item_token_count(item: &ResponseItem) -> i64 { let model_visible_bytes = estimate_response_item_model_visible_bytes(item); approx_tokens_from_byte_count_i64(model_visible_bytes)}/// Approximate model-visible byte cost for one image input.////// The estimator later converts bytes to tokens using a 4-bytes/token heuristic/// with ceiling division, so 7,373 bytes maps to approximately 1,844 tokens.const RESIZED_IMAGE_BYTES_ESTIMATE: i64 = 7373;// See https://platform.openai.com/docs/guides/images-vision#calculating-costs.// Use a direct 32px patch count only for `detail: "original"`;// all other image inputs continue to use `RESIZED_IMAGE_BYTES_ESTIMATE`.const ORIGINAL_IMAGE_PATCH_SIZE: u32 = 32;// See https://platform.openai.com/docs/guides/images-vision#model-sizing-behavior.// Keep this hard-coded for now; move it into model capabilities if the patch// budget starts changing often across model releases.const ORIGINAL_IMAGE_MAX_PATCHES: usize = 10_000;const ORIGINAL_IMAGE_ESTIMATE_CACHE_SIZE: usize = 32;static ORIGINAL_IMAGE_ESTIMATE_CACHE: LazyLock<BlockingLruCache<[u8; 20], Option<i64>>> = LazyLock::new(|| { BlockingLruCache::new( NonZeroUsize::new(ORIGINAL_IMAGE_ESTIMATE_CACHE_SIZE).unwrap_or(NonZeroUsize::MIN), ) });fn estimate_response_item_model_visible_bytes(item: &ResponseItem) -> i64 { match item { ResponseItem::Reasoning { encrypted_content: Some(content), .. } | ResponseItem::Compaction { encrypted_content: content, .. } | ResponseItem::ContextCompaction { encrypted_content: Some(content), .. } => i64::try_from(estimate_reasoning_length(content.len())).unwrap_or(i64::MAX), item => { let raw = serde_json::to_string(item) .map(|serialized| i64::try_from(serialized.len()).unwrap_or(i64::MAX)) .unwrap_or_default(); let (image_payload_bytes, image_replacement_bytes) = image_data_url_estimate_adjustment(item); let (encrypted_payload_bytes, encrypted_replacement_bytes) = encrypted_function_output_estimate_adjustment(item); // Replace raw base64 payload bytes with a per-image estimate. // We intentionally preserve the data URL prefix and JSON // wrapper bytes already included in `raw`. let raw = raw .saturating_sub(image_payload_bytes) .saturating_add(image_replacement_bytes); raw.saturating_sub(encrypted_payload_bytes) .saturating_add(encrypted_replacement_bytes) } }}/// Returns the base64 payload byte length for inline image data URLs that are/// eligible for token-estimation discounting.////// We only discount payloads for `data:image/...;base64,...` URLs (case/// insensitive markers) and leave everything else at raw serialized size.fn parse_base64_image_data_url(url: &str) -> Option<&str> { if !url .get(.."data:".len()) .is_some_and(|prefix| prefix.eq_ignore_ascii_case("data:")) { return None; } let comma_index = url.find(',')?; let metadata = &url[..comma_index]; let payload = &url[comma_index + 1..]; // Parse the media type and parameters without decoding. This keeps the // estimator cheap while ensuring we only apply the fixed-cost image // heuristic to image-typed base64 data URLs. let metadata_without_scheme = &metadata["data:".len()..]; let mut metadata_parts = metadata_without_scheme.split(';'); let mime_type = metadata_parts.next().unwrap_or_default(); let has_base64_marker = metadata_parts.any(|part| part.eq_ignore_ascii_case("base64")); if !mime_type .get(.."image/".len()) .is_some_and(|prefix| prefix.eq_ignore_ascii_case("image/")) { return None; } if !has_base64_marker { return None; } Some(payload)}fn estimate_original_image_bytes(image_url: &str) -> Option<i64> { let key = sha1_digest(image_url.as_bytes()); ORIGINAL_IMAGE_ESTIMATE_CACHE.get_or_insert_with(key, || { let payload = match parse_base64_image_data_url(image_url) { Some(payload) => payload, None => { tracing::trace!("skipping original-detail estimate for non-base64 image data URL"); return None; } }; let bytes = match BASE64_STANDARD.decode(payload) { Ok(bytes) => bytes, Err(error) => { tracing::trace!("failed to decode original-detail image payload: {error}"); return None; } }; let dynamic = match image::load_from_memory(&bytes) { Ok(dynamic) => dynamic, Err(error) => { tracing::trace!("failed to decode original-detail image bytes: {error}"); return None; } }; let width = i64::from(dynamic.width()); let height = i64::from(dynamic.height()); let patch_size = i64::from(ORIGINAL_IMAGE_PATCH_SIZE); let patches_wide = width.saturating_add(patch_size.saturating_sub(1)) / patch_size; let patches_high = height.saturating_add(patch_size.saturating_sub(1)) / patch_size; let patch_count = patches_wide.saturating_mul(patches_high); let patch_count = usize::try_from(patch_count).unwrap_or(usize::MAX); let patch_count = patch_count.min(ORIGINAL_IMAGE_MAX_PATCHES); Some(i64::try_from(approx_bytes_for_tokens(patch_count)).unwrap_or(i64::MAX)) })}/// Scans one response item for discount-eligible inline image data URLs and/// returns:/// - total base64 payload bytes to subtract from raw serialized size/// - total replacement byte estimate for those imagesfn image_data_url_estimate_adjustment(item: &ResponseItem) -> (i64, i64) { let mut payload_bytes = 0i64; let mut replacement_bytes = 0i64; let mut accumulate = |image_url: &str, detail: Option<ImageDetail>| { if let Some(payload_len) = parse_base64_image_data_url(image_url).map(str::len) { payload_bytes = payload_bytes.saturating_add(i64::try_from(payload_len).unwrap_or(i64::MAX)); replacement_bytes = replacement_bytes.saturating_add(match detail { Some(ImageDetail::Original) => { estimate_original_image_bytes(image_url).unwrap_or(RESIZED_IMAGE_BYTES_ESTIMATE) } _ => RESIZED_IMAGE_BYTES_ESTIMATE, }); } }; match item { ResponseItem::Message { content, .. } => { for content_item in content { if let ContentItem::InputImage { image_url, detail } = content_item { accumulate(image_url, *detail); } } } ResponseItem::FunctionCallOutput { output, .. } | ResponseItem::CustomToolCallOutput { output, .. } => { if let FunctionCallOutputBody::ContentItems(items) = &output.body { for content_item in items { if let FunctionCallOutputContentItem::InputImage { image_url, detail } = content_item { accumulate(image_url, *detail); } } } } _ => {} } (payload_bytes, replacement_bytes)}fn encrypted_function_output_estimate_adjustment(item: &ResponseItem) -> (i64, i64) { let ResponseItem::FunctionCallOutput { output, .. } = item else { return (0, 0); }; let FunctionCallOutputBody::ContentItems(items) = &output.body else { return (0, 0); }; items.iter().fold((0i64, 0i64), |acc, item| { let FunctionCallOutputContentItem::EncryptedContent { encrypted_content } = item else { return acc; }; let payload_bytes = acc .0 .saturating_add(i64::try_from(encrypted_content.len()).unwrap_or(i64::MAX)); let replacement_bytes = acc.1.saturating_add( i64::try_from(estimate_encrypted_function_output_length( encrypted_content.len(), )) .unwrap_or(i64::MAX), ); (payload_bytes, replacement_bytes) })}fn is_model_generated_item(item: &ResponseItem) -> bool { match item { ResponseItem::Message { role, .. } => role == "assistant", ResponseItem::Reasoning { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::ToolSearchCall { .. } | ResponseItem::WebSearchCall { .. } | ResponseItem::ImageGenerationCall { .. } | ResponseItem::CustomToolCall { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true, ResponseItem::CompactionTrigger { .. } => false, ResponseItem::FunctionCallOutput { .. } | ResponseItem::ToolSearchOutput { .. } | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::AgentMessage { .. } | ResponseItem::Other => false, }}pub(crate) fn is_user_turn_boundary(item: &ResponseItem) -> bool { if matches!(item, ResponseItem::AgentMessage { .. }) { return true; } let ResponseItem::Message { role, content, .. } = item else { return false; }; (role == "user" && !is_contextual_user_message_content(content)) || (role == "assistant" && is_inter_agent_instruction_content(content))}fn is_inter_agent_instruction_content(content: &[ContentItem]) -> bool { InterAgentCommunication::is_message_content(content)}fn user_message_positions(items: &[ResponseItem]) -> Vec<usize> { let mut positions = Vec::new(); for (idx, item) in items.iter().enumerate() { if is_user_turn_boundary(item) { positions.push(idx); } } positions}#[cfg(test)]#[path = "history_tests.rs"]mod tests;