Files
agcore/src/memory/conversation.rs
T
徐涛 6e1182e64c feat(core): 14 个公开枚举标记 #[non_exhaustive]
为 v0.2.0-rc.1 API 稳定性收尾。覆盖:
- P0 核心 IR: Message / ContentBlock / ContentBlockType / StreamEvent / HookEvent
- P0 Error: AgentError / LlmError / ToolError / MemoryError / PromptError
- P1 其他: MemoryStrategy / StepStatus / ToolChoice / ResponseFormat

明确不加的: 内部 wire-format (OpenaiChatMessage 等) /
语义已收敛 (Role/ServiceTier 等) / 使用面窄 (Permission 等)。

示例侧的 3 处 Message exhaustive match (prompt_composer /
conversation_memory_demo) 补全 `_` 通配分支,零行为变化。

验收: cargo build + clippy -D warnings + test --all-targets 全绿 (200 passed)
2026-07-05 19:47:03 +08:00

280 lines
9.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 对话记忆 —— 多轮对话消息管理,复用 `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<CompactConfig>,
}
impl Default for ConversationMemoryConfig {
fn default() -> Self {
Self {
strategy: MemoryStrategy::SlidingWindow,
max_turns: 50,
compact_config: Some(CompactConfig::default()),
}
}
}
/// 对话记忆 —— 按 session 管理多轮对话消息历史。
///
/// 内部维护 `Vec<Message>`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<dyn MemoryStore>,
session_id: String,
config: ConversationMemoryConfig,
/// 热缓存:消息列表,供 `llm::compact` 直接操作。
messages: Vec<Message>,
/// 与 `messages` 一一对应的存储 ID。
message_ids: Vec<String>,
/// 压缩断路器状态。
compact_state: CompactState,
}
impl ConversationMemory {
pub fn new(
store: Arc<dyn MemoryStore>,
session_id: impl Into<String>,
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::<Message>(&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<dyn MemoryStore>;
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<dyn MemoryStore>;
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<dyn MemoryStore>;
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<dyn MemoryStore>;
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<dyn MemoryStore>;
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());
}
}