Files
agcore/src/agent/session.rs
T
徐涛 5baa170508 docs: 更新 README feature 表 + 升级指南 + 示例注释 + roadmap 同步
- README 添加 feature 组合表 + 模块级 features 清单 + 升级指南
- 18 个 example 顶部添加 Required features 注释
- roadmap.md 和 roadmap-v0.3.2.md 同步 Phase 26-27 完成状态
- cargo fmt 全量格式化(修复预存格式问题,CI format job 可通过)
2026-07-19 08:18:04 +08:00

1652 lines
64 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.
//! AgentSession —— 智能体"会话"实例。
//!
//! 设计要点(参见 `docs/7-agent-runtime.md` §3.2.3 与 `docs/17-phase10-contextslot.md`):
//!
//! - **会话 = 角色 + 状态**:绑定 `session_id` / `agent` / `bundle`,累计 `turn_index` 和 `cost_so_far`
//! - **多上下文分区**Phase 10):通过 `ContextSlot` 管理多个独立的消息上下文
//! - **最小 reference impl**`submit_turn` 演示"组装 LlmCycle → submit_with_tools → 累计 cost"的标准流程
//! - **不做业务循环**:多轮策略、错误重试、记忆回写由上层应用或具体 `TaskAgent` 决定
//! - **不持有 ConversationMemory**:上层可独立 new 一个 `ConversationMemory`,在合适的时机调 `add_message`
use std::collections::HashMap;
use std::pin::Pin;
use std::sync::Arc;
use futures_core::Stream;
use crate::agent::agent::Agent;
use crate::agent::context::{ContextSlot, DeriveStrategy, SlotConfig, SlotMode};
// SlotSource 仅在 `mod tests` 中使用(通过 `use super::*;` 引入),lib 主体保留以避免测试 import 变更。
#[allow(unused_imports)]
use crate::agent::context::SlotSource;
use crate::agent::error::AgentError;
use crate::agent::runtime::RuntimeBundle;
use crate::agent::session_memory::SessionMemory;
use crate::agent::summary::{SummaryConfig, format_messages_as_text};
#[cfg(feature = "engine")]
use crate::engine::EngineError;
#[cfg(feature = "engine")]
use crate::engine::snapshot::{SessionMemoryEntry, SessionSnapshot};
use crate::llm::LlmProvider;
use crate::llm::cycle::{CostTracker, CycleConfig, LlmCycle};
use crate::llm::error::LlmError;
use crate::llm::hooks::{HookContext, HookEvent};
use crate::llm::stream::StreamEvent;
use crate::llm::types::message::Message;
use crate::llm::types::response_v2::MessageResponse;
use crate::memory::store::{InMemoryStore, MemoryStore};
/// Agent 会话实例。
///
/// 同一 `Agent` 可被多个 `AgentSession` 复用(不同 session_id 互不干扰)。
/// `submit_turn` 一次只跑一轮 LLM 调用(含自动 tool 循环)。
///
/// **Phase 10 新增**:通过 `slots: HashMap<String, ContextSlot>` 管理多个独立的对话上下文。
/// `submit_turn` 默认写入当前活跃 slot`current_slot_id`)。
///
/// **不实现 `Clone`**session 持有累计 `turn_index` / `cost_so_far` / `session_memory`
/// 共享这些状态需要显式 sync 语义;如果上层需要并发访问,自己用 `Arc<Mutex<_>>` 包装。
pub struct AgentSession {
/// 会话 ID(由调用方指定,用于日志/追踪/记忆关联)。
pub session_id: String,
/// 角色(可热切换为同 bundle 下的其他角色)。
pub agent: Arc<dyn Agent>,
bundle: Arc<RuntimeBundle>,
turn_index: u32,
cost_so_far: CostTracker,
/// 会话级记忆(Phase 4c 替换内联 HashMap)。
pub session_memory: SessionMemory,
/// Phase 10 新增:所有 slotid → ContextSlot)。
slots: HashMap<String, ContextSlot>,
/// Phase 10 新增:当前活跃 slot 的 id。
current_slot_id: String,
/// Phase 16 新增:上次摘要生成时的 `turn_index`(用于 `debounce_turns` 防抖)。
/// `None` 表示从未生成过摘要(首次触发不受防抖约束)。
last_summary_turn: Option<u32>,
/// Phase 17 新增:`from_snapshot()` 后暂存的待写回条目。
/// `None` 表示无 pending restore(正常状态)。
/// 调用 `restore_memory()` 后会被消费并设为 `None`。
/// 这是 transient state,不参与序列化(AgentSession 本身不 derive Serialize)。
#[cfg(feature = "engine")]
pending_memory_restore: Option<HashMap<String, SessionMemoryEntry>>,
}
impl std::fmt::Debug for AgentSession {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AgentSession")
.field("session_id", &self.session_id)
.field("agent", &self.agent.name())
.field("turn_index", &self.turn_index)
.field("cost_so_far", &self.cost_so_far.total())
.field("session_memory", &"<SessionMemory>")
.field("slots", &self.slots.keys().collect::<Vec<_>>())
.field("current_slot_id", &self.current_slot_id)
.finish()
}
}
impl AgentSession {
/// 创建一个新的会话实例。
///
/// `agent` 与 `bundle` 共同决定 `submit_turn` 行为:system_prompt / 工具集 / LLM 后端均来自它们。
///
/// Phase 10 新增:自动创建 `"default"` slot,确保简单场景无感使用。
///
/// **注意**:`new()` 是同步函数,无法执行异步的 `ContextSlot::load()`。
/// 因此始终创建空的 default slot。若需要从存储恢复 session 历史,
/// 可在创建后调用 `switch_slot("default")` 尝试从存储加载。
/// v0.2 简化:`switch_slot` 在 HashMap 中已有 key 时不会重载——如需恢复,
/// 请在清空 `slots` 后调用 `switch_slot`,或等待 v0.3 的懒加载支持。)
pub fn new(
agent: Arc<dyn Agent>,
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);
// 自动创建 "default" slot
let default_slot = ContextSlot::new(&session_id_str, "default", SlotConfig::default());
let mut slots = HashMap::new();
slots.insert("default".to_string(), default_slot);
Self {
session_id: session_id_str,
agent,
bundle,
turn_index: 0,
cost_so_far: CostTracker::default(),
session_memory,
slots,
current_slot_id: "default".to_string(),
last_summary_turn: None,
#[cfg(feature = "engine")]
pending_memory_restore: None,
}
}
/// 当前 turn 序号(0-based:第一次 `submit_turn` 完成后变 1)。
pub fn turn_index(&self) -> u32 {
self.turn_index
}
/// 累计用量(跨所有 turn)。
pub fn usage(&self) -> &CostTracker {
&self.cost_so_far
}
/// 会话级记忆引用。
pub fn session_memory(&self) -> &SessionMemory {
&self.session_memory
}
/// RuntimeBundle 引用(Phase 17 新增,供 SessionManager::create_child 继承父 bundle)。
#[cfg(feature = "engine")]
pub(crate) fn bundle(&self) -> &Arc<RuntimeBundle> {
&self.bundle
}
/// 写入一条会话级数据(覆盖同名 key)。
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 async fn get_session_data(&self, key: &str) -> Result<Option<String>, AgentError> {
self.session_memory.get(key).await
}
/// Phase 10: 当前 slot id。
pub fn current_slot_id(&self) -> &str {
&self.current_slot_id
}
/// Phase 10: 列出所有 slot 的不可变引用(按 id 顺序)。
pub fn slots(&self) -> impl Iterator<Item = (&String, &ContextSlot)> {
self.slots.iter()
}
/// Phase 10: 解析存储后端。
/// fallback 链:`session_memory_backend` → `memory_store` → `InMemoryStore`。
fn resolve_store(&self) -> Arc<dyn MemoryStore> {
self.bundle
.session_memory_backend
.clone()
.or_else(|| self.bundle.memory_store.clone())
.unwrap_or_else(|| Arc::new(InMemoryStore::new()))
}
// ====== Phase 10: Slot 管理方法 ======
/// 创建新 slot(config 可选,不传则使用默认值)。
pub async fn create_slot(
&mut self,
id: impl Into<String>,
config: Option<SlotConfig>,
) -> Result<(), AgentError> {
let id = id.into();
if self.slots.contains_key(&id) {
return Err(AgentError::SlotAlreadyExists(id));
}
let slot = ContextSlot::new(&self.session_id, &id, config.unwrap_or_default());
slot.save(&*self.resolve_store()).await?;
self.slots.insert(id, slot);
Ok(())
}
/// 切换到指定 slot。
/// - 如果 slot 已在内存中,直接切换 current_slot_id
/// - 如果不在内存中,尝试从存储加载(config 自动从 slot_config key 恢复)
/// - 存储中也不存在则返回 `SlotNotFound`
pub async fn switch_slot(&mut self, id: &str) -> Result<(), AgentError> {
if !self.slots.contains_key(id) {
let store = self.resolve_store();
match ContextSlot::load(id, &self.session_id, &*store).await? {
Some(slot) => {
self.slots.insert(id.to_string(), slot);
}
None => return Err(AgentError::SlotNotFound(id.to_string())),
}
}
self.current_slot_id = id.to_string();
Ok(())
}
/// 列出所有 slot id。
pub fn list_slots(&self) -> impl Iterator<Item = &String> {
self.slots.keys()
}
/// 从父 slot 派生新 slot(继承父 slot 的全量或聚焦消息)。
pub async fn derive_slot(
&mut self,
id: impl Into<String>,
parent_id: &str,
strategy: DeriveStrategy,
) -> Result<(), AgentError> {
let slot_id = id.into();
if self.slots.contains_key(&slot_id) {
return Err(AgentError::SlotAlreadyExists(slot_id));
}
let parent = self
.slots
.get(parent_id)
.ok_or_else(|| AgentError::SlotNotFound(parent_id.to_string()))?;
let child = parent.fork(slot_id.clone(), strategy);
child.save(&*self.resolve_store()).await?;
self.slots.insert(slot_id, child);
Ok(())
}
/// 删除一个 slot。
/// - 禁止删除 `"default"` slot
/// - 至少保留一个 slot
/// - 已删除后再 load 返回 None
pub async fn delete_slot(&mut self, id: &str) -> Result<(), AgentError> {
if id == "default" {
return Err(AgentError::Config(
"Cannot delete the 'default' slot".into(),
));
}
if self.slots.len() <= 1 {
return Err(AgentError::Config("Cannot delete the last slot".into()));
}
ContextSlot::delete(id, &self.session_id, &*self.resolve_store()).await?;
self.slots.remove(id);
if self.current_slot_id == id {
self.current_slot_id = "default".to_string();
}
Ok(())
}
// ====== 原始方法 ======
/// 提交一轮对话(含自动 tool 循环),返回 LLM 响应。
///
/// Phase 10 改造:
/// - 从当前 slot 加载历史(Focused 模式读时过滤)
/// - 提交完成后**增量追加**本轮新增消息到当前 slot(不覆盖,确保 Focused 语义不丢数据)
///
/// 流程:
/// 1. 检查当前 slot 不是 Readonly
/// 2. 触发 `OnTurnStart` hook
/// 3. 加载当前 slot 的历史消息
/// 4. 组装 `LlmCycle`(注入 system_prompt / compact_config / 历史)
/// 5. `submit_with_tools` 跑单轮对话
/// 6. 累计 `cost_so_far`
/// 7. **增量追加**本轮新增消息到当前 slot + 保存到 store
/// 8. 触发 `OnTurnEnd` hook
/// 9. `turn_index += 1`
pub async fn submit_turn(
&mut self,
user_input: impl Into<String>,
) -> Result<MessageResponse, AgentError> {
let turn_index = self.turn_index;
let hook_executor = Arc::clone(&self.bundle.hook_executor);
// 0. Readonly slot 拒绝 submit_turn
{
let slot = self
.slots
.get(&self.current_slot_id)
.ok_or_else(|| AgentError::SlotNotFound(self.current_slot_id.clone()))?;
if matches!(slot.config.mode, SlotMode::Readonly) {
return Err(AgentError::SlotReadonly(format!(
"Cannot submit turn on Readonly slot '{}'",
self.current_slot_id
)));
}
}
// 1. 触发 OnTurnStart hook
let start_ctx = HookContext::new(HookEvent::OnTurnStart).with_turn_index(turn_index);
hook_executor
.execute(HookEvent::OnTurnStart, &start_ctx)
.await;
// 2. 从当前 slot 加载历史消息
let history = self
.slots
.get(&self.current_slot_id)
.ok_or_else(|| AgentError::SlotNotFound(self.current_slot_id.clone()))?
.load_messages();
// 3. 组装 LlmCycle
let _ = self.agent.tool_definitions(&self.bundle);
let mut cycle =
LlmCycle::new_with_arc(Arc::clone(&self.bundle.provider), CycleConfig::default());
let mut messages_with_prompt = history;
if let Some(prompt) = self.agent.system_prompt() {
messages_with_prompt.insert(0, Message::system(prompt));
}
let input_len = messages_with_prompt.len();
cycle = cycle.with_messages(messages_with_prompt);
if let Some(cfg) = self.bundle.config.compact_config.clone() {
cycle = cycle.with_compact_config(cfg);
}
// 4. 提交
let response = cycle
.submit_with_tools(user_input.into(), &self.bundle.tool_registry)
.await?;
// 5. 累计 cost
self.cost_so_far.add(&response.usage);
// 6. 只将本轮新增消息追加到当前 slot(保留全量历史,确保 Focused 模式的"读时过滤"语义不丢失数据)
// cycle.messages() 包含 [system_prompt?, history..., user_input, tool_calls..., final_response]
// 新增消息 = cycle.messages()[input_len..](跳过 initial_messages,即跳过已被持久化的内容)
let new_messages: Vec<Message> = cycle.messages().iter().skip(input_len).cloned().collect();
let store = self.resolve_store();
if let Some(slot) = self.slots.get_mut(&self.current_slot_id) {
slot.append_messages(new_messages)?;
slot.save(&*store).await?;
}
// 7. 触发 OnTurnEnd hook
let end_ctx = HookContext::new(HookEvent::OnTurnEnd).with_turn_index(turn_index);
hook_executor.execute(HookEvent::OnTurnEnd, &end_ctx).await;
// 7.5 Phase 16: 摘要自动生成检查点
self.maybe_summarize(turn_index).await;
// 8. turn_index 递增
self.turn_index += 1;
Ok(response)
}
/// 提交一轮对话(流式版本,含自动 tool 循环),返回 `StreamEvent` 流。
///
/// Phase 10 改造:
/// - 从当前 slot 加载历史(Focused 模式读时过滤)
/// - finalize_turn 需要传入本轮新增消息列表
///
/// **运行时要求**:内部委托 `submit_with_tools_stream`,需要 tokio 多线程运行时。
pub async fn submit_turn_stream(
&mut self,
user_input: impl Into<String>,
) -> Result<Pin<Box<dyn Stream<Item = StreamEvent> + Send>>, AgentError> {
let turn_index = self.turn_index;
let hook_executor = Arc::clone(&self.bundle.hook_executor);
// 0. Readonly 检查
{
let slot = self
.slots
.get(&self.current_slot_id)
.ok_or_else(|| AgentError::SlotNotFound(self.current_slot_id.clone()))?;
if matches!(slot.config.mode, SlotMode::Readonly) {
return Err(AgentError::SlotReadonly(format!(
"Cannot submit turn stream on Readonly slot '{}'",
self.current_slot_id
)));
}
}
// 1. 触发 OnTurnStart hook
let start_ctx = HookContext::new(HookEvent::OnTurnStart).with_turn_index(turn_index);
hook_executor
.execute(HookEvent::OnTurnStart, &start_ctx)
.await;
// 2. 触发子 trait 覆盖(白名单/过滤)的副作用
let _ = self.agent.tool_definitions(&self.bundle);
// 3. 从当前 slot 加载历史
let history = self
.slots
.get(&self.current_slot_id)
.ok_or_else(|| AgentError::SlotNotFound(self.current_slot_id.clone()))?
.load_messages();
// 4. 组装 LlmCycle
let mut cycle =
LlmCycle::new_with_arc(Arc::clone(&self.bundle.provider), CycleConfig::default());
let mut messages_with_prompt = history;
if let Some(prompt) = self.agent.system_prompt() {
messages_with_prompt.insert(0, Message::system(prompt));
}
cycle = cycle.with_messages(messages_with_prompt);
if let Some(cfg) = self.bundle.config.compact_config.clone() {
cycle = cycle.with_compact_config(cfg);
}
// 5. 调用流式工具循环
let stream = cycle
.submit_with_tools_stream(user_input.into(), Arc::clone(&self.bundle.tool_registry))
.await?;
// 6. turn_index 递增 —— 配合 finalize_turn 用 (turn_index - 1) 传递正确的 OnTurnEnd 序号
self.turn_index += 1;
Ok(stream)
}
/// 完成一轮 turn:累计 cost + 触发 OnTurnEnd hook + 增量追加消息到当前 slot。
///
/// Phase 10 改造:
/// - 新增 `new_messages_from_cycle` 参数:流式场景下,本轮新增的消息列表
/// (由消费者在流消费完毕后从 `cycle.messages()[input_len..]` 获取并传入)
/// - 仅**增量追加**到当前 slot(不覆盖已有消息),与 submit_turn 行为一致
/// - 返回类型从 `()` 改为 `Result<(), AgentError>`,错误传播更清晰
///
/// 由消费者在收到 `MessageComplete.full_response` 后调用。
pub async fn finalize_turn(
&mut self,
response: &MessageResponse,
new_messages_from_cycle: Vec<Message>,
) -> Result<(), AgentError> {
self.cost_so_far.add(&response.usage);
// 防御性检查:current_slot_id 必须在 slots 中(与 submit_turn 行为一致)。
// 正常流程:submit_turn_stream 已注册 slotfinalize_turn 不应触发此分支。
if !self.slots.contains_key(&self.current_slot_id) {
return Err(AgentError::SlotNotFound(self.current_slot_id.clone()));
}
// 增量追加到当前 slot(仅 Full/Focused 模式允许,Readonly 阻断)
let store = self.resolve_store();
if let Some(slot) = self.slots.get_mut(&self.current_slot_id) {
if matches!(slot.config.mode, SlotMode::Readonly) {
return Err(AgentError::SlotReadonly(format!(
"Cannot finalize turn on Readonly slot '{}'",
self.current_slot_id
)));
}
slot.append_messages(new_messages_from_cycle)?;
slot.save(&*store).await?;
}
// 防御性 saturating_sub 防止误用 panic。
let end_ctx = HookContext::new(HookEvent::OnTurnEnd)
.with_turn_index(self.turn_index.saturating_sub(1));
self.bundle
.hook_executor
.execute(HookEvent::OnTurnEnd, &end_ctx)
.await;
// Phase 16: 摘要检查点(流式路径 turn_index 已被 submit_turn_stream 提前 ++1
self.maybe_summarize(self.turn_index.saturating_sub(1))
.await;
Ok(())
}
// ====== Phase 17: 快照序列化 ======
/// 将当前状态拍平为 `SessionSnapshot`。
///
/// **需要 async**:因为 `session_memory` 的条目存储在 `MemoryStore` 中,读取需异步 I/O。
/// 通过 `SessionMemory::list_entries()` 获取完整条目(保留 `metadata` 和 `created_at`)。
///
/// `Arc<dyn Agent>` 和 `Arc<RuntimeBundle>` **不进入快照**——由 `from_snapshot()` 调用方注入。
#[cfg(feature = "engine")]
pub async fn to_snapshot(&self) -> SessionSnapshot {
// 拍平 session_memory → HashMap<String, SessionMemoryEntry>
// 失败时回退到空 map(错误已记录,不阻断 checkpoint 主流程)。
let session_memory_data = match self.session_memory.list_entries().await {
Ok(entries) => entries
.into_iter()
.map(|(key, value, metadata, created_at)| {
(
key,
SessionMemoryEntry {
value,
metadata,
created_at: Some(created_at),
},
)
})
.collect(),
Err(e) => {
tracing::error!("session_memory list_entries failed: {}", e);
HashMap::new()
}
};
SessionSnapshot {
session_id: self.session_id.clone(),
agent_name: self.agent.name().to_string(),
turn_index: self.turn_index,
cost_so_far: self.cost_so_far.clone(),
slots: self.slots.clone(),
current_slot_id: self.current_slot_id.clone(),
last_summary_turn: self.last_summary_turn,
session_memory_data,
}
}
/// 从 `SessionSnapshot` + agent + bundle **纯同步**重建 `AgentSession`。
///
/// **不执行任何 I/O**`session_memory_data` 暂存于 `pending_memory_restore` 字段,
/// 由调用方显式 `await session.restore_memory()` 写回持久层。
///
/// 调用方负责提供与 `snapshot.agent_name` 对应的 `Arc<dyn Agent>`(引擎层只保留名字做调试用)。
#[cfg(feature = "engine")]
pub fn from_snapshot(
snapshot: SessionSnapshot,
agent: Arc<dyn Agent>,
bundle: Arc<RuntimeBundle>,
) -> Result<Self, EngineError> {
// 校验 bundle 的 session_memory_backend 与 snapshot 兼容
// v0.3 不强制同 backend——以新构造的 session_memory 所属 backend 为准)
let backend = bundle
.session_memory_backend
.clone()
.unwrap_or_else(|| Arc::new(InMemoryStore::new()));
let session_memory = SessionMemory::new(backend, &snapshot.session_id);
// 解析 agent_name 仅供调试(不强制匹配,因为不同进程的 Agent 实现可能不同)
let _ = snapshot.agent_name.as_str();
// 确保至少有一个 slot(与 new() 行为一致)
let mut slots = snapshot.slots;
if slots.is_empty() {
slots.insert(
"default".to_string(),
ContextSlot::new(&snapshot.session_id, "default", SlotConfig::default()),
);
}
Ok(Self {
session_id: snapshot.session_id,
agent,
bundle,
turn_index: snapshot.turn_index,
cost_so_far: snapshot.cost_so_far,
session_memory,
slots,
current_slot_id: snapshot.current_slot_id,
last_summary_turn: snapshot.last_summary_turn,
pending_memory_restore: if snapshot.session_memory_data.is_empty() {
None
} else {
Some(snapshot.session_memory_data)
},
})
}
/// 将 `from_snapshot()` 暂存的 `session_memory_data` 写回 `SessionMemory` 持久层。
///
/// **从 `from_snapshot()` 中剥离的异步操作**:确保构造函数是纯同步的。
/// 调用方在 `from_snapshot()` 后显式 `await`。
///
/// **错误处理**:逐条写入。某条失败时返回 `Err` 但**不回滚**已写入条目。
/// 调用方可选择重试或忽略——不影响 AgentSession 内存状态。
///
/// **幂等性**:重复调用安全(首次成功后 `pending_memory_restore` 已被设为 `None`
/// 第二次调用立即返回 `Ok(())`)。
///
/// **完整恢复**:使用 `SessionMemory::set_with_meta()` 保留原始 `metadata` 和 `created_at`
/// ——不像 `set()` 会清空 metadata 并把 created_at 设为当前时间。
#[cfg(feature = "engine")]
pub async fn restore_memory(&mut self) -> Result<(), EngineError> {
// 取出 pending 并立即清空(避免重复 restore 时二次写入;幂等性保证)
let entries = self.pending_memory_restore.take();
let entries = match entries {
Some(m) if !m.is_empty() => m,
_ => return Ok(()), // 无 pending 或已被清空 → 立即返回
};
for (key, entry) in entries {
self.session_memory
.set_with_meta(&key, &entry.value, entry.metadata.clone(), entry.created_at)
.await
.map_err(EngineError::Agent)?;
}
Ok(())
}
/// 是否有待写回的 `session_memory_data``from_snapshot()` 后尚未 `restore_memory()`)。
#[cfg(feature = "engine")]
pub fn has_pending_memory_restore(&self) -> bool {
self.pending_memory_restore
.as_ref()
.map(|m| !m.is_empty())
.unwrap_or(false)
}
// ====== Phase 16: 摘要自动生成 ======
/// 读取 SessionMemory 中最新的对话摘要(`None` 表示从未生成过)。
pub async fn get_conversation_summary(&self) -> Result<Option<String>, AgentError> {
self.session_memory.get("conversation_summary").await
}
/// 水位 + 防抖检查:是否应当触发摘要生成。
/// 防抖只对"上一轮与本轮之间的间隔"起作用——首次(`last_summary_turn.is_none()`)不阻塞。
/// `current_turn` 显式传入而非读 `self.turn_index`,因为流式路径中 `submit_turn_stream` 已提前 ++1
/// `finalize_turn` 会用 `saturating_sub(1)` 修正后的值传入此函数。
fn should_summarize(&self, cfg: &SummaryConfig, current_turn: u32) -> bool {
let debounce_ok = match self.last_summary_turn {
None => true,
Some(last) => current_turn.saturating_sub(last) >= cfg.debounce_turns,
};
debounce_ok
&& self.cost_so_far.total().total_tokens as f64
>= cfg.max_context_tokens as f64 * cfg.trigger_token_ratio
}
/// 检查点入口:水位超阈值时调 LLM 生成摘要,写入 slot config 与 SessionMemory。
/// 所有错误(含 LLM error、save 失败、session_memory 写失败)均静默(`tracing::error!` 后返回)。
async fn maybe_summarize(&mut self, current_turn: u32) {
let cfg = match self.bundle.config.summary_config.clone() {
Some(c) => c,
None => return,
};
if !self.should_summarize(&cfg, current_turn) {
return;
}
// 先 clone 出 &self 借用范围内所需数据,后续释放借用再 await/mut
let provider = Arc::clone(&self.bundle.provider);
let messages = self
.slots
.get(&self.current_slot_id)
.map(|s| s.messages.clone())
.unwrap_or_default();
if messages.is_empty() {
return;
}
let max_tool_result_chars = cfg.max_tool_result_chars;
let model = cfg.summary_model.clone();
let prompt = cfg.summary_prompt.clone();
let result = Self::generate_summary(
&provider,
&messages,
&prompt,
model.as_deref(),
max_tool_result_chars,
)
.await;
match result {
Ok(text) => {
tracing::info!(
turn = current_turn,
summary_len = text.len(),
"摘要自动生成成功"
);
// Resolve store first (immutable borrow on self) before mutable borrow on slots.
let store = self.resolve_store();
if let Some(slot) = self.slots.get_mut(&self.current_slot_id)
&& let SlotMode::Focused(ref mut focused_cfg) = slot.config.mode
{
focused_cfg.summary_override = Some(text.clone());
// Full 模式下 summary_override 未被修改,无需持久化 slot
if let Err(e) = slot.save(&*store).await {
tracing::error!("summary config persist failed: {}", e);
}
}
if let Err(e) = self.session_memory.set("conversation_summary", &text).await {
tracing::error!("summary session_memory write failed: {}", e);
}
self.last_summary_turn = Some(current_turn);
}
Err(e) => {
tracing::error!("摘要自动生成失败 (turn={}): {}", current_turn, e);
}
}
}
/// 关联函数:调一次 LLM 生成摘要。空消息列表直接返回空串(不浪费 LLM 调用)。
/// `summary_model=None` 时沿用 `CycleConfig::default()` 的默认模型(避免硬编码到非 OpenAI 用户不适配的 `"gpt-4o"`)。
async fn generate_summary(
provider: &Arc<dyn LlmProvider>,
messages: &[Message],
prompt_template: &str,
summary_model: Option<&str>,
max_tool_result_chars: usize,
) -> Result<String, LlmError> {
if messages.is_empty() {
return Ok(String::new());
}
let messages_text = format_messages_as_text(messages, max_tool_result_chars);
let prompt = prompt_template.replace("{messages}", &messages_text);
let config = CycleConfig {
max_tokens: Some(1024),
..CycleConfig::default()
};
let config = if let Some(model) = summary_model {
CycleConfig {
model: model.to_string(),
..config
}
} else {
config
};
let mut cycle = LlmCycle::new_with_arc(Arc::clone(provider), config);
// submit_messages 使用自身参数构造 request,不读 self.messages——prompt 必须放在 messages 参数里
let response = cycle
.submit_messages(vec![Message::user_text(prompt)], vec![])
.await?;
Ok(response.text())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::FocusedConfig;
use crate::agent::builder::AgentBuilder;
use crate::llm::hooks::{Hook, HookContext, HookExecutor, HookResult};
use crate::llm::mock::MockProvider;
use crate::llm::stream::StreamEvent;
use crate::llm::types::message::ContentBlock;
use crate::llm::types::response_v2::{MessageResponse, StopReason};
use crate::tools::ToolRegistry;
use async_trait::async_trait;
use futures_util::StreamExt;
use std::sync::atomic::{AtomicU32, Ordering};
/// 计数 hook —— 每被调用一次 +1。
struct CountHook(AtomicU32);
#[async_trait]
impl Hook for CountHook {
async fn execute(&self, _ctx: &HookContext<'_>) -> HookResult {
self.0.fetch_add(1, Ordering::SeqCst);
HookResult::allow()
}
}
/// 把 `Arc<CountHook>` 包装为 `Box<dyn Hook>`dyn Hook 不能直接来自 Arc)。
struct CountHookAdapter(Arc<CountHook>);
#[async_trait]
impl Hook for CountHookAdapter {
async fn execute(&self, ctx: &HookContext<'_>) -> HookResult {
self.0.execute(ctx).await
}
}
struct StubAgent {
name: String,
prompt: Option<String>,
}
impl Agent for StubAgent {
fn name(&self) -> &str {
&self.name
}
fn system_prompt(&self) -> Option<&str> {
self.prompt.as_deref()
}
}
fn assistant_text(text: &str) -> MessageResponse {
MessageResponse {
id: String::new(),
model: String::new(),
message: Message::Assistant {
content: vec![ContentBlock::Text { text: text.into() }],
},
usage: crate::llm::types::Usage::from_input_output(10, 5),
stop_reason: StopReason::Stop,
extra: std::collections::HashMap::new(),
}
}
fn build_session(
provider_responses: Vec<MessageResponse>,
) -> (AgentSession, Arc<CountHook>, Arc<CountHook>) {
let mut hook_executor = HookExecutor::new();
let start_count = Arc::new(CountHook(AtomicU32::new(0)));
let end_count = Arc::new(CountHook(AtomicU32::new(0)));
hook_executor.register(
HookEvent::OnTurnStart,
Box::new(CountHookAdapter(start_count.clone())),
);
hook_executor.register(
HookEvent::OnTurnEnd,
Box::new(CountHookAdapter(end_count.clone())),
);
let provider = Arc::new(MockProvider::new(provider_responses));
let agent = Arc::new(StubAgent {
name: "stub".into(),
prompt: Some("you are a test agent".into()),
});
let bundle = Arc::new(
AgentBuilder::new()
.provider(provider)
.tool_registry(Arc::new(ToolRegistry::new()))
.hook_executor(Arc::new(hook_executor))
.build()
.unwrap(),
);
let session = AgentSession::new(agent, "test-session", bundle);
(session, start_count, end_count)
}
/// 烟雾测试 1AgentSession::submit_turn 跑通 mock provider(向后兼容)。
#[tokio::test]
async fn submit_turn_runs_with_mock_provider() {
let (mut session, start_count, end_count) =
build_session(vec![assistant_text("hello back")]);
assert_eq!(session.turn_index(), 0);
let response = session.submit_turn("hi").await.unwrap();
assert_eq!(extract_text(&response.message), "hello back");
assert_eq!(session.turn_index(), 1);
assert_eq!(session.usage().total().prompt_tokens, 10);
assert_eq!(session.usage().total().completion_tokens, 5);
// hook 触发
assert_eq!(start_count.0.load(Ordering::SeqCst), 1);
assert_eq!(end_count.0.load(Ordering::SeqCst), 1);
}
/// 烟雾测试 2session_data 读写。
#[tokio::test]
async fn session_data_set_get() {
let (mut session, _, _) = build_session(vec![]);
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").await.unwrap();
assert_eq!(
session.get_session_data("k").await.unwrap(),
Some("v2".into())
);
}
/// 烟雾测试 3submit_turn 触发 OnTurnStart / OnTurnEnd hook。
#[tokio::test]
async fn submit_turn_triggers_turn_hooks() {
let (mut session, start_count, end_count) =
build_session(vec![assistant_text("ok"), assistant_text("ok 2")]);
session.submit_turn("hi").await.unwrap();
assert_eq!(start_count.0.load(Ordering::SeqCst), 1);
assert_eq!(end_count.0.load(Ordering::SeqCst), 1);
session.submit_turn("hi again").await.unwrap();
assert_eq!(start_count.0.load(Ordering::SeqCst), 2);
assert_eq!(end_count.0.load(Ordering::SeqCst), 2);
}
/// 提取 Message 的第一个 Text block(测试辅助)。
fn extract_text(msg: &Message) -> &str {
use crate::llm::types::message::ContentBlock;
let blocks = match msg {
Message::System { content }
| Message::User { content }
| Message::Assistant { content } => content,
Message::UserImage { .. } => return "",
Message::ToolResult { content, .. } => content,
};
for block in blocks {
if let ContentBlock::Text { text } = block {
return text;
}
}
""
}
// ====== Phase 10 新增测试 ======
/// Phase 10: 默认 slot 自动创建。
#[tokio::test]
async fn default_slot_auto_created() {
let (session, _, _) = build_session(vec![]);
assert_eq!(session.current_slot_id(), "default");
let slots: Vec<_> = session.list_slots().collect();
assert_eq!(slots.len(), 1);
assert!(slots.contains(&&"default".to_string()));
}
/// Phase 10: submit_turn 写入当前 slot。
#[tokio::test]
async fn submit_turn_writes_to_current_slot() {
let (mut session, _, _) = build_session(vec![assistant_text("resp")]);
session.submit_turn("user input").await.unwrap();
// 检查 default slot 内存中的消息
let slot = session.slots.get("default").expect("default slot exists");
// submit_turn 增量追加的是 cycle.messages()[input_len..] 部分,
// 即 [user_input, tool_results?, final_response](不含 system_promptsystem 由 agent 提供)
assert!(slot.messages.len() >= 2, "应至少包含 user 和 assistant");
// 验证 user 输入和 assistant 响应都已写入
let has_user = slot
.messages
.iter()
.any(|m| extract_text(m) == "user input");
let has_resp = slot.messages.iter().any(|m| extract_text(m) == "resp");
assert!(
has_user && has_resp,
"slot 应包含 user input 和 assistant response"
);
}
/// Phase 10: create_slot 创建新 slot。
#[tokio::test]
async fn create_slot_basic() {
let (mut session, _, _) = build_session(vec![]);
session.create_slot("scratch", None).await.unwrap();
let slots: Vec<_> = session.list_slots().cloned().collect();
assert!(slots.contains(&"default".to_string()));
assert!(slots.contains(&"scratch".to_string()));
assert_eq!(slots.len(), 2);
}
/// Phase 10: create_slot 拒绝重复 id。
#[tokio::test]
async fn create_slot_rejects_duplicate() {
let (mut session, _, _) = build_session(vec![]);
session.create_slot("dup", None).await.unwrap();
let err = session.create_slot("dup", None).await.unwrap_err();
assert!(matches!(err, AgentError::SlotAlreadyExists(_)));
}
/// Phase 10: switch_slot 切换并保留各自消息。
#[tokio::test]
async fn switch_slot_isolates_messages() {
let (mut session, _, _) = build_session(vec![
assistant_text("resp a"),
assistant_text("resp b"),
assistant_text("resp c"),
]);
// 1. 在 default 中提交一次
session.submit_turn("msg in default").await.unwrap();
// 2. 创建 slot_a
session.create_slot("slot_a", None).await.unwrap();
session.switch_slot("slot_a").await.unwrap();
assert_eq!(session.current_slot_id(), "slot_a");
session.submit_turn("msg in slot_a").await.unwrap();
// 3. 检查 slot_a 的消息数
let slot_a = session.slots.get("slot_a").unwrap();
let slot_a_count = slot_a.messages.len();
assert!(
slot_a_count >= 2,
"slot_a 至少 2 条消息,实际 {}",
slot_a_count
);
// 4. 切回 default,验证 default 不包含 slot_a 的消息
session.switch_slot("default").await.unwrap();
let slot_default = session.slots.get("default").unwrap();
let default_count = slot_default.messages.len();
assert!(default_count >= 2);
// 验证 default 中没有 "msg in slot_a"
let default_has_a = slot_default
.messages
.iter()
.any(|m| extract_text(m) == "msg in slot_a");
assert!(!default_has_a, "default 不应包含 slot_a 的消息");
// 验证 slot_a 中没有 "msg in default"
let slot_a = session.slots.get("slot_a").unwrap();
let a_has_default = slot_a
.messages
.iter()
.any(|m| extract_text(m) == "msg in default");
assert!(!a_has_default, "slot_a 不应包含 default 的消息");
}
/// Phase 10: Readonly slot 拒绝写入。
#[tokio::test]
async fn readonly_slot_rejects_submit_turn() {
let (mut session, _, _) = build_session(vec![assistant_text("resp")]);
session
.create_slot(
"ro",
Some(SlotConfig {
mode: SlotMode::Readonly,
source: SlotSource::New,
budget: Default::default(),
compact: true,
}),
)
.await
.unwrap();
session.switch_slot("ro").await.unwrap();
let err = session.submit_turn("blocked").await.unwrap_err();
assert!(matches!(err, AgentError::SlotReadonly(_)));
}
/// Phase 10: delete_slot 删除非 default。
#[tokio::test]
async fn delete_slot_removes_non_default() {
let (mut session, _, _) = build_session(vec![]);
session.create_slot("to_delete", None).await.unwrap();
session.delete_slot("to_delete").await.unwrap();
let slots: Vec<_> = session.list_slots().cloned().collect();
assert!(!slots.contains(&"to_delete".to_string()));
assert_eq!(slots.len(), 1);
}
/// Phase 10: delete_slot 禁止删 default。
#[tokio::test]
async fn delete_slot_rejects_default() {
let (mut session, _, _) = build_session(vec![]);
let err = session.delete_slot("default").await.unwrap_err();
assert!(matches!(err, AgentError::Config(_)));
}
/// Phase 10: delete_slot 禁止删最后一个 slot。
#[tokio::test]
async fn delete_slot_rejects_last() {
let (mut session, _, _) = build_session(vec![]);
// 只有 default 一个 slot
let err = session.delete_slot("default").await.unwrap_err();
assert!(matches!(err, AgentError::Config(_)));
}
/// Phase 10: delete_slot 后 current 回退到 default。
#[tokio::test]
async fn delete_slot_falls_back_to_default() {
let (mut session, _, _) = build_session(vec![]);
session.create_slot("temp", None).await.unwrap();
session.switch_slot("temp").await.unwrap();
assert_eq!(session.current_slot_id(), "temp");
session.delete_slot("temp").await.unwrap();
assert_eq!(session.current_slot_id(), "default");
}
/// Phase 10: derive_slot Full 策略复制父 slot 消息。
#[tokio::test]
async fn derive_slot_full_copies_parent() {
let (mut session, _, _) = build_session(vec![assistant_text("resp")]);
session.submit_turn("parent msg").await.unwrap();
session
.derive_slot("child", "default", DeriveStrategy::Full)
.await
.unwrap();
let child = session.slots.get("child").unwrap();
assert!(matches!(child.config.source, SlotSource::Derived { .. }));
// child 应有 parent 的消息拷贝
let has_parent = child
.messages
.iter()
.any(|m| extract_text(m) == "parent msg");
assert!(has_parent);
}
/// Phase 10: derive_slot 拒绝重复 id。
#[tokio::test]
async fn derive_slot_rejects_duplicate() {
let (mut session, _, _) = build_session(vec![]);
session.create_slot("child", None).await.unwrap();
let err = session
.derive_slot("child", "default", DeriveStrategy::Full)
.await
.unwrap_err();
assert!(matches!(err, AgentError::SlotAlreadyExists(_)));
}
/// Phase 10: derive_slot 父 slot 不存在返回 SlotNotFound。
#[tokio::test]
async fn derive_slot_parent_not_found() {
let (mut session, _, _) = build_session(vec![]);
let err = session
.derive_slot("child", "nonexistent", DeriveStrategy::Full)
.await
.unwrap_err();
assert!(matches!(err, AgentError::SlotNotFound(_)));
}
/// Phase 10: switch_slot 加载不存在的 slot 返回 SlotNotFound。
#[tokio::test]
async fn switch_slot_not_found() {
let (mut session, _, _) = build_session(vec![]);
let err = session.switch_slot("missing").await.unwrap_err();
assert!(matches!(err, AgentError::SlotNotFound(_)));
}
/// Phase 10: slot 数据持久化到 storageswitch 时可恢复。
/// 使用 session_memory_backend 配置可验证持久化。
#[tokio::test]
async fn slot_persistence_roundtrip() {
// 创建一个共享的 InMemoryStore 作为后端
let backend = Arc::new(InMemoryStore::new());
let provider = Arc::new(MockProvider::new(vec![assistant_text("resp")]));
let agent = Arc::new(StubAgent {
name: "stub".into(),
prompt: None,
});
let bundle = Arc::new(
AgentBuilder::new()
.provider(provider)
.tool_registry(Arc::new(ToolRegistry::new()))
.hook_executor(Arc::new(HookExecutor::new()))
.session_memory_backend(backend.clone())
.build()
.unwrap(),
);
let mut session = AgentSession::new(agent, "persist-session", bundle);
session.create_slot("persist_test", None).await.unwrap();
session.switch_slot("persist_test").await.unwrap();
session.submit_turn("hi").await.unwrap();
// 验证 data/meta/config 三个 key 都已写入共享 backend
let stored_data = backend
.get(&ContextSlot::data_key("persist-session", "persist_test"))
.await
.unwrap();
assert!(stored_data.is_some(), "data 应已持久化");
let stored_meta = backend
.get(&ContextSlot::meta_key("persist-session", "persist_test"))
.await
.unwrap();
assert!(stored_meta.is_some(), "meta 应已持久化");
let stored_config = backend
.get(&ContextSlot::config_key("persist-session", "persist_test"))
.await
.unwrap();
assert!(stored_config.is_some(), "config 应已持久化");
}
/// Phase 10: 当只有 `memory_store`(无 `session_memory_backend`)时,resolve_store
/// 应 fallback 到 `memory_store`。
#[tokio::test]
async fn resolve_store_falls_back_to_memory_store() {
let backend = Arc::new(InMemoryStore::new());
let provider = Arc::new(MockProvider::new(vec![assistant_text("ok")]));
let agent = Arc::new(StubAgent {
name: "stub".into(),
prompt: None,
});
// 注意:这里只设置 memory_store,不设置 session_memory_backend
let bundle = Arc::new(
AgentBuilder::new()
.provider(provider)
.tool_registry(Arc::new(ToolRegistry::new()))
.hook_executor(Arc::new(HookExecutor::new()))
.memory_store(backend.clone())
.build()
.unwrap(),
);
let mut session = AgentSession::new(agent, "fb-session", bundle);
session.create_slot("fb_slot", None).await.unwrap();
// 验证 backend 中已存在 fb_slot 的数据
let stored = backend
.get(&ContextSlot::data_key("fb-session", "fb_slot"))
.await
.unwrap();
assert!(stored.is_some(), "memory_store fallback 应生效");
}
/// Phase 10: finalize_turn 在 current_slot 不存在时返回 SlotNotFound(与 submit_turn 一致)。
#[tokio::test]
async fn finalize_turn_slot_not_found() {
let (mut session, _, _) = build_session(vec![assistant_text("ok")]);
// 强制 current_slot_id 指向不存在的 slot(模拟异常状态)
session.current_slot_id = "ghost".to_string();
let response = assistant_text("ok");
let err = session.finalize_turn(&response, vec![]).await.unwrap_err();
assert!(matches!(err, AgentError::SlotNotFound(_)));
}
/// Phase 10: finalize_turn 在 Readonly slot 上返回 SlotReadonly。
#[tokio::test]
async fn finalize_turn_readonly_rejects() {
let (mut session, _, _) = build_session(vec![assistant_text("ok")]);
session
.create_slot(
"ro",
Some(SlotConfig {
mode: SlotMode::Readonly,
source: SlotSource::New,
budget: Default::default(),
compact: true,
}),
)
.await
.unwrap();
session.switch_slot("ro").await.unwrap();
let response = assistant_text("ok");
let err = session
.finalize_turn(&response, vec![Message::user_text("x")])
.await
.unwrap_err();
assert!(matches!(err, AgentError::SlotReadonly(_)));
}
// ====== Phase 9 Step 5: 集成测试 ======
/// Phase 9 Step 5.1 — `submit_turn_stream` 端到端链路。
///
/// 验证:mock provider → `submit_turn_stream` 消费流 → 收到 TextDelta + MessageComplete
/// → `finalize_turn` 后 `cost_so_far` 正确更新,turn_index 递增。
#[tokio::test(flavor = "multi_thread")]
async fn submit_turn_stream_end_to_end() {
let (mut session, _, _) = build_session(vec![assistant_text("hi back")]);
let mut stream = session
.submit_turn_stream("user msg")
.await
.expect("submit_turn_stream 应成功");
// 消费流并提取 MessageComplete
let mut final_response: Option<MessageResponse> = None;
while let Some(ev) = stream.next().await {
if let StreamEvent::MessageComplete { full_response } = &ev {
final_response = Some(full_response.clone());
}
}
let response = final_response.expect("流中应有 MessageComplete");
// consumer 负责构造本轮新增消息列表(user_input + assistant_response)。
// submit_turn_stream 不会自动写入 self.slots(流是延迟的),
// 消费者需在 finalize_turn 时把 [user_input, ...tool_results, final_response] 一并传入。
let new_messages = vec![Message::user_text("user msg"), response.message.clone()];
session
.finalize_turn(&response, new_messages)
.await
.expect("finalize_turn 应成功");
// cost_so_far 已累计(assistant_text 的 usage 是 from_input_output(10, 5)
assert_eq!(session.usage().total().prompt_tokens, 10);
assert_eq!(session.usage().total().completion_tokens, 5);
// turn_index 已递增
assert_eq!(session.turn_index(), 1);
// default slot 应包含 user 输入和 assistant 响应
let slot = session.slots.get("default").expect("default slot");
let has_user = slot
.messages
.iter()
.any(|m| matches!(m, Message::User { .. }));
let has_resp = slot.messages.iter().any(|m| extract_text(m) == "hi back");
assert!(
has_user && has_resp,
"default slot 应包含 user 和 assistant 消息"
);
}
/// Phase 9 Step 5.2 — `submit_turn_stream` 触发 OnTurnStart / OnTurnEnd hook。
///
/// 验证:OnTurnStart 在 `submit_turn_stream` 返回流之前已触发;
/// OnTurnEnd 在 `finalize_turn` 调用后才触发。
#[tokio::test(flavor = "multi_thread")]
async fn submit_turn_stream_triggers_turn_hooks() {
let (mut session, start_count, end_count) = build_session(vec![assistant_text("ok")]);
// 初始状态:两个 hook 计数都是 0
assert_eq!(start_count.0.load(Ordering::SeqCst), 0);
assert_eq!(end_count.0.load(Ordering::SeqCst), 0);
// 调 submit_turn_stream
let mut stream = session
.submit_turn_stream("user msg")
.await
.expect("submit_turn_stream 应成功");
// OnTurnStart 应在流返回前已触发
assert_eq!(
start_count.0.load(Ordering::SeqCst),
1,
"OnTurnStart 应在 submit_turn_stream 返回流之前触发"
);
// OnTurnEnd 此时尚未触发
assert_eq!(
end_count.0.load(Ordering::SeqCst),
0,
"OnTurnEnd 不应在 submit_turn_stream 阶段触发"
);
// 消费流
let mut final_response: Option<MessageResponse> = None;
while let Some(ev) = stream.next().await {
if let StreamEvent::MessageComplete { full_response } = &ev {
final_response = Some(full_response.clone());
}
}
// finalize_turn
let response = final_response.expect("流中应有 MessageComplete");
session
.finalize_turn(&response, vec![])
.await
.expect("finalize_turn 应成功");
// OnTurnEnd 已触发
assert_eq!(
end_count.0.load(Ordering::SeqCst),
1,
"OnTurnEnd 应在 finalize_turn 后触发"
);
}
// ====== Phase 16: 摘要自动生成测试 ======
/// 构造带 `SummaryConfig` 的 session。
/// mock provider 队列按 `[conv_1, summary_1, conv_2, summary_2, ...]` 交错排列,
/// 因为每轮 `submit_turn` 中 conversation LLM 调用先于 summary LLM 调用。
fn build_session_with_summary(
provider_responses: Vec<MessageResponse>,
summary_responses: Vec<MessageResponse>,
cfg: SummaryConfig,
) -> AgentSession {
let mut interleaved = Vec::new();
let max_len = provider_responses.len().max(summary_responses.len());
for i in 0..max_len {
if let Some(r) = provider_responses.get(i) {
interleaved.push(r.clone());
}
if let Some(r) = summary_responses.get(i) {
interleaved.push(r.clone());
}
}
let provider = Arc::new(MockProvider::new(interleaved));
let agent = Arc::new(StubAgent {
name: "stub".into(),
prompt: None,
});
let bundle = Arc::new(
AgentBuilder::new()
.provider(provider)
.tool_registry(Arc::new(ToolRegistry::new()))
.hook_executor(Arc::new(HookExecutor::new()))
.summary_config(cfg)
.build()
.unwrap(),
);
AgentSession::new(agent, "summary-session", bundle)
}
/// 默认用法:token 用量 ~15,远低于默认 32K 窗口的 0.75=24K 阈值 → 不触发摘要。
#[tokio::test]
async fn summary_not_generated_below_threshold() {
let mut session = build_session_with_summary(
vec![
assistant_text("a"),
assistant_text("b"),
assistant_text("c"),
],
vec![assistant_text("should_not_appear")],
SummaryConfig::default(),
);
for i in 0..3 {
session
.submit_turn(&format!("msg {}", i))
.await
.expect("submit_turn 应成功");
}
let summary = session.get_conversation_summary().await.unwrap();
assert!(summary.is_none(), "未达阈值时不应生成摘要");
}
/// 设置极低 max_context_tokens=100 + 0.5 比例 → 第一轮触发(usage 为 10+5=15 > 50)。
#[tokio::test]
async fn summary_generated_above_threshold() {
let mut session = build_session_with_summary(
vec![
assistant_text("a"),
assistant_text("b"),
assistant_text("c"),
],
vec![
assistant_text("summary-1"),
assistant_text("summary-2"),
assistant_text("summary-3"),
],
SummaryConfig {
max_context_tokens: 20, // 阈值 20 * 0.5 = 10
trigger_token_ratio: 0.5,
debounce_turns: 0, // 关闭防抖便于测试
..SummaryConfig::default()
},
);
// 第 1 轮:usage=15 ≥ 10,debounce=0 → 触发
session.submit_turn("m1").await.unwrap();
let summary = session.get_conversation_summary().await.unwrap();
assert!(summary.is_some(), "应触发摘要");
}
/// 防抖:trigger 触发后,debounce_turns=3 内即使再次达阈值也不重复。
#[tokio::test]
async fn summary_debounce_works() {
let mut session = build_session_with_summary(
vec![
assistant_text("r1"),
assistant_text("r2"),
assistant_text("r3"),
assistant_text("r4"),
],
vec![assistant_text("sum-1")],
SummaryConfig {
max_context_tokens: 20,
trigger_token_ratio: 0.5,
debounce_turns: 3,
..SummaryConfig::default()
},
);
session.submit_turn("m1").await.unwrap();
let first_summary = session.get_conversation_summary().await.unwrap();
assert_eq!(first_summary.as_deref(), Some("sum-1"));
// 第 2、3 轮:即使都超阈值,debounce 阻止再次触发
for _ in 0..2 {
session.submit_turn("m").await.unwrap();
}
let still_summary = session.get_conversation_summary().await.unwrap();
assert_eq!(
still_summary.as_deref(),
Some("sum-1"),
"debounce 内不应重复生成(Provider 上没有更多预设摘要响应可用)"
);
}
/// Full 模式:摘要被生成并写入 session_memory,但 slot config.summary_override 仍为 None。
#[tokio::test]
async fn summary_written_to_session_memory_but_full_mode_does_not_inject() {
let mut session = build_session_with_summary(
vec![
assistant_text("a"),
assistant_text("b"),
assistant_text("c"),
],
vec![assistant_text("captured-summary")],
SummaryConfig {
max_context_tokens: 20,
trigger_token_ratio: 0.5,
debounce_turns: 0,
..SummaryConfig::default()
},
);
session.submit_turn("m1").await.unwrap();
let summary = session.get_conversation_summary().await.unwrap();
assert_eq!(summary.as_deref(), Some("captured-summary"));
// default slot 是 Full 模式 → summary_override 应为 Nonefilter_focused 不会触发)
let slot = session.slots.get("default").unwrap();
assert!(matches!(slot.config.mode, SlotMode::Full));
}
/// 摘要生成失败不阻断 submit_turn(Provider 队列只够对话轮次,摘要调用返回 Other 错误)。
#[tokio::test]
async fn summary_failure_does_not_block_turn() {
// 故意只提供 1 个对话响应;摘要调用时队列耗尽,MockProvider 返回 LlmError::Other
let mut session = build_session_with_summary(
vec![assistant_text("only-one")], // 后续摘要会失败
vec![], // 无摘要响应
SummaryConfig {
max_context_tokens: 20,
trigger_token_ratio: 0.5,
debounce_turns: 0,
..SummaryConfig::default()
},
);
let response = session
.submit_turn("m1")
.await
.expect("submit_turn 应成功(即便摘要失败)");
assert_eq!(extract_text(&response.message), "only-one");
// 摘要未生成(Provider 已耗尽)
let summary = session.get_conversation_summary().await.unwrap();
assert!(summary.is_none());
}
/// 未配置 SummaryConfig 时零影响。
#[tokio::test]
async fn summary_skipped_when_not_configured() {
let (mut session, _, _) = build_session(vec![assistant_text("r1"), assistant_text("r2")]);
for _ in 0..2 {
session.submit_turn("m").await.unwrap();
}
let summary = session.get_conversation_summary().await.unwrap();
assert!(summary.is_none());
}
/// 流式路径(submit_turn_stream + finalize_turn):摘要检查点正确触发。
#[tokio::test(flavor = "multi_thread")]
async fn summary_stream_path_triggers_check() {
let mut session = build_session_with_summary(
vec![assistant_text("stream-resp")],
vec![assistant_text("stream-summary")],
SummaryConfig {
max_context_tokens: 20,
trigger_token_ratio: 0.5,
debounce_turns: 0,
..SummaryConfig::default()
},
);
let mut stream = session
.submit_turn_stream("user msg")
.await
.expect("stream ok");
let mut response: Option<MessageResponse> = None;
while let Some(ev) = stream.next().await {
if let StreamEvent::MessageComplete { full_response } = &ev {
response = Some(full_response.clone());
}
}
let resp = response.expect("MessageComplete event");
// finalize_turn 需要本轮新增消息:用户输入 + assistant 响应。
// slot.append_messages 之后才会被 maybe_summarize 看到。
let new_messages = vec![Message::user_text("user msg"), resp.message.clone()];
session
.finalize_turn(&resp, new_messages)
.await
.expect("finalize_turn ok");
let summary = session.get_conversation_summary().await.unwrap();
assert_eq!(summary.as_deref(), Some("stream-summary"));
}
/// W7Focused 模式摘要写入 `summary_override` + `slot.save()` 正向验证。
#[tokio::test]
async fn summary_written_to_focused_slot_config() {
let mut session = build_session_with_summary(
vec![assistant_text("a"), assistant_text("b")],
vec![assistant_text("the-summary")],
SummaryConfig {
max_context_tokens: 20,
trigger_token_ratio: 0.5,
debounce_turns: 0,
..SummaryConfig::default()
},
);
// 1. 把 default slot 切到 Focused 模式
session
.create_slot(
"focused",
Some(SlotConfig {
mode: SlotMode::Focused(FocusedConfig {
keep_system: false,
recent_messages: 5,
summary_override: None,
}),
source: SlotSource::New,
budget: Default::default(),
compact: true,
}),
)
.await
.unwrap();
session.switch_slot("focused").await.unwrap();
// 2. 触发摘要
session.submit_turn("m1").await.unwrap();
// 3. SessionMemory 有值
let summary = session.get_conversation_summary().await.unwrap();
assert_eq!(summary.as_deref(), Some("the-summary"));
// 4. Focused slot 的 summary_override 也应有值(正向验证)
let slot = session.slots.get("focused").unwrap();
assert!(
matches!(&slot.config.mode, SlotMode::Focused(focused) if focused.summary_override.is_some()),
"Focused 模式下 summary_override 应被写入"
);
}
/// W1: 空消息守卫——`generate_summary` 空消息直接返回 `""`,不调用 LLM。
/// 这里通过构建一个空 slot 触发,第一次 `submit_turn` 后 slot 才有消息。
/// 验证:先调用 `format_messages_as_text` 走纯函数路径检查。
#[tokio::test]
async fn summary_skipped_for_empty_messages() {
// 直接走 format_messages_as_text,验证空消息返回空串。
// 这等同于 generate_summary 入口守卫(见 session.rs:560-562)。
let text = format_messages_as_text(&[], 500);
assert_eq!(text, "");
}
/// W1: `max_context_tokens` 设置过大时永不触发摘要。
#[tokio::test]
async fn summary_not_generated_if_max_context_unreachable() {
let mut session = build_session_with_summary(
vec![
assistant_text("r1"),
assistant_text("r2"),
assistant_text("r3"),
assistant_text("r4"),
],
vec![assistant_text("should-not-appear")],
SummaryConfig {
max_context_tokens: 1_000_000, // 远大于任何合理累计 token
trigger_token_ratio: 0.75,
debounce_turns: 0,
..SummaryConfig::default()
},
);
// 多轮 submit_turn,全部 15 token/轮,远低于 0.75 * 1M = 750K 阈值
for i in 0..4 {
session.submit_turn(&format!("m{}", i)).await.unwrap();
}
let summary = session.get_conversation_summary().await.unwrap();
assert!(summary.is_none(), "巨型 max_context_tokens 应永不触发");
}
}