Files
codex/codex-rs/guardian-context/src/history.rs
T
jif 1c1e17782a Preserve Guardian review evidence across compaction (#41846)
## Why

Context compaction can replace original conversation items that Guardian still needs to review approval-sensitive actions.

## What changed

- Retain a bounded, chronological review history independently of the model's compacted history.
- Keep separate limits for user messages and other transcript items so tool traffic cannot evict user instructions.
- Use the retained evidence for synchronous and asynchronous Guardian transcripts, while resetting it after rollback or history reconstruction.

## Testing

- Cover retention through compaction and eviction, reset behavior after rollback, independent size limits, and asynchronous transcript construction.

GitOrigin-RevId: c5851e992820504fc18de8cfec7f6f9f1b69f316
2026-08-31 13:57:59 +00:00

135 lines
4.5 KiB
Rust

//! 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<ResponseItem>, 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::<ResponseItem>(),
};
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<Item = &'a ResponseItem>) {
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<dyn Iterator<Item = &ResponseItem> + 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<usize> {
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;