From 081d7dd3fe74c79e1acf7c1f3247842f7ea9cc74 Mon Sep 17 00:00:00 2001 From: Carlos Gamboa Date: Mon, 17 Aug 2026 19:27:19 +0200 Subject: [PATCH] feat(memory)!: `TemplateCompactor` caps are literal, with an explicit opt-out --- crates/rig-memory/CHANGELOG.md | 23 +++++++++++++++ crates/rig-memory/src/lib.rs | 51 ++++++++++++++++++++++++++-------- 2 files changed, 63 insertions(+), 11 deletions(-) diff --git a/crates/rig-memory/CHANGELOG.md b/crates/rig-memory/CHANGELOG.md index 406c8df46b..66e8a3ff0d 100644 --- a/crates/rig-memory/CHANGELOG.md +++ b/crates/rig-memory/CHANGELOG.md @@ -6,6 +6,29 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] + +### Added + +- *(memory)* `TemplateCompactor::without_max_bytes` clears a configured size + cap, restoring the default unbounded rollup. It is the inverse of + `with_max_bytes` and the migration target for callers that passed `0`. + +### Changed + +- *(memory)* [**breaking**] `TemplateCompactor::with_max_bytes(0)` no longer + means "unbounded" — the cap is now taken literally. Use `without_max_bytes` + for unbounded behaviour. The signature is unchanged, so this does not break + compilation: callers that passed `0` as an off switch keep building and + silently lose the rolled-up carry-over instead of seeing an error. + + The sentinel made the cap non-monotonic in its argument, so a bound computed + rather than typed (`budget.saturating_sub(overhead)`, a config field left at + its zero default) could land on the one input meaning the opposite of what + was asked for. Since the summary spliced by `CompactingMemory` sits + **outside** the wrapped `MemoryPolicy`'s budget, an accidentally-unbounded + compactor is precisely the failure the cap exists to prevent; an accidental + `0` now fails safe instead. + ## [0.42.0](https://github.com/0xPlaygrounds/rig/compare/rig-memory-v0.41.0...rig-memory-v0.42.0) - 2026-08-16 ### Other diff --git a/crates/rig-memory/src/lib.rs b/crates/rig-memory/src/lib.rs index bb8f50a63a..0ef928c26d 100644 --- a/crates/rig-memory/src/lib.rs +++ b/crates/rig-memory/src/lib.rs @@ -1217,6 +1217,11 @@ where /// // Custom header plus a 4 KiB cap for use with token-budgeted policies. /// let _bounded = TemplateCompactor::with_header("Earlier context") /// .with_max_bytes(4 * 1024); +/// +/// // Opt back out of a cap inherited from a shared default. +/// let _unbounded = TemplateCompactor::with_header("Earlier context") +/// .with_max_bytes(4 * 1024) +/// .without_max_bytes(); /// ``` #[derive(Debug, Clone)] pub struct TemplateCompactor { @@ -1243,17 +1248,20 @@ impl TemplateCompactor { /// Cap the rolled-up summary at `max_bytes` bytes (UTF-8). When the /// assembled body exceeds the cap, the oldest portion after the /// header is dropped at a char boundary and replaced with a - /// `"[…truncated…]"` marker. - /// - /// `max_bytes` of `0` disables truncation (equivalent to the default - /// unbounded behaviour). The header line plus the marker are always - /// preserved even if they exceed the cap. + /// `"[…truncated…]"` marker. The header line plus the marker are + /// always preserved even if they exceed the cap, so a `max_bytes` of + /// `0` collapses the summary to just the header and marker rather + /// than disabling truncation — use [`Self::without_max_bytes`] for + /// unbounded behaviour. pub fn with_max_bytes(mut self, max_bytes: usize) -> Self { - self.max_bytes = if max_bytes == 0 { - None - } else { - Some(max_bytes) - }; + self.max_bytes = Some(max_bytes); + self + } + + /// Remove any configured size cap, restoring the default unbounded + /// behaviour. + pub fn without_max_bytes(mut self) -> Self { + self.max_bytes = None; self } } @@ -2855,14 +2863,35 @@ mod tests { } #[tokio::test] - async fn template_compactor_with_max_bytes_zero_is_unbounded() { + async fn template_compactor_with_max_bytes_zero_collapses_to_header_and_marker() { let compactor = TemplateCompactor::new().with_max_bytes(0); let mut evicted = Vec::new(); for i in 0..200 { evicted.push(user(&format!("msg {i}"))); } let summary = compactor.compact("c", &evicted, None).await.unwrap(); + assert!(summary.as_str().contains("[\u{2026}truncated\u{2026}]")); + assert!(!summary.as_str().contains("msg 199")); + assert!( + summary + .as_str() + .starts_with("[Conversation summary so far]\n") + ); + } + + #[tokio::test] + async fn template_compactor_without_max_bytes_restores_unbounded() { + let compactor = TemplateCompactor::new() + .with_max_bytes(32) + .without_max_bytes(); + let mut evicted = Vec::new(); + for i in 0..200 { + evicted.push(user(&format!("msg {i}"))); + } + let summary = compactor.compact("c", &evicted, None).await.unwrap(); assert!(!summary.as_str().contains("[\u{2026}truncated\u{2026}]")); + assert!(summary.as_str().contains("msg 0")); + assert!(summary.as_str().contains("msg 199")); } #[tokio::test]