5baa170508
- README 添加 feature 组合表 + 模块级 features 清单 + 升级指南 - 18 个 example 顶部添加 Required features 注释 - roadmap.md 和 roadmap-v0.3.2.md 同步 Phase 26-27 完成状态 - cargo fmt 全量格式化(修复预存格式问题,CI format job 可通过)
1652 lines
64 KiB
Rust
1652 lines
64 KiB
Rust
//! 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 新增:所有 slot(id → 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 已注册 slot,finalize_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)
|
||
}
|
||
|
||
/// 烟雾测试 1:AgentSession::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);
|
||
}
|
||
|
||
/// 烟雾测试 2:session_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())
|
||
);
|
||
}
|
||
|
||
/// 烟雾测试 3:submit_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_prompt,system 由 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 数据持久化到 storage,switch 时可恢复。
|
||
/// 使用 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 应为 None(filter_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"));
|
||
}
|
||
|
||
/// W7:Focused 模式摘要写入 `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 应永不触发");
|
||
}
|
||
}
|