Files
agcore/src/agent/context.rs
T
徐涛 1d51dcdfe0 feat(core): 为 CostTracker/ContextSlot/MergeStrategy 加 Clone+Serialize+Deserialize
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
2026-07-15 08:58:43 +08:00

1124 lines
40 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.
//! 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(())
}
/// 从存储加载 slotconfig 从 `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)
}
}