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(
333                reasoning
334                    .open(reasoning.issuer())
335                    .map_or(0, |reasoning| reasoning.display_text().len()),
336            ),
337            AssistantContent::ToolCall(call) => {
338                let name_bytes = call.function.name.len();
339                let args_bytes = call.function.arguments.to_string().len();
340                self.bytes_to_tokens(name_bytes + args_bytes)
341            }
342            AssistantContent::Image(_) => self.per_attachment_tokens,
343        }
344    }
345}
346
347impl Default for HeuristicTokenCounter {
348    /// Uses four bytes per token, four tokens per message, and 256 per attachment.
349    fn default() -> Self {
350        Self::new(4.0, 4, 256)
351    }
352}
353
354impl TokenCounter for HeuristicTokenCounter {
355    fn count(&self, message: &Message) -> usize {
356        let content_tokens: usize = match message {
357            Message::User { content } => content.iter().map(|c| self.count_user(c)).sum(),
358            Message::Assistant { content, .. } => {
359                content.iter().map(|c| self.count_assistant(c)).sum()
360            }
361            Message::System { content } => self.bytes_to_tokens(content.len()),
362        };
363        content_tokens.saturating_add(self.per_message_overhead)
364    }
365}
366
367/// A [`MemoryPolicy`] that retains the most recent messages up to a token budget.
368///
369/// Messages are walked from newest to oldest, accumulating token counts
370/// produced by a [`TokenCounter`]. Once including a message would exceed
371/// `max_tokens`, the walk stops and the included messages are returned in
372/// original (oldest-first) order. As with [`SlidingWindowMemory`], the leading
373/// prefix through any tool results whose assistant calls were truncated is
374/// demoted, including mixed-content messages.
375pub struct TokenWindowMemory {
376    max_tokens: usize,
377    counter: Arc<dyn TokenCounter>,
378}
379
380impl TokenWindowMemory {
381    /// Create a new policy with a token budget and a counter.
382    pub fn new<C>(max_tokens: usize, counter: C) -> Self
383    where
384        C: TokenCounter + 'static,
385    {
386        Self {
387            max_tokens,
388            counter: Arc::new(counter),
389        }
390    }
391}
392
393impl std::fmt::Debug for TokenWindowMemory {
394    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
395        f.debug_struct("TokenWindowMemory")
396            .field("max_tokens", &self.max_tokens)
397            .field("counter", &"<counter>")
398            .finish()
399    }
400}
401
402impl MemoryPolicy for TokenWindowMemory {
403    fn apply(&self, messages: Vec<Message>) -> Result<Vec<Message>, MemoryError> {
404        Ok(self.apply_with_demoted(messages)?.0)
405    }
406
407    fn apply_with_demoted(
408        &self,
409        messages: Vec<Message>,
410    ) -> Result<(Vec<Message>, Vec<Message>), MemoryError> {
411        let mut budget = self.max_tokens;
412        let mut keep_from = messages.len();
413
414        for (idx, msg) in messages.iter().enumerate().rev() {
415            let cost = self.counter.count(msg);
416            if cost > budget {
417                break;
418            }
419            budget -= cost;
420            keep_from = idx;
421        }
422
423        Ok(split_window(messages, keep_from))
424    }
425}
426
427/// Applies a policy to loaded history, propagating backend and policy errors
428/// unchanged. Append and clear operations delegate to the backend.
429///
430/// # Example
431///
432/// ```no_run
433/// use rig_memory::{InMemoryConversationMemory, PolicyMemory, SlidingWindowMemory};
434///
435/// let memory = PolicyMemory::new(
436///     InMemoryConversationMemory::new(),
437///     SlidingWindowMemory::last_messages(20),
438/// );
439/// ```
440#[derive(Debug, Clone, Copy)]
441pub struct PolicyMemory<M, P> {
442    inner: M,
443    policy: P,
444}
445
446impl<M, P> PolicyMemory<M, P> {
447    /// Wrap `inner` so every loaded history is run through `policy`.
448    pub fn new(inner: M, policy: P) -> Self {
449        Self { inner, policy }
450    }
451
452    /// Return a reference to the wrapped backend.
453    pub fn inner(&self) -> &M {
454        &self.inner
455    }
456
457    /// Return a reference to the wrapped policy.
458    pub fn policy(&self) -> &P {
459        &self.policy
460    }
461
462    /// Consume the wrapper and return the underlying backend and policy.
463    pub fn into_inner(self) -> (M, P) {
464        (self.inner, self.policy)
465    }
466}
467
468impl<M, P> ConversationMemory for PolicyMemory<M, P>
469where
470    M: ConversationMemory,
471    P: MemoryPolicy,
472{
473    fn load<'a>(
474        &'a self,
475        conversation_id: &'a ConversationId,
476    ) -> WasmBoxedFuture<'a, Result<Vec<Message>, MemoryError>> {
477        Box::pin(async move {
478            let messages = self.inner.load(conversation_id).await?;
479            self.policy.apply(messages)
480        })
481    }
482
483    fn append<'a>(
484        &'a self,
485        conversation_id: &'a ConversationId,
486        messages: Vec<Message>,
487    ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
488        self.inner.append(conversation_id, messages)
489    }
490
491    fn clear<'a>(
492        &'a self,
493        conversation_id: &'a ConversationId,
494    ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
495        self.inner.clear(conversation_id)
496    }
497}
498
499/// Applies a policy and delivers newly demoted prefixes to a hook before returning
500/// retained history. The policy must report demotions through
501/// [`MemoryPolicy::apply_with_demoted`].
502///
503/// Only one delivery per tracked conversation runs at a time. Concurrent loads
504/// return retained history immediately without waiting for the hook, so success
505/// does not guarantee durable demotion. Hook errors reach only the delivering
506/// caller; unchanged watermarks allow later loads to retry.
507///
508/// Watermarks are process-local. Hooks must tolerate redelivery after restarts,
509/// cancellation, or discarded tracking state, following [`DemotionHook`].
510///
511/// ```no_run
512/// use rig_memory::{
513///     DemotingPolicyMemory, DemotionHook, InMemoryConversationMemory,
514///     MemoryError, NoopDemotionHook, SlidingWindowMemory,
515/// };
516///
517/// let memory = DemotingPolicyMemory::new(
518///     InMemoryConversationMemory::new(),
519///     SlidingWindowMemory::last_messages(20),
520///     NoopDemotionHook,
521/// );
522/// # let _ = memory;
523/// ```
524pub struct DemotingPolicyMemory<M, P, H> {
525    inner: M,
526    policy: P,
527    hook: H,
528    state: StdMutex<HashMap<ConversationId, ConversationDemotionState>>,
529}
530
531type InFlightReservation = Arc<()>;
532
533/// Emits the members shared by the stateful wrappers
534/// ([`DemotingPolicyMemory`], [`CompactingMemory`]): accessors,
535/// `forget`/`tracked_conversations` over the per-conversation state map, and
536/// a `Debug` impl that elides the non-`Debug` third component.
537macro_rules! stateful_wrapper_common {
538    ($ty:ident, $third:ident: $tgen:ident $(: $tbound:ident)?) => {
539        impl<M, P, $tgen $(: $tbound)?> $ty<M, P, $tgen> {
540            /// Return a reference to the wrapped backend.
541            pub fn inner(&self) -> &M {
542                &self.inner
543            }
544
545            /// Return a reference to the wrapped policy.
546            pub fn policy(&self) -> &P {
547                &self.policy
548            }
549
550            /// Return a reference to the third component.
551            pub fn $third(&self) -> &$tgen {
552                &self.$third
553            }
554
555            /// Consume the wrapper and return its three components.
556            pub fn into_inner(self) -> (M, P, $tgen) {
557                (self.inner, self.policy, self.$third)
558            }
559
560            /// Drop the in-process state for `conversation_id`.
561            ///
562            /// Entries otherwise persist for the wrapper's lifetime. Does nothing
563            /// if the state lock is poisoned. In-flight work is not cancelled.
564            pub fn forget(&self, conversation_id: &ConversationId) {
565                if let Ok(mut guard) = self.state.lock() {
566                    guard.remove(conversation_id);
567                }
568            }
569
570            /// Number of conversations currently tracked in the state map.
571            /// Useful for telemetry and leak detection. Returns `0` if the
572            /// internal state lock is poisoned.
573            pub fn tracked_conversations(&self) -> usize {
574                self.state.lock().map_or(0, |g| g.len())
575            }
576        }
577
578        impl<M, P, $tgen $(: $tbound)?> std::fmt::Debug for $ty<M, P, $tgen>
579        where
580            M: std::fmt::Debug,
581            P: std::fmt::Debug,
582        {
583            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
584                f.debug_struct(stringify!($ty))
585                    .field("inner", &self.inner)
586                    .field("policy", &self.policy)
587                    .field(
588                        stringify!($third),
589                        &concat!("<", stringify!($third), ">"),
590                    )
591                    .finish()
592            }
593        }
594    };
595}
596
597/// Emits the delegating `append` and the `clear`-then-`forget` methods of a
598/// stateful wrapper's [`ConversationMemory`] impl.
599macro_rules! stateful_wrapper_append_clear {
600    () => {
601        fn append<'a>(
602            &'a self,
603            conversation_id: &'a ConversationId,
604            messages: Vec<Message>,
605        ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
606            self.inner.append(conversation_id, messages)
607        }
608
609        fn clear<'a>(
610            &'a self,
611            conversation_id: &'a ConversationId,
612        ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
613            Box::pin(async move {
614                self.inner.clear(conversation_id).await?;
615                self.forget(conversation_id);
616                Ok(())
617            })
618        }
619    };
620}
621
622#[derive(Debug, Default, Clone)]
623struct ConversationDemotionState {
624    /// Number of demoted messages already delivered to the hook within
625    /// this process lifetime. Advanced only on hook success.
626    delivered: usize,
627    /// Reservation held while a `load` is currently awaiting
628    /// `hook.on_demote(...)` for this conversation. Other concurrent loads
629    /// observe this and short-circuit without re-delivering the same messages.
630    in_flight: Option<InFlightReservation>,
631}
632
633impl<M, P, H> DemotingPolicyMemory<M, P, H> {
634    /// Wrap `inner` so every load runs through `policy` and demoted messages
635    /// flow into `hook`.
636    pub fn new(inner: M, policy: P, hook: H) -> Self {
637        Self {
638            inner,
639            policy,
640            hook,
641            state: StdMutex::new(HashMap::new()),
642        }
643    }
644}
645
646stateful_wrapper_common!(DemotingPolicyMemory, hook: H);
647
648impl<M, P, H> ConversationMemory for DemotingPolicyMemory<M, P, H>
649where
650    M: ConversationMemory,
651    P: MemoryPolicy,
652    H: DemotionHook,
653{
654    fn load<'a>(
655        &'a self,
656        conversation_id: &'a ConversationId,
657    ) -> WasmBoxedFuture<'a, Result<Vec<Message>, MemoryError>> {
658        Box::pin(async move {
659            let messages = self.inner.load(conversation_id).await?;
660            let (kept, mut demoted) = self.policy.apply_with_demoted(messages)?;
661            let demoted_count = demoted.len();
662
663            // Reserve under the watermark lock to prevent duplicate concurrent
664            // deliveries for the same tracked conversation.
665            let (pending, reservation) = {
666                let mut guard = self.state.lock().map_err(poisoned)?;
667                if let Some(entry) = guard.get_mut(conversation_id) {
668                    if entry.in_flight.is_some() {
669                        // Another load is mid-delivery for this conversation;
670                        // skip and let the next load see whatever it leaves
671                        // behind.
672                        return Ok(kept);
673                    }
674                    if entry.delivered >= demoted_count {
675                        (Vec::new(), None)
676                    } else {
677                        let split = entry.delivered;
678                        let reservation = Arc::new(());
679                        entry.in_flight = Some(reservation.clone());
680                        (demoted.split_off(split), Some(reservation))
681                    }
682                } else if demoted_count == 0 {
683                    (Vec::new(), None)
684                } else {
685                    let reservation = Arc::new(());
686                    guard.insert(
687                        conversation_id.clone(),
688                        ConversationDemotionState {
689                            delivered: 0,
690                            in_flight: Some(reservation.clone()),
691                        },
692                    );
693                    (std::mem::take(&mut demoted), Some(reservation))
694                }
695            };
696
697            let Some(reservation) = reservation else {
698                return Ok(kept);
699            };
700
701            // Arm an RAII guard so the in-flight gate is released even if
702            // this future is dropped mid-await (caller cancellation) or the
703            // hook panics. The reservation token prevents stale guards from
704            // clearing newer in-flight loads after clear()/forget() reuse the
705            // same conversation id.
706            let in_flight_guard =
707                InFlightGuard::new(&self.state, conversation_id, reservation.clone());
708
709            let result = self.hook.on_demote(conversation_id, pending).await;
710
711            // Only the matching reservation may advance the watermark; a clear
712            // or replacement during delivery must not revive stale state.
713            release_in_flight(&self.state, conversation_id, &reservation, |entry| {
714                if result.is_ok() {
715                    entry.delivered = demoted_count;
716                }
717            })?;
718            in_flight_guard.disarm();
719            result?;
720            Ok(kept)
721        })
722    }
723
724    stateful_wrapper_append_clear!();
725}
726
727fn poisoned<E: std::fmt::Display>(err: E) -> MemoryError {
728    MemoryError::Internal(err.to_string())
729}
730
731/// Clears a matching reservation and runs `on_match` under the state lock.
732/// Returns its result, or `None` for absent or newer reservations. A poisoned
733/// lock returns an error; stale releases cannot restore or overwrite state.
734fn release_in_flight<S: InFlightSlot, T>(
735    state: &StdMutex<HashMap<ConversationId, S>>,
736    key: &ConversationId,
737    reservation: &InFlightReservation,
738    on_match: impl FnOnce(&mut S) -> T,
739) -> Result<Option<T>, MemoryError> {
740    let mut guard = state.lock().map_err(poisoned)?;
741    let Some(entry) = guard.get_mut(key) else {
742        return Ok(None);
743    };
744    if entry
745        .in_flight_mut()
746        .as_ref()
747        .is_some_and(|current| Arc::ptr_eq(current, reservation))
748    {
749        *entry.in_flight_mut() = None;
750        return Ok(Some(on_match(entry)));
751    }
752    Ok(None)
753}
754
755/// A per-conversation state entry with an `in_flight` delivery reservation,
756/// letting [`InFlightGuard`] work over both the demotion and compaction
757/// state maps.
758trait InFlightSlot {
759    fn in_flight_mut(&mut self) -> &mut Option<InFlightReservation>;
760}
761
762impl InFlightSlot for ConversationDemotionState {
763    fn in_flight_mut(&mut self) -> &mut Option<InFlightReservation> {
764        &mut self.in_flight
765    }
766}
767
768impl<A> InFlightSlot for ConversationCompactionState<A> {
769    fn in_flight_mut(&mut self) -> &mut Option<InFlightReservation> {
770        &mut self.in_flight
771    }
772}
773
774/// Releases its matching reservation on drop, including cancellation and unwind.
775/// Disarm after the post-await update releases it. Missing or newer reservations
776/// remain untouched; poisoned locks prevent cleanup.
777struct InFlightGuard<'a, S: InFlightSlot> {
778    state: &'a StdMutex<HashMap<ConversationId, S>>,
779    key: &'a ConversationId,
780    reservation: InFlightReservation,
781    armed: bool,
782}
783
784impl<'a, S: InFlightSlot> InFlightGuard<'a, S> {
785    fn new(
786        state: &'a StdMutex<HashMap<ConversationId, S>>,
787        key: &'a ConversationId,
788        reservation: InFlightReservation,
789    ) -> Self {
790        Self {
791            state,
792            key,
793            reservation,
794            armed: true,
795        }
796    }
797
798    /// Disable the `Drop` clean-up. Call after the post-await state
799    /// update has already cleared `in_flight` while holding the lock.
800    fn disarm(mut self) {
801        self.armed = false;
802    }
803}
804
805impl<S: InFlightSlot> Drop for InFlightGuard<'_, S> {
806    fn drop(&mut self) {
807        if !self.armed {
808            return;
809        }
810        // Drop cannot report a poisoned state lock.
811        let _ = release_in_flight(self.state, self.key, &self.reservation, |_| ());
812    }
813}
814
815/// Prepends a rolling summary of demoted history to the policy's retained window.
816/// New evictions are compacted with the previous artifact as carry-over. The
817/// summary is outside the policy budget; callers needing a bounded prompt must
818/// also bound the compactor's artifact.
819///
820/// Only one compaction per tracked conversation runs at a time. Concurrent loads
821/// return the previous summary and retained window immediately. Errors reach only
822/// the compacting caller; unchanged watermarks allow later loads to retry.
823///
824/// Summaries and watermarks are process-local. Restarts or discarded state cause
825/// recompaction; compactors with side effects must tolerate repeated input.
826///
827/// ```no_run
828/// use rig_memory::{
829///     CompactingMemory, InMemoryConversationMemory, SlidingWindowMemory,
830///     TemplateCompactor,
831/// };
832///
833/// let memory = CompactingMemory::new(
834///     InMemoryConversationMemory::new(),
835///     SlidingWindowMemory::last_messages(20),
836///     TemplateCompactor::new(),
837/// );
838/// # let _ = memory;
839/// ```
840pub struct CompactingMemory<M, P, C: Compactor> {
841    inner: M,
842    policy: P,
843    compactor: C,
844    state: StdMutex<HashMap<ConversationId, ConversationCompactionState<C::Artifact>>>,
845}
846
847struct ConversationCompactionState<A> {
848    /// Latest summary artifact for this conversation, if compaction has
849    /// already happened. Cloned into the loaded history on every `load`.
850    summary: Option<A>,
851    /// Number of demoted messages already absorbed into `summary` within
852    /// this process lifetime. Advanced only on compactor success.
853    absorbed: usize,
854    /// Reservation held while a `load` is currently awaiting the compactor for
855    /// this conversation. Other concurrent loads observe this and short-circuit
856    /// without re-running the compactor.
857    in_flight: Option<InFlightReservation>,
858}
859
860impl<M, P, C: Compactor> CompactingMemory<M, P, C> {
861    /// Wrap `inner` so every load runs through `policy` and demoted messages
862    /// are summarised by `compactor`.
863    pub fn new(inner: M, policy: P, compactor: C) -> Self {
864        Self {
865            inner,
866            policy,
867            compactor,
868            state: StdMutex::new(HashMap::new()),
869        }
870    }
871}
872
873stateful_wrapper_common!(CompactingMemory, compactor: C: Compactor);
874
875impl<M, P, C> ConversationMemory for CompactingMemory<M, P, C>
876where
877    M: ConversationMemory,
878    P: MemoryPolicy,
879    C: Compactor,
880{
881    fn load<'a>(
882        &'a self,
883        conversation_id: &'a ConversationId,
884    ) -> WasmBoxedFuture<'a, Result<Vec<Message>, MemoryError>> {
885        Box::pin(async move {
886            let messages = self.inner.load(conversation_id).await?;
887            let (kept, demoted) = self.policy.apply_with_demoted(messages)?;
888            let demoted_count = demoted.len();
889
890            // Reserve under the watermark lock to prevent concurrent compaction
891            // of the same tracked prefix.
892            let plan = {
893                let mut guard = self.state.lock().map_err(poisoned)?;
894                if let Some(entry) = guard.get_mut(conversation_id) {
895                    if entry.in_flight.is_some() {
896                        // Another load is mid-compaction; return what we
897                        // have so far. Newly-evicted messages will be
898                        // folded in by the next load.
899                        return Ok(splice(entry.summary.clone(), kept));
900                    }
901                    if demoted_count <= entry.absorbed {
902                        return Ok(splice(entry.summary.clone(), kept));
903                    }
904                    let reservation = Arc::new(());
905                    entry.in_flight = Some(reservation.clone());
906                    CompactionPlan {
907                        carry_over: entry.summary.clone(),
908                        skip: entry.absorbed,
909                        reservation,
910                    }
911                } else if demoted_count == 0 {
912                    return Ok(kept);
913                } else {
914                    let reservation = Arc::new(());
915                    guard.insert(
916                        conversation_id.clone(),
917                        ConversationCompactionState {
918                            summary: None,
919                            absorbed: 0,
920                            in_flight: Some(reservation.clone()),
921                        },
922                    );
923                    CompactionPlan {
924                        carry_over: None,
925                        skip: 0,
926                        reservation,
927                    }
928                }
929            };
930
931            // The plan is created only when its watermark is below demoted_count.
932            let CompactionPlan {
933                carry_over,
934                skip,
935                reservation,
936            } = plan;
937
938            // Arm an RAII guard so the in-flight gate is released even if
939            // this future is dropped mid-await (caller cancellation) or
940            // the compactor panics. The guard is disarmed below once the
941            // post-await state update has already cleared the flag under
942            // the same lock acquisition that records the new watermark.
943            let in_flight_guard =
944                InFlightGuard::new(&self.state, conversation_id, reservation.clone());
945
946            let Some(new_slice) = demoted.get(skip..) else {
947                // Drop the guard explicitly so the gate is released
948                // before we surface the invariant break.
949                drop(in_flight_guard);
950                return Err(MemoryError::Internal(
951                    "compaction watermark exceeds demoted slice length".into(),
952                ));
953            };
954
955            let result = self
956                .compactor
957                .compact(conversation_id, new_slice, carry_over.as_ref())
958                .await;
959
960            // Publish only to the matching reservation. A clear or replacement
961            // during compaction discards the artifact instead of reviving stale state.
962            let summary_for_splice = match result {
963                Ok(artifact) => {
964                    release_in_flight(&self.state, conversation_id, &reservation, |entry| {
965                        entry.absorbed = demoted_count;
966                        entry.summary = Some(artifact.clone());
967                        artifact
968                    })?
969                }
970                Err(err) => {
971                    release_in_flight(&self.state, conversation_id, &reservation, |_| ())?;
972                    return Err(err);
973                }
974            };
975
976            // The update already released the reservation; avoid locking again.
977            in_flight_guard.disarm();
978
979            Ok(splice(summary_for_splice, kept))
980        })
981    }
982
983    stateful_wrapper_append_clear!();
984}
985
986struct CompactionPlan<A> {
987    carry_over: Option<A>,
988    skip: usize,
989    reservation: InFlightReservation,
990}
991
992fn splice<A>(summary: Option<A>, kept: Vec<Message>) -> Vec<Message>
993where
994    A: Into<Message>,
995{
996    match summary {
997        Some(artifact) => {
998            let mut out = Vec::with_capacity(kept.len() + 1);
999            out.push(artifact.into());
1000            out.extend(kept);
1001            out
1002        }
1003        None => kept,
1004    }
1005}
1006
1007/// Builds a textual rollup without calling a model.
1008/// Concatenates a header, the previous summary, and rendered evicted messages
1009/// into a [`TextSummary`] convertible to a system message.
1010///
1011/// Unbounded by default. [`Self::with_max_bytes`] removes oldest body bytes at
1012/// UTF-8 boundaries, retaining the first header line and a truncation marker
1013/// even when those exceed the cap.
1014///
1015/// ```
1016/// use rig_memory::TemplateCompactor;
1017///
1018/// // Default header is "[Conversation summary so far]", unbounded.
1019/// let _compactor = TemplateCompactor::new();
1020///
1021/// // Custom header plus a 4 KiB cap for use with token-budgeted policies.
1022/// let _bounded = TemplateCompactor::with_header("Earlier context")
1023///     .with_max_bytes(4 * 1024);
1024/// ```
1025#[derive(Debug, Clone)]
1026pub struct TemplateCompactor {
1027    header: String,
1028    max_bytes: Option<usize>,
1029}
1030
1031impl TemplateCompactor {
1032    /// Create a [`TemplateCompactor`] with the default header
1033    /// `"[Conversation summary so far]"` and no size cap.
1034    pub fn new() -> Self {
1035        Self::with_header("[Conversation summary so far]")
1036    }
1037
1038    /// Create a [`TemplateCompactor`] with a custom header line and no
1039    /// size cap.
1040    pub fn with_header(header: impl Into<String>) -> Self {
1041        Self {
1042            header: header.into(),
1043            max_bytes: None,
1044        }
1045    }
1046
1047    /// Cap the rolled-up summary at `max_bytes` bytes (UTF-8). When the
1048    /// assembled body exceeds the cap, the oldest portion after the
1049    /// header is dropped at a char boundary and replaced with a
1050    /// `"[…truncated…]"` marker.
1051    ///
1052    /// `max_bytes` of `0` disables truncation (equivalent to the default
1053    /// unbounded behaviour). The header line plus the marker are always
1054    /// preserved even if they exceed the cap.
1055    #[must_use = "the setting applies to the returned value"]
1056    pub fn with_max_bytes(mut self, max_bytes: usize) -> Self {
1057        self.max_bytes = if max_bytes == 0 {
1058            None
1059        } else {
1060            Some(max_bytes)
1061        };
1062        self
1063    }
1064}
1065
1066impl Default for TemplateCompactor {
1067    fn default() -> Self {
1068        Self::new()
1069    }
1070}
1071
1072/// Plain-text artifact produced by [`TemplateCompactor`].
1073///
1074/// Convertible into a [`Message::System`] whose body is the rolled-up
1075/// text. The system role is used because the rollup represents
1076/// out-of-band context about the prior conversation, not a turn from
1077/// any participant.
1078#[derive(Debug, Clone)]
1079pub struct TextSummary(String);
1080
1081impl TextSummary {
1082    /// Borrow the underlying summary text.
1083    pub fn as_str(&self) -> &str {
1084        &self.0
1085    }
1086
1087    /// Consume the wrapper and return the underlying `String`.
1088    pub fn into_string(self) -> String {
1089        self.0
1090    }
1091}
1092
1093impl From<TextSummary> for Message {
1094    fn from(value: TextSummary) -> Self {
1095        Message::System { content: value.0 }
1096    }
1097}
1098
1099impl Compactor for TemplateCompactor {
1100    type Artifact = TextSummary;
1101
1102    fn compact<'a>(
1103        &'a self,
1104        _conversation_id: &'a ConversationId,
1105        evicted: &'a [Message],
1106        carry_over: Option<&'a Self::Artifact>,
1107    ) -> WasmBoxedFuture<'a, Result<Self::Artifact, MemoryError>> {
1108        Box::pin(async move {
1109            let mut buf = String::new();
1110            buf.push_str(&self.header);
1111            buf.push('\n');
1112            if let Some(prev) = carry_over {
1113                buf.push_str(prev.as_str());
1114                buf.push('\n');
1115            }
1116            for msg in evicted {
1117                let line = render_message_line(msg);
1118                if !line.is_empty() {
1119                    buf.push_str(&line);
1120                    buf.push('\n');
1121                }
1122            }
1123            if let Some(cap) = self.max_bytes
1124                && buf.len() > cap
1125            {
1126                buf = truncate_summary(&buf, cap);
1127            }
1128            Ok(TextSummary(buf))
1129        })
1130    }
1131}
1132
1133/// Truncate `buf` to fit within `cap` bytes by dropping the oldest
1134/// content after the header line. Always preserves the header plus a
1135/// `"[\u{2026}truncated\u{2026}]"` marker, even if they alone exceed `cap`.
1136///
1137/// The header boundary is located by scanning `buf` for the first `\n`
1138/// rather than by trusting any caller-supplied header length, so a
1139/// header containing embedded newlines does not mis-locate the body.
1140fn truncate_summary(buf: &str, cap: usize) -> String {
1141    const MARKER: &str = "[\u{2026}truncated\u{2026}]\n";
1142    let Some(newline) = buf.find('\n') else {
1143        return buf.to_string();
1144    };
1145    let header_prefix_len = newline + 1;
1146    if buf.len() <= header_prefix_len {
1147        return buf.to_string();
1148    }
1149    let preserved = header_prefix_len + MARKER.len();
1150    let keep_bytes = cap.saturating_sub(preserved);
1151    let body_start = header_prefix_len;
1152    let Some(body) = buf.get(body_start..) else {
1153        return buf.to_string();
1154    };
1155    let mut cut = body.len().saturating_sub(keep_bytes);
1156    while cut < body.len() && !body.is_char_boundary(cut) {
1157        cut += 1;
1158    }
1159    let suffix: &str = body.get(cut..).unwrap_or_default();
1160    let Some(header_with_nl) = buf.get(..header_prefix_len) else {
1161        return buf.to_string();
1162    };
1163    let mut out = String::with_capacity(header_prefix_len + MARKER.len() + suffix.len());
1164    out.push_str(header_with_nl);
1165    out.push_str(MARKER);
1166    out.push_str(suffix);
1167    out
1168}
1169
1170/// Render a single message as a `"role: text"` line for [`TemplateCompactor`].
1171///
1172/// Non-textual content (tool calls, tool results, attachments) is rendered
1173/// as a short marker so the rollup does not silently drop them but also
1174/// does not balloon with serialized JSON.
1175fn render_message_line(msg: &Message) -> String {
1176    use rig_core::message::AssistantContent;
1177
1178    let (role, text) = match msg {
1179        Message::System { content } => ("system", content.clone()),
1180        Message::User { content } => (
1181            "user",
1182            join_parts(content.iter().map(|item| match item {
1183                UserContent::Text(text) => Cow::Borrowed(text.text.as_str()),
1184                UserContent::ToolResult(_) => Cow::Borrowed("[tool result]"),
1185                _ => Cow::Borrowed("[attachment]"),
1186            })),
1187        ),
1188        Message::Assistant { content, .. } => (
1189            "assistant",
1190            join_parts(content.iter().map(|item| match item {
1191                AssistantContent::Text(text) => Cow::Borrowed(text.text.as_str()),
1192                AssistantContent::ToolCall(call) => {
1193                    Cow::Owned(format!("[tool call: {}]", call.function.name))
1194                }
1195                _ => Cow::Borrowed("[reasoning]"),
1196            })),
1197        ),
1198    };
1199
1200    if text.is_empty() {
1201        String::new()
1202    } else {
1203        format!("{role}: {text}")
1204    }
1205}
1206
1207/// Joins parts with spaces, omitting separators until the first nonempty part.
1208fn join_parts<'a>(parts: impl Iterator<Item = Cow<'a, str>>) -> String {
1209    let mut text = String::new();
1210    for part in parts {
1211        if !text.is_empty() {
1212            text.push(' ');
1213        }
1214        text.push_str(&part);
1215    }
1216    text
1217}
1218
1219#[cfg(test)]
1220mod tests;