diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 1abd9df623..16d7dd114a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2783,6 +2783,7 @@ dependencies = [ "codex-feedback", "codex-file-system", "codex-git-utils", + "codex-guardian-context", "codex-history", "codex-home", "codex-hooks", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index ebdee5fce9..315ddc120d 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -213,6 +213,7 @@ codex-install-context = { path = "install-context" } codex-file-search = { path = "file-search" } codex-file-watcher = { path = "file-watcher" } codex-git-utils = { path = "git-utils" } +codex-guardian-context = { path = "guardian-context" } codex-hooks = { path = "hooks" } codex-history = { path = "history" } codex-keyring-store = { path = "keyring-store" } diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 384af1d3cd..932d0b0cbd 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -51,6 +51,7 @@ codex-models-manager = { workspace = true } codex-shell-command = { workspace = true } codex-execpolicy = { workspace = true } codex-git-utils = { workspace = true } +codex-guardian-context = { workspace = true } codex-history = { workspace = true } codex-hooks = { workspace = true } codex-http-client = { workspace = true } diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 768754f7a7..f81c56a05f 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -1,3 +1,6 @@ +//! Model history and bounded original evidence for approval review. +//! Compaction replaces only model history; explicit resets also replace retained evidence. + use crate::context::ContextualUserFragment; use crate::context::ModelSwitchInstructions; use crate::context::world_state::PersistentModeState; @@ -14,6 +17,8 @@ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_context_fragments::set_annotated_content; use codex_context_fragments::to_annotated_content; use codex_extension_api::ConversationHistorySnapshot; +use codex_guardian_context::SectionHistory; +use codex_guardian_context::TranscriptHistory; use codex_history::CodexHarnessMetadata; use codex_history::ResponseItemEnvelope; use codex_protocol::models::AgentMessageInputContent; @@ -48,6 +53,8 @@ pub(crate) struct ContextManager { /// The oldest items are at the beginning of the vector. Snapshots share the vector until a /// caller needs to mutate it, avoiding deep copies for read-only history consumers. items: Arc>, + /// Starts at the first compaction; ordinary history snapshots need no second payload copy. + review_history: Option, /// Bumped whenever history is rewritten, such as compaction or rollback. history_version: u64, /// Monotonic user-input/reset revision, independent of compaction's history generation. @@ -70,6 +77,7 @@ pub(crate) struct ContextManager { struct SharedConversationHistory { items: Arc>, + review_history: Option, history_version: u64, user_message_revision: u64, } @@ -80,6 +88,19 @@ pub(crate) enum HistoryReplacement { } impl ConversationHistorySnapshot for SharedConversationHistory { + fn review_items(&self) -> Box + Send + '_> { + match &self.review_history { + Some(history) => history.items(), + None => self.items(), + } + } + + fn review_history_version(&self) -> u64 { + self.review_history + .as_ref() + .map_or(self.history_version, TranscriptHistory::generation) + } + fn history_version(&self) -> u64 { self.history_version } @@ -108,6 +129,7 @@ impl ContextManager { pub(crate) fn new() -> Self { Self { items: Arc::new(Vec::new()), + review_history: None, history_version: 0, user_message_revision: 0, token_info: TokenUsageInfo::new_or_append( @@ -121,6 +143,7 @@ impl ContextManager { pub(crate) fn conversation_history_snapshot(&self) -> Arc { Arc::new(SharedConversationHistory { items: Arc::clone(&self.items), + review_history: self.review_history.clone(), history_version: self.history_version, user_message_revision: self.user_message_revision, }) @@ -222,6 +245,12 @@ impl ContextManager { .unwrap_or(policy * 1.2); truncate_function_output_payload(output, policy, estimate_audio_token_count); } + if let Some(review_history) = &mut self.review_history + && !matches!(item, ResponseItem::Message { role, content, .. } + if role == "user" && is_contextual_user_message_content(content)) + { + review_history.record(&processed.item); + } Arc::make_mut(&mut self.items).push(processed); if crate::context::is_user_authorization_message(item) { self.user_message_revision = self.user_message_revision.saturating_add(1); @@ -328,11 +357,29 @@ impl ContextManager { pub(crate) fn replace_annotated(&mut self, items: Vec) { self.user_message_revision = self.user_message_revision.saturating_add(1); - self.replace_compacted(items); + if let Some(review_history) = &mut self.review_history { + review_history.reset(items.iter().map(|item| &item.item).filter(|item| { + !matches!(item, ResponseItem::Message { role, content, .. } + if role == "user" && is_contextual_user_message_content(content)) + })); + } + self.items = Arc::new(items); + self.history_version = self.history_version.saturating_add(1); + self.world_state_baseline = None; } /// Compaction changes the model's history without changing the user's authorization. pub(crate) fn replace_compacted(&mut self, items: Vec) { + if self.review_history.is_none() { + let mut retained = TranscriptHistory::new(self.history_version.saturating_add(1)); + for item in self.raw_items().filter(|item| { + !matches!(item, ResponseItem::Message { role, content, .. } + if role == "user" && is_contextual_user_message_content(content)) + }) { + retained.record(item); + } + self.review_history = Some(retained); + } self.items = Arc::new(items); self.history_version = self.history_version.saturating_add(1); self.world_state_baseline = None; diff --git a/codex-rs/core/src/guardian/prompt.rs b/codex-rs/core/src/guardian/prompt.rs index 3fc07a77b7..b3e9278bdf 100644 --- a/codex-rs/core/src/guardian/prompt.rs +++ b/codex-rs/core/src/guardian/prompt.rs @@ -140,7 +140,7 @@ pub(crate) async fn build_guardian_prompt_items_with_parent_turn( } else { GUARDIAN_MAX_TOOL_ENTRY_TOKENS }; - let history = session.clone_history().await; + let history = session.conversation_history_snapshot().await; let root_authorization = session .services .agent_control @@ -151,13 +151,11 @@ pub(crate) async fn build_guardian_prompt_items_with_parent_turn( .services .thread_extension_data .get::() - .map(|evidence| { - evidence.user_input_fragments(history.conversation_history_snapshot().as_ref()) - }) + .map(|evidence| evidence.user_input_fragments(history.as_ref())) .unwrap_or_default(); - let transcript_entries = collect_guardian_transcript_entries(history.raw_items()); + let transcript_entries = collect_guardian_transcript_entries(history.review_items()); let transcript_cursor = GuardianTranscriptCursor { - parent_history_version: history.history_version(), + parent_history_version: history.review_history_version(), transcript_entry_count: transcript_entries.len(), }; let planned_action_json = format_guardian_action_pretty(&request)?; diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 98acd64648..8cd0095ecf 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -482,10 +482,10 @@ impl GuardianReviewSessionManager { pub(crate) async fn shutdown(&self) { self.cancellation_token.cancel(); - self.invalidate_for_node_repl_evidence().await; + self.invalidate().await; } - pub(crate) async fn invalidate_for_node_repl_evidence(&self) { + pub(crate) async fn invalidate(&self) { let (review_session, ephemeral_reviews) = { let mut state = self.state.lock().await; ( diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 44676fde66..b6a6d5938f 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -335,16 +335,10 @@ pub async fn thread_rollback(sess: &Arc, sub_id: String, num_turns: u32 .collect::>(); sess.apply_rollout_reconstruction(turn_context.as_ref(), replay_items.as_slice()) .await; - if sess - .services + sess.services .thread_extension_data - .remove::() - .is_some() - { - sess.guardian_review_session - .invalidate_for_node_repl_evidence() - .await; - } + .remove::(); + sess.guardian_review_session.invalidate().await; sess.services .agent_control .rollout_budget() diff --git a/codex-rs/core/tests/suite/guardian_history.rs b/codex-rs/core/tests/suite/guardian_history.rs new file mode 100644 index 0000000000..3db8fa96b6 --- /dev/null +++ b/codex-rs/core/tests/suite/guardian_history.rs @@ -0,0 +1,134 @@ +//! Exercises retained review history through compaction, eviction, and rollback. + +use anyhow::Result; +use codex_core::config::Constrained; +use codex_features::Feature; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::skip_if_wine_exec; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn guardian_history_survives_compaction_and_eviction_but_not_rollback() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_wine_exec!( + Ok(()), + "Guardian approval actions require host-native paths" + ); + let server = start_mock_server().await; + let test = test_codex() + .with_config(|config| { + config.features.enable(Feature::TokenBudget).unwrap(); + config.update_plan_enabled = true; + config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); + config.approvals_reviewer = ApprovalsReviewer::AutoReview; + }) + .build_with_auto_env(&server) + .await?; + // Enough real tool traffic to evict earlier tools when retention starts. + let plan = r#"{"plan":[{"step":"verify repository visibility","status":"completed"}]}"#; + let mut inspection: Vec<_> = (0..130) + .map(|index| ev_function_call(&format!("inspect-{index}"), "update_plan", plan)) + .collect(); + inspection.push(ev_completed("inspection")); + mount_sse_sequence( + &server, + vec![ + sse(inspection), + sse(vec![ + ev_function_call("inspect-latest", "update_plan", plan), + ev_completed("latest-inspection"), + ]), + sse(vec![ + ev_assistant_message("inspected", "Inspection complete."), + ev_completed("inspection-done"), + ]), + ], + ) + .await; + test.submit_text_turn("Only publish to a private repository.") + .await?; + test.codex.submit(Op::Compact).await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let command = r#"{"cmd":"echo publish","sandbox_permissions":"require_escalated","justification":"Publish the inspected change."}"#; + for (prompt, retained) in [ + ("Now publish.", true), + ("Inspect a different repository.", false), + ] { + let review = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_function_call("publish", "exec_command", command), + ev_completed("publish"), + ]), + sse(vec![ + ev_assistant_message("review", r#"{"outcome":"deny"}"#), + ev_completed("review"), + ]), + sse(vec![ev_completed("publish-done")]), + ], + ) + .await; + test.submit_text_turn(prompt).await?; + let requests = review.requests(); + let guardian = requests + .iter() + .find(|request| { + request.body_json()["client_metadata"]["x-openai-subagent"] == "guardian" + }) + .expect("Guardian request"); + let transcript = serde_json::to_string(&guardian.input())?; + assert!(transcript.contains(prompt)); + if retained { + let positions = [ + "Only publish to a private repository.", + "tool update_plan call", + "tool update_plan result", + "Now publish.", + ] + .map(|text| { + transcript + .find(text) + .unwrap_or_else(|| panic!("missing {text}: {transcript}")) + }); + let mut ordered = positions; + ordered.sort(); + assert_eq!(positions, ordered); + assert!( + requests[0] + .input() + .iter() + .all(|item| item["call_id"] != "inspect-0") + ); + test.codex.ensure_rollout_materialized().await; + test.codex + .submit(Op::ThreadRollback { num_turns: 2 }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::ThreadRolledBack(_)) + }) + .await; + } else { + assert!(!transcript.contains("Only publish to a private repository.")); + assert!(!transcript.contains("tool update_plan call")); + assert!(!transcript.contains("tool update_plan result")); + } + } + Ok(()) +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 4b4a0ba1df..5fc867a5cb 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -72,6 +72,7 @@ mod external_auth; mod fork_thread; mod git_enrichment; mod guardian_authorization; +mod guardian_history; mod guardian_mcp_elicitation; #[cfg(not(target_os = "windows"))] mod guardian_review; diff --git a/codex-rs/ext/extension-api/src/capabilities/conversation_history.rs b/codex-rs/ext/extension-api/src/capabilities/conversation_history.rs index f1febbc334..5751d9e9e8 100644 --- a/codex-rs/ext/extension-api/src/capabilities/conversation_history.rs +++ b/codex-rs/ext/extension-api/src/capabilities/conversation_history.rs @@ -14,4 +14,15 @@ pub trait ConversationHistorySnapshot: Send + Sync { /// Returns the snapshot's response items in conversation order. fn items(&self) -> Box + Send + '_>; + + /// Original review evidence retained across parent compaction, in conversation order. + /// Hosts without separate retention provide their current history. + fn review_items(&self) -> Box + Send + '_> { + self.items() + } + + /// Changes whenever offsets into the retained review evidence become invalid. + fn review_history_version(&self) -> u64 { + self.history_version() + } } diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/extension.rs b/codex-rs/ext/guardian-v2/src/async_scorer/extension.rs index d3a4b3a3c8..10d2d8c678 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/extension.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/extension.rs @@ -546,12 +546,18 @@ impl GuardianV2Extension { let review_model_override = parent_model .as_ref() .and_then(|model| model.auto_review_model_override.clone()); - let conversation_history = Arc::clone(&input.conversation_history); // Snapshot before spawning so a delayed sample cannot see later reviews. let guardian_evidence = input .thread_store .get_or_init(GuardianReviewEvidence::default); let sync_reviews = guardian_evidence.snapshot(); + let authorization_version = + guardian_evidence.authorization_version(input.conversation_history.as_ref()); + let trusted_user_inputs = + guardian_evidence.user_input_fragments(input.conversation_history.as_ref()); + let transcript = guardian_config + .transcript + .build(input.conversation_history.review_items()); let local_trusted_skill_paths = guardian_evidence.trusted_skill_paths(input.turn_id); let node_repl_images = if guardian_config.transcript.include_images { input @@ -562,6 +568,9 @@ impl GuardianV2Extension { } else { Vec::new() }; + let rendered_images = guardian_config + .transcript + .images(input.conversation_history.review_items(), node_repl_images); tokio::spawn(async move { let mut truncations = ClassificationTruncations::default(); @@ -586,28 +595,17 @@ impl GuardianV2Extension { .as_ref() .map(|snapshot| snapshot.authorization_version); let root_conversation = root_snapshot.map(|snapshot| snapshot.messages); - let authorization_version = - guardian_evidence.authorization_version(conversation_history.as_ref()); let score_authorization = ScoreAuthorization { local: authorization_version, root: root_authorization_version, }; - let trusted_user_inputs = - guardian_evidence.user_input_fragments(conversation_history.as_ref()); - let transcript = guardian_config - .transcript - .build(conversation_history.items()); truncations.extend(transcript.truncations); - let rendered_images = guardian_config - .transcript - .images(conversation_history.items(), node_repl_images); truncations.record( "transcript_image", rendered_images.omitted_bytes, /*retained_bytes*/ 0, ); let images = rendered_images.images; - drop(conversation_history); let planned_action = match action.render(guardian_config.max_action_tokens) { Ok(RenderedAction { text, diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs b/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs index 1ca516b845..719aff31c1 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs @@ -344,6 +344,29 @@ impl ExtensionMetrics for RecordingMetrics { struct TestConversationHistory(Vec); +struct TestRetainedHistory { + current: TestConversationHistory, + retained: Vec, +} + +impl ConversationHistorySnapshot for TestRetainedHistory { + fn history_version(&self) -> u64 { + self.current.history_version() + } + + fn user_message_revision(&self) -> u64 { + self.current.user_message_revision() + } + + fn items(&self) -> Box + Send + '_> { + self.current.items() + } + + fn review_items(&self) -> Box + Send + '_> { + Box::new(self.retained.iter()) + } +} + impl ConversationHistorySnapshot for TestConversationHistory { fn history_version(&self) -> u64 { 0 @@ -2781,6 +2804,15 @@ async fn contributor_reuses_the_latest_compatible_parent_compaction() -> Result< .inject_response_items(conversation_history.0.clone()), ) .await?; + let mut retained: Vec = serde_json::from_value(json!([ + {"type":"function_call", "name":"check_repository", "arguments":"{}", "call_id":"before"}, + {"type":"function_call_output", "call_id":"before", "output":"repository is private"} + ]))?; + retained.extend(conversation_history.0.clone()); + let conversation_history = TestRetainedHistory { + retained, + current: conversation_history, + }; thread_store.insert(parent_model); registry.tool_lifecycle_contributors()[0] @@ -2826,6 +2858,9 @@ async fn contributor_reuses_the_latest_compatible_parent_compaction() -> Result< serde_json::to_value(&latest_compaction)? ); assert_eq!(request["input"][3]["role"], "user"); + let transcript = serde_json::to_string(&request["input"][3])?; + assert!(transcript.contains("tool check_repository call")); + assert!(transcript.contains("tool check_repository result: repository is private")); let previous_score = tokio::time::timeout(ASYNC_TEST_TIMEOUT, async { loop { diff --git a/codex-rs/guardian-context/src/history.rs b/codex-rs/guardian-context/src/history.rs new file mode 100644 index 0000000000..5032edd040 --- /dev/null +++ b/codex-rs/guardian-context/src/history.rs @@ -0,0 +1,134 @@ +//! 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 and reset this history on rollback or reconstruction. +//! 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::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. + /// An item too large for its budget is skipped without changing retained entries. + 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() { + 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); + } + } + + /// 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; diff --git a/codex-rs/guardian-context/src/history_tests.rs b/codex-rs/guardian-context/src/history_tests.rs new file mode 100644 index 0000000000..a38cea083f --- /dev/null +++ b/codex-rs/guardian-context/src/history_tests.rs @@ -0,0 +1,79 @@ +use codex_protocol::models::ReasoningItemContent; +use pretty_assertions::assert_eq; +use serde_json::json; + +use super::*; + +fn message(text: &str) -> ResponseItem { + serde_json::from_value(json!({ + "type": "message", "role": "user", "content": [{"type": "input_text", "text": text}] + })) + .unwrap() +} + +fn tool(text: &str) -> ResponseItem { + serde_json::from_value(json!({ + "type": "function_call_output", "call_id": "call", "output": text + })) + .unwrap() +} + +#[test] +fn each_kind_evicts_its_own_oldest_entries_without_reordering() { + let users = [message("first"), message("second"), message("third")]; + let tools: Vec<_> = (0..MAX_ITEMS_PER_KIND) + .map(|index| tool(&index.to_string())) + .collect(); + let mut history = TranscriptHistory::default(); + history.record(&users[0]); + history.record(&tool("old output")); + history.record(&users[1]); + for item in &tools { + history.record(item); + } + history.record(&users[2]); + assert_eq!( + history.items().collect::>(), + users[..2] + .iter() + .chain(&tools) + .chain(&users[2..]) + .collect::>() + ); + let generation = history.generation(); + let newer_users: Vec<_> = (0..MAX_ITEMS_PER_KIND) + .map(|index| message(&index.to_string())) + .collect(); + for item in &newer_users { + history.record(item); + } + assert_eq!( + history.items().collect::>(), + tools.iter().chain(&newer_users).collect::>() + ); + assert!(history.generation() > generation); +} + +#[test] +fn byte_limits_are_independent_and_oversized_items_do_not_clear_history() { + let large = tool(&"x".repeat(MAX_BYTES_PER_KIND / 2)); + let keep = message("keep this"); + let mut history = TranscriptHistory::default(); + history.record(&large); + history.record(&keep); + history.record(&large); + assert_eq!(history.items().collect::>(), vec![&keep, &large]); + + let oversized = "x".repeat(MAX_BYTES_PER_KIND); + history.record(&message(&oversized)); + history.record(&tool(&oversized)); + history.record(&ResponseItem::Reasoning { + id: None, + summary: Vec::new(), + // Serialization omits this content, but retention must still count it. + content: Some(vec![ReasoningItemContent::Text { text: oversized }]), + encrypted_content: None, + internal_chat_message_metadata_passthrough: None, + }); + assert_eq!(history.items().collect::>(), vec![&keep, &large]); +} diff --git a/codex-rs/guardian-context/src/lib.rs b/codex-rs/guardian-context/src/lib.rs index 454d390a3c..052eee165c 100644 --- a/codex-rs/guardian-context/src/lib.rs +++ b/codex-rs/guardian-context/src/lib.rs @@ -1,6 +1,7 @@ //! Shared context sections for synchronous Guardian review and asynchronous scoring. //! -//! Transcript collection is also available directly, without section composition. +//! Transcript collection and bounded host-owned history are also available directly, +//! without section composition. //! Contributor failures abort collection without returning partial context. //! Sections carry structured transcript evidence without depending on either //! consumer's rendering, retention, compaction, or request lifecycle. @@ -17,6 +18,7 @@ use transcript::ConversationTranscriptSection; pub use entry::ConversationTranscriptEntry; pub use entry::ConversationTranscriptEntryKind; +pub use history::TranscriptHistory; pub use transcript::ConversationTranscriptConfig; pub use transcript::ConversationTranscriptOptions; pub use transcript::MANUAL_APPROVAL_DEVELOPER_PREFIX; @@ -26,6 +28,7 @@ pub use transcript::collect_transcript; pub use truncation::truncate_text; mod entry; +mod history; mod transcript; mod truncation;