Skip to main content

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}