Phase 17 前置 derive 改动: - CostTracker 加 Clone + Serialize + Deserialize(3 行)—— 用于 SessionSnapshot 序列化 cost_so_far 字段 - ContextSlot 加 Serialize + Deserialize(1 行)—— 用于 SessionSnapshot 序列化 slots HashMap - MergeStrategy 加 Serialize + Deserialize(1 行)—— 与 ContextSlot 一致 零破坏性:派生仅为新增能力,不修改现有行为。ContextSlot 的 save() 路径独立序列化 messages/meta/config,不走整体 serde,两组路径可共存。 Phase 17: Agent 执行引擎 — Step 1
1124 lines
40 KiB
Rust
1124 lines
40 KiB
Rust
//! ContextSlot —— 多上下文槽位管理。
|
||
//!
|
||
//! 设计要点(参见 `docs/17-phase10-contextslot.md`):
|
||
//!
|
||
//! - **多上下文分区**:单个 session 内可创建/切换/派生多个独立消息上下文
|
||
//! - **三种模式**:Full(完整历史)/ Focused(读时过滤)/ Readonly(禁止写入)
|
||
//! - **三种来源**:New(全新)/ Derived(派生)/ Static(静态)
|
||
//! - **基于 MemoryStore trait 持久化**:JSON blob 批次存储,每 slot 3-4 条 MemoryItem 记录
|
||
//! - **零新依赖方向**:放在 `agent/` 下利用已有的 `agent → memory` 依赖
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
use time::OffsetDateTime;
|
||
|
||
use crate::agent::error::AgentError;
|
||
use crate::llm::types::message::Message;
|
||
use crate::memory::store::MemoryStore;
|
||
use crate::memory::types::{MemoryFilter, MemoryItem};
|
||
|
||
/// 上下文槽 —— 一段带策略配置的消息列表。
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct ContextSlot {
|
||
/// 当前 slot 的唯一标识(同一个 session_id 内唯一)。
|
||
pub id: String,
|
||
/// 所属 session。
|
||
pub session_id: String,
|
||
/// 槽配置。
|
||
pub config: SlotConfig,
|
||
/// 消息列表(全量,Focused/Readonly 在读取时做策略过滤)。
|
||
pub messages: Vec<Message>,
|
||
/// 槽元数据。
|
||
pub meta: SlotMeta,
|
||
}
|
||
|
||
/// 槽配置。
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct SlotConfig {
|
||
/// 槽模式(Full / Focused / Readonly)。
|
||
pub mode: SlotMode,
|
||
/// 槽来源(New / Derived / Static)。
|
||
pub source: SlotSource,
|
||
/// 上下文预算(v0.2 纯数据结构,无消费逻辑)。
|
||
pub budget: ContextBudget,
|
||
/// 是否启用自动压缩(v0.2 保留字段,LlmCycle 内部自行判断)。
|
||
pub compact: bool,
|
||
}
|
||
|
||
impl Default for SlotConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
mode: SlotMode::Full,
|
||
source: SlotSource::New,
|
||
budget: ContextBudget::default(),
|
||
compact: true,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 槽模式。
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
#[non_exhaustive]
|
||
pub enum SlotMode {
|
||
/// 完整对话历史(全部消息)。
|
||
Full,
|
||
/// 聚焦模式 —— 读取时按策略过滤,保持 LLM 注意力。
|
||
Focused(FocusedConfig),
|
||
/// 只读参考上下文 —— 禁止写入。
|
||
Readonly,
|
||
}
|
||
|
||
/// 聚焦模式配置。
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct FocusedConfig {
|
||
/// 是否保留 system prompt。
|
||
pub keep_system: bool,
|
||
/// 保留的最近消息条数(以消息条数而非对话轮次为单位,因为一轮对话可能包含多条 tool 消息)。
|
||
pub recent_messages: usize,
|
||
/// 摘要覆盖(消费端:手动或自动生成的摘要会注入到消息列表末尾)。
|
||
/// v0.3 Phase 16 起,`AgentBuilder::summary_config(cfg)` 内联检查点会
|
||
/// 自动调用 LLM 生成摘要并写入此字段,详见 `docs/22-phase16-summary-auto-generation.md`。
|
||
pub summary_override: Option<String>,
|
||
}
|
||
|
||
/// 槽来源。
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
#[non_exhaustive]
|
||
pub enum SlotSource {
|
||
/// 全新空槽。
|
||
New,
|
||
/// 从父 slot 派生(记录 parent_id)。
|
||
Derived {
|
||
parent_id: String,
|
||
strategy: DeriveStrategy,
|
||
},
|
||
/// 预置静态消息(不持久化,随 session 生命周期存在)。
|
||
Static(Vec<Message>),
|
||
}
|
||
|
||
/// 派生策略。
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub enum DeriveStrategy {
|
||
/// 完整复制父 slot 的消息。
|
||
Full,
|
||
/// 按聚焦策略复制父 slot 的消息。
|
||
Focused(FocusedConfig),
|
||
}
|
||
|
||
/// 合并策略 —— Phase 13 新增,控制 `ContextSlot::merge` 如何将子 slot 消息合入父 slot。
|
||
///
|
||
/// `#[non_exhaustive]` 预留未来扩展(如 `Summarize` 变体)。
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
#[non_exhaustive]
|
||
pub enum MergeStrategy {
|
||
/// 子 slot 消息追加到父 slot 末尾。
|
||
Append,
|
||
/// 用子 slot 消息替换父 slot 内容。
|
||
Replace,
|
||
}
|
||
|
||
/// 上下文预算(v0.2 纯数据结构,无消费逻辑)。
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct ContextBudget {
|
||
/// system prompt 预算。
|
||
pub system: u32,
|
||
/// 对话历史预算。
|
||
pub history: u32,
|
||
/// 工具定义预算。
|
||
pub tools: u32,
|
||
/// 工具结果预算。
|
||
pub tool_results: u32,
|
||
/// 预留 buffer。
|
||
pub reserve: u32,
|
||
}
|
||
|
||
impl Default for ContextBudget {
|
||
fn default() -> Self {
|
||
Self {
|
||
system: 8_000,
|
||
history: 80_000,
|
||
tools: 10_000,
|
||
tool_results: 20_000,
|
||
reserve: 10_000,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl ContextBudget {
|
||
/// 自动分配:按上下文窗口的固定比例分配预算。
|
||
/// v0.2 只做占位实现,v0.3 将根据实际 provider 的 context_window 计算。
|
||
pub fn auto() -> Self {
|
||
Self::default()
|
||
}
|
||
}
|
||
|
||
/// 槽元数据。
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct SlotMeta {
|
||
/// 父 slot id(仅 Derived 来源有值)。
|
||
pub parent_id: Option<String>,
|
||
/// 消息总数。
|
||
pub message_count: usize,
|
||
/// 总 token 估算值(由 add_messages 时累计,v0.2 为近似值)。
|
||
pub total_tokens: u32,
|
||
/// 创建时间(Unix 时间戳,秒)。
|
||
pub created_at: u64,
|
||
}
|
||
|
||
impl SlotMeta {
|
||
pub fn new() -> Self {
|
||
Self {
|
||
parent_id: None,
|
||
message_count: 0,
|
||
total_tokens: 0,
|
||
created_at: std::time::SystemTime::now()
|
||
.duration_since(std::time::SystemTime::UNIX_EPOCH)
|
||
.map(|d| d.as_secs())
|
||
.unwrap_or(0),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Default for SlotMeta {
|
||
fn default() -> Self {
|
||
Self::new()
|
||
}
|
||
}
|
||
|
||
impl ContextSlot {
|
||
/// 持久化 key 前缀。
|
||
const KEY_DATA: &'static str = "slot_data";
|
||
const KEY_META: &'static str = "slot_meta";
|
||
const KEY_CONFIG: &'static str = "slot_config";
|
||
const KEY_REL: &'static str = "slot_rel";
|
||
|
||
/// 校验 id 不含冒号(避免破坏 key 格式与 list prefix 过滤)。
|
||
/// 失败时 panic —— 这是开发者错误而非用户错误。
|
||
fn assert_no_colon(id: &str, field: &str) {
|
||
if id.contains(':') {
|
||
panic!(
|
||
"{field} '{id}' contains ':' which would break key format. \
|
||
Use only letters, digits, hyphens and underscores."
|
||
);
|
||
}
|
||
}
|
||
|
||
pub(crate) fn data_key(session_id: &str, slot_id: &str) -> String {
|
||
Self::assert_no_colon(session_id, "session_id");
|
||
Self::assert_no_colon(slot_id, "slot_id");
|
||
format!("{}:{}:{}", Self::KEY_DATA, session_id, slot_id)
|
||
}
|
||
pub(crate) fn meta_key(session_id: &str, slot_id: &str) -> String {
|
||
Self::assert_no_colon(session_id, "session_id");
|
||
Self::assert_no_colon(slot_id, "slot_id");
|
||
format!("{}:{}:{}", Self::KEY_META, session_id, slot_id)
|
||
}
|
||
pub(crate) fn config_key(session_id: &str, slot_id: &str) -> String {
|
||
Self::assert_no_colon(session_id, "session_id");
|
||
Self::assert_no_colon(slot_id, "slot_id");
|
||
format!("{}:{}:{}", Self::KEY_CONFIG, session_id, slot_id)
|
||
}
|
||
pub(crate) fn rel_key(session_id: &str, child_id: &str) -> String {
|
||
Self::assert_no_colon(session_id, "session_id");
|
||
Self::assert_no_colon(child_id, "child_id");
|
||
format!("{}:{}:{}", Self::KEY_REL, session_id, child_id)
|
||
}
|
||
|
||
/// 构造 MemoryItem 的辅助函数。
|
||
fn make_item(key: String, content: String) -> MemoryItem {
|
||
MemoryItem {
|
||
id: key,
|
||
content,
|
||
metadata: serde_json::json!({}),
|
||
created_at: OffsetDateTime::now_utc(),
|
||
}
|
||
}
|
||
|
||
/// 创建一个新的空 ContextSlot(不持久化,仅内存构造)。
|
||
pub fn new(
|
||
session_id: impl Into<String>,
|
||
slot_id: impl Into<String>,
|
||
config: SlotConfig,
|
||
) -> Self {
|
||
Self {
|
||
id: slot_id.into(),
|
||
session_id: session_id.into(),
|
||
config,
|
||
messages: Vec::new(),
|
||
meta: SlotMeta::new(),
|
||
}
|
||
}
|
||
|
||
/// 保存 slot 数据到存储后端(全量写入,含 config)。
|
||
pub async fn save(&self, store: &dyn MemoryStore) -> Result<(), AgentError> {
|
||
let data = serde_json::to_string(&self.messages)
|
||
.map_err(|e| AgentError::Other(e.to_string()))?;
|
||
let meta = serde_json::to_string(&self.meta)
|
||
.map_err(|e| AgentError::Other(e.to_string()))?;
|
||
let config = serde_json::to_string(&self.config)
|
||
.map_err(|e| AgentError::Other(e.to_string()))?;
|
||
|
||
store
|
||
.save(Self::make_item(
|
||
Self::data_key(&self.session_id, &self.id),
|
||
data,
|
||
))
|
||
.await
|
||
.map_err(AgentError::Memory)?;
|
||
store
|
||
.save(Self::make_item(
|
||
Self::meta_key(&self.session_id, &self.id),
|
||
meta,
|
||
))
|
||
.await
|
||
.map_err(AgentError::Memory)?;
|
||
store
|
||
.save(Self::make_item(
|
||
Self::config_key(&self.session_id, &self.id),
|
||
config,
|
||
))
|
||
.await
|
||
.map_err(AgentError::Memory)?;
|
||
|
||
// 派生关系
|
||
if let SlotSource::Derived { parent_id, .. } = &self.config.source {
|
||
store
|
||
.save(Self::make_item(
|
||
Self::rel_key(&self.session_id, &self.id),
|
||
parent_id.clone(),
|
||
))
|
||
.await
|
||
.map_err(AgentError::Memory)?;
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// 从存储加载 slot,config 从 `slot_config` key 自行恢复。
|
||
/// 若 config 记录不存在(旧版本升级场景),使用 `SlotConfig::default()`。
|
||
pub async fn load(
|
||
id: &str,
|
||
session_id: &str,
|
||
store: &dyn MemoryStore,
|
||
) -> Result<Option<Self>, AgentError> {
|
||
let meta_item = store
|
||
.get(&Self::meta_key(session_id, id))
|
||
.await
|
||
.map_err(AgentError::Memory)?;
|
||
let data_item = store
|
||
.get(&Self::data_key(session_id, id))
|
||
.await
|
||
.map_err(AgentError::Memory)?;
|
||
let config_item = store
|
||
.get(&Self::config_key(session_id, id))
|
||
.await
|
||
.map_err(AgentError::Memory)?;
|
||
|
||
match (meta_item, data_item) {
|
||
(Some(m), Some(d)) => {
|
||
let meta: SlotMeta = serde_json::from_str(&m.content)
|
||
.map_err(|e| AgentError::Other(e.to_string()))?;
|
||
let messages: Vec<Message> = serde_json::from_str(&d.content)
|
||
.map_err(|e| AgentError::Other(e.to_string()))?;
|
||
// config 从存储恢复;不存在则使用 default(兼容旧版本)
|
||
let config = match config_item {
|
||
Some(c) => serde_json::from_str(&c.content)
|
||
.map_err(|e| AgentError::Other(e.to_string()))?,
|
||
None => SlotConfig::default(),
|
||
};
|
||
Ok(Some(Self {
|
||
id: id.to_string(),
|
||
session_id: session_id.to_string(),
|
||
config,
|
||
messages,
|
||
meta,
|
||
}))
|
||
}
|
||
_ => Ok(None),
|
||
}
|
||
}
|
||
|
||
/// 列出某 session 下的所有 slot 元数据。
|
||
pub async fn list(
|
||
session_id: &str,
|
||
store: &dyn MemoryStore,
|
||
) -> Result<Vec<SlotMeta>, AgentError> {
|
||
let prefix_str = format!("{}:{}:", Self::KEY_META, session_id);
|
||
let filter = MemoryFilter {
|
||
prefix: Some(prefix_str),
|
||
..Default::default()
|
||
};
|
||
let items = store.list(&filter).await.map_err(AgentError::Memory)?;
|
||
let mut metas = Vec::new();
|
||
for item in items {
|
||
if let Ok(meta) = serde_json::from_str::<SlotMeta>(&item.content) {
|
||
metas.push(meta);
|
||
}
|
||
}
|
||
Ok(metas)
|
||
}
|
||
|
||
/// 删除 slot 的所有存储记录(slot_data + slot_meta + slot_config + slot_rel)。
|
||
pub async fn delete(
|
||
id: &str,
|
||
session_id: &str,
|
||
store: &dyn MemoryStore,
|
||
) -> Result<(), AgentError> {
|
||
store
|
||
.delete(&Self::data_key(session_id, id))
|
||
.await
|
||
.map_err(AgentError::Memory)?;
|
||
store
|
||
.delete(&Self::meta_key(session_id, id))
|
||
.await
|
||
.map_err(AgentError::Memory)?;
|
||
store
|
||
.delete(&Self::config_key(session_id, id))
|
||
.await
|
||
.map_err(AgentError::Memory)?;
|
||
// slot_rel 是 best-effort(仅 Derived 来源的 slot 才有此 key)
|
||
let _ = store.delete(&Self::rel_key(session_id, id)).await;
|
||
Ok(())
|
||
}
|
||
|
||
/// 追加消息(Readonly 模式下返回 `SlotReadonly` 错误)。
|
||
/// Full / Focused 模式下允许追加。
|
||
pub fn append_messages(&mut self, new_messages: Vec<Message>) -> Result<(), AgentError> {
|
||
if matches!(self.config.mode, SlotMode::Readonly) {
|
||
return Err(AgentError::SlotReadonly(
|
||
"Readonly slot does not allow writes".into(),
|
||
));
|
||
}
|
||
let count = new_messages.len();
|
||
self.messages.extend(new_messages);
|
||
self.meta.message_count += count;
|
||
Ok(())
|
||
}
|
||
|
||
/// 按 FocusedConfig 过滤消息(静态辅助函数,被 `load_messages` 和 `derive_slot` 复用)。
|
||
///
|
||
/// 过滤逻辑:
|
||
/// 1. 保留第一条 system 消息(如果 `keep_system=true`)
|
||
/// 2. 取最近 `recent_messages` 条非 system 消息(如果 `recent_messages > 0`)
|
||
/// 3. 追加摘要消息(如果 `summary_override` 存在)
|
||
pub fn filter_focused(messages: &[Message], cfg: &FocusedConfig) -> Vec<Message> {
|
||
let mut result = Vec::new();
|
||
// 保留 system prompt
|
||
if cfg.keep_system
|
||
&& let Some(msg) = messages
|
||
.iter()
|
||
.find(|m| matches!(m, Message::System { .. }))
|
||
{
|
||
result.push(msg.clone());
|
||
}
|
||
// 处理 recent_messages=0 边界:上面已处理 system,下面仅取最近 N 条
|
||
if cfg.recent_messages > 0 {
|
||
let recent: Vec<&Message> = messages
|
||
.iter()
|
||
.filter(|m| !matches!(m, Message::System { .. }))
|
||
.collect();
|
||
let start = recent.len().saturating_sub(cfg.recent_messages);
|
||
for msg in recent.iter().skip(start) {
|
||
result.push((*msg).clone());
|
||
}
|
||
}
|
||
// 注入摘要
|
||
if let Some(summary) = &cfg.summary_override {
|
||
result.push(Message::system(format!("[上下文摘要] {}", summary)));
|
||
}
|
||
result
|
||
}
|
||
|
||
/// 返回消息列表。Focused 模式下按策略过滤(裁剪到最近 recent_messages 条)。
|
||
pub fn load_messages(&self) -> Vec<Message> {
|
||
match &self.config.mode {
|
||
SlotMode::Focused(cfg) => Self::filter_focused(&self.messages, cfg),
|
||
_ => self.messages.clone(),
|
||
}
|
||
}
|
||
|
||
/// 从当前 slot 派生出独立的子 slot(不持久化,调用方负责 `save`)。
|
||
///
|
||
/// 子 slot 的 `meta` 全新创建(`SlotMeta::new()`),不继承父 slot 的 `message_count`。
|
||
/// 子 slot 的 `source` 标记为 `Derived { parent_id, strategy }`,血缘可追溯。
|
||
pub fn fork(&self, child_id: String, strategy: DeriveStrategy) -> ContextSlot {
|
||
let messages = match &strategy {
|
||
DeriveStrategy::Full => self.messages.clone(),
|
||
DeriveStrategy::Focused(cfg) => Self::filter_focused(&self.messages, cfg),
|
||
};
|
||
tracing::debug!(
|
||
parent_id = %self.id,
|
||
child_id = %child_id,
|
||
?strategy,
|
||
"ContextSlot::fork"
|
||
);
|
||
ContextSlot {
|
||
id: child_id,
|
||
session_id: self.session_id.clone(),
|
||
config: SlotConfig {
|
||
mode: match &strategy {
|
||
DeriveStrategy::Full => SlotMode::Full,
|
||
DeriveStrategy::Focused(cfg) => SlotMode::Focused(cfg.clone()),
|
||
},
|
||
source: SlotSource::Derived {
|
||
parent_id: self.id.clone(),
|
||
strategy,
|
||
},
|
||
budget: self.config.budget.clone(),
|
||
compact: self.config.compact,
|
||
},
|
||
messages,
|
||
meta: SlotMeta::new(),
|
||
}
|
||
}
|
||
|
||
/// 将子 slot 的消息合并到当前 slot。
|
||
///
|
||
/// **注意**:本方法仅操作内存数据,不自动持久化。
|
||
/// 调用方需在 merge 后自行调用 `self.save(&store)` 将结果写入后端存储。
|
||
///
|
||
/// 防御性检查:
|
||
/// - 禁止 self-merge(`self.id == child.id`)
|
||
/// - 禁止跨 session merge
|
||
/// - 禁止合并到 Readonly slot
|
||
pub fn merge(&mut self, child: ContextSlot, strategy: MergeStrategy) -> Result<(), AgentError> {
|
||
if self.id == child.id {
|
||
return Err(AgentError::Config("不能将 slot 合并到自身".into()));
|
||
}
|
||
if self.session_id != child.session_id {
|
||
return Err(AgentError::Config("不能合并不同 session 的 slot".into()));
|
||
}
|
||
if matches!(self.config.mode, SlotMode::Readonly) {
|
||
return Err(AgentError::SlotReadonly("Readonly slot 不允许合并".into()));
|
||
}
|
||
|
||
tracing::debug!(
|
||
self_id = %self.id,
|
||
child_id = %child.id,
|
||
?strategy,
|
||
"ContextSlot::merge"
|
||
);
|
||
|
||
match strategy {
|
||
MergeStrategy::Append => {
|
||
let count = child.messages.len();
|
||
self.messages.extend(child.messages);
|
||
self.meta.message_count += count;
|
||
}
|
||
MergeStrategy::Replace => {
|
||
self.messages = child.messages;
|
||
self.meta.message_count = self.messages.len();
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::memory::store::InMemoryStore;
|
||
|
||
fn make_store() -> std::sync::Arc<dyn MemoryStore> {
|
||
std::sync::Arc::new(InMemoryStore::new())
|
||
}
|
||
|
||
fn make_slot(id: &str, session: &str) -> ContextSlot {
|
||
ContextSlot::new(session, id, SlotConfig::default())
|
||
}
|
||
|
||
/// 提取 `Message` 的第一个 Text block 的内容(用于测试断言)。
|
||
/// 返回 None 表示该消息不含纯文本 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;
|
||
}
|
||
}
|
||
""
|
||
}
|
||
|
||
// ===== 持久化 =====
|
||
|
||
#[tokio::test]
|
||
async fn slot_save_load_roundtrip() {
|
||
let store = make_store();
|
||
let mut slot = make_slot("default", "s1");
|
||
slot.append_messages(vec![Message::user_text("hi")]).unwrap();
|
||
slot.append_messages(vec![Message::assistant("hello")]).unwrap();
|
||
|
||
slot.save(&*store).await.unwrap();
|
||
let loaded = ContextSlot::load("default", "s1", &*store).await.unwrap();
|
||
let loaded = loaded.expect("slot should exist after save");
|
||
assert_eq!(loaded.id, "default");
|
||
assert_eq!(loaded.session_id, "s1");
|
||
assert_eq!(loaded.messages.len(), 2);
|
||
assert_eq!(loaded.meta.message_count, 2);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn slot_session_isolation() {
|
||
let store = make_store();
|
||
let mut a = make_slot("main", "sA");
|
||
a.append_messages(vec![Message::user_text("only in A")])
|
||
.unwrap();
|
||
a.save(&*store).await.unwrap();
|
||
|
||
let mut b = make_slot("main", "sB");
|
||
b.append_messages(vec![Message::user_text("only in B")])
|
||
.unwrap();
|
||
b.save(&*store).await.unwrap();
|
||
|
||
let loaded_a = ContextSlot::load("main", "sA", &*store).await.unwrap().unwrap();
|
||
let loaded_b = ContextSlot::load("main", "sB", &*store).await.unwrap().unwrap();
|
||
assert_eq!(extract_text(&loaded_a.messages[0]), "only in A");
|
||
assert_eq!(extract_text(&loaded_b.messages[0]), "only in B");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn slot_derived_parent_id_recorded() {
|
||
let store = make_store();
|
||
let slot = ContextSlot::new(
|
||
"s1",
|
||
"child",
|
||
SlotConfig {
|
||
mode: SlotMode::Full,
|
||
source: SlotSource::Derived {
|
||
parent_id: "default".to_string(),
|
||
strategy: DeriveStrategy::Full,
|
||
},
|
||
budget: ContextBudget::default(),
|
||
compact: true,
|
||
},
|
||
);
|
||
slot.save(&*store).await.unwrap();
|
||
|
||
// rel_key 直接读
|
||
let rel = store
|
||
.get(&ContextSlot::rel_key("s1", "child"))
|
||
.await
|
||
.unwrap()
|
||
.unwrap();
|
||
assert_eq!(rel.content, "default");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn slot_readonly_rejects_write() {
|
||
let mut slot = make_slot("ro", "s1");
|
||
slot.config.mode = SlotMode::Readonly;
|
||
let result = slot.append_messages(vec![Message::user_text("nope")]);
|
||
assert!(matches!(result, Err(AgentError::SlotReadonly(_))));
|
||
assert!(slot.messages.is_empty());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn slot_delete_then_load_none() {
|
||
let store = make_store();
|
||
let mut slot = make_slot("to_delete", "s1");
|
||
slot.append_messages(vec![Message::user_text("hi")]).unwrap();
|
||
slot.save(&*store).await.unwrap();
|
||
|
||
ContextSlot::delete("to_delete", "s1", &*store).await.unwrap();
|
||
let loaded = ContextSlot::load("to_delete", "s1", &*store).await.unwrap();
|
||
assert!(loaded.is_none());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn slot_list_multiple() {
|
||
let store = make_store();
|
||
for id in ["alpha", "beta", "gamma"] {
|
||
let mut s = make_slot(id, "sX");
|
||
s.append_messages(vec![Message::user_text(id)]).unwrap();
|
||
s.save(&*store).await.unwrap();
|
||
}
|
||
// 不同 session 不该列出
|
||
let mut s2 = make_slot("alpha", "sY");
|
||
s2.append_messages(vec![Message::user_text("y")]).unwrap();
|
||
s2.save(&*store).await.unwrap();
|
||
|
||
let metas = ContextSlot::list("sX", &*store).await.unwrap();
|
||
assert_eq!(metas.len(), 3);
|
||
let metas_y = ContextSlot::list("sY", &*store).await.unwrap();
|
||
assert_eq!(metas_y.len(), 1);
|
||
}
|
||
|
||
// ===== Focused 模式 =====
|
||
|
||
#[tokio::test]
|
||
async fn slot_focused_recent_messages() {
|
||
let mut slot = make_slot("f", "s1");
|
||
slot.append_messages(vec![Message::system("sys")]).unwrap();
|
||
for i in 0..5 {
|
||
slot.append_messages(vec![Message::user_text(format!("u{i}"))])
|
||
.unwrap();
|
||
slot.append_messages(vec![Message::assistant(format!("a{i}"))])
|
||
.unwrap();
|
||
}
|
||
slot.config.mode = SlotMode::Focused(FocusedConfig {
|
||
keep_system: true,
|
||
recent_messages: 3,
|
||
summary_override: None,
|
||
});
|
||
|
||
let loaded = slot.load_messages();
|
||
// system + 最近 3 条 (assistant 4, user 4, assistant 5 实际是按 vec 顺序取最近 3 条非 system)
|
||
let has_sys = loaded.iter().any(|m| matches!(m, Message::System { .. }));
|
||
assert!(has_sys, "system 提示应保留");
|
||
// 最近 3 条非 system 应该是 a4, u4, a5 (按 messages 存储顺序的最后 3 条)
|
||
assert_eq!(loaded.len(), 1 + 3);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn slot_focused_summary_override() {
|
||
let mut slot = make_slot("f", "s1");
|
||
slot.append_messages(vec![Message::user_text("u")]).unwrap();
|
||
slot.append_messages(vec![Message::assistant("a")]).unwrap();
|
||
slot.config.mode = SlotMode::Focused(FocusedConfig {
|
||
keep_system: false,
|
||
recent_messages: 100,
|
||
summary_override: Some("讨论了 X".to_string()),
|
||
});
|
||
|
||
let loaded = slot.load_messages();
|
||
// 2 条原始 + 1 条摘要 system = 3
|
||
assert_eq!(loaded.len(), 3);
|
||
// 最后一条是摘要
|
||
if let Message::System { content } = &loaded[2] {
|
||
let text = format!("{:?}", content);
|
||
assert!(text.contains("上下文摘要"));
|
||
assert!(text.contains("讨论了 X"));
|
||
} else {
|
||
panic!("最后一条应为 system 摘要");
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn slot_focused_zero_messages() {
|
||
let mut slot = make_slot("f", "s1");
|
||
slot.append_messages(vec![Message::system("sys")]).unwrap();
|
||
slot.append_messages(vec![Message::user_text("u")]).unwrap();
|
||
slot.config.mode = SlotMode::Focused(FocusedConfig {
|
||
keep_system: true,
|
||
recent_messages: 0,
|
||
summary_override: None,
|
||
});
|
||
|
||
let loaded = slot.load_messages();
|
||
// recent_messages=0 但 keep_system=true 应只含 system
|
||
assert_eq!(loaded.len(), 1);
|
||
assert!(matches!(loaded[0], Message::System { .. }));
|
||
}
|
||
|
||
// ===== 边界 =====
|
||
|
||
#[tokio::test]
|
||
async fn slot_empty_messages_roundtrip() {
|
||
let store = make_store();
|
||
let slot = make_slot("empty", "s1");
|
||
slot.save(&*store).await.unwrap();
|
||
let loaded = ContextSlot::load("empty", "s1", &*store).await.unwrap().unwrap();
|
||
assert!(loaded.messages.is_empty());
|
||
assert_eq!(loaded.meta.message_count, 0);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn slot_save_on_readonly_side_effect() {
|
||
let store = make_store();
|
||
let mut slot = make_slot("ro", "s1");
|
||
slot.config.mode = SlotMode::Readonly;
|
||
// save 本身允许(只禁止 append)
|
||
slot.save(&*store).await.unwrap();
|
||
let loaded = ContextSlot::load("ro", "s1", &*store).await.unwrap();
|
||
assert!(loaded.is_some());
|
||
}
|
||
|
||
// ===== 派生 (derive_slot 行为) =====
|
||
|
||
#[tokio::test]
|
||
async fn derive_full_copies_parent_messages() {
|
||
let mut parent = make_slot("p", "s1");
|
||
for i in 0..3 {
|
||
parent.append_messages(vec![Message::user_text(format!("u{i}"))])
|
||
.unwrap();
|
||
}
|
||
|
||
// 模拟 derive_slot 内部 Full 策略
|
||
let child_messages = parent.messages.clone();
|
||
let child = ContextSlot::new(
|
||
"s1",
|
||
"c",
|
||
SlotConfig {
|
||
mode: SlotMode::Full,
|
||
source: SlotSource::Derived {
|
||
parent_id: "p".to_string(),
|
||
strategy: DeriveStrategy::Full,
|
||
},
|
||
budget: ContextBudget::default(),
|
||
compact: true,
|
||
},
|
||
);
|
||
let mut child = child;
|
||
child.messages = child_messages;
|
||
let store = make_store();
|
||
child.save(&*store).await.unwrap();
|
||
|
||
let loaded = ContextSlot::load("c", "s1", &*store).await.unwrap().unwrap();
|
||
assert_eq!(loaded.messages.len(), 3);
|
||
assert!(matches!(loaded.config.source, SlotSource::Derived { .. }));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn derive_focused_filters_parent_messages() {
|
||
let mut parent = make_slot("p", "s1");
|
||
parent.append_messages(vec![Message::system("sys")]).unwrap();
|
||
for i in 0..5 {
|
||
parent.append_messages(vec![Message::user_text(format!("u{i}"))])
|
||
.unwrap();
|
||
}
|
||
|
||
// 模拟 derive_slot 内部 Focused 策略:按 FocusedConfig 过滤
|
||
let cfg = FocusedConfig {
|
||
keep_system: true,
|
||
recent_messages: 2,
|
||
summary_override: None,
|
||
};
|
||
// 应用 load_messages 同样的过滤
|
||
let mut filtered = Vec::new();
|
||
if cfg.keep_system
|
||
&& let Some(m) = parent
|
||
.messages
|
||
.iter()
|
||
.find(|m| matches!(m, Message::System { .. }))
|
||
{
|
||
filtered.push(m.clone());
|
||
}
|
||
if cfg.recent_messages > 0 {
|
||
let recent: Vec<&Message> = parent
|
||
.messages
|
||
.iter()
|
||
.filter(|m| !matches!(m, Message::System { .. }))
|
||
.collect();
|
||
let start = recent.len().saturating_sub(cfg.recent_messages);
|
||
for m in recent.iter().skip(start) {
|
||
filtered.push((*m).clone());
|
||
}
|
||
}
|
||
|
||
assert_eq!(filtered.len(), 1 + 2); // system + 2 条
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn derived_slot_loadable_independently() {
|
||
let store = make_store();
|
||
let mut parent = make_slot("p", "s1");
|
||
parent.append_messages(vec![Message::user_text("u")]).unwrap();
|
||
parent.save(&*store).await.unwrap();
|
||
|
||
// 派生 child
|
||
let mut child = ContextSlot::new(
|
||
"s1",
|
||
"c",
|
||
SlotConfig {
|
||
mode: SlotMode::Full,
|
||
source: SlotSource::Derived {
|
||
parent_id: "p".to_string(),
|
||
strategy: DeriveStrategy::Full,
|
||
},
|
||
budget: ContextBudget::default(),
|
||
compact: true,
|
||
},
|
||
);
|
||
child.append_messages(vec![Message::user_text("derived msg")])
|
||
.unwrap();
|
||
child.save(&*store).await.unwrap();
|
||
|
||
// child 可独立加载
|
||
let loaded = ContextSlot::load("c", "s1", &*store).await.unwrap().unwrap();
|
||
assert_eq!(loaded.messages.len(), 1);
|
||
assert_eq!(extract_text(&loaded.messages[0]), "derived msg");
|
||
}
|
||
|
||
// ===== delete 保护 (AgentSession 层,但 ContextSlot.delete 不保护;逻辑测试在 session.rs) =====
|
||
|
||
#[tokio::test]
|
||
async fn slot_delete_cleans_all_records() {
|
||
let store = make_store();
|
||
let mut slot = ContextSlot::new(
|
||
"s1",
|
||
"x",
|
||
SlotConfig {
|
||
mode: SlotMode::Full,
|
||
source: SlotSource::Derived {
|
||
parent_id: "p".to_string(),
|
||
strategy: DeriveStrategy::Full,
|
||
},
|
||
budget: ContextBudget::default(),
|
||
compact: true,
|
||
},
|
||
);
|
||
slot.append_messages(vec![Message::user_text("u")]).unwrap();
|
||
slot.save(&*store).await.unwrap();
|
||
|
||
// 确认所有记录存在
|
||
assert!(store.get(&ContextSlot::data_key("s1", "x")).await.unwrap().is_some());
|
||
assert!(store.get(&ContextSlot::meta_key("s1", "x")).await.unwrap().is_some());
|
||
assert!(store.get(&ContextSlot::config_key("s1", "x")).await.unwrap().is_some());
|
||
assert!(store.get(&ContextSlot::rel_key("s1", "x")).await.unwrap().is_some());
|
||
|
||
ContextSlot::delete("x", "s1", &*store).await.unwrap();
|
||
|
||
// data/meta/config 已删
|
||
assert!(store.get(&ContextSlot::data_key("s1", "x")).await.unwrap().is_none());
|
||
assert!(store.get(&ContextSlot::meta_key("s1", "x")).await.unwrap().is_none());
|
||
assert!(store.get(&ContextSlot::config_key("s1", "x")).await.unwrap().is_none());
|
||
}
|
||
|
||
// ===== 基础类型测试 =====
|
||
|
||
#[test]
|
||
fn slot_meta_new_sets_zero_message_count() {
|
||
let m = SlotMeta::new();
|
||
assert_eq!(m.message_count, 0);
|
||
assert_eq!(m.total_tokens, 0);
|
||
assert!(m.parent_id.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn context_budget_default_sum_128k() {
|
||
let b = ContextBudget::default();
|
||
assert_eq!(b.system + b.history + b.tools + b.tool_results + b.reserve, 128_000);
|
||
}
|
||
|
||
#[test]
|
||
fn slot_config_default_is_full_new() {
|
||
let c = SlotConfig::default();
|
||
assert!(matches!(c.mode, SlotMode::Full));
|
||
assert!(matches!(c.source, SlotSource::New));
|
||
assert!(c.compact);
|
||
}
|
||
|
||
#[test]
|
||
fn focused_config_serializes_roundtrip() {
|
||
let cfg = FocusedConfig {
|
||
keep_system: true,
|
||
recent_messages: 5,
|
||
summary_override: Some("sum".into()),
|
||
};
|
||
let json = serde_json::to_string(&cfg).unwrap();
|
||
let back: FocusedConfig = serde_json::from_str(&json).unwrap();
|
||
assert_eq!(back.recent_messages, 5);
|
||
assert_eq!(back.summary_override.as_deref(), Some("sum"));
|
||
}
|
||
|
||
// ====== filter_focused 静态方法(被 load_messages 和 derive_slot 复用) ======
|
||
|
||
#[test]
|
||
fn filter_focused_keeps_system_and_recent() {
|
||
let mut messages = vec![Message::system("sys")];
|
||
for i in 0..5 {
|
||
messages.push(Message::user_text(format!("u{i}")));
|
||
messages.push(Message::assistant(format!("a{i}")));
|
||
}
|
||
let cfg = FocusedConfig {
|
||
keep_system: true,
|
||
recent_messages: 3,
|
||
summary_override: None,
|
||
};
|
||
let filtered = ContextSlot::filter_focused(&messages, &cfg);
|
||
// system + 3 条最近的非 system 消息
|
||
assert_eq!(filtered.len(), 1 + 3);
|
||
assert!(matches!(filtered[0], Message::System { .. }));
|
||
}
|
||
|
||
#[test]
|
||
fn filter_focused_injects_summary() {
|
||
let messages = vec![
|
||
Message::user_text("u"),
|
||
Message::assistant("a"),
|
||
];
|
||
let cfg = FocusedConfig {
|
||
keep_system: false,
|
||
recent_messages: 100,
|
||
summary_override: Some("讨论了 X".into()),
|
||
};
|
||
let filtered = ContextSlot::filter_focused(&messages, &cfg);
|
||
// 2 条原始 + 1 条摘要 system
|
||
assert_eq!(filtered.len(), 3);
|
||
if let Message::System { content } = &filtered[2] {
|
||
let text = format!("{:?}", content);
|
||
assert!(text.contains("上下文摘要"));
|
||
} else {
|
||
panic!("最后一条应为 system 摘要");
|
||
}
|
||
}
|
||
|
||
// ====== Phase 13: fork/merge ======
|
||
|
||
#[test]
|
||
fn fork_full_copies_messages() {
|
||
let mut parent = make_slot("p", "s1");
|
||
parent.append_messages(vec![Message::user_text("a")]).unwrap();
|
||
parent.append_messages(vec![Message::assistant("b")]).unwrap();
|
||
let child = parent.fork("c".into(), DeriveStrategy::Full);
|
||
assert_eq!(child.messages.len(), 2);
|
||
assert!(matches!(child.config.mode, SlotMode::Full));
|
||
}
|
||
|
||
#[test]
|
||
fn fork_focused_filters_messages() {
|
||
let mut parent = make_slot("p", "s1");
|
||
parent.append_messages(vec![Message::system("sys")]).unwrap();
|
||
for i in 0..5 {
|
||
parent
|
||
.append_messages(vec![Message::user_text(format!("u{i}"))])
|
||
.unwrap();
|
||
}
|
||
let cfg = FocusedConfig {
|
||
keep_system: true,
|
||
recent_messages: 2,
|
||
summary_override: None,
|
||
};
|
||
let child = parent.fork("c".into(), DeriveStrategy::Focused(cfg));
|
||
// system + 最近 2 条非 system
|
||
assert_eq!(child.messages.len(), 1 + 2);
|
||
assert!(matches!(child.config.mode, SlotMode::Focused(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn fork_preserves_independence() {
|
||
let mut parent = make_slot("p", "s1");
|
||
parent.append_messages(vec![Message::user_text("a")]).unwrap();
|
||
let mut child = parent.fork("c".into(), DeriveStrategy::Full);
|
||
let child_count_at_fork = child.messages.len();
|
||
|
||
// 父 slot 追加
|
||
parent
|
||
.append_messages(vec![Message::user_text("b")])
|
||
.unwrap();
|
||
// 子 slot 追加
|
||
child.append_messages(vec![Message::user_text("c")]).unwrap();
|
||
|
||
assert_eq!(parent.messages.len(), 2);
|
||
assert_eq!(child.messages.len(), child_count_at_fork + 1);
|
||
assert_eq!(extract_text(child.messages.last().unwrap()), "c");
|
||
}
|
||
|
||
#[test]
|
||
fn fork_sets_derived_source() {
|
||
let mut parent = make_slot("p", "s1");
|
||
parent.append_messages(vec![Message::user_text("a")]).unwrap();
|
||
let child = parent.fork("c".into(), DeriveStrategy::Full);
|
||
match &child.config.source {
|
||
SlotSource::Derived { parent_id, strategy } => {
|
||
assert_eq!(parent_id, "p");
|
||
assert!(matches!(strategy, DeriveStrategy::Full));
|
||
}
|
||
_ => panic!("子 slot source 应为 Derived"),
|
||
}
|
||
// 子 slot 的 meta 全新创建
|
||
assert_eq!(child.meta.message_count, 0);
|
||
assert!(child.meta.parent_id.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn merge_append_appends_messages() {
|
||
let mut parent = make_slot("p", "s1");
|
||
parent.append_messages(vec![Message::user_text("p1")]).unwrap();
|
||
let child = {
|
||
let mut c = parent.fork("c".into(), DeriveStrategy::Full);
|
||
// fork 时 child 继承父的 "p1";再追加一条 c1
|
||
c.append_messages(vec![Message::user_text("c1")]).unwrap();
|
||
c
|
||
};
|
||
parent
|
||
.merge(child, MergeStrategy::Append)
|
||
.expect("merge ok");
|
||
// Append 追加 child 全部消息到父:1 (p1) + 2 (p1 + c1) = 3
|
||
assert_eq!(parent.messages.len(), 3);
|
||
assert_eq!(parent.meta.message_count, 3);
|
||
assert_eq!(extract_text(&parent.messages[2]), "c1");
|
||
}
|
||
|
||
#[test]
|
||
fn merge_replace_replaces_messages() {
|
||
let mut parent = make_slot("p", "s1");
|
||
parent.append_messages(vec![Message::user_text("p1")]).unwrap();
|
||
parent.append_messages(vec![Message::user_text("p2")]).unwrap();
|
||
let child = {
|
||
let mut c = parent.fork("c".into(), DeriveStrategy::Full);
|
||
// 清空 child 再追加
|
||
c.messages.clear();
|
||
c.append_messages(vec![Message::user_text("c-only")])
|
||
.unwrap();
|
||
c
|
||
};
|
||
parent
|
||
.merge(child, MergeStrategy::Replace)
|
||
.expect("merge ok");
|
||
assert_eq!(parent.messages.len(), 1);
|
||
assert_eq!(extract_text(&parent.messages[0]), "c-only");
|
||
assert_eq!(parent.meta.message_count, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn merge_self_rejected() {
|
||
let mut slot = make_slot("p", "s1");
|
||
slot.append_messages(vec![Message::user_text("a")]).unwrap();
|
||
let child = slot.fork("p".into(), DeriveStrategy::Full);
|
||
let err = slot.merge(child, MergeStrategy::Append).unwrap_err();
|
||
assert!(matches!(err, AgentError::Config(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn merge_readonly_rejected() {
|
||
let mut parent = make_slot("p", "s1");
|
||
parent.config.mode = SlotMode::Readonly;
|
||
let child = ContextSlot::new("s1", "c", SlotConfig::default());
|
||
let err = parent.merge(child, MergeStrategy::Append).unwrap_err();
|
||
assert!(matches!(err, AgentError::SlotReadonly(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn merge_cross_session_rejected() {
|
||
let mut parent = make_slot("p", "s1");
|
||
parent.append_messages(vec![Message::user_text("a")]).unwrap();
|
||
let child = ContextSlot::new("OTHER_SESSION", "c", SlotConfig::default());
|
||
let err = parent.merge(child, MergeStrategy::Append).unwrap_err();
|
||
assert!(matches!(err, AgentError::Config(_)));
|
||
}
|
||
|
||
// ====== Colon 校验(key 格式保护) ======
|
||
|
||
#[test]
|
||
#[should_panic(expected = "session_id 's:1' contains ':'")]
|
||
fn key_constructor_rejects_colon_in_session_id() {
|
||
// 通过 make_slot 间接调用 slot.save 时会触发 data_key -> assert_no_colon
|
||
let store = make_store();
|
||
let slot = ContextSlot::new("s:1", "default", SlotConfig::default());
|
||
let _ = tokio_test_runtime(slot.save(&*store));
|
||
}
|
||
|
||
#[test]
|
||
#[should_panic(expected = "slot_id 'a:b' contains ':'")]
|
||
fn key_constructor_rejects_colon_in_slot_id() {
|
||
let store = make_store();
|
||
let slot = ContextSlot::new("s1", "a:b", SlotConfig::default());
|
||
let _ = tokio_test_runtime(slot.save(&*store));
|
||
}
|
||
|
||
/// 在同步测试中运行 future 的辅助函数。
|
||
fn tokio_test_runtime<F: std::future::Future>(f: F) -> F::Output {
|
||
tokio::runtime::Builder::new_current_thread()
|
||
.enable_all()
|
||
.build()
|
||
.unwrap()
|
||
.block_on(f)
|
||
}
|
||
} |