Skip to main content

rig_memory/
lib.rs

1#![cfg_attr(
2    test,
3    allow(
4        clippy::expect_used,
5        clippy::indexing_slicing,
6        clippy::panic,
7        clippy::unwrap_used,
8        clippy::unreachable
9    )
10)]
11//! Conversation-history windows, demotion hooks, and rolling summaries.
12//! Window policies remove leading orphaned tool results along with truncated
13//! history. Stateful adapters deliver evicted prefixes to hooks or compactors.
14//!
15//! ```
16//! use rig_memory::{InMemoryConversationMemory, IntoFilter, SlidingWindowMemory};
17//!
18//! let memory = InMemoryConversationMemory::new()
19//!     .with_filter(SlidingWindowMemory::last_messages(20).into_filter());
20//! ```
21
22use std::{
23    borrow::Cow,
24    collections::HashMap,
25    sync::{Arc, Mutex as StdMutex},
26};
27
28/// Re-exports of the core memory abstractions so callers only need a single
29/// dependency on `rig-memory` for both the trait/backend and the policies.
30pub use rig_core::memory::{
31    Compactor, ConversationMemory, DemotionHook, InMemoryConversationMemory, MemoryError,
32    NoopDemotionHook,
33};
34
35use rig_core::completion::Message;
36use rig_core::id::ConversationId;
37use rig_core::message::UserContent;
38use rig_core::wasm_compat::{WasmBoxedFuture, WasmCompatSend, WasmCompatSync};
39
40/// A transformation applied to messages loaded from a [`ConversationMemory`].
41///
42/// Policies typically truncate, summarize, or re-order history. They are
43/// pure, fallible message transformers: implementors that cannot fail should
44/// always return `Ok`.
45pub trait MemoryPolicy: WasmCompatSend + WasmCompatSync {
46    /// Transforms loaded messages into retained history or returns a policy error.
47    fn apply(&self, messages: Vec<Message>) -> Result<Vec<Message>, MemoryError>;
48
49    /// Transform `messages` and report which messages were demoted (excluded
50    /// from the returned history).
51    ///
52    /// Returns `(kept, demoted)`. The default implementation returns
53    /// `(self.apply(messages)?, Vec::new())`, which is correct for
54    /// non-truncating policies. Truncating policies (sliding window, token
55    /// window, …) override this method to populate `demoted` with the
56    /// messages they evicted.
57    ///
58    /// Implementors must guarantee that `demoted` is the prefix of the
59    /// original input not retained in `kept`, in original order. Composing
60    /// adapters such as [`DemotingPolicyMemory`] rely on this contract to
61    /// track delivery watermarks correctly.
62    fn apply_with_demoted(
63        &self,
64        messages: Vec<Message>,
65    ) -> Result<(Vec<Message>, Vec<Message>), MemoryError> {
66        Ok((self.apply(messages)?, Vec::new()))
67    }
68}
69
70impl<P> MemoryPolicy for Arc<P>
71where
72    P: MemoryPolicy + ?Sized,
73{
74    fn apply(&self, messages: Vec<Message>) -> Result<Vec<Message>, MemoryError> {
75        (**self).apply(messages)
76    }
77
78    fn apply_with_demoted(
79        &self,
80        messages: Vec<Message>,
81    ) -> Result<(Vec<Message>, Vec<Message>), MemoryError> {
82        (**self).apply_with_demoted(messages)
83    }
84}
85
86impl<P> MemoryPolicy for Box<P>
87where
88    P: MemoryPolicy + ?Sized,
89{
90    fn apply(&self, messages: Vec<Message>) -> Result<Vec<Message>, MemoryError> {
91        (**self).apply(messages)
92    }
93
94    fn apply_with_demoted(
95        &self,
96        messages: Vec<Message>,
97    ) -> Result<(Vec<Message>, Vec<Message>), MemoryError> {
98        (**self).apply_with_demoted(messages)
99    }
100}
101
102/// Adapt a [`MemoryPolicy`] into a closure suitable for
103/// [`InMemoryConversationMemory::with_filter`].
104///
105/// Errors raised by the policy are swallowed because `with_filter` does not
106/// propagate failures. Use [`MemoryPolicy::apply`] directly when you need to
107/// observe policy errors.
108pub trait IntoFilter: MemoryPolicy + Sized + 'static {
109    /// Convert this policy into a filter closure.
110    ///
111    /// On policy error, returns the original input unchanged and logs a warning.
112    fn into_filter(self) -> BoxedFilter {
113        let policy = Arc::new(self);
114        Box::new(move |msgs| {
115            // Deliberate clone: `apply` consumes the history, and the
116            // graceful-degradation contract above requires handing the
117            // original back when the policy errors.
118            let fallback = msgs.clone();
119            match policy.apply(msgs) {
120                Ok(out) => out,
121                Err(err) => {
122                    tracing::warn!(error = %err, "memory policy failed; returning unfiltered history");
123                    fallback
124                }
125            }
126        })
127    }
128}
129
130/// Boxed filter closure returned by [`IntoFilter::into_filter`].
131#[cfg(not(target_family = "wasm"))]
132pub type BoxedFilter = Box<dyn Fn(Vec<Message>) -> Vec<Message> + Send + Sync>;
133
134/// Boxed filter closure returned by [`IntoFilter::into_filter`].
135#[cfg(target_family = "wasm")]
136pub type BoxedFilter = Box<dyn Fn(Vec<Message>) -> Vec<Message>>;
137
138impl<P> IntoFilter for P where P: MemoryPolicy + 'static {}
139
140/// A [`MemoryPolicy`] that returns its input unchanged.
141#[derive(Debug, Default, Clone, Copy)]
142pub struct NoopMemoryPolicy;
143
144impl MemoryPolicy for NoopMemoryPolicy {
145    fn apply(&self, messages: Vec<Message>) -> Result<Vec<Message>, MemoryError> {
146        Ok(messages)
147    }
148}
149
150/// A [`MemoryPolicy`] that retains only the most recent `max_messages` entries.
151///
152/// When the window starts mid-conversation, tool results before its first
153/// [`Message::Assistant`] have lost their calls. The prefix through the last
154/// such [`Message::User`] is demoted, including any accompanying text or
155/// intervening system messages. Whole messages remain in original order in
156/// `(kept, demoted)`; paired results later in the window are left untouched.
157#[derive(Debug, Clone, Copy)]
158pub struct SlidingWindowMemory {
159    max_messages: usize,
160}
161
162impl SlidingWindowMemory {
163    /// Keep at most `n` messages.
164    pub fn last_messages(n: usize) -> Self {
165        Self { max_messages: n }
166    }
167}
168
169impl MemoryPolicy for SlidingWindowMemory {
170    fn apply(&self, messages: Vec<Message>) -> Result<Vec<Message>, MemoryError> {
171        Ok(self.apply_with_demoted(messages)?.0)
172    }
173
174    fn apply_with_demoted(
175        &self,
176        messages: Vec<Message>,
177    ) -> Result<(Vec<Message>, Vec<Message>), MemoryError> {
178        if messages.len() <= self.max_messages {
179            return Ok((messages, Vec::new()));
180        }
181
182        let keep_from = messages.len() - self.max_messages;
183        Ok(split_window(messages, keep_from))
184    }
185}
186
187/// Split `messages` at `keep_from` into `(window, demoted)`.
188///
189/// Results before the first retained assistant have lost their calls. Scan
190/// all content blocks, including past system messages (which canonical
191/// validation allows between calls and results). Demote the entire prefix
192/// through those results to preserve the delivery-watermark contract without
193/// modifying messages or repairing unrelated history later in the window.
194fn split_window(messages: Vec<Message>, keep_from: usize) -> (Vec<Message>, Vec<Message>) {
195    let mut iter = messages.into_iter();
196    let mut demoted: Vec<Message> = (&mut iter).take(keep_from).collect();
197    let mut window: Vec<Message> = iter.collect();
198
199    let mut orphan_prefix_len = 0;
200    for (index, message) in window.iter().enumerate() {
201        match message {
202            Message::Assistant(_) => break,
203            Message::User { content }
204                if content
205                    .iter()
206                    .any(|item| matches!(item, UserContent::ToolResult(_))) =>
207            {
208                orphan_prefix_len = index + 1;
209            }
210            _ => {}
211        }
212    }
213    demoted.extend(window.drain(..orphan_prefix_len));
214
215    (window, demoted)
216}
217
218/// Counts the tokens contributed by a single [`Message`].
219///
220/// Implementors should pick a counting strategy appropriate for their target
221/// provider (for example, `tiktoken-rs` for OpenAI). Counting must be cheap;
222/// it runs once per message on every memory load.
223pub trait TokenCounter: WasmCompatSend + WasmCompatSync {
224    /// Approximate the number of tokens contributed by `message`.
225    fn count(&self, message: &Message) -> usize;
226}
227
228impl<F> TokenCounter for F
229where
230    F: Fn(&Message) -> usize + WasmCompatSend + WasmCompatSync,
231{
232    fn count(&self, message: &Message) -> usize {
233        (self)(message)
234    }
235}
236
237impl<C> TokenCounter for Arc<C>
238where
239    C: TokenCounter + ?Sized,
240{
241    fn count(&self, message: &Message) -> usize {
242        (**self).count(message)
243    }
244}
245
246impl TokenCounter for Box<dyn TokenCounter> {
247    fn count(&self, message: &Message) -> usize {
248        (**self).count(message)
249    }
250}
251
252/// Estimates token cost from UTF-8 lengths, rounded up per text-bearing part.
253/// Tool calls count name bytes plus serialized argument bytes. Attachments and
254/// message overhead have fixed costs. These estimates do not bound provider usage.
255///
256/// ```
257/// use rig_memory::{HeuristicTokenCounter, TokenWindowMemory};
258///
259/// let policy = TokenWindowMemory::new(2_000, HeuristicTokenCounter::default());
260/// # let _ = policy;
261/// ```
262#[derive(Debug, Clone, Copy)]
263pub struct HeuristicTokenCounter {
264    bytes_per_token: f32,
265    per_message_overhead: usize,
266    per_attachment_tokens: usize,
267}
268
269impl HeuristicTokenCounter {
270    /// Create a counter with explicit parameters.
271    ///
272    /// Non-finite ratios and ratios below `1.0` become `1.0`.
273    /// Overhead and attachment costs may be zero.
274    pub fn new(
275        bytes_per_token: f32,
276        per_message_overhead: usize,
277        per_attachment_tokens: usize,
278    ) -> Self {
279        let bytes_per_token = if bytes_per_token.is_finite() && bytes_per_token >= 1.0 {
280            bytes_per_token
281        } else {
282            1.0
283        };
284        Self {
285            bytes_per_token,
286            per_message_overhead,
287            per_attachment_tokens,
288        }
289    }
290
291    /// Uses 3.5 bytes per token, four tokens per message, and 256 per attachment.
292    pub fn anthropic() -> Self {
293        Self::new(3.5, 4, 256)
294    }
295
296    fn bytes_to_tokens(&self, bytes: usize) -> usize {
297        // `bytes_per_token` is clamped to >= 1.0 in the constructor, so the
298        // division is well-defined. We round up so a single non-empty
299        // input still costs at least one token.
300        let tokens = (bytes as f32) / self.bytes_per_token;
301        tokens.ceil() as usize
302    }
303
304    fn count_user(&self, content: &rig_core::message::UserContent) -> usize {
305        use rig_core::message::UserContent;
306        match content {
307            UserContent::Text(text) => self.bytes_to_tokens(text.text.len()),
308            UserContent::ToolResult(result) => result
309                .content
310                .iter()
311                .map(|c| match c {
312                    rig_core::message::ToolResultContent::Text(t) => {
313                        self.bytes_to_tokens(t.text.len())
314                    }
315                    rig_core::message::ToolResultContent::Json { value } => {
316                        self.bytes_to_tokens(value.to_string().len())
317                    }
318                    rig_core::message::ToolResultContent::Image(_) => self.per_attachment_tokens,
319                })
320                .sum(),
321            UserContent::Image(_)
322            | UserContent::Audio(_)
323            | UserContent::Video(_)
324            | UserContent::Document(_) => self.per_attachment_tokens,
325        }
326    }
327
328    fn count_assistant(&self, content: &rig_core::message::AssistantContent) -> usize {
329        use rig_core::message::AssistantContent;
330        match content {
331            AssistantContent::Text(text) => self.bytes_to_tokens(text.text.len()),
332            AssistantContent::Reasoning(reasoning) => self.bytes_to_tokens(reasoning.text.len()),
333            AssistantContent::ToolCall(call) => {
334                let name_bytes = call.function.name.len();
335                let args_bytes = call.function.arguments_value().to_string().len();
336                self.bytes_to_tokens(name_bytes + args_bytes)
337            }
338            AssistantContent::Image(_) => self.per_attachment_tokens,
339            // A provider-only item is sent back to the model that made it.
340            AssistantContent::Opaque(opaque) => self.bytes_to_tokens(opaque.item.to_string().len()),
341            other => self.bytes_to_tokens(
342                rig_core::serde_json::to_string(other).map_or(0, |json| json.len()),
343            ),
344        }
345    }
346}
347
348impl Default for HeuristicTokenCounter {
349    /// Uses four bytes per token, four tokens per message, and 256 per attachment.
350    fn default() -> Self {
351        Self::new(4.0, 4, 256)
352    }
353}
354
355impl TokenCounter for HeuristicTokenCounter {
356    fn count(&self, message: &Message) -> usize {
357        let content_tokens: usize = match message {
358            Message::User { content } => content.iter().map(|c| self.count_user(c)).sum(),
359            Message::Assistant(turn) => turn.content.iter().map(|c| self.count_assistant(c)).sum(),
360            Message::System { content } => self.bytes_to_tokens(content.len()),
361        };
362        content_tokens.saturating_add(self.per_message_overhead)
363    }
364}
365
366/// A [`MemoryPolicy`] that retains the most recent messages up to a token budget.
367///
368/// Messages are walked from newest to oldest, accumulating token counts
369/// produced by a [`TokenCounter`]. Once including a message would exceed
370/// `max_tokens`, the walk stops and the included messages are returned in
371/// original (oldest-first) order. As with [`SlidingWindowMemory`], the leading
372/// prefix through any tool results whose assistant calls were truncated is
373/// demoted, including mixed-content messages.
374pub struct TokenWindowMemory {
375    max_tokens: usize,
376    counter: Arc<dyn TokenCounter>,
377}
378
379impl TokenWindowMemory {
380    /// Create a new policy with a token budget and a counter.
381    pub fn new<C>(max_tokens: usize, counter: C) -> Self
382    where
383        C: TokenCounter + 'static,
384    {
385        Self {
386            max_tokens,
387            counter: Arc::new(counter),
388        }
389    }
390}
391
392impl std::fmt::Debug for TokenWindowMemory {
393    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
394        f.debug_struct("TokenWindowMemory")
395            .field("max_tokens", &self.max_tokens)
396            .field("counter", &"<counter>")
397            .finish()
398    }
399}
400
401impl MemoryPolicy for TokenWindowMemory {
402    fn apply(&self, messages: Vec<Message>) -> Result<Vec<Message>, MemoryError> {
403        Ok(self.apply_with_demoted(messages)?.0)
404    }
405
406    fn apply_with_demoted(
407        &self,
408        messages: Vec<Message>,
409    ) -> Result<(Vec<Message>, Vec<Message>), MemoryError> {
410        let mut budget = self.max_tokens;
411        let mut keep_from = messages.len();
412
413        for (idx, msg) in messages.iter().enumerate().rev() {
414            let cost = self.counter.count(msg);
415            if cost > budget {
416                break;
417            }
418            budget -= cost;
419            keep_from = idx;
420        }
421
422        Ok(split_window(messages, keep_from))
423    }
424}
425
426/// Applies a policy to loaded history, propagating backend and policy errors
427/// unchanged. Append and clear operations delegate to the backend.
428///
429/// # Example
430///
431/// ```no_run
432/// use rig_memory::{InMemoryConversationMemory, PolicyMemory, SlidingWindowMemory};
433///
434/// let memory = PolicyMemory::new(
435///     InMemoryConversationMemory::new(),
436///     SlidingWindowMemory::last_messages(20),
437/// );
438/// ```
439#[derive(Debug, Clone, Copy)]
440pub struct PolicyMemory<M, P> {
441    inner: M,
442    policy: P,
443}
444
445impl<M, P> PolicyMemory<M, P> {
446    /// Wrap `inner` so every loaded history is run through `policy`.
447    pub fn new(inner: M, policy: P) -> Self {
448        Self { inner, policy }
449    }
450
451    /// Return a reference to the wrapped backend.
452    pub fn inner(&self) -> &M {
453        &self.inner
454    }
455
456    /// Return a reference to the wrapped policy.
457    pub fn policy(&self) -> &P {
458        &self.policy
459    }
460
461    /// Consume the wrapper and return the underlying backend and policy.
462    pub fn into_inner(self) -> (M, P) {
463        (self.inner, self.policy)
464    }
465}
466
467impl<M, P> ConversationMemory for PolicyMemory<M, P>
468where
469    M: ConversationMemory,
470    P: MemoryPolicy,
471{
472    fn load<'a>(
473        &'a self,
474        conversation_id: &'a ConversationId,
475    ) -> WasmBoxedFuture<'a, Result<Vec<Message>, MemoryError>> {
476        Box::pin(async move {
477            let messages = self.inner.load(conversation_id).await?;
478            self.policy.apply(messages)
479        })
480    }
481
482    fn append<'a>(
483        &'a self,
484        conversation_id: &'a ConversationId,
485        messages: Vec<Message>,
486    ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
487        self.inner.append(conversation_id, messages)
488    }
489
490    fn clear<'a>(
491        &'a self,
492        conversation_id: &'a ConversationId,
493    ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
494        self.inner.clear(conversation_id)
495    }
496}
497
498/// Applies a policy and delivers newly demoted prefixes to a hook before returning
499/// retained history. The policy must report demotions through
500/// [`MemoryPolicy::apply_with_demoted`].
501///
502/// Only one delivery per tracked conversation runs at a time. Concurrent loads
503/// return retained history immediately without waiting for the hook, so success
504/// does not guarantee durable demotion. Hook errors reach only the delivering
505/// caller; unchanged watermarks allow later loads to retry.
506///
507/// Watermarks are process-local. Hooks must tolerate redelivery after restarts,
508/// cancellation, or discarded tracking state, following [`DemotionHook`].
509///
510/// ```no_run
511/// use rig_memory::{
512///     DemotingPolicyMemory, DemotionHook, InMemoryConversationMemory,
513///     MemoryError, NoopDemotionHook, SlidingWindowMemory,
514/// };
515///
516/// let memory = DemotingPolicyMemory::new(
517///     InMemoryConversationMemory::new(),
518///     SlidingWindowMemory::last_messages(20),
519///     NoopDemotionHook,
520/// );
521/// # let _ = memory;
522/// ```
523pub struct DemotingPolicyMemory<M, P, H> {
524    inner: M,
525    policy: P,
526    hook: H,
527    state: StdMutex<HashMap<ConversationId, ConversationDemotionState>>,
528}
529
530type InFlightReservation = Arc<()>;
531
532/// Emits the members shared by the stateful wrappers
533/// ([`DemotingPolicyMemory`], [`CompactingMemory`]): accessors,
534/// `forget`/`tracked_conversations` over the per-conversation state map, and
535/// a `Debug` impl that elides the non-`Debug` third component.
536macro_rules! stateful_wrapper_common {
537    ($ty:ident, $third:ident: $tgen:ident $(: $tbound:ident)?) => {
538        impl<M, P, $tgen $(: $tbound)?> $ty<M, P, $tgen> {
539            /// Return a reference to the wrapped backend.
540            pub fn inner(&self) -> &M {
541                &self.inner
542            }
543
544            /// Return a reference to the wrapped policy.
545            pub fn policy(&self) -> &P {
546                &self.policy
547            }
548
549            /// Return a reference to the third component.
550            pub fn $third(&self) -> &$tgen {
551                &self.$third
552            }
553
554            /// Consume the wrapper and return its three components.
555            pub fn into_inner(self) -> (M, P, $tgen) {
556                (self.inner, self.policy, self.$third)
557            }
558
559            /// Drop the in-process state for `conversation_id`.
560            ///
561            /// Entries otherwise persist for the wrapper's lifetime. Does nothing
562            /// if the state lock is poisoned. In-flight work is not cancelled.
563            pub fn forget(&self, conversation_id: &ConversationId) {
564                if let Ok(mut guard) = self.state.lock() {
565                    guard.remove(conversation_id);
566                }
567            }
568
569            /// Number of conversations currently tracked in the state map.
570            /// Useful for telemetry and leak detection. Returns `0` if the
571            /// internal state lock is poisoned.
572            pub fn tracked_conversations(&self) -> usize {
573                self.state.lock().map_or(0, |g| g.len())
574            }
575        }
576
577        impl<M, P, $tgen $(: $tbound)?> std::fmt::Debug for $ty<M, P, $tgen>
578        where
579            M: std::fmt::Debug,
580            P: std::fmt::Debug,
581        {
582            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
583                f.debug_struct(stringify!($ty))
584                    .field("inner", &self.inner)
585                    .field("policy", &self.policy)
586                    .field(
587                        stringify!($third),
588                        &concat!("<", stringify!($third), ">"),
589                    )
590                    .finish()
591            }
592        }
593    };
594}
595
596/// Emits the delegating `append` and the `clear`-then-`forget` methods of a
597/// stateful wrapper's [`ConversationMemory`] impl.
598macro_rules! stateful_wrapper_append_clear {
599    () => {
600        fn append<'a>(
601            &'a self,
602            conversation_id: &'a ConversationId,
603            messages: Vec<Message>,
604        ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
605            self.inner.append(conversation_id, messages)
606        }
607
608        fn clear<'a>(
609            &'a self,
610            conversation_id: &'a ConversationId,
611        ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
612            Box::pin(async move {
613                self.inner.clear(conversation_id).await?;
614                self.forget(conversation_id);
615                Ok(())
616            })
617        }
618    };
619}
620
621#[derive(Debug, Default, Clone)]
622struct ConversationDemotionState {
623    /// Number of demoted messages already delivered to the hook within
624    /// this process lifetime. Advanced only on hook success.
625    delivered: usize,
626    /// Reservation held while a `load` is currently awaiting
627    /// `hook.on_demote(...)` for this conversation. Other concurrent loads
628    /// observe this and short-circuit without re-delivering the same messages.
629    in_flight: Option<InFlightReservation>,
630}
631
632impl<M, P, H> DemotingPolicyMemory<M, P, H> {
633    /// Wrap `inner` so every load runs through `policy` and demoted messages
634    /// flow into `hook`.
635    pub fn new(inner: M, policy: P, hook: H) -> Self {
636        Self {
637            inner,
638            policy,
639            hook,
640            state: StdMutex::new(HashMap::new()),
641        }
642    }
643}
644
645stateful_wrapper_common!(DemotingPolicyMemory, hook: H);
646
647impl<M, P, H> ConversationMemory for DemotingPolicyMemory<M, P, H>
648where
649    M: ConversationMemory,
650    P: MemoryPolicy,
651    H: DemotionHook,
652{
653    fn load<'a>(
654        &'a self,
655        conversation_id: &'a ConversationId,
656    ) -> WasmBoxedFuture<'a, Result<Vec<Message>, MemoryError>> {
657        Box::pin(async move {
658            let messages = self.inner.load(conversation_id).await?;
659            let (kept, mut demoted) = self.policy.apply_with_demoted(messages)?;
660            let demoted_count = demoted.len();
661
662            // Reserve under the watermark lock to prevent duplicate concurrent
663            // deliveries for the same tracked conversation.
664            let (pending, reservation) = {
665                let mut guard = self.state.lock().map_err(poisoned)?;
666                if let Some(entry) = guard.get_mut(conversation_id) {
667                    if entry.in_flight.is_some() {
668                        // Another load is mid-delivery for this conversation;
669                        // skip and let the next load see whatever it leaves
670                        // behind.
671                        return Ok(kept);
672                    }
673                    if entry.delivered >= demoted_count {
674                        (Vec::new(), None)
675                    } else {
676                        let split = entry.delivered;
677                        let reservation = Arc::new(());
678                        entry.in_flight = Some(reservation.clone());
679                        (demoted.split_off(split), Some(reservation))
680                    }
681                } else if demoted_count == 0 {
682                    (Vec::new(), None)
683                } else {
684                    let reservation = Arc::new(());
685                    guard.insert(
686                        conversation_id.clone(),
687                        ConversationDemotionState {
688                            delivered: 0,
689                            in_flight: Some(reservation.clone()),
690                        },
691                    );
692                    (std::mem::take(&mut demoted), Some(reservation))
693                }
694            };
695
696            let Some(reservation) = reservation else {
697                return Ok(kept);
698            };
699
700            // Arm an RAII guard so the in-flight gate is released even if
701            // this future is dropped mid-await (caller cancellation) or the
702            // hook panics. The reservation token prevents stale guards from
703            // clearing newer in-flight loads after clear()/forget() reuse the
704            // same conversation id.
705            let in_flight_guard =
706                InFlightGuard::new(&self.state, conversation_id, reservation.clone());
707
708            let result = self.hook.on_demote(conversation_id, pending).await;
709
710            // Only the matching reservation may advance the watermark; a clear
711            // or replacement during delivery must not revive stale state.
712            release_in_flight(&self.state, conversation_id, &reservation, |entry| {
713                if result.is_ok() {
714                    entry.delivered = demoted_count;
715                }
716            })?;
717            in_flight_guard.disarm();
718            result?;
719            Ok(kept)
720        })
721    }
722
723    stateful_wrapper_append_clear!();
724}
725
726fn poisoned<E: std::fmt::Display>(err: E) -> MemoryError {
727    MemoryError::Internal(err.to_string())
728}
729
730/// Clears a matching reservation and runs `on_match` under the state lock.
731/// Returns its result, or `None` for absent or newer reservations. A poisoned
732/// lock returns an error; stale releases cannot restore or overwrite state.
733fn release_in_flight<S: InFlightSlot, T>(
734    state: &StdMutex<HashMap<ConversationId, S>>,
735    key: &ConversationId,
736    reservation: &InFlightReservation,
737    on_match: impl FnOnce(&mut S) -> T,
738) -> Result<Option<T>, MemoryError> {
739    let mut guard = state.lock().map_err(poisoned)?;
740    let Some(entry) = guard.get_mut(key) else {
741        return Ok(None);
742    };
743    if entry
744        .in_flight_mut()
745        .as_ref()
746        .is_some_and(|current| Arc::ptr_eq(current, reservation))
747    {
748        *entry.in_flight_mut() = None;
749        return Ok(Some(on_match(entry)));
750    }
751    Ok(None)
752}
753
754/// A per-conversation state entry with an `in_flight` delivery reservation,
755/// letting [`InFlightGuard`] work over both the demotion and compaction
756/// state maps.
757trait InFlightSlot {
758    fn in_flight_mut(&mut self) -> &mut Option<InFlightReservation>;
759}
760
761impl InFlightSlot for ConversationDemotionState {
762    fn in_flight_mut(&mut self) -> &mut Option<InFlightReservation> {
763        &mut self.in_flight
764    }
765}
766
767impl<A> InFlightSlot for ConversationCompactionState<A> {
768    fn in_flight_mut(&mut self) -> &mut Option<InFlightReservation> {
769        &mut self.in_flight
770    }
771}
772
773/// Releases its matching reservation on drop, including cancellation and unwind.
774/// Disarm after the post-await update releases it. Missing or newer reservations
775/// remain untouched; poisoned locks prevent cleanup.
776struct InFlightGuard<'a, S: InFlightSlot> {
777    state: &'a StdMutex<HashMap<ConversationId, S>>,
778    key: &'a ConversationId,
779    reservation: InFlightReservation,
780    armed: bool,
781}
782
783impl<'a, S: InFlightSlot> InFlightGuard<'a, S> {
784    fn new(
785        state: &'a StdMutex<HashMap<ConversationId, S>>,
786        key: &'a ConversationId,
787        reservation: InFlightReservation,
788    ) -> Self {
789        Self {
790            state,
791            key,
792            reservation,
793            armed: true,
794        }
795    }
796
797    /// Disable the `Drop` clean-up. Call after the post-await state
798    /// update has already cleared `in_flight` while holding the lock.
799    fn disarm(mut self) {
800        self.armed = false;
801    }
802}
803
804impl<S: InFlightSlot> Drop for InFlightGuard<'_, S> {
805    fn drop(&mut self) {
806        if !self.armed {
807            return;
808        }
809        // Drop cannot report a poisoned state lock.
810        let _ = release_in_flight(self.state, self.key, &self.reservation, |_| ());
811    }
812}
813
814/// Prepends a rolling summary of demoted history to the policy's retained window.
815/// New evictions are compacted with the previous artifact as carry-over. The
816/// summary is outside the policy budget; callers needing a bounded prompt must
817/// also bound the compactor's artifact.
818///
819/// Only one compaction per tracked conversation runs at a time. Concurrent loads
820/// return the previous summary and retained window immediately. Errors reach only
821/// the compacting caller; unchanged watermarks allow later loads to retry.
822///
823/// Summaries and watermarks are process-local. Restarts or discarded state cause
824/// recompaction; compactors with side effects must tolerate repeated input.
825///
826/// ```no_run
827/// use rig_memory::{
828///     CompactingMemory, InMemoryConversationMemory, SlidingWindowMemory,
829///     TemplateCompactor,
830/// };
831///
832/// let memory = CompactingMemory::new(
833///     InMemoryConversationMemory::new(),
834///     SlidingWindowMemory::last_messages(20),
835///     TemplateCompactor::new(),
836/// );
837/// # let _ = memory;
838/// ```
839pub struct CompactingMemory<M, P, C: Compactor> {
840    inner: M,
841    policy: P,
842    compactor: C,
843    state: StdMutex<HashMap<ConversationId, ConversationCompactionState<C::Artifact>>>,
844}
845
846struct ConversationCompactionState<A> {
847    /// Latest summary artifact for this conversation, if compaction has
848    /// already happened. Cloned into the loaded history on every `load`.
849    summary: Option<A>,
850    /// Number of demoted messages already absorbed into `summary` within
851    /// this process lifetime. Advanced only on compactor success.
852    absorbed: usize,
853    /// Reservation held while a `load` is currently awaiting the compactor for
854    /// this conversation. Other concurrent loads observe this and short-circuit
855    /// without re-running the compactor.
856    in_flight: Option<InFlightReservation>,
857}
858
859impl<M, P, C: Compactor> CompactingMemory<M, P, C> {
860    /// Wrap `inner` so every load runs through `policy` and demoted messages
861    /// are summarised by `compactor`.
862    pub fn new(inner: M, policy: P, compactor: C) -> Self {
863        Self {
864            inner,
865            policy,
866            compactor,
867            state: StdMutex::new(HashMap::new()),
868        }
869    }
870}
871
872stateful_wrapper_common!(CompactingMemory, compactor: C: Compactor);
873
874impl<M, P, C> ConversationMemory for CompactingMemory<M, P, C>
875where
876    M: ConversationMemory,
877    P: MemoryPolicy,
878    C: Compactor,
879{
880    fn load<'a>(
881        &'a self,
882        conversation_id: &'a ConversationId,
883    ) -> WasmBoxedFuture<'a, Result<Vec<Message>, MemoryError>> {
884        Box::pin(async move {
885            let messages = self.inner.load(conversation_id).await?;
886            let (kept, demoted) = self.policy.apply_with_demoted(messages)?;
887            let demoted_count = demoted.len();
888
889            // Reserve under the watermark lock to prevent concurrent compaction
890            // of the same tracked prefix.
891            let plan = {
892                let mut guard = self.state.lock().map_err(poisoned)?;
893                if let Some(entry) = guard.get_mut(conversation_id) {
894                    if entry.in_flight.is_some() {
895                        // Another load is mid-compaction; return what we
896                        // have so far. Newly-evicted messages will be
897                        // folded in by the next load.
898                        return Ok(splice(entry.summary.clone(), kept));
899                    }
900                    if demoted_count <= entry.absorbed {
901                        return Ok(splice(entry.summary.clone(), kept));
902                    }
903                    let reservation = Arc::new(());
904                    entry.in_flight = Some(reservation.clone());
905                    CompactionPlan {
906                        carry_over: entry.summary.clone(),
907                        skip: entry.absorbed,
908                        reservation,
909                    }
910                } else if demoted_count == 0 {
911                    return Ok(kept);
912                } else {
913                    let reservation = Arc::new(());
914                    guard.insert(
915                        conversation_id.clone(),
916                        ConversationCompactionState {
917                            summary: None,
918                            absorbed: 0,
919                            in_flight: Some(reservation.clone()),
920                        },
921                    );
922                    CompactionPlan {
923                        carry_over: None,
924                        skip: 0,
925                        reservation,
926                    }
927                }
928            };
929
930            // The plan is created only when its watermark is below demoted_count.
931            let CompactionPlan {
932                carry_over,
933                skip,
934                reservation,
935            } = plan;
936
937            // Arm an RAII guard so the in-flight gate is released even if
938            // this future is dropped mid-await (caller cancellation) or
939            // the compactor panics. The guard is disarmed below once the
940            // post-await state update has already cleared the flag under
941            // the same lock acquisition that records the new watermark.
942            let in_flight_guard =
943                InFlightGuard::new(&self.state, conversation_id, reservation.clone());
944
945            let Some(new_slice) = demoted.get(skip..) else {
946                // Drop the guard explicitly so the gate is released
947                // before we surface the invariant break.
948                drop(in_flight_guard);
949                return Err(MemoryError::Internal(
950                    "compaction watermark exceeds demoted slice length".into(),
951                ));
952            };
953
954            let result = self
955                .compactor
956                .compact(conversation_id, new_slice, carry_over.as_ref())
957                .await;
958
959            // Publish only to the matching reservation. A clear or replacement
960            // during compaction discards the artifact instead of reviving stale state.
961            let summary_for_splice = match result {
962                Ok(artifact) => {
963                    release_in_flight(&self.state, conversation_id, &reservation, |entry| {
964                        entry.absorbed = demoted_count;
965                        entry.summary = Some(artifact.clone());
966                        artifact
967                    })?
968                }
969                Err(err) => {
970                    release_in_flight(&self.state, conversation_id, &reservation, |_| ())?;
971                    return Err(err);
972                }
973            };
974
975            // The update already released the reservation; avoid locking again.
976            in_flight_guard.disarm();
977
978            Ok(splice(summary_for_splice, kept))
979        })
980    }
981
982    stateful_wrapper_append_clear!();
983}
984
985struct CompactionPlan<A> {
986    carry_over: Option<A>,
987    skip: usize,
988    reservation: InFlightReservation,
989}
990
991fn splice<A>(summary: Option<A>, kept: Vec<Message>) -> Vec<Message>
992where
993    A: Into<Message>,
994{
995    match summary {
996        Some(artifact) => {
997            let mut out = Vec::with_capacity(kept.len() + 1);
998            out.push(artifact.into());
999            out.extend(kept);
1000            out
1001        }
1002        None => kept,
1003    }
1004}
1005
1006/// Builds a textual rollup without calling a model.
1007/// Concatenates a header, the previous summary, and rendered evicted messages
1008/// into a [`TextSummary`] convertible to a system message.
1009///
1010/// Unbounded by default. [`Self::with_max_bytes`] removes oldest body bytes at
1011/// UTF-8 boundaries, retaining the first header line and a truncation marker
1012/// even when those exceed the cap.
1013///
1014/// ```
1015/// use rig_memory::TemplateCompactor;
1016///
1017/// // Default header is "[Conversation summary so far]", unbounded.
1018/// let _compactor = TemplateCompactor::new();
1019///
1020/// // Custom header plus a 4 KiB cap for use with token-budgeted policies.
1021/// let _bounded = TemplateCompactor::with_header("Earlier context")
1022///     .with_max_bytes(4 * 1024);
1023/// ```
1024#[derive(Debug, Clone)]
1025pub struct TemplateCompactor {
1026    header: String,
1027    max_bytes: Option<usize>,
1028}
1029
1030impl TemplateCompactor {
1031    /// Create a [`TemplateCompactor`] with the default header
1032    /// `"[Conversation summary so far]"` and no size cap.
1033    pub fn new() -> Self {
1034        Self::with_header("[Conversation summary so far]")
1035    }
1036
1037    /// Create a [`TemplateCompactor`] with a custom header line and no
1038    /// size cap.
1039    pub fn with_header(header: impl Into<String>) -> Self {
1040        Self {
1041            header: header.into(),
1042            max_bytes: None,
1043        }
1044    }
1045
1046    /// Cap the rolled-up summary at `max_bytes` bytes (UTF-8). When the
1047    /// assembled body exceeds the cap, the oldest portion after the
1048    /// header is dropped at a char boundary and replaced with a
1049    /// `"[…truncated…]"` marker.
1050    ///
1051    /// `max_bytes` of `0` disables truncation (equivalent to the default
1052    /// unbounded behaviour). The header line plus the marker are always
1053    /// preserved even if they exceed the cap.
1054    #[must_use = "the setting applies to the returned value"]
1055    pub fn with_max_bytes(mut self, max_bytes: usize) -> Self {
1056        self.max_bytes = if max_bytes == 0 {
1057            None
1058        } else {
1059            Some(max_bytes)
1060        };
1061        self
1062    }
1063}
1064
1065impl Default for TemplateCompactor {
1066    fn default() -> Self {
1067        Self::new()
1068    }
1069}
1070
1071/// Plain-text artifact produced by [`TemplateCompactor`].
1072///
1073/// Convertible into a [`Message::System`] whose body is the rolled-up
1074/// text. The system role is used because the rollup represents
1075/// out-of-band context about the prior conversation, not a turn from
1076/// any participant.
1077#[derive(Debug, Clone)]
1078pub struct TextSummary(String);
1079
1080impl TextSummary {
1081    /// Borrow the underlying summary text.
1082    pub fn as_str(&self) -> &str {
1083        &self.0
1084    }
1085
1086    /// Consume the wrapper and return the underlying `String`.
1087    pub fn into_string(self) -> String {
1088        self.0
1089    }
1090}
1091
1092impl From<TextSummary> for Message {
1093    fn from(value: TextSummary) -> Self {
1094        Message::System { content: value.0 }
1095    }
1096}
1097
1098impl Compactor for TemplateCompactor {
1099    type Artifact = TextSummary;
1100
1101    fn compact<'a>(
1102        &'a self,
1103        _conversation_id: &'a ConversationId,
1104        evicted: &'a [Message],
1105        carry_over: Option<&'a Self::Artifact>,
1106    ) -> WasmBoxedFuture<'a, Result<Self::Artifact, MemoryError>> {
1107        Box::pin(async move {
1108            let mut buf = String::new();
1109            buf.push_str(&self.header);
1110            buf.push('\n');
1111            if let Some(prev) = carry_over {
1112                buf.push_str(prev.as_str());
1113                buf.push('\n');
1114            }
1115            for msg in evicted {
1116                let line = render_message_line(msg);
1117                if !line.is_empty() {
1118                    buf.push_str(&line);
1119                    buf.push('\n');
1120                }
1121            }
1122            if let Some(cap) = self.max_bytes
1123                && buf.len() > cap
1124            {
1125                buf = truncate_summary(&buf, cap);
1126            }
1127            Ok(TextSummary(buf))
1128        })
1129    }
1130}
1131
1132/// Truncate `buf` to fit within `cap` bytes by dropping the oldest
1133/// content after the header line. Always preserves the header plus a
1134/// `"[\u{2026}truncated\u{2026}]"` marker, even if they alone exceed `cap`.
1135///
1136/// The header boundary is located by scanning `buf` for the first `\n`
1137/// rather than by trusting any caller-supplied header length, so a
1138/// header containing embedded newlines does not mis-locate the body.
1139fn truncate_summary(buf: &str, cap: usize) -> String {
1140    const MARKER: &str = "[\u{2026}truncated\u{2026}]\n";
1141    let Some(newline) = buf.find('\n') else {
1142        return buf.to_string();
1143    };
1144    let header_prefix_len = newline + 1;
1145    if buf.len() <= header_prefix_len {
1146        return buf.to_string();
1147    }
1148    let preserved = header_prefix_len + MARKER.len();
1149    let keep_bytes = cap.saturating_sub(preserved);
1150    let body_start = header_prefix_len;
1151    let Some(body) = buf.get(body_start..) else {
1152        return buf.to_string();
1153    };
1154    let mut cut = body.len().saturating_sub(keep_bytes);
1155    while cut < body.len() && !body.is_char_boundary(cut) {
1156        cut += 1;
1157    }
1158    let suffix: &str = body.get(cut..).unwrap_or_default();
1159    let Some(header_with_nl) = buf.get(..header_prefix_len) else {
1160        return buf.to_string();
1161    };
1162    let mut out = String::with_capacity(header_prefix_len + MARKER.len() + suffix.len());
1163    out.push_str(header_with_nl);
1164    out.push_str(MARKER);
1165    out.push_str(suffix);
1166    out
1167}
1168
1169/// Render a single message as a `"role: text"` line for [`TemplateCompactor`].
1170///
1171/// Non-textual content (tool calls, tool results, attachments) is rendered
1172/// as a short marker so the rollup does not silently drop them but also
1173/// does not balloon with serialized JSON.
1174fn render_message_line(msg: &Message) -> String {
1175    use rig_core::message::AssistantContent;
1176
1177    let (role, text) = match msg {
1178        Message::System { content } => ("system", content.clone()),
1179        Message::User { content } => (
1180            "user",
1181            join_parts(content.iter().map(|item| match item {
1182                UserContent::Text(text) => Cow::Borrowed(text.text.as_str()),
1183                UserContent::ToolResult(_) => Cow::Borrowed("[tool result]"),
1184                _ => Cow::Borrowed("[attachment]"),
1185            })),
1186        ),
1187        Message::Assistant(turn) => (
1188            "assistant",
1189            join_parts(turn.content.iter().map(|item| match item {
1190                AssistantContent::Text(text) => Cow::Borrowed(text.text.as_str()),
1191                AssistantContent::ToolCall(call) => {
1192                    Cow::Owned(format!("[tool call: {}]", call.function.name))
1193                }
1194                _ => Cow::Borrowed("[reasoning]"),
1195            })),
1196        ),
1197    };
1198
1199    if text.is_empty() {
1200        String::new()
1201    } else {
1202        format!("{role}: {text}")
1203    }
1204}
1205
1206/// Joins parts with spaces, omitting separators until the first nonempty part.
1207fn join_parts<'a>(parts: impl Iterator<Item = Cow<'a, str>>) -> String {
1208    let mut text = String::new();
1209    for part in parts {
1210        if !text.is_empty() {
1211            text.push(' ');
1212        }
1213        text.push_str(&part);
1214    }
1215    text
1216}
1217
1218#[cfg(test)]
1219mod tests;