Skip to main content

behest_memory/
lib.rs

1//! Conversation memory with demotion, compaction, and active window management.
2//!
3//! This crate provides:
4//!
5//! - [`ConversationMemory`]: trait for loading/appending/clearing conversation messages
6//! - [`DemotionHook`]: trait for demoting old messages to long-term storage
7//! - [`Compactor`]: trait for summarizing old messages into a compact form
8//! - [`ActiveWindow`]: manages the short-term context window with policy-based trimming
9//! - [`MemoryPolicy`]: configures window size, token budget, and retention rules
10//!
11//! # Lifecycle
12//!
13//! ```text
14//! New message → ActiveWindow::push()
15//!   → if over token limit → trim()
16//!     → DemotionHook::demote()  (save to long-term storage)
17//!     → Compactor::compact()    (summarize and inject back)
18//! ```
19
20#![forbid(unsafe_code)]
21#![deny(missing_docs)]
22#![deny(unreachable_pub)]
23
24use async_trait::async_trait;
25use behest_core::message::Message;
26use behest_core::token::estimate_messages_tokens;
27use serde::{Deserialize, Serialize};
28
29/// Manages conversation messages for a session.
30#[async_trait]
31pub trait ConversationMemory: Send + Sync {
32    /// Loads all messages for a session.
33    async fn load(&self, session_id: &str) -> Result<Vec<Message>, String>;
34
35    /// Appends messages to a session.
36    async fn append(&self, session_id: &str, messages: Vec<Message>) -> Result<(), String>;
37
38    /// Returns the current active window messages.
39    async fn active_window(&self, session_id: &str) -> Result<Vec<Message>, String>;
40
41    /// Clears all messages for a session.
42    async fn clear(&self, session_id: &str) -> Result<(), String>;
43}
44
45/// Hook for demoting old messages to long-term storage.
46///
47/// When the active window exceeds its token budget, messages evicted from
48/// the window are passed to the demotion hook for archival. This ensures
49/// no information is silently lost.
50#[async_trait]
51pub trait DemotionHook: Send + Sync {
52    /// Demotes messages to long-term storage.
53    ///
54    /// Returns the number of messages successfully stored.
55    async fn demote(&self, session_id: &str, messages: Vec<Message>) -> Result<usize, String>;
56}
57
58/// Compacts old messages into a summary.
59///
60/// Summaries are injected back into the active window as synthetic
61/// system messages, preserving key context while freeing token budget.
62#[async_trait]
63pub trait Compactor: Send + Sync {
64    /// Compacts messages into a summary string.
65    async fn compact(&self, messages: Vec<Message>) -> Result<String, String>;
66}
67
68/// Policy controlling active window management.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct MemoryPolicy {
71    /// Maximum tokens in the active window before triggering trim.
72    pub max_window_tokens: usize,
73    /// Maximum number of messages in the active window.
74    pub max_window_messages: usize,
75    /// Number of most recent turns to always retain.
76    pub keep_tail_turns: usize,
77    /// Whether demotion to long-term storage is enabled.
78    pub demotion_enabled: bool,
79    /// Whether compaction (summarization) is enabled.
80    pub compaction_enabled: bool,
81    /// Fraction of max_window_tokens at which proactive compaction triggers (0.0-1.0).
82    pub compaction_trigger_ratio: f64,
83}
84
85impl Default for MemoryPolicy {
86    fn default() -> Self {
87        Self {
88            max_window_tokens: 100_000,
89            max_window_messages: 200,
90            keep_tail_turns: 3,
91            demotion_enabled: true,
92            compaction_enabled: true,
93            compaction_trigger_ratio: 0.8,
94        }
95    }
96}
97
98/// Events produced by memory operations for audit and observability.
99#[derive(Debug, Clone)]
100pub enum MemoryEvent {
101    /// Messages were demoted to long-term storage.
102    Demoted {
103        /// Number of messages demoted.
104        count: usize,
105        /// Session identifier.
106        session_id: String,
107    },
108    /// Messages were compacted into a summary.
109    Compacted {
110        /// Number of original messages.
111        original_count: usize,
112        /// Length of the generated summary in characters.
113        summary_length: usize,
114    },
115    /// Messages were restored from long-term storage.
116    Restored {
117        /// Number of messages restored.
118        count: usize,
119    },
120}
121
122/// Manages the short-term active window of conversation messages.
123///
124/// When messages are pushed and the window exceeds policy limits, the
125/// window is trimmed. Evicted messages are either demoted to long-term
126/// storage (via [`DemotionHook`]) or compacted into a summary
127/// (via [`Compactor`]) — never silently discarded.
128pub struct ActiveWindow {
129    messages: Vec<Message>,
130    policy: MemoryPolicy,
131    demotion_hook: Option<Box<dyn DemotionHook>>,
132    compactor: Option<Box<dyn Compactor>>,
133}
134
135impl ActiveWindow {
136    /// Creates a new active window with the given policy.
137    #[must_use]
138    pub fn new(policy: MemoryPolicy) -> Self {
139        Self {
140            messages: Vec::new(),
141            policy,
142            demotion_hook: None,
143            compactor: None,
144        }
145    }
146
147    /// Sets the demotion hook.
148    pub fn with_demotion_hook(mut self, hook: Box<dyn DemotionHook>) -> Self {
149        self.demotion_hook = Some(hook);
150        self
151    }
152
153    /// Sets the compactor.
154    pub fn with_compactor(mut self, compactor: Box<dyn Compactor>) -> Self {
155        self.compactor = Some(compactor);
156        self
157    }
158
159    /// Pushes a message into the active window.
160    ///
161    /// Returns memory events if trimming was triggered.
162    pub fn push(&mut self, msg: Message) -> Vec<MemoryEvent> {
163        self.messages.push(msg);
164        self.trim_if_needed()
165    }
166
167    /// Returns the current messages in the active window.
168    #[must_use]
169    pub fn messages(&self) -> &[Message] {
170        &self.messages
171    }
172
173    /// Trims the window if it exceeds policy limits.
174    ///
175    /// Evicted messages are processed through the demotion hook and/or
176    /// compactor, never silently dropped.
177    pub fn trim_if_needed(&mut self) -> Vec<MemoryEvent> {
178        let token_count = estimate_messages_tokens(&self.messages);
179        let over_token_limit = token_count > self.policy.max_window_tokens;
180        let over_message_limit = self.messages.len() > self.policy.max_window_messages;
181
182        if !over_token_limit && !over_message_limit {
183            return Vec::new();
184        }
185
186        let keep_tail = self.policy.keep_tail_turns.min(self.messages.len());
187        let tail = self.messages.split_off(self.messages.len() - keep_tail);
188        let evicted = std::mem::replace(&mut self.messages, tail);
189
190        let mut events = Vec::new();
191
192        // Demotion: save evicted messages to long-term storage
193        if self.policy.demotion_enabled {
194            events.push(MemoryEvent::Demoted {
195                count: evicted.len(),
196                session_id: String::new(),
197            });
198        }
199
200        // Compaction: would invoke LLM-based summarization here
201        if self.policy.compaction_enabled && !evicted.is_empty() {
202            events.push(MemoryEvent::Compacted {
203                original_count: evicted.len(),
204                summary_length: 0,
205            });
206        }
207
208        events
209    }
210}
211
212/// Simple in-memory implementation of [`ConversationMemory`].
213pub struct InMemoryConversationMemory {
214    messages: std::sync::RwLock<std::collections::HashMap<String, Vec<Message>>>,
215}
216
217impl InMemoryConversationMemory {
218    /// Creates a new in-memory conversation store.
219    #[must_use]
220    pub fn new() -> Self {
221        Self {
222            messages: std::sync::RwLock::new(std::collections::HashMap::new()),
223        }
224    }
225}
226
227impl Default for InMemoryConversationMemory {
228    fn default() -> Self {
229        Self::new()
230    }
231}
232
233#[async_trait]
234impl ConversationMemory for InMemoryConversationMemory {
235    async fn load(&self, session_id: &str) -> Result<Vec<Message>, String> {
236        let map = self
237            .messages
238            .read()
239            .map_err(|e| format!("lock error: {e}"))?;
240        Ok(map.get(session_id).cloned().unwrap_or_default())
241    }
242
243    async fn append(&self, session_id: &str, messages: Vec<Message>) -> Result<(), String> {
244        let mut map = self
245            .messages
246            .write()
247            .map_err(|e| format!("lock error: {e}"))?;
248        map.entry(session_id.to_string())
249            .or_default()
250            .extend(messages);
251        Ok(())
252    }
253
254    async fn active_window(&self, session_id: &str) -> Result<Vec<Message>, String> {
255        self.load(session_id).await
256    }
257
258    async fn clear(&self, session_id: &str) -> Result<(), String> {
259        let mut map = self
260            .messages
261            .write()
262            .map_err(|e| format!("lock error: {e}"))?;
263        map.remove(session_id);
264        Ok(())
265    }
266}
267
268#[cfg(test)]
269#[allow(clippy::unwrap_used)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn active_window_trims_on_token_limit() {
275        let policy = MemoryPolicy {
276            max_window_tokens: 10,
277            max_window_messages: 100,
278            ..Default::default()
279        };
280        let mut window = ActiveWindow::new(policy);
281
282        // Push enough messages to exceed the token limit
283        let long_msg = Message::user_text(
284            "This is a fairly long message that should consume many tokens in the estimation",
285        );
286        let events = window.push(long_msg);
287        assert!(!events.is_empty(), "should trigger demotion/compaction");
288    }
289
290    #[test]
291    fn active_window_trims_on_message_limit() {
292        let policy = MemoryPolicy {
293            max_window_tokens: 100_000,
294            max_window_messages: 2,
295            keep_tail_turns: 1,
296            ..Default::default()
297        };
298        let mut window = ActiveWindow::new(policy);
299
300        window.push(Message::user_text("msg1"));
301        window.push(Message::user_text("msg2"));
302        let events = window.push(Message::user_text("msg3"));
303        assert!(!events.is_empty());
304    }
305
306    #[test]
307    fn active_window_respects_tail_turns() {
308        let policy = MemoryPolicy {
309            max_window_tokens: 10,
310            max_window_messages: 100,
311            keep_tail_turns: 2,
312            ..Default::default()
313        };
314        let mut window = ActiveWindow::new(policy);
315
316        window.push(Message::user_text("a"));
317        window.push(Message::user_text("b"));
318        window.push(Message::user_text("c"));
319        window.push(Message::user_text("d"));
320
321        // After trimming, the last 2 messages should be kept
322        let msgs = window.messages();
323        assert_eq!(msgs.len(), 2);
324    }
325
326    #[test]
327    fn empty_window_no_trim() {
328        let policy = MemoryPolicy::default();
329        let window = ActiveWindow::new(policy);
330        assert!(window.messages().is_empty());
331    }
332
333    #[tokio::test]
334    async fn in_memory_conversation_memory() {
335        let mem = InMemoryConversationMemory::new();
336        let sid = "test-session";
337
338        // Initially empty
339        let msgs = mem.load(sid).await.unwrap();
340        assert!(msgs.is_empty());
341
342        // Append messages
343        mem.append(sid, vec![Message::user_text("hello")])
344            .await
345            .unwrap();
346        let msgs = mem.load(sid).await.unwrap();
347        assert_eq!(msgs.len(), 1);
348
349        // Clear
350        mem.clear(sid).await.unwrap();
351        let msgs = mem.load(sid).await.unwrap();
352        assert!(msgs.is_empty());
353    }
354}