Codex Handbook
core/src/context_manager/history.rs 733 lines
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;