//! Bounded, chronological review evidence independent of the parent's compaction. //! //! User messages and other entries have separate limits and keep their original order. //! Hosts append original items, restore bounded checkpoints, and trim rolled-back turns. //! Clones share immutable payloads; eviction changes the generation so readers cannot //! reuse an offset into a different retained prefix. Prompt selection remains caller-owned. use std::collections::VecDeque; use std::io; use std::io::Write; use std::sync::Arc; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use crate::SectionHistory; const MAX_ITEMS_PER_KIND: usize = 128; // Encoded payloads (including omitted reasoning) plus the fixed ResponseItem size. // Each kind gets half the storage budget; tool traffic cannot evict user messages. const MAX_BYTES_PER_KIND: usize = 4 * 1024 * 1024; /// Thread-owned review history with shared payloads and bounded storage. #[derive(Clone, Default)] pub struct TranscriptHistory { items: VecDeque<(Arc, usize)>, generation: u64, } impl std::fmt::Debug for TranscriptHistory { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter .debug_struct("TranscriptHistory") .field("items", &self.items.len()) .field("generation", &self.generation) .finish() } } impl TranscriptHistory { /// Starts retention in a generation newer than the host's previous review snapshot. pub fn new(generation: u64) -> Self { Self { generation, ..Self::default() } } /// Appends one original item, evicting only older entries of the same kind. /// Oversized user images fall back to bounded text; other oversized items are skipped. pub fn record(&mut self, item: &ResponseItem) { let mut size = BoundedSize { bytes: std::mem::size_of::(), }; let measured = serde_json::to_writer(&mut size, item).and_then(|()| { // ResponseItem serialization can omit reasoning content that cloning retains. // Charge it separately, conservatively counting serialized reasoning twice. if let ResponseItem::Reasoning { content, .. } = item { serde_json::to_writer(&mut size, content)?; } Ok(()) }); if measured.is_err() { if let ResponseItem::Message { id, role, content, phase, internal_chat_message_metadata_passthrough, } = item && role == "user" && content .iter() .any(|item| matches!(item, ContentItem::InputImage { .. })) { // Measure text and metadata before cloning, without copying image data. size.bytes = std::mem::size_of::(); if serde_json::to_writer( &mut size, &(id, role, phase, internal_chat_message_metadata_passthrough), ) .is_err() { return; } let mut text = Vec::new(); for item in content.iter().filter(|item| { matches!( item, ContentItem::InputText { .. } | ContentItem::OutputText { .. } ) }) { if serde_json::to_writer(&mut size, item).is_err() { return; } text.push(item.clone()); } if !text.is_empty() { self.record(&ResponseItem::Message { id: id.clone(), role: role.clone(), content: text, phase: phase.clone(), internal_chat_message_metadata_passthrough: internal_chat_message_metadata_passthrough.clone(), }); } } return; } let is_user = item.is_user_message(); let (mut count, mut bytes) = self .items .iter() .filter(|(item, _)| item.is_user_message() == is_user) .fold((0, size.bytes), |(count, bytes), (_, size)| { (count + 1, bytes + size) }); if count >= MAX_ITEMS_PER_KIND || bytes > MAX_BYTES_PER_KIND { self.items.retain(|(item, size)| { if item.is_user_message() == is_user && (count >= MAX_ITEMS_PER_KIND || bytes > MAX_BYTES_PER_KIND) { count -= 1; bytes -= size; false } else { true } }); self.generation = self.generation.saturating_add(1); } self.items.push_back((Arc::new(item.clone()), size.bytes)); } /// Replaces evidence after a host history reset; compaction must not call this. pub fn reset<'a>(&mut self, items: impl IntoIterator) { self.items.clear(); self.generation = self.generation.saturating_add(1); for item in items { self.record(item); } } /// Removes a rolled-back boundary and everything after it. If retention already /// evicted the boundary, discard the remaining evidence rather than keep later grants. pub fn truncate_before(&mut self, boundary: &ResponseItem) { let index = self.items.iter().position(|(item, _)| { item.id() .zip(boundary.id()) .is_some_and(|(item_id, boundary_id)| item_id == boundary_id) || item.as_ref() == boundary }); self.items.truncate(index.unwrap_or(0)); self.generation = self.generation.saturating_add(1); } /// Generation of the retained prefix, independent of parent compaction. pub fn generation(&self) -> u64 { self.generation } } impl SectionHistory for TranscriptHistory { fn items(&self) -> Box + Send + '_> { Box::new(self.items.iter().map(|(item, _)| item.as_ref())) } } // Count without allocating a serialized copy, and stop before cloning an oversized item. struct BoundedSize { bytes: usize, } impl Write for BoundedSize { fn write(&mut self, bytes: &[u8]) -> io::Result { if bytes.len() > MAX_BYTES_PER_KIND.saturating_sub(self.bytes) { return Err(io::Error::other( "review history item exceeds storage budget", )); } self.bytes += bytes.len(); Ok(bytes.len()) } fn flush(&mut self) -> io::Result<()> { Ok(()) } } #[cfg(test)] #[path = "history_tests.rs"] mod tests;