feat(core): 新增 Phase 10 ContextSlot 多上下文分区管理

- 新增 ContextSlot 类型(Full / Focused / Readonly 三种模式,
  New / Derived / Static 三种来源),支持 JSON blob 批次持久化
- AgentSession 新增 slots 字段与 5 个管理方法
  (create_slot / switch_slot / list_slots / derive_slot / delete_slot),
  自动创建 "default" slot
- submit_turn / finalize_turn 改造为基于当前 slot 的增量追加写回,
  确保 Focused 模式"读时过滤"语义不丢失数据
- finalize_turn 签名变更(新增 new_messages_from_cycle 参数,
  返回 Result<(), AgentError>),向后兼容列于 docs/17
- 新增 3 个 AgentError 变体(SlotReadonly / SlotNotFound / SlotAlreadyExists)
- 新增分支对话示例 context_slot_demo(法律咨询→两个派生方向→切换→隔离验证)
- 新增 43 个测试覆盖持久化、Focused 过滤、Readonly 阻断、delete 保护、
  派生逻辑、流式 finalize_turn、key 注入防护等场景
- 方案文档:docs/17-phase10-contextslot.md(含 §5 推荐方案、§6 实施建议、
  §9 实施计划,经过 4 轮方案/计划/实施审查 + 1 轮非阻塞建议修复)
This commit is contained in:
徐涛
2026-07-06 09:46:04 +08:00
parent fe51961202
commit 635942248b
6 changed files with 2990 additions and 210 deletions
File diff suppressed because it is too large Load Diff
+161
View File
@@ -0,0 +1,161 @@
//! context_slot_demo —— 多上下文槽位管理示例。
//!
//! 场景:法律咨询入口 → 派生两个独立探索方向 → 切换 → 隔离验证 → 删除。
//!
//! 展示:
//! - 默认 slot 自动创建
//! - 多 slot 间的消息隔离
//! - 派生 slot 从父 slot 复制消息
//! - 删除非 default slot 后自动回退到 default
//!
//! 运行:`cargo run --example context_slot_demo`(离线,零配置)
use std::sync::Arc;
use agcore::agent::{Agent, AgentBuilder, AgentSession};
use agcore::llm::hooks::HookExecutor;
use agcore::llm::mock::MockProvider;
use agcore::llm::provider::LlmProvider;
use agcore::llm::types::message::{ContentBlock, Message};
use agcore::llm::types::response_v2::{MessageResponse, StopReason};
use agcore::llm::types::Usage;
use agcore::tools::ToolRegistry;
struct LegalAdvisor;
impl Agent for LegalAdvisor {
fn name(&self) -> &str {
"legal-advisor"
}
fn system_prompt(&self) -> Option<&str> {
Some("你是法律顾问。请用一句话回答用户问题。")
}
}
/// 构造一个简单的 Assistant 响应(用于 MockProvider)。
fn assistant_resp(text: &str) -> MessageResponse {
MessageResponse {
id: String::new(),
model: "mock".into(),
message: Message::Assistant {
content: vec![ContentBlock::Text { text: text.into() }],
},
usage: Usage::from_input_output(5, 5),
stop_reason: StopReason::Stop,
extra: Default::default(),
}
}
#[tokio::main]
async fn main() {
// 1. 构造 session(自动包含 default slot
let provider: Arc<dyn LlmProvider> = Arc::new(MockProvider::new(vec![
assistant_resp("您好,我可以帮您处理法律问题。"),
assistant_resp("管辖权问题:建议选择合同签订地法院。"),
assistant_resp("条款修改:建议将上限调整为 80 万。"),
assistant_resp("已回到主对话。"),
]));
let bundle = Arc::new(
AgentBuilder::new()
.provider(provider)
.tool_registry(Arc::new(ToolRegistry::new()))
.hook_executor(Arc::new(HookExecutor::new()))
.build()
.unwrap(),
);
let mut session = AgentSession::new(Arc::new(LegalAdvisor), "legal-001", bundle);
println!("=== 1. 默认 slot 自动创建 ===");
assert_eq!(session.current_slot_id(), "default");
let slots: Vec<_> = session.list_slots().collect();
println!("初始 slots: {slots:?}");
assert_eq!(slots.len(), 1);
assert!(slots.contains(&&"default".to_string()));
println!("\n=== 2. 在 default slot 中提交一轮 ===");
let r1 = session.submit_turn("我需要法律援助").await.unwrap();
println!("default slot response: {}", r1.text());
println!("\n=== 3. 派生两个独立探索方向的 slot ===");
session
.derive_slot("option_jurisdiction", "default", agcore::agent::DeriveStrategy::Full)
.await
.unwrap();
session
.derive_slot("option_amendment", "default", agcore::agent::DeriveStrategy::Full)
.await
.unwrap();
let slots: Vec<_> = session.list_slots().cloned().collect();
println!("派生后 slots: {slots:?}");
assert_eq!(slots.len(), 3);
println!("\n=== 4. 切到 option_jurisdiction 并提交 ===");
session.switch_slot("option_jurisdiction").await.unwrap();
assert_eq!(session.current_slot_id(), "option_jurisdiction");
let r2 = session.submit_turn("如果用户质疑管辖权?").await.unwrap();
println!("option_jurisdiction response: {}", r2.text());
println!("\n=== 5. 切到 option_amendment 并提交 ===");
session.switch_slot("option_amendment").await.unwrap();
let r3 = session.submit_turn("用户要求提高赔偿上限?").await.unwrap();
println!("option_amendment response: {}", r3.text());
println!("\n=== 6. 切回 default,验证消息隔离 ===");
session.switch_slot("default").await.unwrap();
let r4 = session.submit_turn("汇总一下我们的讨论").await.unwrap();
println!("default response: {}", r4.text());
// 验证 default slot 不包含 option_jurisdiction 的"管辖权"问题
let (_, default_slot) = session.slots().find(|(id, _)| *id == "default").unwrap();
let default_has_jurisdiction = default_slot
.messages
.iter()
.any(|m| message_contains(m, "管辖权"));
assert!(
!default_has_jurisdiction,
"default slot 不应包含 option_jurisdiction 的消息"
);
println!("\n=== 7. 删除 option_amendment,验证回退到 default ===");
session.delete_slot("option_amendment").await.unwrap();
let slots: Vec<_> = session.list_slots().cloned().collect();
println!("删除后 slots: {slots:?}");
assert!(!slots.contains(&"option_amendment".to_string()));
assert_eq!(slots.len(), 2);
println!("\n=== 8. 切到 option_jurisdiction 并删除,验证 current 回退 ===");
session.switch_slot("option_jurisdiction").await.unwrap();
session.delete_slot("option_jurisdiction").await.unwrap();
assert_eq!(session.current_slot_id(), "default");
let slots: Vec<_> = session.list_slots().cloned().collect();
println!("删除后 slots: {slots:?}");
assert_eq!(slots.len(), 1);
assert_eq!(slots[0], "default");
println!("\n=== 9. 验证 delete_slot 保护逻辑 ===");
let err = session.delete_slot("default").await.unwrap_err();
println!("删除 default 返回错误: {err}");
assert!(matches!(err, agcore::agent::AgentError::Config(_)));
println!("\n✓ context_slot_demo 完成");
}
/// 检查 Message 是否包含指定文本(提取第一个 Text block)。
fn message_contains(msg: &Message, needle: &str) -> bool {
use agcore::llm::types::message::ContentBlock;
let blocks = match msg {
Message::System { content }
| Message::User { content }
| Message::Assistant { content } => content,
Message::UserImage { .. } => return false,
Message::ToolResult { content, .. } => content,
_ => return false,
};
for block in blocks {
if let ContentBlock::Text { text } = block
&& text.contains(needle)
{
return true;
}
}
false
}
+5
View File
@@ -11,6 +11,7 @@
pub mod agent;
pub mod builder;
pub mod context;
pub mod error;
pub mod runtime;
pub mod session;
@@ -20,6 +21,10 @@ pub mod task;
// 重导出公共 API(按使用频度排序)
pub use agent::Agent;
pub use builder::AgentBuilder;
pub use context::{
ContextBudget, ContextSlot, DeriveStrategy, FocusedConfig, SlotConfig, SlotMeta, SlotMode,
SlotSource,
};
pub use error::AgentError;
pub use runtime::{AgentConfig, RuntimeBundle};
pub use session::AgentSession;
+900
View File
@@ -0,0 +1,900 @@
//! 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)]
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.2 仅消费端:手动设置则注入,不自动生成)。
/// v0.3 将支持 Hook 驱动的自动摘要生成。
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),
}
/// 上下文预算(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(),
}
}
}
#[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 摘要");
}
}
// ====== 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)
}
}
+53 -3
View File
@@ -36,6 +36,18 @@ pub enum AgentError {
#[error("Plan 解析错误: {0}")]
PlanParse(String),
/// Readonly slot 不允许写入(Phase 10 新增)。
#[error("Readonly slot 不允许写入: {0}")]
SlotReadonly(String),
/// Slot 不存在(Phase 10 新增)。
#[error("Slot '{0}' 不存在")]
SlotNotFound(String),
/// Slot 已存在(Phase 10 新增)。
#[error("Slot '{0}' 已存在")]
SlotAlreadyExists(String),
/// 钩子阻断操作(Agent 层特有)。
#[error("钩子阻断: {0}")]
HookBlocked(String),
@@ -60,6 +72,7 @@ impl AgentError {
/// - `Tool`:由内层 `is_recoverable()` 决定
/// - `HookBlocked` / `LimitExceeded`:不可恢复(需人工介入或终止循环)
/// - `Config` / `Other`:不可恢复
/// - `SlotReadonly` / `SlotNotFound` / `SlotAlreadyExists`:不可恢复(结构性错误)
pub fn is_recoverable(&self) -> bool {
match self {
Self::Llm(e) => matches!(
@@ -69,9 +82,13 @@ impl AgentError {
Self::Tool(e) => e.is_recoverable(),
Self::Memory(e) => e.is_recoverable(),
Self::PlanParse(_) => false,
Self::HookBlocked(_) | Self::LimitExceeded(_) | Self::Config(_) | Self::Other(_) => {
false
}
Self::SlotReadonly(_)
| Self::SlotNotFound(_)
| Self::SlotAlreadyExists(_)
| Self::HookBlocked(_)
| Self::LimitExceeded(_)
| Self::Config(_)
| Self::Other(_) => false,
}
}
}
@@ -181,4 +198,37 @@ mod tests {
let err = caller().unwrap_err();
assert!(matches!(err, AgentError::Memory(_)));
}
// ====== Phase 10: Slot 错误变体测试 ======
#[test]
fn slot_readonly_not_recoverable() {
assert!(!AgentError::SlotReadonly("readonly".into()).is_recoverable());
}
#[test]
fn slot_not_found_not_recoverable() {
assert!(!AgentError::SlotNotFound("missing".into()).is_recoverable());
}
#[test]
fn slot_already_exists_not_recoverable() {
assert!(!AgentError::SlotAlreadyExists("dup".into()).is_recoverable());
}
#[test]
fn slot_error_messages() {
assert_eq!(
format!("{}", AgentError::SlotReadonly("readonly".into())),
"Readonly slot 不允许写入: readonly"
);
assert_eq!(
format!("{}", AgentError::SlotNotFound("foo".into())),
"Slot 'foo' 不存在"
);
assert_eq!(
format!("{}", AgentError::SlotAlreadyExists("bar".into())),
"Slot 'bar' 已存在"
);
}
}
+644 -207
View File
File diff suppressed because it is too large Load Diff