molo_agent/memory/window.rs
1//! Window memory: an in-memory implementation with a budget and trim strategy.
2//!
3//! When the conversation history exceeds the budget, the retrieved context is
4//! trimmed to a recent window so the model's context window is never exceeded.
5//! This module provides three pieces:
6//!
7//! - [`Budget`] — a two-dimensional budget of token and round limits;
8//! - [`TrimStrategy`] — the trim strategy trait; the default [`WindowDrop`]
9//! drops the earliest messages by round; heavier strategies such as
10//! summarization are injected by the user;
11//! - [`WindowMemory`] — the [`Memory`] implementation combining budget,
12//! counting, and strategy.
13//!
14//! This implementation does not manage System messages: the system prompt is
15//! the Agent layer's responsibility (assembled per request); Memory only
16//! manages the conversation history. If a user records System messages
17//! themselves, trimming treats them like any other message, with no special
18//! case.
19
20use std::fmt;
21use std::sync::{Arc, Mutex};
22
23use super::{Memory, MemoryError};
24use crate::message::{ContentBlock, Message};
25
26/// Window budget: token and round limits (both optional; when both are set, the
27/// smaller window wins).
28///
29/// - `max_tokens`: total token budget for the context ([`WindowMemory::new`]
30/// requires it);
31/// - `max_rounds`: maximum number of complete rounds to keep. A round is one
32/// User message plus everything after it until the next User message (a tool
33/// message belongs to the same round as its Assistant).
34///
35/// The two dimensions can be used independently or together: when both are
36/// set, the kept result is the smaller window satisfying **both** (trim by
37/// tokens first, then constrained by rounds).
38///
39/// # Example
40///
41/// ```rust
42/// # extern crate molo_agent as molo;
43/// use molo::memory::Budget;
44///
45/// // Token-only budget: window of at most 4096 tokens, no round limit.
46/// let by_tokens = Budget::tokens(4096);
47/// // Rounds-only budget: keep at most 8 rounds.
48/// let by_rounds = Budget::rounds(8);
49/// // Both set: the smaller window wins.
50/// let both = Budget::both(4096, 8);
51///
52/// assert_eq!(by_tokens.max_tokens, Some(4096));
53/// assert_eq!(by_tokens.max_rounds, None);
54/// assert_eq!(by_rounds.max_rounds, Some(8));
55/// assert!(both.max_tokens.is_some() && both.max_rounds.is_some());
56/// ```
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub struct Budget {
59 /// Token budget limit; `None` = unlimited.
60 pub max_tokens: Option<usize>,
61 /// Round limit; `None` = unlimited.
62 pub max_rounds: Option<usize>,
63}
64
65impl Default for Budget {
66 /// Unlimited in both dimensions (consistent with "no trimming by default").
67 fn default() -> Self {
68 Self {
69 max_tokens: None,
70 max_rounds: None,
71 }
72 }
73}
74
75impl Budget {
76 /// Token-only budget (no round limit).
77 pub fn tokens(max_tokens: usize) -> Self {
78 Self {
79 max_tokens: Some(max_tokens),
80 max_rounds: None,
81 }
82 }
83
84 /// Rounds-only budget (no token limit).
85 ///
86 /// `max_rounds == 0` is treated as 1 (the window always keeps the most
87 /// recent round, avoiding an empty window).
88 pub fn rounds(max_rounds: usize) -> Self {
89 Self {
90 max_tokens: None,
91 max_rounds: Some(max_rounds),
92 }
93 }
94
95 /// Sets both the token budget and the round limit (the smaller window wins
96 /// when both are set).
97 ///
98 /// `max_rounds == 0` is treated as 1 (see [`rounds`](Budget::rounds)).
99 pub fn both(max_tokens: usize, max_rounds: usize) -> Self {
100 Self {
101 max_tokens: Some(max_tokens),
102 max_rounds: Some(max_rounds),
103 }
104 }
105}
106
107/// Counts the token number of a text.
108///
109/// Memory does not know the model; "exact" token counts are user-side
110/// knowledge, so this trait carries no model or provider information:
111/// - single-model setups: inject a counter built for that model when
112/// constructing [`WindowMemory`] (e.g., wiring in tiktoken-rs); the counting
113/// convention is bound at construction;
114/// - multi-model dynamic routing: the custom implementation holds shared state
115/// internally and switches conventions when the application switches models.
116///
117/// The default implementation [`CharTokenCounter`] is a heuristic
118/// approximation that depends on no model.
119///
120/// The trait is `async`: it supports remote counting (e.g., calling a vendor's
121/// counting API); local implementations just return `Ok(approx)`. All call
122/// sites (record / context / trim) are already async, so remote counting costs
123/// nothing extra.
124///
125/// # Example
126///
127/// Inject a custom counter (e.g., trimming by message count):
128///
129/// ```rust
130/// # extern crate molo_agent as molo;
131/// use molo::memory::{Memory, MemoryError, TokenCounter, WindowMemory};
132/// use molo::Message;
133///
134/// // Each message counts as exactly 1 token: the token budget degenerates to
135/// // a message-count limit.
136/// #[derive(Default)]
137/// struct OnePerMessage;
138///
139/// #[molo::async_trait]
140/// impl TokenCounter for OnePerMessage {
141/// async fn count(&self, _text: &str) -> Result<usize, MemoryError> {
142/// Ok(1)
143/// }
144/// }
145///
146/// #[tokio::main]
147/// async fn main() -> Result<(), MemoryError> {
148/// let mut memory =
149/// WindowMemory::new(2).with_token_counter(Box::new(OnePerMessage));
150/// memory.record(Message::user("u1")).await?;
151/// memory.record(Message::assistant("a1")).await?;
152/// memory.record(Message::user("u2")).await?;
153/// memory.record(Message::assistant("a2")).await?;
154///
155/// // 4 messages > 2 tokens: trimmed to the most recent round.
156/// assert_eq!(memory.context().await?.len(), 2);
157/// Ok(())
158/// }
159/// ```
160#[async_trait::async_trait]
161pub trait TokenCounter: Send + Sync {
162 /// Counts the token number of a text.
163 ///
164 /// # Errors
165 ///
166 /// Returns [`MemoryError::TokenCount`] when counting fails (e.g., a remote
167 /// counting API is unavailable).
168 async fn count(&self, text: &str) -> Result<usize, MemoryError>;
169}
170
171/// Default counter: CJK characters count as 1 token each, other characters
172/// count as 1 token per 4 characters (rounded up).
173///
174/// The common chars/4 approximation underestimates Chinese by roughly 4x
175/// (Chinese is roughly 1 token per character), so this implementation
176/// distinguishes CJK to stay closer to reality; it is still an approximation —
177/// inject a custom implementation when exact counts are needed.
178///
179/// # Example
180///
181/// ```rust
182/// # extern crate molo_agent as molo;
183/// # #[tokio::main]
184/// # async fn main() -> Result<(), molo::memory::MemoryError> {
185/// use molo::memory::{CharTokenCounter, TokenCounter};
186///
187/// let counter = CharTokenCounter;
188/// // 5 characters → 2 tokens (1 token per 4 characters, rounded up).
189/// assert_eq!(counter.count("hello").await?, 2);
190/// // 4 characters → exactly 1 token.
191/// assert_eq!(counter.count("abcd").await?, 1);
192/// // 7 characters → 2 tokens (rounded up).
193/// assert_eq!(counter.count("morning").await?, 2);
194/// # Ok(())
195/// # }
196/// ```
197#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
198pub struct CharTokenCounter;
199
200#[async_trait::async_trait]
201impl TokenCounter for CharTokenCounter {
202 async fn count(&self, text: &str) -> Result<usize, MemoryError> {
203 let (mut cjk, mut other) = (0usize, 0usize);
204 for c in text.chars() {
205 if is_cjk(c) {
206 cjk += 1;
207 } else {
208 other += 1;
209 }
210 }
211 Ok(cjk + other.div_ceil(4))
212 }
213}
214
215/// CJK ideographs (unified ideographs plus extensions A–F and compatibility
216/// ideographs; kana / Hangul excluded).
217fn is_cjk(c: char) -> bool {
218 matches!(
219 c as u32,
220 0x3400..=0x4DBF
221 | 0x4E00..=0x9FFF
222 | 0xF900..=0xFAFF
223 | 0x20000..=0x2FA1F
224 )
225}
226
227/// Token count of a message: the sum over all its text blocks.
228///
229/// The Assistant's `reasoning` and `tool_calls` arguments are counted too —
230/// they are sent back to the API verbatim and occupy the window; thinking-model
231/// reasoning is long, and undercounting would really blow the limit.
232pub(crate) async fn count_message(
233 counter: &dyn TokenCounter,
234 message: &Message,
235) -> Result<usize, MemoryError> {
236 match message {
237 Message::System(s) => counter.count(s).await,
238 Message::User(blocks) => {
239 let mut total = 0usize;
240 for b in blocks {
241 match b {
242 ContentBlock::Text(t) => total += counter.count(t).await?,
243 // Images and pass-through blocks carry no text to count;
244 // the message stays in the window whole so the content
245 // still reaches the provider.
246 ContentBlock::Image(_) | ContentBlock::Wire(_) => {}
247 }
248 }
249 Ok(total)
250 }
251 Message::Assistant {
252 content,
253 reasoning,
254 tool_calls,
255 } => {
256 let mut total = counter.count(content).await?;
257 if let Some(r) = reasoning {
258 total += counter.count(r).await?;
259 }
260 for tc in tool_calls {
261 total += counter.count(&tc.arguments).await?;
262 }
263 Ok(total)
264 }
265 Message::ToolResult { content, .. } => counter.count(content).await,
266 }
267}
268
269/// Output of a trim strategy: the trimmed message sequence + how the result is
270/// handled.
271///
272/// # Comparison: materialization vs. projection
273///
274/// - `replace: true` (**materialized**): the result is written back to
275/// storage; the replaced old messages are no longer kept. Subsequent
276/// retrievals recompute nothing until new messages breach the budget again
277/// (the strategy input = previous result + new messages). Heavy operations
278/// such as LLM summarization should pick this semantic to avoid
279/// recomputation every turn.
280/// - `replace: false` (**projection**): the result is only visible for this
281/// retrieval; storage is unchanged and nothing is lost — raising the budget
282/// restores all trimmed history. The cost is recomputation whenever over
283/// budget, so it suits cheap operations (e.g., window dropping).
284#[derive(Debug, Clone, PartialEq, Eq)]
285pub struct TrimResult {
286 /// The trimmed message sequence.
287 pub messages: Vec<Message>,
288 /// Whether the result is materialized back into storage (semantics in the
289 /// type docs).
290 pub replace: bool,
291}
292
293/// Trim strategy: decides "how to trim" — window dropping, summarization, LLM
294/// compaction, etc. Users can inject custom implementations.
295///
296/// The strategy is fully responsible for the trim result: the output must
297/// satisfy the message interface constraints (role alternation etc.), and
298/// round boundaries are the strategy's own responsibility; `budget` and
299/// `counter` are provided for the strategy to compute how much to compact.
300#[async_trait::async_trait]
301pub trait TrimStrategy: Send + Sync {
302 /// Trims a message sequence against a budget and counter, returning the
303 /// trim result (semantics in [`TrimResult`]).
304 ///
305 /// # Errors
306 ///
307 /// Returns [`MemoryError`] when counting or trimming fails (e.g., remote
308 /// counting is unavailable).
309 async fn trim(
310 &self,
311 messages: &[Message],
312 budget: &Budget,
313 counter: &dyn TokenCounter,
314 ) -> Result<TrimResult, MemoryError>;
315
316 /// Trims with per-message token counts; the default implementation
317 /// delegates to [`trim`](TrimStrategy::trim), so custom strategies need not
318 /// know about it. `WindowDrop` overrides it to amortized O(1) — counts are
319 /// cached by Memory, avoiding per-message recounts inside the strategy (and
320 /// saving IO with remote counters).
321 async fn trim_with_counts(
322 &self,
323 messages: &[Message],
324 _counts: &[usize],
325 budget: &Budget,
326 counter: &dyn TokenCounter,
327 ) -> Result<TrimResult, MemoryError> {
328 self.trim(messages, budget, counter).await
329 }
330}
331
332/// Default trim strategy: drops the earliest complete rounds until the
333/// remaining sequence fits the budget.
334///
335/// Uses projection semantics (`replace: false`): lossless — raising the budget
336/// restores the trimmed history, and each recomputation is cheap. Guarantees:
337///
338/// - at least the most recent round is kept (kept even when a single round
339/// exceeds the budget; nothing more can be trimmed);
340/// - a tool message and its Assistant message share a round and are kept as a
341/// pair;
342/// - recorded System messages are treated like any other message and may be
343/// trimmed (the system prompt is managed by the Agent layer; Memory does not
344/// manage System).
345///
346/// # Comparison
347///
348/// When a heavy strategy such as summarization or LLM compaction is needed,
349/// inject a custom [`TrimStrategy`] that declares materialization
350/// (`replace: true`), trading the materialized zero-recompute for a longer
351/// context; light and lossless round-wise dropping is this implementation.
352#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
353pub struct WindowDrop;
354
355#[async_trait::async_trait]
356impl TrimStrategy for WindowDrop {
357 async fn trim(
358 &self,
359 messages: &[Message],
360 budget: &Budget,
361 counter: &dyn TokenCounter,
362 ) -> Result<TrimResult, MemoryError> {
363 // Fall back to the self-counting async path (when counts are not
364 // cached).
365 let mut counts = Vec::with_capacity(messages.len());
366 for m in messages {
367 counts.push(count_message(counter, m).await?);
368 }
369 Ok(TrimResult {
370 messages: window_from_counts(messages, &counts, budget),
371 replace: false,
372 })
373 }
374
375 async fn trim_with_counts(
376 &self,
377 messages: &[Message],
378 counts: &[usize],
379 budget: &Budget,
380 _counter: &dyn TokenCounter,
381 ) -> Result<TrimResult, MemoryError> {
382 Ok(TrimResult {
383 messages: window_from_counts(messages, counts, budget),
384 replace: false,
385 })
386 }
387}
388
389/// A round's boundaries and token total: (start index, end index, tokens).
390/// A round = one User message plus everything after it until the next User;
391/// a leading non-User message (defensive) belongs to the first round.
392pub(crate) type Round = (usize, usize, usize);
393
394/// Splits a message sequence into complete rounds (shared convention for both
395/// window trimming and summarization).
396pub(crate) fn split_rounds(messages: &[Message], counts: &[usize]) -> Vec<Round> {
397 debug_assert_eq!(messages.len(), counts.len());
398 if messages.is_empty() {
399 return Vec::new();
400 }
401 let mut rounds = Vec::new();
402 let mut start = 0usize;
403 let mut tokens = counts[0];
404 for (i, message) in messages.iter().enumerate().skip(1) {
405 if matches!(message, Message::User(_)) {
406 rounds.push((start, i, tokens));
407 start = i;
408 tokens = counts[i];
409 } else {
410 tokens += counts[i];
411 }
412 }
413 rounds.push((start, messages.len(), tokens));
414 rounds
415}
416
417/// Keeps complete rounds from the tail forward, returning the number of rounds
418/// kept (at least 1; a single over-budget round is still kept); `reserved` is
419/// the portion of the budget set aside in advance (e.g., room for the summary
420/// output).
421///
422/// Rules: the most recent round is kept unconditionally; later rounds are kept
423/// only while "adding them stays within budget"; `max_rounds` then caps the
424/// count (at most the recent N rounds, at least 1).
425pub(crate) fn keep_rounds(rounds: &[Round], budget: &Budget, reserved: usize) -> usize {
426 let mut keep = 0usize;
427 let mut sum = 0usize;
428 for &(_, _, t) in rounds.iter().rev() {
429 if keep > 0
430 && let Some(limit) = budget.max_tokens
431 && sum + t > limit.saturating_sub(reserved)
432 {
433 break;
434 }
435 sum += t;
436 keep += 1;
437 }
438 if let Some(limit) = budget.max_rounds {
439 keep = keep.min(limit.max(1));
440 }
441 keep
442}
443
444/// Window view: trims from the earliest complete round, returning the kept
445/// sequence (at least the most recent round).
446/// Pure-synchronous variant: per-message token counts come from the caller
447/// (amortized O(1) on cache hit).
448fn window_from_counts(messages: &[Message], counts: &[usize], budget: &Budget) -> Vec<Message> {
449 if messages.is_empty() {
450 return Vec::new();
451 }
452 let rounds = split_rounds(messages, counts);
453 let keep = keep_rounds(&rounds, budget, 0);
454 let start_index = rounds[rounds.len() - keep].0;
455 messages[start_index..].to_vec()
456}
457
458/// Window memory: stores all messages; `context()` trims to the budget on
459/// retrieval.
460///
461/// - Within budget: returns a full clone as-is;
462/// - Over budget: calls the [`TrimStrategy`] — the default [`WindowDrop`]
463/// drops the earliest messages by round (projection, lossless); an injected
464/// heavy strategy can declare `replace: true` to materialize (write back to
465/// storage, zero recomputation afterwards).
466///
467/// Counting is lazy: after a materialized write-back nothing is counted until
468/// the first async call (record / context) recomputes in one pass —
469/// [`TokenCounter`] may involve remote IO, and the synchronous path never
470/// touches it. Materialization happens inside [`context`](Memory::context) on
471/// `&self`, using an internal `std::sync::Mutex` for interior mutability; the
472/// critical section contains no await, so single-threaded runtimes are not
473/// blocked.
474///
475/// # Comparison
476///
477/// Compared to [`InMemoryMemory`](crate::memory::InMemoryMemory): the latter
478/// stores all messages verbatim and never trims, suiting small conversations
479/// without budget control; use this type when context size must be controlled
480/// (the model's context window is finite).
481///
482/// # Panics
483///
484/// Panics only when the internal lock is poisoned (a panic occurred while
485/// holding it); never in normal use.
486///
487/// # Examples
488///
489/// Basic usage:
490///
491/// ```rust
492/// # extern crate molo_agent as molo;
493/// # #[tokio::main]
494/// # async fn main() -> Result<(), molo::memory::MemoryError> {
495/// use molo::memory::{Memory, WindowMemory};
496///
497/// let mut memory = WindowMemory::new(100);
498/// memory.record(molo::message::Message::user("hello")).await?;
499/// assert_eq!(memory.context().await?.len(), 1);
500/// # Ok(())
501/// # }
502/// ```
503///
504/// Trims by round when over budget, keeping at least the most recent round:
505///
506/// ```rust
507/// # extern crate molo_agent as molo;
508/// # #[tokio::main]
509/// # async fn main() -> Result<(), molo::memory::MemoryError> {
510/// use molo::memory::{Memory, WindowMemory};
511///
512/// let mut memory = WindowMemory::new(3);
513/// memory.record(molo::message::Message::user("u1")).await?;
514/// memory.record(molo::message::Message::assistant("a1")).await?;
515/// memory.record(molo::message::Message::user("u2")).await?;
516/// memory.record(molo::message::Message::assistant("a2")).await?;
517///
518/// // 4 tokens total, over the 3-token budget: the earliest round is dropped.
519/// let context = memory.context().await?;
520/// assert_eq!(context.len(), 2);
521/// assert_eq!(context[0], molo::message::Message::user("u2"));
522/// # Ok(())
523/// # }
524/// ```
525pub struct WindowMemory {
526 inner: Mutex<WindowInner>,
527 budget: Budget,
528 counter: Box<dyn TokenCounter>,
529 strategy: Arc<dyn TrimStrategy>,
530}
531
532struct WindowInner {
533 messages: Vec<Message>,
534 /// Protection flags parallel to `messages`: protected messages (e.g.,
535 /// skill bodies) are exempt from trimming.
536 protected: Vec<bool>,
537 /// Per-message token counts parallel to `messages` (used directly by the
538 /// default strategy's trim, avoiding rescans; invalidated together with
539 /// `total_tokens`).
540 tokens: Vec<usize>,
541 /// Total token count of all messages; `None` = not yet counted (after a
542 /// materialized write-back / after swapping the counter), recomputed on the
543 /// first async call.
544 total_tokens: Option<usize>,
545 user_count: usize,
546}
547
548impl fmt::Debug for WindowMemory {
549 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
550 // The counter and strategy are trait objects and cannot derive Debug:
551 // print the budget and message count, and mark the other fields' types.
552 let message_count = self
553 .inner
554 .lock()
555 .expect("WindowMemory internal lock poisoned")
556 .messages
557 .len();
558 f.debug_struct("WindowMemory")
559 .field("budget", &self.budget)
560 .field("message_count", &message_count)
561 .field("counter", &"Box<dyn TokenCounter>")
562 .field("strategy", &"Arc<dyn TrimStrategy>")
563 .finish()
564 }
565}
566
567impl WindowMemory {
568 /// Creates an empty session with a context budget of `max_tokens` tokens.
569 ///
570 /// Counting and trimming default to [`CharTokenCounter`] and
571 /// [`WindowDrop`]; replace them with
572 /// [`with_token_counter`](WindowMemory::with_token_counter) and
573 /// [`with_strategy`](WindowMemory::with_strategy) respectively.
574 pub fn new(max_tokens: usize) -> Self {
575 Self {
576 inner: Mutex::new(WindowInner {
577 messages: Vec::new(),
578 protected: Vec::new(),
579 tokens: Vec::new(),
580 total_tokens: Some(0),
581 user_count: 0,
582 }),
583 budget: Budget::tokens(max_tokens),
584 counter: Box::new(CharTokenCounter),
585 strategy: Arc::new(WindowDrop),
586 }
587 }
588
589 /// Adds a "keep at most the recent N rounds" limit (unlimited by default;
590 /// the smaller window wins when set together with the token budget).
591 pub fn with_max_rounds(mut self, max_rounds: usize) -> Self {
592 self.budget.max_rounds = Some(max_rounds);
593 self
594 }
595
596 /// Replaces the default heuristic counting with exact counting (matching
597 /// your own model).
598 ///
599 /// Swapping changes the counting convention and invalidates previously
600 /// counted results: the first async call (record / context) recomputes
601 /// with the new counter.
602 pub fn with_token_counter(mut self, counter: Box<dyn TokenCounter>) -> Self {
603 self.counter = counter;
604 // The counting convention changed: invalidate and recompute lazily via
605 // ensure_counts.
606 let mut inner = self
607 .inner
608 .lock()
609 .expect("WindowMemory internal lock poisoned");
610 inner.total_tokens = None;
611 drop(inner);
612 self
613 }
614
615 /// Injects a custom trim strategy (summarization, LLM compaction, etc.);
616 /// defaults to [`WindowDrop`].
617 ///
618 /// # Example
619 ///
620 /// A summarization strategy: replaces everything but the most recent round
621 /// with a summary message and materializes it (`replace: true`):
622 ///
623 /// ```rust
624 /// # extern crate molo_agent as molo;
625 /// use std::sync::Arc;
626 /// use molo::memory::{
627 /// Budget, Memory, MemoryError, TokenCounter, TrimResult, TrimStrategy, WindowMemory,
628 /// };
629 /// use molo::Message;
630 ///
631 /// #[derive(Default)]
632 /// struct Summarize;
633 ///
634 /// #[molo::async_trait]
635 /// impl TrimStrategy for Summarize {
636 /// async fn trim(
637 /// &self,
638 /// messages: &[Message],
639 /// _budget: &Budget,
640 /// _counter: &dyn TokenCounter,
641 /// ) -> Result<TrimResult, MemoryError> {
642 /// // Keep the most recent round; replace earlier messages with a
643 /// // summary message.
644 /// // The summary uses the System role so no consecutive User
645 /// // messages appear after replacement (role-alternation
646 /// // constraint).
647 /// let pos = messages
648 /// .iter()
649 /// .rposition(|m| matches!(m, Message::User(_)))
650 /// .unwrap_or(0);
651 /// let mut result = Vec::with_capacity(messages.len() - pos + 1);
652 /// result.push(Message::system("prior summary"));
653 /// result.extend_from_slice(&messages[pos..]);
654 /// Ok(TrimResult { messages: result, replace: true })
655 /// }
656 /// }
657 ///
658 /// #[tokio::main]
659 /// async fn main() {
660 /// let mut memory = WindowMemory::new(7).with_strategy(Arc::new(Summarize));
661 /// for i in 1..=4 {
662 /// memory.record(Message::user(format!("u{i}"))).await.unwrap();
663 /// memory.record(Message::assistant(format!("a{i}"))).await.unwrap();
664 /// }
665 ///
666 /// // 8 > 7: compacted to [summary, most recent round].
667 /// let context = memory.context().await.unwrap();
668 /// assert_eq!(context.len(), 3);
669 /// assert!(matches!(context[0], Message::System(_)));
670 /// }
671 /// ```
672 pub fn with_strategy(mut self, strategy: Arc<dyn TrimStrategy>) -> Self {
673 self.strategy = strategy;
674 self
675 }
676
677 /// Adjusts the token budget at runtime; takes effect on the next context
678 /// retrieval.
679 ///
680 /// With a projection strategy, raising the budget restores the trimmed
681 /// history; with a materializing strategy, old messages are gone and a
682 /// higher budget cannot restore them.
683 pub fn set_max_tokens(&mut self, max_tokens: usize) {
684 self.budget.max_tokens = Some(max_tokens);
685 }
686
687 /// Adjusts the round limit at runtime (`None` = unlimited); takes effect
688 /// on the next context retrieval.
689 pub fn set_max_rounds(&mut self, max_rounds: Option<usize>) {
690 self.budget.max_rounds = max_rounds;
691 }
692
693 /// Lazily recomputes counts (when not counted): awaits outside the lock
694 /// (remote counting may do IO), writes back inside it.
695 async fn ensure_counts(&self) -> Result<(), MemoryError> {
696 let missing: Option<Vec<Message>> = {
697 let inner = self
698 .inner
699 .lock()
700 .expect("WindowMemory internal lock poisoned");
701 if inner.total_tokens.is_none() {
702 Some(inner.messages.clone())
703 } else {
704 None
705 }
706 };
707 let Some(messages) = missing else {
708 return Ok(());
709 };
710 if messages.is_empty() {
711 let mut inner = self
712 .inner
713 .lock()
714 .expect("WindowMemory internal lock poisoned");
715 inner.total_tokens = Some(0);
716 return Ok(());
717 }
718 let mut total = 0usize;
719 let mut tokens = Vec::with_capacity(messages.len());
720 for m in &messages {
721 let t = count_message(&*self.counter, m).await?;
722 total += t;
723 tokens.push(t);
724 }
725 let user_count = messages
726 .iter()
727 .filter(|m| matches!(m, Message::User(_)))
728 .count();
729 let mut inner = self
730 .inner
731 .lock()
732 .expect("WindowMemory internal lock poisoned");
733 inner.total_tokens = Some(total);
734 inner.tokens = tokens;
735 inner.user_count = user_count;
736 Ok(())
737 }
738}
739
740#[async_trait::async_trait]
741impl Memory for WindowMemory {
742 async fn record(&mut self, message: Message) -> Result<(), MemoryError> {
743 self.record_impl(message, false).await
744 }
745
746 async fn record_protected(&mut self, message: Message) -> Result<(), MemoryError> {
747 self.record_impl(message, true).await
748 }
749
750 async fn context(&self) -> Result<Vec<Message>, MemoryError> {
751 self.ensure_counts().await?;
752 // Snapshot and budget check: the critical section only clones, no
753 // await.
754 let (over_budget, snapshot, protected, tokens) = {
755 let inner = self
756 .inner
757 .lock()
758 .expect("WindowMemory internal lock poisoned");
759 let total = inner
760 .total_tokens
761 .expect("counts guaranteed by ensure_counts");
762 let over = self.budget.max_tokens.is_some_and(|limit| total > limit)
763 || self
764 .budget
765 .max_rounds
766 .is_some_and(|limit| inner.user_count > limit);
767 (
768 over,
769 inner.messages.clone(),
770 inner.protected.clone(),
771 inner.tokens.clone(),
772 )
773 };
774 if !over_budget {
775 return Ok(snapshot);
776 }
777
778 // Rounds containing protected messages are exempt as a whole: they are
779 // pulled out of the candidate set, and the strategy only handles
780 // trimmable messages (transparent to the strategy; custom strategies
781 // get the exemption for free). The pulled-out part goes first in the
782 // result, naturally aligned with the window trim's "keep recent
783 // rounds" tail semantics.
784 let protected_set: std::collections::HashSet<usize> =
785 protected_round_indices(&snapshot, &protected)
786 .into_iter()
787 .collect();
788 let mut kept: Vec<Message> = Vec::with_capacity(snapshot.len());
789 let mut candidates: Vec<Message> = Vec::new();
790 let mut candidate_tokens: Vec<usize> = Vec::new();
791 for (i, message) in snapshot.into_iter().enumerate() {
792 if protected_set.contains(&i) {
793 kept.push(message);
794 } else {
795 candidates.push(message);
796 candidate_tokens.push(tokens[i]);
797 }
798 }
799
800 let result = self
801 .strategy
802 .trim_with_counts(&candidates, &candidate_tokens, &self.budget, &*self.counter)
803 .await?;
804 if result.replace {
805 // Materialize: write back to storage (protected part + trimmed
806 // candidates); the replaced old messages are no longer kept; counts
807 // are invalidated and recomputed next time.
808 let protected_len = kept.len();
809 kept.extend(result.messages);
810 let mut inner = self
811 .inner
812 .lock()
813 .expect("WindowMemory internal lock poisoned");
814 inner.messages = kept.clone();
815 inner.protected = (0..kept.len()).map(|i| i < protected_len).collect();
816 inner.total_tokens = None;
817 inner.tokens.clear();
818 inner.user_count = 0;
819 } else {
820 kept.extend(result.messages);
821 }
822 Ok(kept)
823 }
824}
825
826impl WindowMemory {
827 /// Shared implementation of record / record_protected: count + append +
828 /// protection flag.
829 async fn record_impl(&mut self, message: Message, protected: bool) -> Result<(), MemoryError> {
830 self.ensure_counts().await?;
831 let tokens = count_message(&*self.counter, &message).await?;
832 let mut inner = self
833 .inner
834 .lock()
835 .expect("WindowMemory internal lock poisoned");
836 let total = inner
837 .total_tokens
838 .as_mut()
839 .expect("counts guaranteed by ensure_counts");
840 *total += tokens;
841 if matches!(message, Message::User(_)) {
842 inner.user_count += 1;
843 }
844 inner.messages.push(message);
845 inner.protected.push(protected);
846 inner.tokens.push(tokens);
847 Ok(())
848 }
849}
850
851/// Indices of the rounds that contain protected messages (a round = one User
852/// message plus everything until the next User; a leading non-User message
853/// belongs to the first round — same round-splitting convention as window
854/// trimming).
855fn protected_round_indices(messages: &[Message], protected: &[bool]) -> Vec<usize> {
856 let mut result = Vec::new();
857 let mut round_start = 0usize;
858 for (i, message) in messages.iter().enumerate().skip(1) {
859 if matches!(message, Message::User(_)) {
860 if protected[round_start..i].iter().any(|&p| p) {
861 result.extend(round_start..i);
862 }
863 round_start = i;
864 }
865 }
866 if protected[round_start..].iter().any(|&p| p) {
867 result.extend(round_start..messages.len());
868 }
869 result
870}
871
872#[cfg(test)]
873mod tests {
874 use super::*;
875 use crate::message::{ContentBlock, ToolCall};
876
877 fn tool_result(id: &str, content: &str) -> Message {
878 Message::ToolResult {
879 id: id.into(),
880 content: content.into(),
881 }
882 }
883
884 /// Test fake summarization strategy: replaces everything but the most
885 /// recent round with a summary message (materializing); records the call
886 /// count and the message count of the last input.
887 #[derive(Debug, Default)]
888 struct FakeSummarizer {
889 calls: std::sync::atomic::AtomicUsize,
890 last_input_len: std::sync::atomic::AtomicUsize,
891 last_input_has_summary: std::sync::atomic::AtomicBool,
892 }
893
894 #[async_trait::async_trait]
895 impl TrimStrategy for FakeSummarizer {
896 async fn trim(
897 &self,
898 messages: &[Message],
899 _budget: &Budget,
900 _counter: &dyn TokenCounter,
901 ) -> Result<TrimResult, MemoryError> {
902 self.calls
903 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
904 self.last_input_len
905 .store(messages.len(), std::sync::atomic::Ordering::Relaxed);
906 let has_summary = messages
907 .iter()
908 .any(|m| matches!(m, Message::System(s) if s.starts_with("prior summary")));
909 self.last_input_has_summary
910 .store(has_summary, std::sync::atomic::Ordering::Relaxed);
911
912 // Keep the most recent round; replace earlier messages with a
913 // summary message.
914 // The summary uses the System role: avoids consecutive User
915 // messages after replacement (wire role-alternation constraint).
916 let result = match messages.iter().rposition(|m| matches!(m, Message::User(_))) {
917 Some(pos) => {
918 let mut result = Vec::with_capacity(messages.len() - pos + 1);
919 result.push(Message::system("prior summary"));
920 result.extend_from_slice(&messages[pos..]);
921 result
922 }
923 None => messages.to_vec(),
924 };
925 Ok(TrimResult {
926 messages: result,
927 replace: true,
928 })
929 }
930 }
931
932 #[tokio::test]
933 async fn within_budget_returns_all() {
934 let mut memory = WindowMemory::new(1000);
935 memory.record(Message::user("hello")).await.unwrap();
936 memory.record(Message::assistant("hello!")).await.unwrap();
937
938 let context = memory.context().await.unwrap();
939 assert_eq!(context.len(), 2);
940 assert_eq!(context[0], Message::user("hello"));
941 assert_eq!(context[1], Message::assistant("hello!"));
942 }
943
944 /// Over budget: drops the earliest complete round, keeps the most recent
945 /// one; the result starts with a User message.
946 #[tokio::test]
947 async fn drops_earliest_rounds_when_over_budget() {
948 let mut memory = WindowMemory::new(3);
949 // Each round is about 2 tokens (ASCII: 4 chars = 1 token): User 1 +
950 // Asst 1.
951 memory.record(Message::user("u1")).await.unwrap();
952 memory.record(Message::assistant("a1")).await.unwrap();
953 memory.record(Message::user("u2")).await.unwrap();
954 memory.record(Message::assistant("a2")).await.unwrap();
955 memory.record(Message::user("u3")).await.unwrap();
956 memory.record(Message::assistant("a3")).await.unwrap();
957
958 let context = memory.context().await.unwrap();
959 assert_eq!(context, vec![Message::user("u3"), Message::assistant("a3")]);
960 }
961
962 /// Wire constraint: a tool message and its Assistant message are trimmed
963 /// as a pair, never split.
964 #[tokio::test]
965 async fn tool_messages_trimmed_with_assistant() {
966 let mut memory = WindowMemory::new(8);
967 // Round 1: User + Assistant (with tool calls) + ToolResult ×2 — a
968 // complete round (calculate 3 + empty content 0 + args 1 + 1 + results
969 // 1 + 1 = 7 tokens).
970 memory.record(Message::user("calculate")).await.unwrap();
971 memory
972 .record(Message::Assistant {
973 content: "".into(),
974 reasoning: None,
975 tool_calls: vec![
976 ToolCall {
977 id: "t1".into(),
978 name: "calc".into(),
979 arguments: "1+1".into(),
980 },
981 ToolCall {
982 id: "t2".into(),
983 name: "calc".into(),
984 arguments: "2+2".into(),
985 },
986 ],
987 })
988 .await
989 .unwrap();
990 memory.record(tool_result("t1", "2")).await.unwrap();
991 memory.record(tool_result("t2", "4")).await.unwrap();
992 // Round 2: User + Assistant (continue 2 + okay 1 = 3 tokens).
993 memory.record(Message::user("continue")).await.unwrap();
994 memory.record(Message::assistant("okay")).await.unwrap();
995
996 let context = memory.context().await.unwrap();
997 assert_eq!(
998 context,
999 vec![Message::user("continue"), Message::assistant("okay")]
1000 );
1001 // The trimmed round is gone entirely: no Assistant with tool_calls and
1002 // no ToolResult.
1003 assert!(
1004 !context.iter().any(
1005 |m| matches!(m, Message::Assistant { tool_calls, .. } if !tool_calls.is_empty())
1006 )
1007 );
1008 assert!(
1009 !context
1010 .iter()
1011 .any(|m| matches!(m, Message::ToolResult { .. }))
1012 );
1013 }
1014
1015 /// Memory does not manage System: a recorded System message is treated
1016 /// like any other and may be trimmed when over budget (the system prompt
1017 /// is the Agent layer's responsibility).
1018 #[tokio::test]
1019 async fn recorded_system_can_be_trimmed() {
1020 let mut memory = WindowMemory::new(3);
1021 memory.record(Message::system("setup")).await.unwrap();
1022 memory.record(Message::user("u1")).await.unwrap();
1023 memory.record(Message::assistant("a1")).await.unwrap();
1024 memory.record(Message::user("u2")).await.unwrap();
1025 memory.record(Message::assistant("a2")).await.unwrap();
1026
1027 // 6 tokens total > 3: the first round (including System) is dropped,
1028 // keeping the most recent round.
1029 let context = memory.context().await.unwrap();
1030 assert_eq!(context, vec![Message::user("u2"), Message::assistant("a2")]);
1031 }
1032
1033 /// A single round over budget: the most recent round is kept as a fallback
1034 /// (nothing more can be trimmed).
1035 #[tokio::test]
1036 async fn keeps_last_round_even_when_over_budget() {
1037 let mut memory = WindowMemory::new(3);
1038 memory
1039 .record(Message::user(
1040 "An extremely long user message, over budget in a single round",
1041 ))
1042 .await
1043 .unwrap();
1044 memory.record(Message::assistant("Reply")).await.unwrap();
1045 memory.record(Message::user("u2")).await.unwrap();
1046 memory.record(Message::assistant("a2")).await.unwrap();
1047
1048 let context = memory.context().await.unwrap();
1049 assert_eq!(context, vec![Message::user("u2"), Message::assistant("a2")]);
1050 }
1051
1052 /// max_rounds: keeps only the most recent N rounds.
1053 #[tokio::test]
1054 async fn max_rounds_window() {
1055 let mut memory = WindowMemory::new(1000).with_max_rounds(2);
1056 for i in 1..=4 {
1057 memory.record(Message::user(format!("u{i}"))).await.unwrap();
1058 memory
1059 .record(Message::assistant(format!("a{i}")))
1060 .await
1061 .unwrap();
1062 }
1063
1064 let context = memory.context().await.unwrap();
1065 assert_eq!(
1066 context,
1067 vec![
1068 Message::user("u3"),
1069 Message::assistant("a3"),
1070 Message::user("u4"),
1071 Message::assistant("a4")
1072 ]
1073 );
1074 }
1075
1076 /// max_tokens and max_rounds both set: the smaller window wins.
1077 #[tokio::test]
1078 async fn tokens_and_rounds_take_smaller() {
1079 let mut memory = WindowMemory::new(3).with_max_rounds(3);
1080 for i in 1..=4 {
1081 memory.record(Message::user(format!("u{i}"))).await.unwrap();
1082 memory
1083 .record(Message::assistant(format!("a{i}")))
1084 .await
1085 .unwrap();
1086 }
1087 // max_rounds=3 would allow 3 rounds, but max_tokens=3 fits only 1
1088 // round → the smaller one wins.
1089 let context = memory.context().await.unwrap();
1090 assert_eq!(context, vec![Message::user("u4"), Message::assistant("a4")]);
1091 }
1092
1093 /// Projection: raising the budget restores the trimmed history (lossless).
1094 #[tokio::test]
1095 async fn raising_budget_restores_history() {
1096 let mut memory = WindowMemory::new(3);
1097 memory.record(Message::user("u1")).await.unwrap();
1098 memory.record(Message::assistant("a1")).await.unwrap();
1099 memory.record(Message::user("u2")).await.unwrap();
1100 memory.record(Message::assistant("a2")).await.unwrap();
1101
1102 assert_eq!(memory.context().await.unwrap().len(), 2); // most recent round
1103
1104 memory.set_max_tokens(1000);
1105 assert_eq!(memory.context().await.unwrap().len(), 4); // history restored
1106 }
1107
1108 /// reasoning counts toward the budget: the same content with long
1109 /// reasoning triggers trimming; without it, nothing is trimmed.
1110 #[tokio::test]
1111 async fn reasoning_counts_toward_budget() {
1112 let mut memory = WindowMemory::new(11);
1113 // Round 1: User + Assistant (with reasoning): 1 + 1 + 10 = 12.
1114 memory.record(Message::user("u1")).await.unwrap();
1115 memory
1116 .record(Message::assistant_with_reasoning(
1117 "hi",
1118 "a".repeat(40), // 40 chars = 10 tokens
1119 ))
1120 .await
1121 .unwrap();
1122 // Round 2: User + Assistant (no reasoning): 1 + 1 = 2.
1123 memory.record(Message::user("u2")).await.unwrap();
1124 memory.record(Message::assistant("hi")).await.unwrap();
1125
1126 // 14 total > 11 → round 1 is trimmed.
1127 let context = memory.context().await.unwrap();
1128 assert_eq!(context, vec![Message::user("u2"), Message::assistant("hi")]);
1129
1130 // Same setup without reasoning: 4 ≤ 11 → nothing is trimmed.
1131 let mut memory2 = WindowMemory::new(11);
1132 memory2.record(Message::user("u1")).await.unwrap();
1133 memory2.record(Message::assistant("hi")).await.unwrap();
1134 memory2.record(Message::user("u2")).await.unwrap();
1135 memory2.record(Message::assistant("hi")).await.unwrap();
1136 assert_eq!(memory2.context().await.unwrap().len(), 4);
1137 }
1138
1139 /// Custom counter: each message counts as exactly 1 token (trim by message
1140 /// count).
1141 #[tokio::test]
1142 async fn custom_token_counter() {
1143 #[derive(Debug, Default)]
1144 struct OnePerMessage;
1145 #[async_trait::async_trait]
1146 impl TokenCounter for OnePerMessage {
1147 async fn count(&self, _text: &str) -> Result<usize, MemoryError> {
1148 Ok(1)
1149 }
1150 }
1151
1152 let mut memory = WindowMemory::new(2).with_token_counter(Box::new(OnePerMessage));
1153 // 5 messages (5) > 2 → trimmed to the most recent 1 round (a round
1154 // starts at a User).
1155 memory.record(Message::user("u1")).await.unwrap();
1156 memory.record(Message::assistant("a1")).await.unwrap();
1157 memory.record(Message::user("u2")).await.unwrap();
1158 memory.record(Message::assistant("a2")).await.unwrap();
1159 memory.record(Message::user("u3")).await.unwrap();
1160
1161 let context = memory.context().await.unwrap();
1162 assert_eq!(context, vec![Message::user("u3")]);
1163 }
1164
1165 /// Materialization: `replace: true` writes back to storage; subsequent
1166 /// in-budget context() calls recompute nothing (the strategy is not
1167 /// called).
1168 #[tokio::test]
1169 async fn materialize_replaces_storage_and_skips_strategy() {
1170 let summarizer = Arc::new(FakeSummarizer::default());
1171 let mut memory = WindowMemory::new(7).with_strategy(summarizer.clone());
1172 for i in 1..=4 {
1173 memory.record(Message::user(format!("u{i}"))).await.unwrap();
1174 memory
1175 .record(Message::assistant(format!("a{i}")))
1176 .await
1177 .unwrap();
1178 }
1179
1180 // 8 > 7 → compacted to [summary (4 tokens), u4, a4].
1181 let first = memory.context().await.unwrap();
1182 assert_eq!(
1183 summarizer.calls.load(std::sync::atomic::Ordering::Relaxed),
1184 1
1185 );
1186 assert_eq!(
1187 first,
1188 vec![
1189 Message::system("prior summary"),
1190 Message::user("u4"),
1191 Message::assistant("a4"),
1192 ]
1193 );
1194
1195 // After materialization the budget holds (4 + 2 = 6 ≤ 7): the second
1196 // context() just clones, without calling the strategy.
1197 let second = memory.context().await.unwrap();
1198 assert_eq!(
1199 summarizer.calls.load(std::sync::atomic::Ordering::Relaxed),
1200 1
1201 );
1202 assert_eq!(second, first);
1203 }
1204
1205 /// Over budget again after materialization: the strategy input = previous
1206 /// compaction result + new messages (chained, including the summary).
1207 #[tokio::test]
1208 async fn materialize_strategy_input_is_materialized_sequence() {
1209 let summarizer = Arc::new(FakeSummarizer::default());
1210 let mut memory = WindowMemory::new(7).with_strategy(summarizer.clone());
1211 for i in 1..=4 {
1212 memory.record(Message::user(format!("u{i}"))).await.unwrap();
1213 memory
1214 .record(Message::assistant(format!("a{i}")))
1215 .await
1216 .unwrap();
1217 }
1218 memory.context().await.unwrap(); // first compaction, materialized
1219
1220 // Append new messages until over budget again → the strategy is called
1221 // again, with the previous summary message in the input.
1222 memory.record(Message::user("u5")).await.unwrap();
1223 memory.record(Message::assistant("a5")).await.unwrap();
1224 memory.record(Message::user("u6")).await.unwrap();
1225 memory.record(Message::assistant("a6")).await.unwrap();
1226 memory.context().await.unwrap();
1227
1228 assert_eq!(
1229 summarizer.calls.load(std::sync::atomic::Ordering::Relaxed),
1230 2
1231 );
1232 assert!(
1233 summarizer
1234 .last_input_has_summary
1235 .load(std::sync::atomic::Ordering::Relaxed)
1236 );
1237 }
1238
1239 /// Projection: `replace: false` only affects this retrieval's view;
1240 /// storage is unchanged (lossless).
1241 #[tokio::test]
1242 async fn projection_keeps_storage_unchanged() {
1243 #[derive(Debug, Default)]
1244 struct KeepLastRound;
1245 #[async_trait::async_trait]
1246 impl TrimStrategy for KeepLastRound {
1247 async fn trim(
1248 &self,
1249 messages: &[Message],
1250 _budget: &Budget,
1251 _counter: &dyn TokenCounter,
1252 ) -> Result<TrimResult, MemoryError> {
1253 let pos = messages
1254 .iter()
1255 .rposition(|m| matches!(m, Message::User(_)))
1256 .unwrap_or(0);
1257 Ok(TrimResult {
1258 messages: messages[pos..].to_vec(),
1259 replace: false,
1260 })
1261 }
1262 }
1263
1264 let strategy = Arc::new(KeepLastRound);
1265 let mut memory = WindowMemory::new(1).with_strategy(strategy);
1266 memory.record(Message::user("u1")).await.unwrap();
1267 memory.record(Message::assistant("a1")).await.unwrap();
1268 memory.record(Message::user("u2")).await.unwrap();
1269 memory.record(Message::assistant("a2")).await.unwrap();
1270
1271 let view = memory.context().await.unwrap();
1272 assert_eq!(view, vec![Message::user("u2"), Message::assistant("a2")]);
1273
1274 // Storage unchanged: raising the budget restores everything.
1275 memory.set_max_tokens(1000);
1276 assert_eq!(memory.context().await.unwrap().len(), 4);
1277 }
1278
1279 /// Behavior: `context()` returns a clone; mutating it does not affect the
1280 /// internals.
1281 #[tokio::test]
1282 async fn context_is_a_copy() {
1283 let mut memory = WindowMemory::new(1000);
1284 memory.record(Message::user("a")).await.unwrap();
1285
1286 let mut context = memory.context().await.unwrap();
1287 context.push(Message::assistant("b"));
1288
1289 assert_eq!(memory.context().await.unwrap().len(), 1);
1290 }
1291
1292 /// Defensive: a history without User messages does not panic and is
1293 /// returned as-is.
1294 #[tokio::test]
1295 async fn no_user_messages_returns_all() {
1296 let mut memory = WindowMemory::new(1);
1297 memory
1298 .record(Message::assistant("assistant-only message"))
1299 .await
1300 .unwrap();
1301
1302 let context = memory.context().await.unwrap();
1303 assert_eq!(context.len(), 1);
1304 }
1305
1306 /// ContentBlock with multiple text blocks: all of them count toward the
1307 /// budget.
1308 #[tokio::test]
1309 async fn user_blocks_all_counted() {
1310 let mut memory = WindowMemory::new(2);
1311 // The User message has two text blocks totalling over budget → the
1312 // single-round fallback keeps the whole round.
1313 memory
1314 .record(Message::user_blocks(vec![
1315 ContentBlock::Text("aaaa".into()),
1316 ContentBlock::Text("bbbb".into()),
1317 ]))
1318 .await
1319 .unwrap();
1320 memory.record(Message::assistant("hi")).await.unwrap();
1321
1322 let context = memory.context().await.unwrap();
1323 assert_eq!(context.len(), 2); // whole round kept as fallback
1324 }
1325
1326 /// Swapping the counter: the counting convention changes, so counts are
1327 /// invalidated and recomputed — the new counter recounts every message.
1328 #[tokio::test]
1329 async fn changing_counter_recounts() {
1330 #[derive(Debug)]
1331 struct CountingCounter {
1332 calls: Arc<std::sync::atomic::AtomicUsize>,
1333 }
1334 impl CountingCounter {
1335 fn shared(calls: Arc<std::sync::atomic::AtomicUsize>) -> Self {
1336 Self { calls }
1337 }
1338 }
1339 #[async_trait::async_trait]
1340 impl TokenCounter for CountingCounter {
1341 async fn count(&self, _text: &str) -> Result<usize, MemoryError> {
1342 self.calls
1343 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1344 Ok(1)
1345 }
1346 }
1347
1348 let first_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1349 let mut memory = WindowMemory::new(100)
1350 .with_token_counter(Box::new(CountingCounter::shared(first_calls.clone())));
1351 memory.record(Message::user("u1")).await.unwrap();
1352 memory.record(Message::assistant("a1")).await.unwrap();
1353 memory.context().await.unwrap(); // lazy recompute of the 2 messages
1354 assert_eq!(first_calls.load(std::sync::atomic::Ordering::Relaxed), 2);
1355
1356 // Swap the counter → invalidated and recomputed: the new counter
1357 // counts the existing 2 messages + the new record (1).
1358 let second_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1359 let mut memory =
1360 memory.with_token_counter(Box::new(CountingCounter::shared(second_calls.clone())));
1361 memory.record(Message::user("u2")).await.unwrap();
1362 assert_eq!(second_calls.load(std::sync::atomic::Ordering::Relaxed), 3);
1363 }
1364
1365 /// Remote counting failure: MemoryError::TokenCount propagates (record
1366 /// returns Err).
1367 #[tokio::test]
1368 async fn token_count_failure_propagates() {
1369 #[derive(Debug, Default)]
1370 struct FailingCounter;
1371 #[async_trait::async_trait]
1372 impl TokenCounter for FailingCounter {
1373 async fn count(&self, _text: &str) -> Result<usize, MemoryError> {
1374 Err(MemoryError::TokenCount(
1375 "remote counting API unavailable".into(),
1376 ))
1377 }
1378 }
1379
1380 let mut memory = WindowMemory::new(100).with_token_counter(Box::new(FailingCounter));
1381 let err = memory.record(Message::user("hi")).await.unwrap_err();
1382 assert!(matches!(err, MemoryError::TokenCount(_)));
1383 }
1384
1385 /// Protected messages (and their rounds) are exempt as a whole when over
1386 /// budget; ordinary rounds are trimmed as usual.
1387 #[tokio::test]
1388 async fn protected_round_survives_trim() {
1389 // Candidates (ordinary rounds) total 4 tokens > budget 3: the earliest
1390 // ordinary round is dropped; the protected round is exempt, so the
1391 // total context may exceed the budget (the inherent cost of resident
1392 // skill instructions).
1393 let mut memory = WindowMemory::new(3);
1394 memory.record(Message::user("u1")).await.unwrap();
1395 memory.record(Message::assistant("a1")).await.unwrap();
1396 // Round 2: the load_skill call round (its ToolResult is protected).
1397 memory.record(Message::user("u2")).await.unwrap();
1398 memory
1399 .record(Message::Assistant {
1400 content: "a2".into(),
1401 reasoning: None,
1402 tool_calls: vec![ToolCall {
1403 id: "c1".into(),
1404 name: "load_skill".into(),
1405 arguments: r#"{"name":"x"}"#.into(),
1406 }],
1407 })
1408 .await
1409 .unwrap();
1410 memory
1411 .record_protected(tool_result("c1", "<skill_content>body</skill_content>"))
1412 .await
1413 .unwrap();
1414 // Round 3 (ordinary, most recent).
1415 memory.record(Message::user("u3")).await.unwrap();
1416 memory.record(Message::assistant("a3")).await.unwrap();
1417
1418 // Total tokens exceed the budget: the ordinary first round is dropped;
1419 // the protected second round and the most recent third round are kept.
1420 let context = memory.context().await.unwrap();
1421 assert_eq!(
1422 context,
1423 vec![
1424 Message::user("u2"),
1425 Message::Assistant {
1426 content: "a2".into(),
1427 reasoning: None,
1428 tool_calls: vec![ToolCall {
1429 id: "c1".into(),
1430 name: "load_skill".into(),
1431 arguments: r#"{"name":"x"}"#.into(),
1432 }],
1433 },
1434 tool_result("c1", "<skill_content>body</skill_content>"),
1435 Message::user("u3"),
1436 Message::assistant("a3"),
1437 ]
1438 );
1439 }
1440
1441 /// After a protected message is recorded, trimming never removes it;
1442 /// ordinary messages are unaffected.
1443 #[tokio::test]
1444 async fn protected_message_never_pruned() {
1445 let mut memory = WindowMemory::new(3);
1446 memory.record(Message::user("u1")).await.unwrap();
1447 memory.record(Message::assistant("a1")).await.unwrap();
1448 memory.record(Message::user("u2")).await.unwrap();
1449 memory.record(Message::assistant("a2")).await.unwrap();
1450 // The protected message forms a round by itself (no User precedes it;
1451 // a leading non-User message belongs to the first round).
1452 memory
1453 .record_protected(tool_result("c1", "skill body"))
1454 .await
1455 .unwrap();
1456
1457 // After many context() rounds: the protected message is still there
1458 // (ordinary rounds have been trimmed away).
1459 let mut all_kept = true;
1460 for _ in 0..5 {
1461 let context = memory.context().await.unwrap();
1462 if !context.iter().any(
1463 |m| matches!(m, Message::ToolResult { content, .. } if content == "skill body"),
1464 ) {
1465 all_kept = false;
1466 break;
1467 }
1468 }
1469 assert!(
1470 all_kept,
1471 "the protected message must survive repeated trims"
1472 );
1473 }
1474}