feat(agent): 实现 Phase 4c 会话级记忆功能

- 新增 `SessionMemory` 结构体,基于 `MemoryStore` 按 namespace 隔离键值数据
- `AgentBuilder` 增加 `session_memory_backend` 配置入口
- `RuntimeBundle` 透传 `session_memory_backend` 字段
- `AgentSession` 将内联 `HashMap` 替换为完整的 `SessionMemory`,`set_session_data` 和 `get_session_data` 改为异步方法
- 新增 3 个内联测试,全量测试从 113 增至 116,clippy 0 警告
This commit is contained in:
徐涛
2026-06-11 22:14:15 +08:00
parent 4de7db0b2c
commit ce1f1aaca0
6 changed files with 257 additions and 30 deletions
+35 -21
View File
@@ -7,22 +7,23 @@
//! - **不做业务循环**:多轮策略、错误重试、记忆回写由上层应用或具体 `TaskAgent` 决定
//! - **不持有 ConversationMemory**:上层可独立 new 一个 `ConversationMemory`,在合适的时机调 `add_message`
use std::collections::HashMap;
use std::sync::Arc;
use crate::agent::agent::Agent;
use crate::agent::error::AgentError;
use crate::agent::runtime::RuntimeBundle;
use crate::agent::session_memory::SessionMemory;
use crate::llm::cycle::{CostTracker, CycleConfig, LlmCycle};
use crate::llm::hooks::{HookContext, HookEvent};
use crate::llm::types::ChatResponse;
use crate::memory::store::InMemoryStore;
/// Agent 会话实例。
///
/// 同一 `Agent` 可被多个 `AgentSession` 复用(不同 session_id 互不干扰)。
/// `submit_turn` 一次只跑一轮 LLM 调用(含自动 tool 循环)。
///
/// **不实现 `Clone`**session 持有累计 `turn_index` / `cost_so_far` / `session_data`
/// **不实现 `Clone`**session 持有累计 `turn_index` / `cost_so_far` / `session_memory`
/// 共享这些状态需要显式 sync 语义;如果上层需要并发访问,自己用 `Arc<Mutex<_>>` 包装。
pub struct AgentSession {
/// 会话 ID(由调用方指定,用于日志/追踪/记忆关联)。
@@ -32,8 +33,8 @@ pub struct AgentSession {
bundle: Arc<RuntimeBundle>,
turn_index: u32,
cost_so_far: CostTracker,
/// 会话级键值数据Phase 4a 用内联 HashMapPhase 4c 替换为 `SessionMemory`)。
session_data: HashMap<String, String>,
/// 会话级记忆Phase 4c 替换内联 HashMap)。
pub session_memory: SessionMemory,
}
impl std::fmt::Debug for AgentSession {
@@ -43,7 +44,7 @@ impl std::fmt::Debug for AgentSession {
.field("agent", &self.agent.name())
.field("turn_index", &self.turn_index)
.field("cost_so_far", &self.cost_so_far.total())
.field("session_data_keys", &self.session_data.keys().collect::<Vec<_>>())
.field("session_memory", &"<SessionMemory>")
.finish()
}
}
@@ -57,13 +58,19 @@ impl AgentSession {
session_id: impl Into<String>,
bundle: Arc<RuntimeBundle>,
) -> Self {
let session_id_str = session_id.into();
let backend = bundle
.session_memory_backend
.clone()
.unwrap_or_else(|| Arc::new(InMemoryStore::new()));
let session_memory = SessionMemory::new(backend, &session_id_str);
Self {
session_id: session_id.into(),
session_id: session_id_str,
agent,
bundle,
turn_index: 0,
cost_so_far: CostTracker::default(),
session_data: HashMap::new(),
session_memory,
}
}
@@ -77,19 +84,23 @@ impl AgentSession {
&self.cost_so_far
}
/// 会话级数据快照引用。
pub fn session_data(&self) -> &HashMap<String, String> {
&self.session_data
/// 会话级记忆引用。
pub fn session_memory(&self) -> &SessionMemory {
&self.session_memory
}
/// 写入一条会话级数据(覆盖同名 key)。
pub fn set_session_data(&mut self, key: impl Into<String>, value: impl Into<String>) {
self.session_data.insert(key.into(), value.into());
pub async fn set_session_data(
&mut self,
key: impl Into<String>,
value: impl Into<String>,
) -> Result<(), AgentError> {
self.session_memory.set(&key.into(), &value.into()).await
}
/// 读取一条会话级数据。
pub fn get_session_data(&self, key: &str) -> Option<&str> {
self.session_data.get(key).map(String::as_str)
pub async fn get_session_data(&self, key: &str) -> Result<Option<String>, AgentError> {
self.session_memory.get(key).await
}
/// 提交一轮对话(含自动 tool 循环),返回 LLM 响应。
@@ -273,8 +284,8 @@ mod tests {
}
/// 烟雾测试 2session_data 读写。
#[test]
fn session_data_set_get() {
#[tokio::test]
async fn session_data_set_get() {
let provider = Arc::new(MockProvider::new(vec![]));
let agent = Arc::new(StubAgent {
name: "stub".into(),
@@ -290,12 +301,15 @@ mod tests {
);
let mut session = AgentSession::new(agent, "s2", bundle);
assert!(session.get_session_data("k").is_none());
session.set_session_data("k", "v");
assert_eq!(session.get_session_data("k"), Some("v"));
assert!(session.get_session_data("k").await.unwrap().is_none());
session.set_session_data("k", "v").await.unwrap();
assert_eq!(session.get_session_data("k").await.unwrap(), Some("v".into()));
// 覆盖写
session.set_session_data("k", "v2");
assert_eq!(session.get_session_data("k"), Some("v2"));
session.set_session_data("k", "v2").await.unwrap();
assert_eq!(
session.get_session_data("k").await.unwrap(),
Some("v2".into())
);
}
/// 烟雾测试 3submit_turn 触发 OnTurnStart / OnTurnEnd hook。