Skip to main content

rig_core/
memory.rs

1//! Conversation memory: Rig-managed persistent conversation history for agents.
2//!
3//! Memory differs from existing agent context features:
4//! - classic runtime context: static documents always included in prompts;
5//! - classic runtime request patches: per-turn documents supplied by application hooks;
6//! - caller-managed message history supplied directly on completion requests;
7//! - **Memory** (this module): Rig-managed history loaded and saved automatically per
8//!   conversation id.
9//!
10//! # Example
11//!
12//! ```no_run
13//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
14//! use rig_core::{
15//!     completion::Message,
16//!     memory::{ConversationMemory, InMemoryConversationMemory},
17//! };
18//!
19//! let memory = InMemoryConversationMemory::new();
20//! memory
21//!     .append(
22//!         "thread-1",
23//!         vec![
24//!             Message::user("My name is Alice."),
25//!             Message::assistant("Hello, Alice!"),
26//!         ],
27//!     )
28//!     .await?;
29//! let history = memory.load("thread-1").await?;
30//! assert_eq!(history.len(), 2);
31//! # Ok(()) }
32//! ```
33//!
34//! Truncation, summarization, and other history-shaping policies live in the
35//! `rig-memory` companion crate. To shape history inside the in-tree backend,
36//! pass a closure to [`InMemoryConversationMemory::with_filter`].
37
38use std::{
39    collections::HashMap,
40    sync::{Arc, Mutex},
41};
42
43use crate::{
44    completion::Message,
45    wasm_compat::{WasmBoxedFuture, WasmCompatSend, WasmCompatSync},
46};
47
48/// Boxed error source for memory backend failures.
49#[cfg(not(target_family = "wasm"))]
50pub type MemoryBackendError = Box<dyn std::error::Error + Send + Sync + 'static>;
51
52/// Boxed error source for memory backend failures.
53#[cfg(target_family = "wasm")]
54pub type MemoryBackendError = Box<dyn std::error::Error + 'static>;
55
56/// Errors produced by a [`ConversationMemory`] backend.
57#[derive(Debug, thiserror::Error)]
58pub enum MemoryError {
59    /// The backing store failed to load, append, or clear messages.
60    #[error("Memory backend error: {0}")]
61    Backend(MemoryBackendError),
62
63    /// A history-shaping filter or policy rejected the loaded history.
64    #[error("Memory policy error: {0}")]
65    Policy(String),
66
67    /// An internal invariant was violated (e.g. a poisoned in-process lock).
68    /// Distinct from [`MemoryError::Backend`], which is reserved for failures
69    /// of the underlying conversation store.
70    #[error("Memory internal error: {0}")]
71    Internal(String),
72}
73
74impl MemoryError {
75    /// Wrap an arbitrary error from a backend implementation.
76    pub fn backend<E>(source: E) -> Self
77    where
78        E: Into<MemoryBackendError>,
79    {
80        Self::Backend(source.into())
81    }
82}
83
84/// A persistent conversation history backend.
85///
86/// Implementors store an ordered list of [`Message`]s per `conversation_id`. Rig
87/// runtimes invoke [`ConversationMemory::load`] before sending a prompt and
88/// [`ConversationMemory::append`] after a successful turn.
89///
90/// Implementations should keep `append` cheap; it runs inline before the agent
91/// returns its response.
92pub trait ConversationMemory: WasmCompatSend + WasmCompatSync {
93    /// Load the full conversation history for `conversation_id`.
94    ///
95    /// Returns an empty `Vec` if the conversation has no stored messages.
96    fn load<'a>(
97        &'a self,
98        conversation_id: &'a str,
99    ) -> WasmBoxedFuture<'a, Result<Vec<Message>, MemoryError>>;
100
101    /// Append `messages` to the conversation identified by `conversation_id`.
102    ///
103    /// Called after a successful agent turn with the user prompt, the assistant
104    /// response, and any tool-call/tool-result pairs that occurred during the turn.
105    fn append<'a>(
106        &'a self,
107        conversation_id: &'a str,
108        messages: Vec<Message>,
109    ) -> WasmBoxedFuture<'a, Result<(), MemoryError>>;
110
111    /// Remove all stored messages for `conversation_id`.
112    fn clear<'a>(
113        &'a self,
114        conversation_id: &'a str,
115    ) -> WasmBoxedFuture<'a, Result<(), MemoryError>>;
116}
117
118// Forwarding impls so callers can pass smart pointers (`Arc<M>`, `Box<M>`,
119// including unsized trait objects) wherever a memory trait is expected. Each
120// arm forwards every method of one trait through `(**self)` for the listed
121// pointer types.
122macro_rules! forward_memory_trait {
123    (ConversationMemory: $($ptr:ident)+) => {$(
124        impl<M> ConversationMemory for $ptr<M>
125        where
126            M: ConversationMemory + ?Sized,
127        {
128            fn load<'a>(
129                &'a self,
130                conversation_id: &'a str,
131            ) -> WasmBoxedFuture<'a, Result<Vec<Message>, MemoryError>> {
132                (**self).load(conversation_id)
133            }
134
135            fn append<'a>(
136                &'a self,
137                conversation_id: &'a str,
138                messages: Vec<Message>,
139            ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
140                (**self).append(conversation_id, messages)
141            }
142
143            fn clear<'a>(
144                &'a self,
145                conversation_id: &'a str,
146            ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
147                (**self).clear(conversation_id)
148            }
149        }
150    )+};
151    (DemotionHook: $($ptr:ident)+) => {$(
152        impl<H> DemotionHook for $ptr<H>
153        where
154            H: DemotionHook + ?Sized,
155        {
156            fn on_demote<'a>(
157                &'a self,
158                conversation_id: &'a str,
159                messages: Vec<Message>,
160            ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
161                (**self).on_demote(conversation_id, messages)
162            }
163        }
164    )+};
165    (Compactor: $($ptr:ident)+) => {$(
166        impl<C> Compactor for $ptr<C>
167        where
168            C: Compactor + ?Sized,
169        {
170            type Artifact = C::Artifact;
171
172            fn compact<'a>(
173                &'a self,
174                conversation_id: &'a str,
175                evicted: &'a [Message],
176                carry_over: Option<&'a Self::Artifact>,
177            ) -> WasmBoxedFuture<'a, Result<Self::Artifact, MemoryError>> {
178                (**self).compact(conversation_id, evicted, carry_over)
179            }
180        }
181    )+};
182}
183
184forward_memory_trait!(ConversationMemory: Arc Box);
185
186/// A history-shaping closure applied during [`InMemoryConversationMemory::load`].
187///
188/// Implemented automatically for any closure with the right signature; the
189/// trait exists to combine `Fn` with the WASM-compatible `Send`/`Sync` markers
190/// in a single trait object.
191pub trait MessageFilter:
192    Fn(Vec<Message>) -> Vec<Message> + WasmCompatSend + WasmCompatSync
193{
194}
195
196impl<F> MessageFilter for F where
197    F: Fn(Vec<Message>) -> Vec<Message> + WasmCompatSend + WasmCompatSync
198{
199}
200
201/// A side-channel for messages that a memory policy or adapter removes from
202/// active history during [`ConversationMemory::load`].
203///
204/// Truncating policies (sliding window, token budget, …) drop older turns
205/// once their limit is exceeded. Without a hook those messages are silently
206/// lost. A [`DemotionHook`] receives the demoted messages and can persist
207/// them into a long-tail store (semantic memory, episodic recall, archival
208/// storage, …), turning truncation into demotion.
209///
210/// The trait is defined here in `rig-core` so that *any* memory backend
211/// (in-memory, vector store, file archive, …) can implement it without
212/// taking on a `rig-memory` dependency. The composing adapter that actually
213/// wires a [`ConversationMemory`] backend, a policy, and a hook together
214/// lives in the `rig-memory` companion crate.
215///
216/// Hooks should be inexpensive: their future is awaited inline on every
217/// `load` that produces demoted messages, so a slow hook delays the agent's
218/// next turn. Offload heavy I/O (network writes, disk fsyncs, …) to a
219/// background task or a buffered channel inside the implementation.
220///
221/// # Idempotency contract
222///
223/// Implementations **must** be idempotent on the
224/// `(conversation_id, messages)` pair. Composing adapters such as the
225/// `DemotingPolicyMemory` in `rig-memory` track in-process delivery
226/// watermarks to avoid replaying the same demotion within a single
227/// process lifetime, but those watermarks are not persisted: across
228/// process restarts (or when a new adapter is constructed over an
229/// existing backend) the hook will receive previously-delivered
230/// messages again. Hooks that append to durable storage should
231/// deduplicate by content hash, by `(conversation_id, message_id)`,
232/// or by an equivalent stable key.
233pub trait DemotionHook: WasmCompatSend + WasmCompatSync {
234    /// Receive `messages` that were demoted out of the active window for
235    /// `conversation_id`.
236    ///
237    /// `messages` are in original conversation order. Errors are propagated
238    /// as [`MemoryError::Backend`] by the composing adapter.
239    fn on_demote<'a>(
240        &'a self,
241        conversation_id: &'a str,
242        messages: Vec<Message>,
243    ) -> WasmBoxedFuture<'a, Result<(), MemoryError>>;
244}
245
246/// A [`DemotionHook`] that does nothing. Useful as a default when an adapter
247/// requires a hook value but the caller has no long-tail store wired up yet.
248#[derive(Debug, Default, Clone, Copy)]
249pub struct NoopDemotionHook;
250
251impl DemotionHook for NoopDemotionHook {
252    fn on_demote<'a>(
253        &'a self,
254        _conversation_id: &'a str,
255        _messages: Vec<Message>,
256    ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
257        Box::pin(async move { Ok(()) })
258    }
259}
260
261// Forwarding impl so callers can pass `Arc<H>` wherever a `DemotionHook`
262// is expected (e.g. when sharing a single hook between multiple memory
263// adapters).
264forward_memory_trait!(DemotionHook: Arc);
265
266/// Derives a single [`Message`]-shaped artifact from a slice of messages
267/// that a memory policy has evicted from the active window.
268///
269/// Where a [`DemotionHook`] is a one-way drain — observe what fell out and
270/// return `()` — a `Compactor` is the inverse: it takes the evicted prefix
271/// (and optionally the previous summary) and produces a derived artifact
272/// that the composing adapter splices *back into* the active history. The
273/// resulting prompt is no longer a verbatim suffix of the conversation; it
274/// is `[summary, ...recent_window]`.
275///
276/// Implementations typically wrap an LLM call (`LlmCompactor<M>`) or a
277/// pure template rollup. They run inline on the load path whenever the
278/// policy demotes new messages, so a slow compactor delays the agent's
279/// next turn — keep them fast or offload to a cached/background pipeline.
280///
281/// # Rolling summaries
282///
283/// `carry_over` is the artifact produced by the previous compaction for
284/// this conversation, if any. Implementations that want a *recursive*
285/// summary (the canonical pattern for long-running agents) should
286/// summarize `evicted` *together with* `carry_over` so context lost in
287/// earlier compactions is preserved transitively. Stateless implementations
288/// can ignore `carry_over` and produce a fresh summary of `evicted` alone.
289///
290/// # Idempotency contract
291///
292/// Composing adapters track per-conversation in-process delivery so the
293/// same `evicted` slice is not compacted twice within a process lifetime,
294/// but those watermarks are not persisted across restarts. Implementations
295/// that have side effects (writing summaries to a vector store, billing an
296/// LLM call) should deduplicate by conversation id and content hash, the
297/// same way [`DemotionHook`] implementations do.
298pub trait Compactor: WasmCompatSend + WasmCompatSync {
299    /// The summary value produced by [`Compactor::compact`].
300    ///
301    /// `Into<Message>` is required so the composing adapter can splice the
302    /// artifact at the front of the loaded history. `Clone` is required so
303    /// the adapter can keep a private copy as `carry_over` for the next
304    /// compaction.
305    type Artifact: Into<Message> + Clone + WasmCompatSend + WasmCompatSync + 'static;
306
307    /// Produce a summary artifact for `evicted`, optionally combining it
308    /// with the previous summary in `carry_over`.
309    ///
310    /// `evicted` is in original conversation order. Errors are propagated
311    /// unchanged by composing adapters; pick the [`MemoryError`] variant
312    /// that best describes the failure ([`MemoryError::Backend`] for I/O
313    /// or remote-LLM faults, [`MemoryError::Internal`] for invariant
314    /// breaks, and so on). The adapter does not re-wrap the returned
315    /// variant.
316    fn compact<'a>(
317        &'a self,
318        conversation_id: &'a str,
319        evicted: &'a [Message],
320        carry_over: Option<&'a Self::Artifact>,
321    ) -> WasmBoxedFuture<'a, Result<Self::Artifact, MemoryError>>;
322}
323
324// Forwarding impl so callers can pass `Arc<C>` wherever a `Compactor` is
325// expected (e.g. when sharing a single compactor across adapters).
326forward_memory_trait!(Compactor: Arc);
327
328/// A simple thread-safe in-memory [`ConversationMemory`] backed by a `HashMap`.
329///
330/// Messages are stored in process memory only and lost on restart. Useful for
331/// tests, examples, and short-lived agents. Pass a closure to
332/// [`InMemoryConversationMemory::with_filter`] to apply a history-shaping
333/// transformation on every load (truncation, summarization, re-ordering, etc.).
334/// Reusable named policies live in the `rig-memory` companion crate.
335#[derive(Clone, Default)]
336pub struct InMemoryConversationMemory {
337    inner: Arc<Mutex<HashMap<String, Vec<Message>>>>,
338    filter: Option<Arc<dyn MessageFilter>>,
339}
340
341impl InMemoryConversationMemory {
342    /// Create an empty in-memory store with no filter.
343    pub fn new() -> Self {
344        Self::default()
345    }
346
347    /// Apply `filter` to the loaded message list on every `load`.
348    ///
349    /// The filter runs after raw messages are read from the store and before
350    /// they are returned to the agent. Use it for truncation, summarization, or
351    /// any other shaping. For reusable named policies, depend on `rig-memory`.
352    pub fn with_filter<F>(mut self, filter: F) -> Self
353    where
354        F: MessageFilter + 'static,
355    {
356        self.filter = Some(Arc::new(filter));
357        self
358    }
359
360    fn lock(
361        &self,
362    ) -> Result<std::sync::MutexGuard<'_, HashMap<String, Vec<Message>>>, MemoryError> {
363        self.inner
364            .lock()
365            .map_err(|e| MemoryError::Internal(e.to_string()))
366    }
367}
368
369impl std::fmt::Debug for InMemoryConversationMemory {
370    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
371        f.debug_struct("InMemoryConversationMemory")
372            .field("filter", &self.filter.as_ref().map(|_| "<filter>"))
373            .finish()
374    }
375}
376
377impl ConversationMemory for InMemoryConversationMemory {
378    fn load<'a>(
379        &'a self,
380        conversation_id: &'a str,
381    ) -> WasmBoxedFuture<'a, Result<Vec<Message>, MemoryError>> {
382        Box::pin(async move {
383            let messages = {
384                let guard = self.lock()?;
385                guard.get(conversation_id).cloned().unwrap_or_default()
386            };
387            match &self.filter {
388                Some(filter) => Ok(filter(messages)),
389                None => Ok(messages),
390            }
391        })
392    }
393
394    fn append<'a>(
395        &'a self,
396        conversation_id: &'a str,
397        messages: Vec<Message>,
398    ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
399        Box::pin(async move {
400            let mut guard = self.lock()?;
401            guard
402                .entry(conversation_id.to_string())
403                .or_default()
404                .extend(messages);
405            Ok(())
406        })
407    }
408
409    fn clear<'a>(
410        &'a self,
411        conversation_id: &'a str,
412    ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
413        Box::pin(async move {
414            let mut guard = self.lock()?;
415            guard.remove(conversation_id);
416            Ok(())
417        })
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424    use crate::completion::Message;
425
426    fn user(text: &str) -> Message {
427        Message::user(text)
428    }
429
430    fn assistant(text: &str) -> Message {
431        Message::assistant(text)
432    }
433
434    #[tokio::test]
435    async fn round_trip() {
436        let mem = InMemoryConversationMemory::new();
437        assert!(mem.load("c1").await.unwrap().is_empty());
438
439        mem.append("c1", vec![user("hello"), assistant("hi")])
440            .await
441            .unwrap();
442
443        let loaded = mem.load("c1").await.unwrap();
444        assert_eq!(loaded.len(), 2);
445    }
446
447    #[tokio::test]
448    async fn isolation_between_conversations() {
449        let mem = InMemoryConversationMemory::new();
450        mem.append("a", vec![user("hi a")]).await.unwrap();
451        mem.append("b", vec![user("hi b")]).await.unwrap();
452
453        assert_eq!(mem.load("a").await.unwrap().len(), 1);
454        assert_eq!(mem.load("b").await.unwrap().len(), 1);
455    }
456
457    #[tokio::test]
458    async fn clear_removes_history() {
459        let mem = InMemoryConversationMemory::new();
460        mem.append("c", vec![user("x")]).await.unwrap();
461        mem.clear("c").await.unwrap();
462        assert!(mem.load("c").await.unwrap().is_empty());
463    }
464
465    #[tokio::test]
466    async fn with_filter_transforms_loaded_messages() {
467        let mem = InMemoryConversationMemory::new()
468            .with_filter(|msgs: Vec<Message>| msgs.into_iter().rev().take(2).collect());
469
470        mem.append(
471            "c",
472            vec![user("1"), assistant("2"), user("3"), assistant("4")],
473        )
474        .await
475        .unwrap();
476
477        let loaded = mem.load("c").await.unwrap();
478        assert_eq!(loaded.len(), 2, "filter should retain only 2 messages");
479    }
480
481    #[tokio::test]
482    async fn arc_conversation_memory_forwards_to_inner() {
483        let inner = Arc::new(InMemoryConversationMemory::new());
484        let mem: Arc<dyn ConversationMemory> = inner.clone();
485
486        mem.append("c", vec![user("hello")]).await.unwrap();
487
488        assert_eq!(inner.load("c").await.unwrap().len(), 1);
489        mem.clear("c").await.unwrap();
490        assert!(inner.load("c").await.unwrap().is_empty());
491    }
492
493    #[tokio::test]
494    async fn boxed_conversation_memory_forwards_to_inner() {
495        let mem: Box<dyn ConversationMemory> = Box::new(InMemoryConversationMemory::new());
496
497        mem.append("c", vec![user("hello")]).await.unwrap();
498
499        assert_eq!(mem.load("c").await.unwrap().len(), 1);
500        mem.clear("c").await.unwrap();
501        assert!(mem.load("c").await.unwrap().is_empty());
502    }
503}