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}