mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
## 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
135 lines
4.5 KiB
Rust
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;
|