mirror of
https://github.com/openai/codex.git
synced 2026-10-24 18:18:41 +00:00
Extract the head-tail buffer into a utility crate (#56535)
## What changed Move `HeadTailBuffer` and `format_output_omission_marker` into the new `codex-utils-head-tail-buffer` crate, exposing the buffer API with an explicit const-generic byte budget. Update core callers to use the crate and add Cargo and Bazel definitions. Move the existing buffer tests alongside the implementation, preserving the prefix/suffix retention and omission-marker behavior. GitOrigin-RevId: ff488d0a0a62c39c0e11c11ebb539166cc4c6e74
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "head-tail-buffer",
|
||||
crate_name = "codex_utils_head_tail_buffer",
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "codex-utils-head-tail-buffer"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
@@ -0,0 +1,91 @@
|
||||
use super::HeadTailBuffer;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn keeps_prefix_and_suffix_when_over_budget() {
|
||||
let mut buf = HeadTailBuffer::<10>::default();
|
||||
|
||||
buf.push_chunk(b"0123456789");
|
||||
assert_eq!(buf.omitted_bytes(), 0);
|
||||
|
||||
// Exceeds max by 2; we should keep head+tail and omit the middle.
|
||||
buf.push_chunk(b"ab");
|
||||
assert!(buf.omitted_bytes() > 0);
|
||||
|
||||
let rendered = String::from_utf8_lossy(&buf.to_bytes()).to_string();
|
||||
assert!(rendered.starts_with("01234"));
|
||||
assert!(rendered.ends_with("89ab"));
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&buf.to_bytes_with_omission_marker()),
|
||||
"01234\n... 2 bytes omitted ...\n789ab"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_bytes_zero_drops_everything() {
|
||||
let mut buf = HeadTailBuffer::<0>::default();
|
||||
buf.push_chunk(b"abc");
|
||||
|
||||
assert_eq!(buf.retained_bytes(), 0);
|
||||
assert_eq!(buf.omitted_bytes(), 3);
|
||||
assert_eq!(buf.to_bytes(), b"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn head_budget_zero_keeps_only_last_byte_in_tail() {
|
||||
let mut buf = HeadTailBuffer::<1>::default();
|
||||
buf.push_chunk(b"abc");
|
||||
|
||||
assert_eq!(buf.retained_bytes(), 1);
|
||||
assert_eq!(buf.omitted_bytes(), 2);
|
||||
assert_eq!(buf.to_bytes(), b"c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunk_larger_than_tail_budget_keeps_only_tail_end() {
|
||||
let mut buf = HeadTailBuffer::<10>::default();
|
||||
buf.push_chunk(b"0123456789");
|
||||
|
||||
// Tail budget is 5 bytes. This chunk should replace the tail and keep only its last 5 bytes.
|
||||
buf.push_chunk(b"ABCDEFGHIJK");
|
||||
|
||||
let out = String::from_utf8_lossy(&buf.to_bytes()).to_string();
|
||||
assert!(out.starts_with("01234"));
|
||||
assert!(out.ends_with("GHIJK"));
|
||||
assert!(buf.omitted_bytes() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fills_head_then_tail_across_multiple_chunks() {
|
||||
let mut buf = HeadTailBuffer::<10>::default();
|
||||
|
||||
// Fill the 5-byte head budget across multiple chunks.
|
||||
buf.push_chunk(b"01");
|
||||
buf.push_chunk(b"234");
|
||||
assert_eq!(buf.to_bytes(), b"01234");
|
||||
|
||||
// Then fill the 5-byte tail budget.
|
||||
buf.push_chunk(b"567");
|
||||
buf.push_chunk(b"89");
|
||||
assert_eq!(buf.to_bytes(), b"0123456789");
|
||||
assert_eq!(buf.omitted_bytes(), 0);
|
||||
|
||||
// One more byte causes the tail to drop its oldest byte.
|
||||
buf.push_chunk(b"a");
|
||||
assert_eq!(buf.to_bytes(), b"012346789a");
|
||||
assert_eq!(buf.omitted_bytes(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_tiny_chunks_have_bounded_metadata() {
|
||||
let mut buf = HeadTailBuffer::<10>::default();
|
||||
|
||||
for byte in b"0123456789ab" {
|
||||
buf.push_chunk(&[]);
|
||||
buf.push_chunk(&[*byte]);
|
||||
}
|
||||
|
||||
assert_eq!(buf.retained_bytes(), 10);
|
||||
assert_eq!(buf.omitted_bytes(), 2);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! A capped byte buffer that preserves a stable prefix and the most recent suffix.
|
||||
//! Retained bytes never exceed the caller's const-generic budget.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// A capped buffer that preserves a stable prefix ("head") and suffix ("tail"),
|
||||
/// dropping the middle once it exceeds the configured maximum. The buffer is
|
||||
/// symmetric meaning 50% of the capacity is allocated to the head and 50% is
|
||||
/// allocated to the tail.
|
||||
#[derive(Debug, Default, Eq, PartialEq)]
|
||||
pub struct HeadTailBuffer<const MAX_BYTES: usize> {
|
||||
head: Vec<u8>,
|
||||
tail: VecDeque<u8>,
|
||||
omitted_bytes: usize,
|
||||
}
|
||||
|
||||
impl<const MAX_BYTES: usize> HeadTailBuffer<MAX_BYTES> {
|
||||
const HEAD_BUDGET: usize = MAX_BYTES / 2;
|
||||
const TAIL_BUDGET: usize = MAX_BYTES.saturating_sub(Self::HEAD_BUDGET);
|
||||
|
||||
/// Total bytes currently retained by the buffer (head + tail).
|
||||
pub fn retained_bytes(&self) -> usize {
|
||||
self.head.len().saturating_add(self.tail.len())
|
||||
}
|
||||
|
||||
/// Total bytes that were dropped from the middle due to the size cap.
|
||||
pub fn omitted_bytes(&self) -> usize {
|
||||
self.omitted_bytes
|
||||
}
|
||||
|
||||
/// Total bytes observed by the buffer, including bytes omitted by the cap.
|
||||
pub fn total_bytes(&self) -> usize {
|
||||
self.retained_bytes().saturating_add(self.omitted_bytes)
|
||||
}
|
||||
|
||||
/// Append a chunk of bytes to the buffer.
|
||||
///
|
||||
/// Bytes are first added to the head until the head budget is full; any
|
||||
/// remaining bytes are added to the tail, with older tail bytes being
|
||||
/// dropped to preserve the tail budget.
|
||||
pub fn push_chunk(&mut self, chunk: &[u8]) {
|
||||
let chunk = self.fill_head(chunk);
|
||||
self.push_tail(chunk);
|
||||
}
|
||||
|
||||
/// Fill the stable prefix and return the bytes that did not fit.
|
||||
fn fill_head<'a>(&mut self, chunk: &'a [u8]) -> &'a [u8] {
|
||||
let Self {
|
||||
head,
|
||||
tail: _,
|
||||
omitted_bytes: _,
|
||||
} = self;
|
||||
|
||||
let remaining_head = Self::HEAD_BUDGET.saturating_sub(head.len());
|
||||
// A shorter chunk fits entirely in the head.
|
||||
let (chunk_head, chunk_tail) = chunk
|
||||
.split_at_checked(remaining_head)
|
||||
.unwrap_or((chunk, &[]));
|
||||
head.extend_from_slice(chunk_head);
|
||||
chunk_tail
|
||||
}
|
||||
|
||||
/// Append bytes known not to belong in the head, keeping the newest tail bytes.
|
||||
fn push_tail(&mut self, chunk: &[u8]) {
|
||||
let Self {
|
||||
head: _,
|
||||
tail,
|
||||
omitted_bytes,
|
||||
} = self;
|
||||
|
||||
let remaining_tail = Self::TAIL_BUDGET.saturating_sub(tail.len());
|
||||
let excess_tail = chunk.len().saturating_sub(remaining_tail);
|
||||
*omitted_bytes = omitted_bytes.saturating_add(excess_tail);
|
||||
|
||||
// Discard old tail bytes first, then skip any excess incoming bytes.
|
||||
let chunk = match excess_tail.checked_sub(tail.len()) {
|
||||
None => {
|
||||
tail.drain(..excess_tail);
|
||||
chunk
|
||||
}
|
||||
Some(skip) => {
|
||||
tail.clear();
|
||||
&chunk[skip..]
|
||||
}
|
||||
};
|
||||
tail.extend(chunk);
|
||||
}
|
||||
|
||||
/// Return the retained output as a single byte vector.
|
||||
///
|
||||
/// The output is formed by concatenating head chunks, then tail chunks.
|
||||
/// Omitted bytes are not represented in the returned value.
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(self.retained_bytes());
|
||||
out.extend_from_slice(&self.head);
|
||||
out.extend(self.tail.iter().copied());
|
||||
out
|
||||
}
|
||||
|
||||
/// Return the retained output with an explicit marker between the head and
|
||||
/// tail when bytes were omitted.
|
||||
pub fn to_bytes_with_omission_marker(&self) -> Vec<u8> {
|
||||
if self.omitted_bytes == 0 {
|
||||
return self.to_bytes();
|
||||
}
|
||||
|
||||
let marker = format_output_omission_marker(self.omitted_bytes);
|
||||
let marker_delimiter_bytes = 2;
|
||||
let mut out = Vec::with_capacity(
|
||||
self.retained_bytes()
|
||||
.saturating_add(marker.len())
|
||||
.saturating_add(marker_delimiter_bytes),
|
||||
);
|
||||
out.extend_from_slice(&self.head);
|
||||
out.push(b'\n');
|
||||
out.extend_from_slice(marker.as_bytes());
|
||||
out.push(b'\n');
|
||||
out.extend(self.tail.iter().copied());
|
||||
out
|
||||
}
|
||||
|
||||
/// Append a later buffer with the same budget. This preserves the summary
|
||||
/// of the original concatenated output, including its omission count.
|
||||
pub fn push_buffer(&mut self, buffer: Self) {
|
||||
let Self {
|
||||
head,
|
||||
tail,
|
||||
omitted_bytes,
|
||||
} = buffer;
|
||||
|
||||
self.omitted_bytes = self.omitted_bytes.saturating_add(omitted_bytes);
|
||||
|
||||
// Preserve an existing prefix; otherwise reuse the source head.
|
||||
let overflow = if self.head.is_empty() {
|
||||
self.head = head;
|
||||
&[]
|
||||
} else {
|
||||
self.fill_head(&head)
|
||||
};
|
||||
|
||||
// A full source tail displaces both the old tail and the unused source head.
|
||||
if tail.len() == Self::TAIL_BUDGET {
|
||||
self.omitted_bytes = self
|
||||
.omitted_bytes
|
||||
.saturating_add(self.tail.len())
|
||||
.saturating_add(overflow.len());
|
||||
self.tail = tail;
|
||||
} else {
|
||||
self.push_tail(overflow);
|
||||
// An empty destination can take a partial source tail without copying it.
|
||||
if self.tail.is_empty() {
|
||||
self.tail = tail;
|
||||
} else {
|
||||
// A nonempty source tail means its head, and now ours, is full.
|
||||
let (first, second) = tail.as_slices();
|
||||
self.push_tail(first);
|
||||
self.push_tail(second);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the byte omission notice used by callers of the buffer.
|
||||
pub fn format_output_omission_marker(omitted_bytes: usize) -> String {
|
||||
format!("... {omitted_bytes} bytes omitted ...")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "head_tail_buffer_tests.rs"]
|
||||
mod tests;
|
||||
Reference in New Issue
Block a user