3 Commits
Author SHA1 Message Date
徐涛 2af92cd554 docs(roadmap): 标记 Phase 10 ContextSlot 上下文管理已完成
- 顶部状态行更新:Phase 0-10 全部完成,11 个离线示例,254 测试
- Phase 10 状态:Step 10.1/10.2/10.3 全部标记 
- 详细实际新增:agent/context.rs (~430 行) + agent/error.rs 3 个变体 +
  agent/session.rs 改造(slots 字段 + 5 个管理方法 + submit_turn/finalize_turn
  增量追加写回) + context_slot_demo 示例 + 43 个新测试
- finalize_turn 签名变更记录(new_messages_from_cycle 参数 + Result 返回)
- 依赖关系图 P10 节点标 
- 里程碑 M6 →  2026-07-07
- 下一步行动:Phase 10 → Phase 11(测试与检索补强)
- 已完成阶段列表追加 Phase 10 完整说明
- 最后更新日期:2026-07-06 → 2026-07-07
2026-07-06 09:53:44 +08:00
徐涛 635942248b 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 轮非阻塞建议修复)
2026-07-06 09:46:04 +08:00
徐涛 fe51961202 docs(roadmap): 标记 Phase 9 流式体验增强已完成
- 顶部「当前状态」「最后更新」同步到 Phase 0-9 完成
- Phase 9 Step 9.1 标记完成并新增「实际新增」段落
- Mermaid 依赖图 P9 节点 class 从 p1 切换为 done
- 里程碑 M5 标记  2026-07-06
- 下一步行动与已完成列表同步更新(指向 Phase 10)
2026-07-06 05:43:47 +08:00
7 changed files with 3033 additions and 224 deletions
File diff suppressed because it is too large Load Diff
+43 -14
View File
@@ -1,13 +1,13 @@
# AG Core Roadmap
> 定稿日期:2026-05-11
> 最后更新:2026-07-05
> 最后更新:2026-07-07
## 愿景
AG Core 定位为构建 AI 智能体的底层工具箱,通过模块化、可插拔的架构,提供大模型调用、提示词工程、工具系统、记忆检索四大核心能力,支持快速组合出符合业务需求的智能体应用。
**当前状态**v0.1.0 已发布(2026-07-04)。Phase 0-8 全部完成,v0.2.0-rc.1 已打标签。Provider IR 重构 + LlmCycle 简化 + 10 个离线示例(含 `quick_start` 30 行最小示例`end_to_end` 完整集成示例)+ SqliteStore 持久化 + 14 个公开枚举 `#[non_exhaustive]` 护栏 + `StepStatus` IR 迁移已交付。下一步进入 Phase 9(流式体验增强 / `submit_turn_stream`)。
**当前状态**v0.1.0 已发布(2026-07-04)。Phase 0-10 全部完成,v0.2.0-rc.1 已打标签。Provider IR 重构 + LlmCycle 简化 + 11 个离线示例(含 `quick_start` 30 行最小示例`end_to_end` 完整集成示例`context_slot_demo` 分支对话示例+ SqliteStore 持久化 + 14 个公开枚举 `#[non_exhaustive]` 护栏 + `StepStatus` IR 迁移 + `submit_turn_stream` 流式体验 + ContextSlot 多上下文分区管理已交付。下一步进入 Phase 11(测试与检索补强)。
---
@@ -460,13 +460,23 @@ pub struct ContextBudget { system, history, tools, tool_results, reserve }
| Step | 内容 | 文件 | 验证标准 |
|------|------|-----|---------|
| **9.1** | `AgentSession::submit_turn_stream(user_input) -> impl Stream<Item=StreamEvent>` | `agent/session.rs` | 单元测试验证流事件序列:`TextDelta → ... → MessageComplete` |
| **9.1** | `AgentSession::submit_turn_stream(user_input) -> impl Stream<Item=StreamEvent>` | `agent/session.rs` | 单元测试验证流事件序列:`TextDelta → ... → MessageComplete` |
**注意**tool 自动循环时流中插入 `ToolExecutionStarted` 事件,用户端 UI 显示"正在调用工具..."。
**依赖**Phase 6ToolDef+ `LlmProvider.chat_stream`v0.1 已有)
**优先级**P1
**实际新增**2026-07-06 commit `212cfcc`,详见 `docs/16-phase9-streaming-experience.md`):
- 方案文档:`docs/16-phase9-streaming-experience.md`(821 行,含状态机设计推演与边界情况)
- 修改文件 3 个:`src/agent/session.rs`+208,含 `submit_turn_stream` / `finalize_turn`)、`src/llm/cycle.rs`+784,含 `submit_with_tools_stream` / `run_tool_loop` spawn + mpsc 状态机)、`src/llm/types/response_v2.rs`+21,含 `StreamEvent::ToolExecutionStarted`/`Completed` 变体 + `apply_to` 元事件)
- 关键设计:`CycleConfig``Clone` derive 以支持 spawn 跨 task`finalize_turn` 手动同步状态(`submit_turn_stream` 返回流前不落库,避免半成品被 hook 误读)
- 测试:新增 9 个单元测试 + 2 个集成测试(含 `submit_turn_stream_end_to_end` 端到端 mock provider 流消费 + `submit_turn_stream_triggers_turn_hooks` Hook 触发验证),全量 200 → 211(+11,0 失败)
- clippy 0 警告
- 无新增外部依赖
**状态**:✅ Phase 9 全部交付物已完成
---
#### Phase 10: ContextSlot 上下文管理
@@ -475,14 +485,31 @@ pub struct ContextBudget { system, history, tools, tool_results, reserve }
| Step | 内容 | 验证标准 |
|------|------|---------|
| **10.1** | `src/llm/context.rs``ContextSlot` + `SlotConfig` / `SlotMode` / `SlotSource` / `ContextBudget` 核心类型 | `cargo build` |
| **10.2** | ContextSlot 持久化:基于 `MemoryStore` trait(不绑定 SqliteStore)实现 save/load/list + slot 命名空间 key 策略 | 单元测试:slot 创建/写入/读取/隔离(不串数据) |
| **10.3** | `AgentSession` 扩展:`create_slot` / `switch_slot` / `list_slots` / `derive_slot` + `AgentBuilder` 默认创建 `"default"` slot | 集成测试 + 新示例 `context_slot_demo` |
| **10.1** | `src/agent/context.rs``ContextSlot` + `SlotConfig` / `SlotMode` / `FocusedConfig` / `SlotSource` / `DeriveStrategy` / `ContextBudget` / `SlotMeta` 核心类型 | `cargo build --all-targets` |
| **10.2** | ContextSlot 持久化:基于 `MemoryStore` trait(不绑定 SqliteStore)实现 save/load/list/delete + slot 命名空间 key 策略 + `load_messages()` Focused 读时过滤 + `append_messages()` Readonly 阻断 + colon 注入防护 | 单元测试:持久化 roundtrip / session 隔离 / Focused 边界 / delete 保护 / 派生 / load_messages() |
| **10.3** | `AgentSession` 扩展:`create_slot` / `switch_slot` / `list_slots` / `derive_slot` / `delete_slot` + `new()` 自动创建 `"default"` slot + `submit_turn`/`finalize_turn` 改造为基于当前 slot 的增量追加写回 + 新示例 `context_slot_demo` | 集成测试 + `cargo run --example context_slot_demo` exit 0 |
**如何保证简单场景无感**`AgentBuilder::build()` 内部检查,如果用户没手动 `create_slot`,自动创建 `"default"` slot → `submit_turn` 默认写到 default slot。
**如何保证简单场景无感**`AgentSession::new()` 内部检查,自动创建 `"default"` slot → `submit_turn` 默认写到 default slot。
**依赖**Phase 7SqliteStore 作为推荐持久化后端;`MemoryStore` trait 即可)
**实际新增**2026-07-07 commit `6359422`,详见 `docs/17-phase10-contextslot.md`):
- 方案文档:`docs/17-phase10-contextslot.md`(1227 行,含 §5 推荐方案、§6 实施建议、§9 实施计划,经过 4 轮方案/计划/实施审查 + 1 轮非阻塞建议修复)
- 新增文件 3 个:`src/agent/context.rs`~430 行 ContextSlot 核心类型 + 持久化方法 + 22 个测试)、`src/agent/context.rs` 中的 `ContextSlot::filter_focused` 静态方法(被 `load_messages``derive_slot` 复用,消除代码重复)、`examples/context_slot_demo.rs`(~160 行分支对话示例:法律咨询 → 派生两个方向 → 切换 → 隔离验证 → 删除保护)
- 修改文件 3 个:`src/agent.rs`+5 行 module 声明 + re-export)、`src/agent/error.rs`+56 行:3 个新变体 `SlotReadonly`/`SlotNotFound`/`SlotAlreadyExists` + 4 个测试)、`src/agent/session.rs`+825/-197 行:slots 字段 + 6 个管理方法 + submit_turn/finalize_turn 改造 + 17 个测试)
- 关键设计:
- **模块归属**`agent/context.rs`(零新依赖方向,遵循 `agent → memory` 已有依赖)
- **持久化**JSON blob 批次存储,每 slot 3-4 条 `MemoryItem``slot_data` / `slot_meta` / `slot_config` / `slot_rel`
- **submit_turn 签名不变**:方案 A(内部 `current_slot_id` 状态),向后兼容
- **Focused 模式读时过滤**`load_messages() -> Vec<Message>`,避免 Rust 借用检查问题
- **增量追加写回**`cycle.messages()[input_len..]` 提取本轮新增消息,确保 Focused 模式数据不丢失
- **delete_slot 双重保护**:禁止删 `"default"` + 至少保留一个 slot
- **colon 注入防护**`assert_no_colon` 在 key 构造时 panic
- **错误传播**`serde_json` / `MemoryStore` 所有错误用 `?` 传播,无静默吞掉
- 验证:211 → 254 测试(+43 新测试),clippy 0 警告,doc 0 warning10 + 1 示例全部 exit 0
- finalize_turn 签名变更(破坏性):新增 `new_messages_from_cycle: Vec<Message>` 参数,返回从 `()` 改为 `Result<(), AgentError>`——影响 Phase 9 的 `submit_turn_stream_triggers_turn_hooks``submit_turn_stream_end_to_end` 2 个测试,已适配
**依赖**Phase 5`#[non_exhaustive]` 预置 SlotMode 等枚举)、Phase 7SqliteStore 推荐持久化后端;`MemoryStore` trait 即可)
**优先级**P1
**状态**:✅ Phase 10 全部交付物已完成
---
@@ -523,8 +550,8 @@ graph BT
P6["<b>Phase 6: ToolDef IR</b><br/>Provider 无关工具定义"]:::done
P7["<b>Phase 7: SqliteStore</b><br/>rusqlite + WAL<br/>9 个内联测试<br/>持久化 round-trip"]:::done
P8["<b>Phase 8: MVP 出口</b><br/>rc.1 标签<br/>14 枚举 #[non_exhaustive]<br/>StepStatus IR 迁移<br/>quick_start + end_to_end"]:::done
P9["Phase 9<br/>流式体验增强"]:::p1
P10["Phase 10<br/>ContextSlot"]:::p1
P9["<b>Phase 9: 流式体验增强</b><br/>submit_turn_stream<br/>submit_with_tools_stream<br/>9 单元测试 + 2 集成测试"]:::done
P10["<b>Phase 10: ContextSlot</b><br/>ContextSlot 类型<br/>JSON blob 持久化<br/>AgentSession 集成<br/>43 个新测试"]:::done
P11["Phase 11<br/>测试与检索"]:::p1
P12["Phase 12<br/>P2 锦上添花"]:::p2
@@ -557,8 +584,8 @@ graph BT
| **M2** | Phase 6 | `ToolDef` 全量切换,`cargo test --all-targets` 全绿 | ✅ 2026-07-05 |
| **M3** | Phase 7 | SqliteStore CRUD + 并发测试通过,进程重启数据不丢 | ✅ 2026-07-05 |
| **M4** | **Phase 8 (rc.1)** | P0 五项全部交付,`cargo run --example quick_start` 跑通 | ✅ 2026-07-05 |
| **M5** | Phase 9 | `submit_turn_stream` 流式事件序列验证通过 | |
| **M6** | Phase 10 | ContextSlot 创建/切换/派生集成测试通过 | |
| **M5** | Phase 9 | `submit_turn_stream` 流式事件序列验证通过 | ✅ 2026-07-06 |
| **M6** | Phase 10 | ContextSlot 创建/切换/派生集成测试通过 | ✅ 2026-07-07 |
| **M7** | Phase 11 | wiremock + 并发测试补强,测试总量 200+ | ⏳ |
| **M8** | Phase 12(可选) | P2 功能按需交付 | ⏳ |
@@ -603,9 +630,9 @@ graph BT
## 下一步行动
1. **Phase 9 启动**流式体验增强(`AgentSession::submit_turn_stream` 流式事件序列),P1 功能
1. **Phase 11 启动**测试与检索补强(`VectorRetriever` trait + wiremock Provider roundtrip + 并发写入验证),P1 功能
2. **示例先行**:每完成一个 Phase 立即更新对应示例,验证通过后再合入
3. **里程碑追踪**:以 Phase 8MVP 出口,v0.2.0-rc.1 已打标签)为节点,逐 Phase 验收
3. **里程碑追踪**:以 Phase 10ContextSlot,已完成)为最新节点,逐 Phase 验收
4. **v0.2.0 正式版**:Phase 8-11 全部完成后,去掉 rc 后缀打 `v0.2.0` 正式版
**已完成 / 进行中阶段**
@@ -620,6 +647,8 @@ graph BT
- ✅ Phase 6 ToolDefinition IR — `ToolDef` 新类型 + 双向 `From` 转换 + 别名彻底移除 + `#[allow(deprecated)]` 清理(cycle/registry/mcp/agent);Anthropic 零改动;roundtrip 测试覆盖
- ✅ Phase 7 SqliteStore — `rusqlite 0.32` + WAL 模式 + `Arc<Mutex<Connection>>` + `spawn_blocking``memory/store.rs``store/{in_memory,sqlite_store}.rs` 模块化;9 个内联测试覆盖 CRUD/upsert/过滤/10×10 并发/持久化 round-trip`InMemoryStore ↔ SqliteStore` trait-box 互换兼容
-**Phase 8 MVP 集成出口** — 14 个公开枚举追加 `#[non_exhaustive]`P0 核心 IR + P0 Error + P1 其他) + `StepStatus::Completed(ChatResponse)``Completed(MessageResponse)` 迁移 + CHANGELOG v0.2.0-rc.1 + 2 个新示例(`quick_start` 60 行 + `end_to_end` 246 行),10 个离线示例全部 exit 0**v0.2.0-rc.1 标签已打**;实施后三方审查发现 6 项问题(1 🔴 + 2 🟡 + 3 💭)已全部修复
-**Phase 9 流式体验增强**`AgentSession::submit_turn_stream` 流式事件序列 + `LlmCycle::submit_with_tools_stream` spawn + mpsc 状态机 + `StreamEvent::ToolExecutionStarted`/`Completed` 新变体 + 9 单元测试 + 2 集成测试(含 `submit_turn_stream_end_to_end` 端到端 mock 验证 + `submit_turn_stream_triggers_turn_hooks` Hook 触发验证),全量 200 → 211;`CycleConfig``Clone` derive;方案文档 `docs/16-phase9-streaming-experience.md`821 行)
-**Phase 10 ContextSlot 上下文管理**`src/agent/context.rs` 新增 `ContextSlot` 核心类型(Full / Focused / Readonly 三种模式,New / Derived / Static 三种来源)+ JSON blob 批次持久化(每 slot 3-4 条 MemoryItem`slot_config` key 自恢复支持旧版本兼容);`AgentSession` 扩展 slots 字段 + 5 个管理方法(`create_slot` / `switch_slot` / `list_slots` / `derive_slot` / `delete_slot`,自动创建 `"default"` slot`delete_slot` 双重保护禁止删 default/最后一个);`submit_turn`/`finalize_turn` 改造为基于当前 slot 的增量追加写回(`cycle.messages()[input_len..]` 提取本轮新增消息,确保 Focused 模式"读时过滤"语义不丢失数据);`finalize_turn` 签名变更(新增 `new_messages_from_cycle: Vec<Message>` 参数,返回 `Result<(), AgentError>`);`agent/error.rs` 新增 3 个 Slot 错误变体(`SlotReadonly` / `SlotNotFound` / `SlotAlreadyExists`);`examples/context_slot_demo.rs` 新增分支对话示例(法律咨询入口 → 两个派生方向 → 切换 → 隔离验证 → 删除保护);方案文档 `docs/17-phase10-contextslot.md`(1227 行,含 §5 推荐方案、§6 实施建议、§9 实施计划,经过 4 轮方案/计划/实施审查 + 1 轮非阻塞建议修复);全量 211 → 254(+43 新测试),clippy 0 警告,doc 0 warning11 个离线示例全部 exit 0
- ✅ Provider IR 重构 — 统一类型系统 + OpenAI/Anthropic/DeepSeek/Qwen/Ollama 适配
- ✅ LlmCycle 简化 — IR 消息类型切换 + Phase 0 桥接层移除
- ✅ v0.1 Release — 技术债扫清、MockProvider 公开化、8 个离线示例(含 `simple_visit`)、README + 错误消息友好化、CHANGELOG 初始化
+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