//! 对话记忆 —— 多轮对话消息管理,复用 `llm::compact` 的压缩逻辑。 use std::sync::Arc; use time::OffsetDateTime; use crate::llm::compact::{CompactConfig, CompactState, microcompact, should_compact}; use crate::llm::types::message::Message; use crate::memory::error::MemoryError; use crate::memory::store::MemoryStore; use crate::memory::types::MemoryItem; /// 对话消息管理策略。 #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[non_exhaustive] pub enum MemoryStrategy { /// 滑动窗口:达到上限时删除最旧消息。 SlidingWindow, /// 保留所有消息(仅压缩,不删除)。 Full, } /// 对话记忆配置。 #[derive(Debug, Clone)] pub struct ConversationMemoryConfig { pub strategy: MemoryStrategy, pub max_turns: usize, pub compact_config: Option, } impl Default for ConversationMemoryConfig { fn default() -> Self { Self { strategy: MemoryStrategy::SlidingWindow, max_turns: 50, compact_config: Some(CompactConfig::default()), } } } /// 对话记忆 —— 按 session 管理多轮对话消息历史。 /// /// 内部维护 `Vec`(Phase 2 IR 类型)作为热缓存, /// `MemoryStore` 用作冷持久化层。 /// /// ponytail: Phase 2 切换消息存储从 `OpenaiChatMessage` 到 `Message`。 /// `Message` 已实现 `Serialize` / `Deserialize`(Phase 0 FIX-B 引入), /// 序列化格式采用 `#[serde(tag = "type", rename_all = "snake_case")]`, /// 例如: /// ```json /// {"type": "user", "content": [{"type": "text", "text": "hi"}]} /// {"type": "tool_result", "tool_call_id": "c1", "content": [...], "is_error": false} /// ``` pub struct ConversationMemory { store: Arc, session_id: String, config: ConversationMemoryConfig, /// 热缓存:消息列表,供 `llm::compact` 直接操作。 messages: Vec, /// 与 `messages` 一一对应的存储 ID。 message_ids: Vec, /// 压缩断路器状态。 compact_state: CompactState, } impl ConversationMemory { pub fn new( store: Arc, session_id: impl Into, config: ConversationMemoryConfig, ) -> Self { Self { store, session_id: session_id.into(), config, messages: Vec::new(), message_ids: Vec::new(), compact_state: CompactState::new(), } } pub fn session_id(&self) -> &str { &self.session_id } pub fn config(&self) -> &ConversationMemoryConfig { &self.config } pub async fn load(&mut self) -> Result<(), MemoryError> { let filter = crate::memory::types::MemoryFilter { prefix: Some(self.session_prefix()), ..Default::default() }; let items = self.store.list(&filter).await?; let mut pairs: Vec<(String, Message, OffsetDateTime)> = Vec::with_capacity(items.len()); for item in items { match serde_json::from_str::(&item.content) { Ok(msg) => pairs.push((item.id, msg, item.created_at)), Err(e) => { return Err(MemoryError::Serialization(format!( "load message {} failed: {e}", item.id ))); } } } pairs.sort_by_key(|p| p.2); self.message_ids = pairs.iter().map(|p| p.0.clone()).collect(); self.messages = pairs.into_iter().map(|p| p.1).collect(); Ok(()) } pub async fn add_message(&mut self, msg: Message) -> Result<(), MemoryError> { let now = OffsetDateTime::now_utc(); let index = self.messages.len(); let id = self.make_message_id(index, &now); // 写入热缓存 self.messages.push(msg); self.message_ids.push(id.clone()); // ponytail: 通过 `Message` 的 Serialize 派生实现持久化 let item = MemoryItem { id: id.clone(), content: serde_json::to_string(self.messages.last().unwrap()) .map_err(|e| MemoryError::Serialization(e.to_string()))?, metadata: serde_json::json!({ "session_id": &self.session_id, "index": index }), created_at: now, }; self.store.save(item).await?; self.maybe_evict_and_compact().await; Ok(()) } pub fn get_history(&self) -> &[Message] { &self.messages } pub async fn clear(&mut self) -> Result<(), MemoryError> { let to_delete = std::mem::take(&mut self.message_ids); self.messages.clear(); self.compact_state = CompactState::new(); for id in to_delete { self.store.delete(&id).await?; } Ok(()) } pub fn len(&self) -> usize { self.messages.len() } pub fn is_empty(&self) -> bool { self.messages.is_empty() } fn session_prefix(&self) -> String { format!("conv:{}:", self.session_id) } fn make_message_id(&self, index: usize, now: &OffsetDateTime) -> String { format!( "{}{:010}_{}", self.session_prefix(), index, now.unix_timestamp_nanos() ) } async fn maybe_evict_and_compact(&mut self) { if self.config.strategy == MemoryStrategy::SlidingWindow { while self.messages.len() > self.config.max_turns { if let Some(removed_id) = self.message_ids.first().cloned() { let _ = self.store.delete(&removed_id).await; } self.messages.remove(0); self.message_ids.remove(0); } } if let Some(ref compact_config) = self.config.compact_config && should_compact(&self.messages, compact_config, &self.compact_state) { let keep_recent = compact_config.keep_recent; let freed = microcompact(&mut self.messages, keep_recent); if freed > 0 { self.compact_state.record_success(); } else { let _ = self.compact_state.record_failure(); } } } } #[cfg(test)] mod tests { use super::*; use crate::memory::InMemoryStore; use crate::memory::MemoryStore; #[tokio::test] async fn add_and_get_history() { let store = Arc::new(InMemoryStore::new()) as Arc; let mut conv = ConversationMemory::new(store, "session1", ConversationMemoryConfig::default()); conv.add_message(Message::user_text("hello")).await.unwrap(); conv.add_message(Message::user_text("world")).await.unwrap(); assert_eq!(conv.len(), 2); assert_eq!(conv.get_history().len(), 2); } /// 验证 Message → JSON → Message 往返(包含 ToolResult 等完整信息)。 #[tokio::test] async fn json_roundtrip_preserves_tool_result() { let store = Arc::new(InMemoryStore::new()) as Arc; let mut conv = ConversationMemory::new(store, "s1", ConversationMemoryConfig::default()); conv.add_message(Message::tool_result("call_1", "ok", false)) .await .unwrap(); conv.add_message(Message::assistant("done")).await.unwrap(); let original = conv.get_history().to_vec(); assert_eq!(original.len(), 2); // 各变体可序列化 + 反序列化 for msg in &original { let json = serde_json::to_string(msg).unwrap(); let decoded: Message = serde_json::from_str(&json).unwrap(); assert_eq!(format!("{:?}", decoded), format!("{:?}", msg)); } } #[tokio::test] async fn sliding_window_evicts_oldest() { let store = Arc::new(InMemoryStore::new()) as Arc; let config = ConversationMemoryConfig { strategy: MemoryStrategy::SlidingWindow, max_turns: 3, compact_config: None, }; let mut conv = ConversationMemory::new(store, "s1", config); for i in 0..5 { conv.add_message(Message::user_text(format!("msg-{i}"))) .await .unwrap(); } assert_eq!(conv.len(), 3); } #[tokio::test] async fn full_strategy_no_evict() { let store = Arc::new(InMemoryStore::new()) as Arc; let config = ConversationMemoryConfig { strategy: MemoryStrategy::Full, max_turns: 3, compact_config: None, }; let mut conv = ConversationMemory::new(store, "s1", config); for i in 0..5 { conv.add_message(Message::user_text(format!("msg-{i}"))) .await .unwrap(); } assert_eq!(conv.len(), 5); } #[tokio::test] async fn clear_empties_messages() { let store = Arc::new(InMemoryStore::new()) as Arc; let mut conv = ConversationMemory::new(store.clone(), "s1", ConversationMemoryConfig::default()); conv.add_message(Message::user_text("hello")).await.unwrap(); assert!(!conv.is_empty()); conv.clear().await.unwrap(); assert!(conv.is_empty()); } }