1#![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#[async_trait]
31pub trait ConversationMemory: Send + Sync {
32 async fn load(&self, session_id: &str) -> Result<Vec<Message>, String>;
34
35 async fn append(&self, session_id: &str, messages: Vec<Message>) -> Result<(), String>;
37
38 async fn active_window(&self, session_id: &str) -> Result<Vec<Message>, String>;
40
41 async fn clear(&self, session_id: &str) -> Result<(), String>;
43}
44
45#[async_trait]
51pub trait DemotionHook: Send + Sync {
52 async fn demote(&self, session_id: &str, messages: Vec<Message>) -> Result<usize, String>;
56}
57
58#[async_trait]
63pub trait Compactor: Send + Sync {
64 async fn compact(&self, messages: Vec<Message>) -> Result<String, String>;
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct MemoryPolicy {
71 pub max_window_tokens: usize,
73 pub max_window_messages: usize,
75 pub keep_tail_turns: usize,
77 pub demotion_enabled: bool,
79 pub compaction_enabled: bool,
81 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#[derive(Debug, Clone)]
100pub enum MemoryEvent {
101 Demoted {
103 count: usize,
105 session_id: String,
107 },
108 Compacted {
110 original_count: usize,
112 summary_length: usize,
114 },
115 Restored {
117 count: usize,
119 },
120}
121
122pub 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 #[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 pub fn with_demotion_hook(mut self, hook: Box<dyn DemotionHook>) -> Self {
149 self.demotion_hook = Some(hook);
150 self
151 }
152
153 pub fn with_compactor(mut self, compactor: Box<dyn Compactor>) -> Self {
155 self.compactor = Some(compactor);
156 self
157 }
158
159 pub fn push(&mut self, msg: Message) -> Vec<MemoryEvent> {
163 self.messages.push(msg);
164 self.trim_if_needed()
165 }
166
167 #[must_use]
169 pub fn messages(&self) -> &[Message] {
170 &self.messages
171 }
172
173 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 if self.policy.demotion_enabled {
194 events.push(MemoryEvent::Demoted {
195 count: evicted.len(),
196 session_id: String::new(),
197 });
198 }
199
200 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
212pub struct InMemoryConversationMemory {
214 messages: std::sync::RwLock<std::collections::HashMap<String, Vec<Message>>>,
215}
216
217impl InMemoryConversationMemory {
218 #[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 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 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 let msgs = mem.load(sid).await.unwrap();
340 assert!(msgs.is_empty());
341
342 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 mem.clear(sid).await.unwrap();
351 let msgs = mem.load(sid).await.unwrap();
352 assert!(msgs.is_empty());
353 }
354}