Skip to main content

rig_core/
memory.rs

1//! Conversation history storage, filtering, and compaction interfaces.
2//!
3//! ```no_run
4//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
5//! use rig_core::{
6//!     completion::Message,
7//!     memory::{ConversationMemory, InMemoryConversationMemory},
8//! };
9//!
10//! let memory = InMemoryConversationMemory::new();
11//! memory
12//!     .append(
13//!         &"thread-1".into(),
14//!         vec![
15//!             Message::user("My name is Alice."),
16//!             Message::assistant("Hello, Alice!"),
17//!         ],
18//!     )
19//!     .await?;
20//! let history = memory.load(&"thread-1".into()).await?;
21//! assert_eq!(history.len(), 2);
22//! # Ok(()) }
23//! ```
24//!
25//! Truncation, summarization, and other history-shaping policies live in the
26//! `rig-memory` companion crate. To shape history inside the in-tree backend,
27//! pass a closure to [`InMemoryConversationMemory::with_filter`].
28
29use std::{
30    collections::HashMap,
31    sync::{Arc, Mutex},
32};
33
34use crate::id::ConversationId;
35use crate::{
36    completion::Message,
37    wasm_compat::{WasmBoxedFuture, WasmCompatSend, WasmCompatSync},
38};
39
40/// Boxed error source for memory backend failures.
41#[cfg(not(target_family = "wasm"))]
42pub type MemoryBackendError = Box<dyn std::error::Error + Send + Sync + 'static>;
43
44/// Boxed error source for memory backend failures.
45#[cfg(target_family = "wasm")]
46pub type MemoryBackendError = Box<dyn std::error::Error + 'static>;
47
48/// Errors produced by a [`ConversationMemory`] backend.
49#[derive(Debug, thiserror::Error)]
50pub enum MemoryError {
51    /// The backing store failed to load, append, or clear messages.
52    #[error("Memory backend error: {0}")]
53    Backend(#[source] MemoryBackendError),
54
55    /// A history-shaping filter or policy rejected the loaded history.
56    #[error("Memory policy error: {0}")]
57    Policy(String),
58
59    /// An internal invariant was violated (e.g. a poisoned in-process lock).
60    /// Distinct from [`MemoryError::Backend`], which is reserved for failures
61    /// of the underlying conversation store.
62    #[error("Memory internal error: {0}")]
63    Internal(String),
64}
65
66impl MemoryError {
67    /// Wrap an arbitrary error from a backend implementation.
68    pub fn backend<E>(source: E) -> Self
69    where
70        E: Into<MemoryBackendError>,
71    {
72        Self::Backend(source.into())
73    }
74}
75
76/// A persistent conversation history backend.
77///
78/// Implementors store an ordered list of [`Message`]s per `conversation_id`. Rig
79/// runtimes invoke [`ConversationMemory::load`] before sending a prompt and
80/// [`ConversationMemory::append`] after a successful run.
81///
82/// Appends run inline before the agent returns its response. Load failures
83/// prevent model calls; append failures are reported alongside the successful
84/// answer without retry. Writes are not transactional or exactly-once: an
85/// error may occur after the backend has persisted messages.
86pub trait ConversationMemory: WasmCompatSend + WasmCompatSync {
87    /// Load the full conversation history for `conversation_id`.
88    ///
89    /// Returns an empty `Vec` if the conversation has no stored messages.
90    fn load<'a>(
91        &'a self,
92        conversation_id: &'a ConversationId,
93    ) -> WasmBoxedFuture<'a, Result<Vec<Message>, MemoryError>>;
94
95    /// Append `messages` to the conversation identified by `conversation_id`.
96    ///
97    /// Called after a successful agent turn with the user prompt, the assistant
98    /// response, and any tool-call/tool-result pairs that occurred during the turn.
99    fn append<'a>(
100        &'a self,
101        conversation_id: &'a ConversationId,
102        messages: Vec<Message>,
103    ) -> WasmBoxedFuture<'a, Result<(), MemoryError>>;
104
105    /// Remove all stored messages for `conversation_id`.
106    fn clear<'a>(
107        &'a self,
108        conversation_id: &'a ConversationId,
109    ) -> WasmBoxedFuture<'a, Result<(), MemoryError>>;
110}
111
112macro_rules! forward_memory_trait {
113    (ConversationMemory: $($ptr:ident)+) => {$(
114        impl<M> ConversationMemory for $ptr<M>
115        where
116            M: ConversationMemory + ?Sized,
117        {
118            fn load<'a>(
119                &'a self,
120                conversation_id: &'a ConversationId,
121            ) -> WasmBoxedFuture<'a, Result<Vec<Message>, MemoryError>> {
122                (**self).load(conversation_id)
123            }
124
125            fn append<'a>(
126                &'a self,
127                conversation_id: &'a ConversationId,
128                messages: Vec<Message>,
129            ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
130                (**self).append(conversation_id, messages)
131            }
132
133            fn clear<'a>(
134                &'a self,
135                conversation_id: &'a ConversationId,
136            ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
137                (**self).clear(conversation_id)
138            }
139        }
140    )+};
141    (DemotionHook: $($ptr:ident)+) => {$(
142        impl<H> DemotionHook for $ptr<H>
143        where
144            H: DemotionHook + ?Sized,
145        {
146            fn on_demote<'a>(
147                &'a self,
148                conversation_id: &'a ConversationId,
149                messages: Vec<Message>,
150            ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
151                (**self).on_demote(conversation_id, messages)
152            }
153        }
154    )+};
155    (Compactor: $($ptr:ident)+) => {$(
156        impl<C> Compactor for $ptr<C>
157        where
158            C: Compactor + ?Sized,
159        {
160            type Artifact = C::Artifact;
161
162            fn compact<'a>(
163                &'a self,
164                conversation_id: &'a ConversationId,
165                evicted: &'a [Message],
166                carry_over: Option<&'a Self::Artifact>,
167            ) -> WasmBoxedFuture<'a, Result<Self::Artifact, MemoryError>> {
168                (**self).compact(conversation_id, evicted, carry_over)
169            }
170        }
171    )+};
172}
173
174forward_memory_trait!(ConversationMemory: Arc Box);
175
176/// A history-shaping closure applied during [`InMemoryConversationMemory::load`].
177///
178/// Implemented automatically for any closure with the right signature; the
179/// trait exists to combine `Fn` with the WASM-compatible `Send`/`Sync` markers
180/// in a single trait object.
181pub trait MessageFilter:
182    Fn(Vec<Message>) -> Vec<Message> + WasmCompatSend + WasmCompatSync
183{
184}
185
186impl<F> MessageFilter for F where
187    F: Fn(Vec<Message>) -> Vec<Message> + WasmCompatSend + WasmCompatSync
188{
189}
190
191/// Receives messages removed from active history during [`ConversationMemory::load`].
192/// Hooks are awaited inline, so their latency delays the next turn.
193///
194/// Implementations must be idempotent on `(conversation_id, messages)`.
195/// Adapter delivery watermarks are not persisted; restarts or newly constructed
196/// adapters may redeliver messages. Durable hooks should deduplicate with a
197/// stable key such as a conversation ID and content hash.
198pub trait DemotionHook: WasmCompatSend + WasmCompatSync {
199    /// Receive `messages` that were demoted out of the active window for
200    /// `conversation_id`.
201    ///
202    /// `messages` are in original conversation order. Errors are propagated
203    /// as [`MemoryError::Backend`] by the composing adapter.
204    fn on_demote<'a>(
205        &'a self,
206        conversation_id: &'a ConversationId,
207        messages: Vec<Message>,
208    ) -> WasmBoxedFuture<'a, Result<(), MemoryError>>;
209}
210
211/// A [`DemotionHook`] that does nothing. Useful as a default when an adapter
212/// requires a hook value but the caller has no long-tail store wired up yet.
213#[derive(Debug, Default, Clone, Copy)]
214pub struct NoopDemotionHook;
215
216impl DemotionHook for NoopDemotionHook {
217    fn on_demote<'a>(
218        &'a self,
219        _conversation_id: &'a ConversationId,
220        _messages: Vec<Message>,
221    ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
222        Box::pin(async move { Ok(()) })
223    }
224}
225
226forward_memory_trait!(DemotionHook: Arc);
227
228/// Derives an artifact from evicted messages for insertion before recent history.
229/// Compaction runs inline during loading and delays the next turn.
230///
231/// `carry_over` contains the previous artifact, if any. Combine it with the
232/// evicted messages to preserve earlier context in a rolling summary, or ignore
233/// it to summarize only the newly evicted messages.
234///
235/// Delivery watermarks are not persisted across restarts. Implementations with
236/// side effects should deduplicate by conversation ID and content hash.
237pub trait Compactor: WasmCompatSend + WasmCompatSync {
238    /// Summary convertible to a history message and clonable for the next
239    /// compaction's `carry_over`.
240    type Artifact: Into<Message> + Clone + WasmCompatSend + WasmCompatSync + 'static;
241
242    /// Produce a summary artifact for `evicted`, optionally combining it
243    /// with the previous summary in `carry_over`.
244    ///
245    /// `evicted` is in original conversation order. Errors are propagated
246    /// unchanged by composing adapters; pick the [`MemoryError`] variant
247    /// that best describes the failure ([`MemoryError::Backend`] for I/O
248    /// or remote-LLM faults, [`MemoryError::Internal`] for invariant
249    /// breaks, and so on). The adapter does not re-wrap the returned
250    /// variant.
251    fn compact<'a>(
252        &'a self,
253        conversation_id: &'a ConversationId,
254        evicted: &'a [Message],
255        carry_over: Option<&'a Self::Artifact>,
256    ) -> WasmBoxedFuture<'a, Result<Self::Artifact, MemoryError>>;
257}
258
259forward_memory_trait!(Compactor: Arc);
260
261/// A simple thread-safe in-memory [`ConversationMemory`] backed by a `HashMap`.
262///
263/// Messages are stored in process memory only and lost on restart. Useful for
264/// tests, examples, and short-lived agents. Pass a closure to
265/// [`InMemoryConversationMemory::with_filter`] to apply a history-shaping
266/// transformation on every load (truncation, summarization, re-ordering, etc.).
267/// Reusable named policies live in the `rig-memory` companion crate.
268#[derive(Clone, Default)]
269pub struct InMemoryConversationMemory {
270    inner: Arc<Mutex<HashMap<ConversationId, Vec<Message>>>>,
271    filter: Option<Arc<dyn MessageFilter>>,
272}
273
274impl InMemoryConversationMemory {
275    /// Create an empty in-memory store with no filter.
276    pub fn new() -> Self {
277        Self::default()
278    }
279
280    /// Replaces the filter applied to each loaded history after releasing the
281    /// store lock. Filtering does not modify stored messages.
282    pub fn with_filter<F>(mut self, filter: F) -> Self
283    where
284        F: MessageFilter + 'static,
285    {
286        self.filter = Some(Arc::new(filter));
287        self
288    }
289
290    fn lock(
291        &self,
292    ) -> Result<std::sync::MutexGuard<'_, HashMap<ConversationId, Vec<Message>>>, MemoryError> {
293        self.inner
294            .lock()
295            .map_err(|e| MemoryError::Internal(e.to_string()))
296    }
297}
298
299impl std::fmt::Debug for InMemoryConversationMemory {
300    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
301        f.debug_struct("InMemoryConversationMemory")
302            .field("filter", &self.filter.as_ref().map(|_| "<filter>"))
303            .finish()
304    }
305}
306
307impl ConversationMemory for InMemoryConversationMemory {
308    fn load<'a>(
309        &'a self,
310        conversation_id: &'a ConversationId,
311    ) -> WasmBoxedFuture<'a, Result<Vec<Message>, MemoryError>> {
312        Box::pin(async move {
313            let messages = {
314                let guard = self.lock()?;
315                guard.get(conversation_id).cloned().unwrap_or_default()
316            };
317            match &self.filter {
318                Some(filter) => Ok(filter(messages)),
319                None => Ok(messages),
320            }
321        })
322    }
323
324    fn append<'a>(
325        &'a self,
326        conversation_id: &'a ConversationId,
327        messages: Vec<Message>,
328    ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
329        Box::pin(async move {
330            let mut guard = self.lock()?;
331            guard
332                .entry(conversation_id.clone())
333                .or_default()
334                .extend(messages);
335            Ok(())
336        })
337    }
338
339    fn clear<'a>(
340        &'a self,
341        conversation_id: &'a ConversationId,
342    ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
343        Box::pin(async move {
344            let mut guard = self.lock()?;
345            guard.remove(conversation_id);
346            Ok(())
347        })
348    }
349}
350
351#[cfg(test)]
352mod tests;