# Phase 10 — ContextSlot 上下文管理 - **文档编号**:17 - **标题**:Phase 10 — ContextSlot 上下文管理 - **日期**:2026-07-06 - **状态**:已定稿 - **涉及模块**:agent/context(新建)、agent/session、examples - **关联文档**:roadmap.md(§Phase 10)、7-agent-runtime.md、16-phase9-streaming-experience.md --- ## 1. 背景与目标 agcore 已发布 v0.2.0-rc.1,Phase 0-9 全部完成。当前 `AgentSession` 只有"单一对话线程"——所有 `submit_turn` 调用共享同一消息列表,无法在同一个 session 内部管理多个独立上下文。 Phase 10 的目标是引入 **ContextSlot(上下文槽)** 概念,使 Agent 能在单个 session 内创建、切换、派生多个独立的消息上下文。开发者可像"多标签页"一样管理对话分支,每个 slot 有独立的配置、消息列表和持久化。 ### 1.1 成功标准 - `ContextSlot` 核心类型可编译,支持三种模式(Full / Focused / Readonly)、三种来源(New / Derived / Static) - 基于 `MemoryStore` trait 的持久化:save / load / list / delete 完整,session 间隔离 - `AgentSession` 扩展:`create_slot` / `switch_slot` / `list_slots` / `derive_slot` / `delete_slot`,自动创建 `"default"` slot - 向后兼容:现有 `submit_turn` 签名不变,存量测试 0 回归 - 分支对话示例可运行:`cargo run --example context_slot_demo` exit 0 --- ## 2. 当前状态分析 ### 2.1 AgentSession 现状 当前 `AgentSession` **没有 slot 概念**。其消息流向如下: ``` submit_turn(user_input) └─ 组装 initial_messages: [system_prompt] └─ LlmCycle::new().with_messages(initial_messages) └─ cycle.submit_with_tools(user_input, tool_registry) └─ LlmCycle 内部 Arc>> 管理 tool 循环迭代 └─ turn_index += 1, cost_so_far.add(...) └─ 返回 MessageResponse ``` 关键观察:`AgentSession` 当前**不维护自身的 `messages: Vec`**,每次 `submit_turn` 从头组装消息(system_prompt + user_input),由 `LlmCycle` 内部管理 tool 循环中的消息迭代。这意味着 session 层面没有任何消息历史保留——跨 turn 的消息积累完全依赖上层应用自行实现。 ### 2.2 相关模块 | 模块 | 路径 | 与 ContextSlot 的关系 | |------|------|----------------------| | `MemoryStore` trait | `memory/store.rs` | 持久化后端接口(4 方法:save/get/delete/list) | | `SqliteStore` | `memory/store/sqlite_store.rs` | 推荐持久化后端,Phase 7 已完成 | | `SessionMemory` | `agent/session_memory.rs` | session 级 KV 存储,namespace `_session_{session_id}` | | `RuntimeBundle` | `agent/runtime.rs` | 含 `memory_store`、`session_memory_backend`、`config` | | `CompactConfig` | `llm/compact.rs` | context_window=128k, reserved_tokens=20k, keep_recent=6 | | `Message` | `llm/types/message.rs` | IR 层统一消息类型,带 `Serialize/Deserialize` | ### 2.3 预置枚举 Roadmap 中 Phase 5.3 已规划的 `#[non_exhaustive]` 枚举尚未实际创建,将在 Phase 10 中首次定义: - `SlotMode` — 三种模式 - `SlotSource` — 三种来源 - `DeriveStrategy` — 派生策略 ### 2.4 当前约束 1. **无跨 turn 消息持久化**:AgentSession 不持有 `Vec`,`submit_turn` 每次从空白开始 2. **无上下文隔离**:所有对话共享同一命名空间,无法分支或隔离 3. **无显式消息写入**:LlmCycle 内部的 `Arc>>` 无法被外部直接读取 --- ## 3. 调研发现 ### 3.1 产品视角(PM) #### 3.1.1 三个核心场景 | 场景 | 描述 | 用户价值 | |------|------|---------| | **多子任务上下文隔离** | Agent 同时处理多个独立任务(如:同时追踪两个法律咨询),每个任务的消息历史互不干扰 | 避免上下文污染 | | **派生/假设推演** | 从决策点分出"试错分支"(如:分别推演方案 A 和方案 B),独立演进,不相互影响 | 探索式决策 | | **长会话聚焦管理** | 大模型注意力有限,聚焦模式裁剪非核心历史,保留关键上下文 | 节省 token 成本 | #### 3.1.2 关键发现 1. **CRUD 缺 D**:派生场景会持续创建新 slot,若没有 `delete_slot` 会导致存储泄漏。`delete_slot` 必须纳入 v0.2 2. **`FocusedConfig.inject_summary` 半成品风险**:当前设计 `inject_summary: Option` 字段名暗示"注入",但消费端逻辑未实现。建议改名 `summary_override: Option`,明确表达"覆盖而非自动生成"的语义,v0.2 只做消费端,生成推迟 v0.3 3. **API 极简默认值关键**:`create_slot("id")` 应一行创建,config 全部走 `Default`。开发者只有需要定制时才传 `SlotConfig` 4. **示例场景选择**:Focused 模式的"注意力管理"概念较抽象,示例以**"分支对话"**场景呈现更直观(法律咨询入口 → 两个派生方向 → 切换 → 隔离验证) 5. **storage 后端统一**:ContextSlot 应使用 `session_memory_backend`(与 `SessionMemory` 同一后端),避免引入第三种存储路径 6. **ContextBudget 工厂函数**:需提供 `ContextBudget::auto()` 工厂函数,自动按上下文窗口比例分配预算 ### 3.2 架构视角(SA) #### 3.2.1 模块归属争议 Roadmap 将 ContextSlot 放在 `src/llm/context.rs`,但引入 `llm → memory` 的新依赖方向(当前 `llm` 模块不依赖 `memory`)。 依赖关系推演: ``` llm/context.rs → memory::store::MemoryStore // ❌ 新依赖方向 agent/context.rs → memory::store::MemoryStore // ✅ 已有依赖 (agent → memory) agent/context.rs ← agent/session.rs // ✅ 同模块内引用 ``` **结论**:推荐 `agent/context.rs`,零新依赖方向。 #### 3.2.2 持久化策略 两种存法对比: | 维度 | 方案 A: JSON blob 批次 | 方案 B: per-message 独立 key | |------|----------------------|-----------------------------| | 存储单位 | `Vec` 整体序列化 | 每条消息一个 key | | 代码量 | ~30 行 | ~150 行 | | 变更风险 | 低(serde_json 已依赖) | 中(需额外 id 生成) | | 按消息粒度操作 | 不支持 | 支持 | | v0.2 使用场景 | 无(只做全量读/写) | 无(无按消息粒度的操作需求) | **结论**:推荐 JSON blob 批次存储。serde_json 已是现有依赖,95% 代码减少,v0.2 没有 per-message 操作场景。每 slot 对应 2-3 条 `MemoryItem` 记录。 #### 3.2.3 submit_turn 改造方向 | | 方案 A(推荐) | 方案 B | |---|---------------|--------| | 签名 | `submit_turn(&mut self, input)` 不变 | `submit_turn(&mut self, slot_id, input)` | | slot 角色 | 内部状态,`current_slot_id` 隐式消费 | 显式参数 | | 向后兼容 | ✅ 完全兼容 | ❌ 破坏现有 API | | 心智负担 | 低 "默认往当前 slot 写" | 高 "每次都要传 slot" | | 显式度 | 隐式(需开发者意识"当前 slot"存在) | 显式(每次调用明确目标) | **结论**:推荐方案 A。`submit_turn` 签名不变,默认写入 `current_slot_id` 指向的 slot。当开发者需要操作非当前 slot 时,先 `switch_slot` 再 `submit_turn`。零破坏,低侵入。 #### 3.2.4 其他架构发现 | 维度 | 发现 | 结论 | |------|------|------| | **消息同步** | LlmCycle 内部 `Arc>>` 无法外部观测 | 手动写回:AgentSession 在 submit_turn 返回后显式覆盖 slot.messages | | **Compact 时机** | 压缩逻辑在 LlmCycle 内部下游触发(`maybe_compact`) | 保持现状,slot 只做纯存储,不干预压缩 | | **Focused 模式** | 读取消息时按策略过滤(非持久化时过滤) | 读时过滤:`load_messages()` 返回裁剪后的 `Vec` | | **Readonly 模式** | 阻止 append/overwrite | 写时阻断:`append_messages()` 返回 `SlotReadonly` 错误 | | **改动量** | 1 新文件 + 2-3 修改文件 | 约 500 行净增(含测试和示例) | --- ## 4. 可选方案 ### 4.1 模块归属 | 方案 | 位置 | 优点 | 缺点 | |------|------|------|------| | **A(推荐)** | `agent/context.rs` | 零新依赖方向;agent→memory 已存在 | 偏离 Roadmap 文本 | | B | `llm/context.rs` | 与 `compact.rs` 同级,语义上接近"上下文" | 引入 `llm→memory` 新依赖方向 | ### 4.2 持久化粒度 | 方案 | 存储方式 | 优点 | 缺点 | |------|---------|------|------| | **A(推荐)** | JSON blob,每 slot 2-3 条记录 | 代码量极小(~30 行),serde_json 已依赖 | 不支持按消息粒度操作 | | B | per-message 独立 key | 粒度最细 | 代码量 5 倍 +,v0.2 无此场景 | ### 4.3 submit_turn 改造方式 | 方案 | 签名 | 优点 | 缺点 | |------|------|------|------| | **A(推荐)** | 签名不变,内部状态 | 向后兼容,侵入小 | 隐式消费当前 slot | | B | 显式 `slot_id` 参数 | 调用意图明确 | 破坏向后兼容 | ### 4.4 消息同步机制 | 方案 | 方式 | 优点 | 缺点 | |------|------|------|------| | **A(推荐)** | 手动写回(AgentSession 在 submit_turn 后显式 overwrite) | 简单、可预测、职责清晰 | 需注意同步时机 | | B | 观察者模式(LlmCycle 发出消息变更事件) | 实时同步 | LlmCycle 内部 `Arc>>` 无法外部观测,改造成本高 | ### 4.5 Focused 模式实现 | 方案 | 方式 | 优点 | 缺点 | |------|------|------|------| | **A(推荐)** | 读时过滤 | 持久化不依赖 mode,切换 mode 无需写回 | 每次读取都需计算 | | B | 写时裁剪 | 存储空间最小 | 切换 mode 需重新裁剪,逻辑复杂 | ### 4.6 ContextBudget 消费逻辑 | 方案 | 内容 | 优点 | 缺点 | |------|------|------|------| | **A(推荐)** | v0.2 仅数据结构 + `Default` + `auto()`,无消费逻辑 | 低风险,可交付 | 功能半成品 | | B | v0.2 同时实现消费逻辑 | 完整闭环 | 半成品风险高,时间不足 | --- ## 5. 推荐方案 ### 5.1 总体设计 **模块归属**:`src/agent/context.rs`(零新依赖方向) **持久化**:JSON blob 批次存储,使用 `session_memory_backend` **submit_turn**:签名不变,当前 slot 内部状态 **消息同步**:手动写回(非观察者模式) **Compact 时机**:保持现状(LlmCycle 内部下游压缩) **Focused 模式**:读时过滤 **Readonly 模式**:写时阻断 ### 5.2 核心类型定义 ```rust /// 上下文槽 —— 一段带策略配置的消息列表。 pub struct ContextSlot { /// 当前 slot 的唯一标识(同一个 session_id 内唯一)。 pub id: String, /// 所属 session。 pub session_id: String, /// 槽配置。 pub config: SlotConfig, /// 消息列表(全量,Focused/Readonly 在读取时做策略过滤)。 pub messages: Vec, /// 槽元数据。 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, } /// 槽来源。 #[derive(Debug, Clone, Serialize, Deserialize)] #[non_exhaustive] pub enum SlotSource { /// 全新空槽。 New, /// 从父 slot 派生(记录 parent_id)。 Derived { parent_id: String, strategy: DeriveStrategy, }, /// 预置静态消息(不持久化,随 session 生命周期存在)。 Static(Vec), } /// 派生策略。 #[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, /// 消息总数。 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::UNIX_EPOCH) .unwrap_or_default() .as_secs(), } } } ``` ### 5.3 持久化 Key 设计 使用 `session_memory_backend`(`MemoryStore` trait)存储,Key 命名规则: | Key 格式 | 内容 | 序列化方式 | |----------|------|-----------| | `slot_data:{session_id}:{slot_id}` | `Vec` | `serde_json::to_string` | | `slot_meta:{session_id}:{slot_id}` | `SlotMeta` | `serde_json::to_string` | | `slot_config:{session_id}:{slot_id}` | `SlotConfig` | `serde_json::to_string` | | `slot_rel:{session_id}:{child_id}` | `parent_id`(纯文本) | 直接字符串 | 每条 `MemoryItem` 的 `id` 和 `content` 字段使用上述 key 格式和序列化后的字符串。每 slot 对应 3-4 条 `MemoryItem` 记录。 > **⚠️ Key 编码约定**:`session_id` 和 `slot_id` 中**不应包含冒号 `:`**,否则会导致 `list` 的 prefix 匹配(`LIKE 'slot_data:{session_id}:%'`)返回异常结果。建议使用字母、数字、连字符和下划线。如果 session_id 来自外部系统(如 UUID),需确认不含 `:`。 ### 5.4 ContextSlot 方法 ```rust impl ContextSlot { /// 保存 slot 数据到存储后端(全量写入,含 config)。 pub async fn save(&self, store: &dyn MemoryStore) -> Result<(), AgentError> { ... } /// 从存储后端加载 slot(config 从存储自行恢复,不依赖调用方传入)。 pub async fn load( id: &str, session_id: &str, store: &dyn MemoryStore, ) -> Result, AgentError> { ... } /// 列出某 session 下的所有 slot 元数据。 pub async fn list( session_id: &str, store: &dyn MemoryStore, ) -> Result, AgentError> { ... } /// 删除 slot 的所有存储记录(slot_data + slot_meta + slot_config + slot_rel)。 pub async fn delete( id: &str, session_id: &str, store: &dyn MemoryStore, ) -> Result<(), AgentError> { ... } /// 追加消息(Readonly 模式下返回 `SlotReadonly` 错误)。 /// Full / Focused 模式下允许追加。 pub fn append_messages(&mut self, messages: Vec) -> Result<(), AgentError> { ... } /// 读取当前 slot 的消息列表。 /// Focused 模式下按策略过滤后返回;Full/Readonly 返回全量。 /// 返回 `Vec`(而非 `&[Message]`),因为 Focused 模式需要分配新 Vec 来存储裁剪后的消息。 pub fn load_messages(&self) -> Vec { ... } } ``` ### 5.5 AgentSession 扩展 ```rust pub struct AgentSession { pub session_id: String, pub agent: Arc, bundle: Arc, turn_index: u32, cost_so_far: CostTracker, pub session_memory: SessionMemory, // === Phase 10 新增字段 === /// session 内的所有 slot(id → ContextSlot)。 slots: HashMap, /// 当前活跃 slot 的 id。 current_slot_id: String, } impl AgentSession { /// 创建新 slot(config 可选,不传则使用默认值)。 pub async fn create_slot( &mut self, id: impl Into, config: Option, ) -> Result<(), AgentError> { ... } /// 切换到指定 slot(必须已存在,否则返回 `SlotNotFound` 错误)。 pub async fn switch_slot(&mut self, id: &str) -> Result<(), AgentError> { ... } /// 列出所有 slot 的 id 列表。 pub fn list_slots(&self) -> impl Iterator { ... } /// 从父 slot 派生新 slot(继承父 slot 的全量或聚焦消息)。 pub async fn derive_slot( &mut self, id: impl Into, parent_id: &str, strategy: DeriveStrategy, ) -> Result<(), AgentError> { ... } /// 删除一个 slot(仅内存 + 持久化记录清理;已删除后再 load 返回 None)。 pub async fn delete_slot(&mut self, id: &str) -> Result<(), AgentError> { ... } // === 内部方法 === /// 解析存储后端:fallback 链 session_memory.store → bundle.memory_store → InMemoryStore。 fn resolve_store(&self) -> Arc { ... } } ``` **`new()` 改动**:自动创建 `"default"` slot,确保简单场景无感使用。 **`submit_turn` 改造**: ```rust pub async fn submit_turn(&mut self, user_input: impl Into) -> Result { // 1. 从 current slot 加载历史消息 let history = self.load_current_slot_history(); // 2. 组装 LlmCycle(system_prompt + history + user_input) // 3. submit_with_tools // 4. 累计 cost // 5. 将新消息写回当前 slot // 6. save 到存储后端 // 7. 触发 OnTurnEnd hook // 8. turn_index += 1 } ``` **`finalize_turn` 扩展**:增加 `new_messages_from_cycle` 参数,用于流结束时增量追加到 slot: ```rust pub async fn finalize_turn( &mut self, response: &MessageResponse, new_messages_from_cycle: Vec, // ← 新增 ) -> Result<(), AgentError> { ... } ``` ### 5.6 向后兼容性 | 维度 | 状态 | 说明 | |------|------|------| | `submit_turn` 签名 | ✅ 不变 | 仍是 `(&mut self, user_input: impl Into)`;新增 Readonly slot 前置检查(返回 `SlotReadonly` 错误) | | `AgentSession::new` 签名 | ✅ 不变 | 内部自动创建 `"default"` slot | | `finalize_turn` 签名 | ⚠️ **变更** — 新增 `new_messages_from_cycle: Vec` 参数,返回从 `()` 改为 `Result<(), AgentError>` | 流式场景的消费者需从 `cycle.messages()[input_len..]` 提取新增消息传入;之前忽略错误的调用需处理 Result | | 存量测试 | ✅ 0 回归 | 不传 `session_memory_backend` 时,`resolve_store()` fallback 到 `InMemoryStore` | | 新增错误变体 | 新增 | `AgentError::SlotReadonly(String)` + `AgentError::SlotNotFound(String)` + `AgentError::Config(String)` 已有 | | 公开类型 | 新增 | 所有新类型默认 `#[non_exhaustive]` 策略枚举 | --- ## 6. 实施建议 ### Step 10.1 — 核心类型(约 150 行,可编译 checkpoint) **目标**:所有类型定义可编译,无逻辑。 #### 文件变更 | 文件 | 操作 | 说明 | |------|------|------| | `src/agent/context.rs` | **新增** | 全部类型定义(~150 行) | | `src/agent.rs` | **修改** | + `pub mod context;` | | `src/agent/error.rs` | **修改** | 追加 `SlotReadonly`/`SlotNotFound`/`SlotAlreadyExists` 错误变体 | > 在 `AgentError` 中新增以下变体(用于 slot 相关错误场景): > ```rust > #[error("Readonly slot 不允许写入: {0}")] > SlotReadonly(String), > #[error("Slot '{0}' 不存在")] > SlotNotFound(String), > #[error("Slot '{0}' 已存在")] > SlotAlreadyExists(String), > ``` #### 核心代码 ```rust // src/agent/context.rs use serde::{Deserialize, Serialize}; use crate::llm::types::message::Message; /// 上下文槽。 pub struct ContextSlot { /* ... §5.2 完整定义 ... */ } /// 槽配置。 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SlotConfig { /* ... */ } /// 槽模式。 #[derive(Debug, Clone, Serialize, Deserialize)] #[non_exhaustive] pub enum SlotMode { Full, Focused(FocusedConfig), Readonly } /// 聚焦配置。 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FocusedConfig { pub keep_system: bool, pub recent_messages: usize, pub summary_override: Option } /// 槽来源。 #[derive(Debug, Clone, Serialize, Deserialize)] #[non_exhaustive] pub enum SlotSource { New, Derived { parent_id: String, strategy: DeriveStrategy }, Static(Vec) } /// 派生策略。 #[derive(Debug, Clone, Serialize, Deserialize)] pub enum DeriveStrategy { Full, Focused(FocusedConfig) } /// 上下文预算。 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ContextBudget { /* ... §5.2 完整定义 ... */ } /// 槽元数据。 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SlotMeta { /* ... §5.2 完整定义 ... */ } impl Default for SlotConfig { /* ... */ } impl Default for ContextBudget { /* ... */ } impl ContextBudget { pub fn auto() -> Self { Self::default() } } impl SlotMeta { pub fn new() -> Self { /* ... */ } } ``` #### `src/agent.rs` 修改 ```rust // 在 pub mod session_memory; 下一行插入 pub mod context; ``` #### 验证 ```bash cargo build --all-targets # 预期:编译通过,无新 warning ``` --- ### Step 10.2 — 持久化(约 200 行,含测试) **目标**:ContextSlot 的 save/load/list/delete/append_messages/load_messages 完整实现并通过测试。 #### 文件变更 | 文件 | 操作 | 说明 | |------|------|------| | `src/agent/context.rs` | **修改** | 追加所有方法实现 + `#[cfg(test)] mod tests` | #### 核心代码 ```rust use std::collections::HashMap; 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}; use crate::memory::store::InMemoryStore; 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"; fn data_key(session_id: &str, slot_id: &str) -> String { format!("{}:{}:{}", Self::KEY_DATA, session_id, slot_id) } fn meta_key(session_id: &str, slot_id: &str) -> String { format!("{}:{}:{}", Self::KEY_META, session_id, slot_id) } fn config_key(session_id: &str, slot_id: &str) -> String { format!("{}:{}:{}", Self::KEY_CONFIG, session_id, slot_id) } fn rel_key(session_id: &str, child_id: &str) -> String { 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(), } } 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?; store.save(Self::make_item(Self::meta_key(&self.session_id, &self.id), meta)).await?; store.save(Self::make_item(Self::config_key(&self.session_id, &self.id), config)).await?; // 派生关系 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?; } Ok(()) } /// 从存储加载 slot,config 从 `slot_config` key 自行恢复。 /// 若 config 记录不存在(旧版本升级场景),使用 `SlotConfig::default()`。 pub async fn load( id: &str, session_id: &str, store: &dyn MemoryStore, ) -> Result, AgentError> { let meta_item = store.get(&Self::meta_key(session_id, id)).await?; let data_item = store.get(&Self::data_key(session_id, id)).await?; let config_item = store.get(&Self::config_key(session_id, id)).await?; 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 = 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), } } pub async fn list( session_id: &str, store: &dyn MemoryStore, ) -> Result, 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?; let mut metas = Vec::new(); for item in items { if let Ok(meta) = serde_json::from_str::(&item.content) { metas.push(meta); } } Ok(metas) } pub async fn delete( id: &str, session_id: &str, store: &dyn MemoryStore, ) -> Result<(), AgentError> { store.delete(&Self::data_key(session_id, id)).await?; store.delete(&Self::meta_key(session_id, id)).await?; store.delete(&Self::config_key(session_id, id)).await?; store.delete(&Self::rel_key(session_id, id)).await?; Ok(()) } pub fn append_messages(&mut self, new_messages: Vec) -> 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(()) } /// 返回消息列表。Focused 模式下按策略过滤(裁剪到最近 recent_messages 条)。 pub fn load_messages(&self) -> Vec { match &self.config.mode { SlotMode::Focused(cfg) => { let mut result = Vec::new(); // 保留 system prompt if cfg.keep_system { if let Some(msg) = self.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> = self.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 } _ => self.messages.clone(), } } } ``` #### 测试用例 | 测试 | 验证点 | |------|--------| | `slot_save_load_roundtrip` | 创建 → 保存 → 加载 → 消息一致 | | `slot_session_isolation` | 不同 session_id 下相同 slot_id 不串数据 | | `slot_derived_parent_id` | Derived 来源的 parent_id 正确记录 | | `slot_readonly_rejects_write` | Readonly 模式下 append_messages 返回 `SlotReadonly` 错误 | | `slot_delete_then_load_none` | delete 后 load 返回 None | | `slot_list_multiple` | 同一 session 下多个 slot 的 list 正确 | | `slot_focused_recent_messages` | Focused 模式 `load_messages()` 仅返回最近 N 条消息(含 system prompt 可选) | | `slot_focused_summary_override` | Focused 模式注入 `summary_override` 生成 system 摘要消息 | | `slot_focused_zero_messages` | Focused 模式 `recent_messages=0` 返回空列表(`keep_system=true` 时只含 system) | | `slot_empty_messages_roundtrip` | 空消息列表 save → load 后仍为空列表 | | `slot_save_on_readonly_side_effect` | Readonly slot 调用 save 仍可持久化(只禁止 append) | #### 验证 ```bash cargo test -- --test-threads=1 # 预期:所有新增测试通过,存量 211 个测试 0 回归 ``` --- ### Step 10.3 — AgentSession 集成 + 示例(约 150 行) **目标**:`AgentSession` 支持 slot 管理方法,`submit_turn` 读写当前 slot,分支对话示例可运行。 #### 文件变更 | 文件 | 操作 | 说明 | |------|------|------| | `src/agent/session.rs` | **修改** | slots 字段 + 6 新方法 + submit_turn/finalize_turn 改造 | | `examples/context_slot_demo.rs` | **新增** | 分支对话展示(~朵创建后派生两个方向) | #### AgentSession 关键改动 ```rust use std::collections::HashMap; use crate::agent::context::{ContextSlot, SlotConfig, SlotSource, DeriveStrategy}; pub struct AgentSession { // ... 原有字段不变 ... slots: HashMap, current_slot_id: String, } impl AgentSession { /// 带 slot 的新构造函数。 /// 注意:`new()` 是同步函数,无法执行异步的 `ContextSlot::load()`。 /// 因此始终创建空的 default slot。若需要从存储恢复 session 历史, /// 可在创建后调用 `switch_slot("default")` 尝试从存储加载。 /// (v0.2 简化:`switch_slot` 在 HashMap 中已有 key 时不会重载——如需恢复, /// 请在清空 `slots` 后调用 `switch_slot`,或等待 v0.3 的懒加载支持。) pub fn new(agent: Arc, session_id: impl Into, bundle: Arc) -> Self { let session_id_str = session_id.into(); let backend = bundle.session_memory_backend.clone() .unwrap_or_else(|| Arc::new(InMemoryStore::new())); let session_memory = SessionMemory::new(backend, &session_id_str); // 自动创建 "default" slot let default_slot = ContextSlot { id: "default".to_string(), session_id: session_id_str.clone(), config: SlotConfig::default(), messages: Vec::new(), meta: SlotMeta::new(), }; let mut slots = HashMap::new(); slots.insert("default".to_string(), default_slot); Self { session_id: session_id_str, agent, bundle, turn_index: 0, cost_so_far: CostTracker::default(), session_memory, slots, current_slot_id: "default".to_string(), } } fn resolve_store(&self) -> Arc { self.bundle.session_memory_backend.clone() .or_else(|| self.bundle.memory_store.clone()) .unwrap_or_else(|| Arc::new(InMemoryStore::new())) } pub async fn create_slot(&mut self, id: impl Into, config: Option) -> Result<(), AgentError> { let id = id.into(); // 禁止创建已存在的 slot if self.slots.contains_key(&id) { return Err(AgentError::SlotAlreadyExists(id)); } let slot = ContextSlot { id: id.clone(), session_id: self.session_id.clone(), config: config.unwrap_or_default(), messages: Vec::new(), meta: SlotMeta::new(), }; slot.save(&*self.resolve_store()).await?; self.slots.insert(id, slot); Ok(()) } pub async fn switch_slot(&mut self, id: &str) -> Result<(), AgentError> { if !self.slots.contains_key(id) { // 尝试从存储加载(config 自动从 slot_config key 恢复) let store = self.resolve_store(); if let Some(slot) = ContextSlot::load(id, &self.session_id, &*store).await? { self.slots.insert(id.to_string(), slot); } else { return Err(AgentError::SlotNotFound(id.to_string())); } } self.current_slot_id = id.to_string(); Ok(()) } pub fn list_slots(&self) -> impl Iterator { self.slots.keys() } /// 按 FocusedConfig 过滤消息(辅助函数,与 ContextSlot::load_messages 的 Focused 逻辑一致)。 fn apply_focused_filter(messages: &[Message], cfg: &FocusedConfig) -> Vec { let mut result = Vec::new(); if cfg.keep_system { if let Some(msg) = messages.iter().find(|m| matches!(m, Message::System(_))) { result.push(msg.clone()); } } 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()); } } result } pub async fn derive_slot(&mut self, id: impl Into, parent_id: &str, strategy: DeriveStrategy) -> Result<(), AgentError> { // 禁止创建已存在的 slot(与 create_slot 行为一致) let slot_id = id.into(); if self.slots.contains_key(&slot_id) { return Err(AgentError::SlotAlreadyExists(slot_id.clone())); } let parent = self.slots.get(parent_id) .ok_or_else(|| AgentError::SlotNotFound(parent_id.to_string()))?; let parent_messages = parent.messages.clone(); let (messages, focused_cfg) = match &strategy { DeriveStrategy::Full => (parent_messages, None), DeriveStrategy::Focused(cfg) => { let filtered = Self::apply_focused_filter(&parent_messages, &cfg); (filtered, Some(cfg)) } }; let mode = match focused_cfg { Some(cfg) => SlotMode::Focused(cfg), None => SlotMode::Full, }; let slot = ContextSlot { id: slot_id.clone(), session_id: self.session_id.clone(), config: SlotConfig { mode, source: SlotSource::Derived { parent_id: parent_id.to_string(), strategy }, ..SlotConfig::default() }, messages, meta: SlotMeta::new(), }; slot.save(&*self.resolve_store()).await?; self.slots.insert(slot_id.clone(), slot); Ok(()) } pub async fn delete_slot(&mut self, id: &str) -> Result<(), AgentError> { // 禁止删除 "default" slot——它是 session 的基础生命周期锚点 if id == "default" { return Err(AgentError::Config("Cannot delete the 'default' slot".into())); } // 确保 session 至少保留一个 slot if self.slots.len() <= 1 { return Err(AgentError::Config("Cannot delete the last slot".into())); } ContextSlot::delete(id, &self.session_id, &*self.resolve_store()).await?; self.slots.remove(id); if self.current_slot_id == id { self.current_slot_id = "default".to_string(); } Ok(()) } } ``` **`submit_turn` 改造后的核心流程**: ```rust pub async fn submit_turn(&mut self, user_input: impl Into) -> Result { let turn_index = self.turn_index; let hook_executor = Arc::clone(&self.bundle.hook_executor); // 0. Readonly slot 拒绝 submit_turn { let slot = self.slots.get(&self.current_slot_id) .ok_or_else(|| AgentError::SlotNotFound(self.current_slot_id.clone()))?; if matches!(slot.config.mode, SlotMode::Readonly) { return Err(AgentError::SlotReadonly( format!("Cannot submit turn on Readonly slot '{}'", self.current_slot_id) )); } } // 1. OnTurnStart hook let start_ctx = HookContext::new(HookEvent::OnTurnStart).with_turn_index(turn_index); hook_executor.execute(HookEvent::OnTurnStart, &start_ctx).await; // 2. 从当前 slot 加载历史消息 let history = self.slots.get(&self.current_slot_id) .ok_or_else(|| AgentError::SlotNotFound(self.current_slot_id.clone()))? .load_messages(); // 3. 组装 LlmCycle let _ = self.agent.tool_definitions(&self.bundle); let mut cycle = LlmCycle::new_with_arc(Arc::clone(&self.bundle.provider), CycleConfig::default()); let initial_messages_len = history.len(); let mut messages_with_prompt = history; if let Some(prompt) = self.agent.system_prompt() { messages_with_prompt.insert(0, Message::system(prompt)); } let input_len = messages_with_prompt.len(); cycle = cycle.with_messages(messages_with_prompt); if let Some(cfg) = self.bundle.config.compact_config.clone() { cycle = cycle.with_compact_config(cfg); } // 4. submit_with_tools(prompt 参数即为本轮 user_input) let response = cycle.submit_with_tools(user_input.into(), &self.bundle.tool_registry).await?; // 5. 累计 cost self.cost_so_far.add(&response.usage); // 6. 只将本轮新增消息追加到当前 slot(保留全量历史,确保 Focused 模式的"读时过滤"语义不丢失数据) // cycle.messages() 包含 [system_prompt?, history..., user_input, tool_calls..., final_response] // 新增消息 = cycle.messages()[input_len..](跳过 initial_messages,即跳过已被持久化的内容) if let Some(slot) = self.slots.get_mut(&self.current_slot_id) { let new_messages: Vec = cycle.messages().iter().skip(input_len).cloned().collect(); slot.append_messages(new_messages)?; let store = self.resolve_store(); slot.save(&*store).await?; } // 7. OnTurnEnd hook let end_ctx = HookContext::new(HookEvent::OnTurnEnd).with_turn_index(turn_index); hook_executor.execute(HookEvent::OnTurnEnd, &end_ctx).await; // 8. turn_index 递增 self.turn_index += 1; Ok(response) } ``` **`finalize_turn` 扩展**: ```rust /// 结束本轮流式对话,将新增消息追加到当前 slot。 /// - `response`: 本次 turn 的最终响应(用于累计 cost 和触发 hook) /// - `new_messages_from_cycle`: 本轮**新增消息**(user input + tool 循环中间消息 + 最终回复), /// 消费者在流消费完成后,从 `cycle.messages()[initial_input_len..]` 获取并传入。 /// 仅增量追加(不覆盖 slot 已有消息),与 `submit_turn` 行为一致。 pub async fn finalize_turn( &mut self, response: &MessageResponse, new_messages_from_cycle: Vec, ) -> Result<(), AgentError> { self.cost_so_far.add(&response.usage); if let Some(slot) = self.slots.get_mut(&self.current_slot_id) { // Readonly 检查 if matches!(slot.config.mode, SlotMode::Readonly) { return Err(AgentError::SlotReadonly( format!("Cannot finalize turn on Readonly slot '{}'", self.current_slot_id) )); } // 增量追加(不覆盖已有消息),确保 Focused 模式的"读时过滤"语义不丢失数据 slot.append_messages(new_messages_from_cycle)?; let store = self.resolve_store(); slot.save(&*store).await?; } let end_ctx = HookContext::new(HookEvent::OnTurnEnd) .with_turn_index(self.turn_index.saturating_sub(1)); self.bundle.hook_executor.execute(HookEvent::OnTurnEnd, &end_ctx).await; Ok(()) } ``` #### 分支对话示例 ```rust // examples/context_slot_demo.rs // 场景:法律咨询入口 → 两个派生方向 → 切换 → 隔离验证 // 1. 创建 AgentSession(自动带 "default" slot) // 2. 在 default slot 中咨询:"我需要法律援助" // 3. 创建派生 slot "option-a"(DeriveStrategy::Full) // 4. 切换到 "option-a",咨询具体场景 // 5. 创建派生 slot "option-b"(DeriveStrategy::Full) // 6. 切换到 "option-b",咨询另一个方向 // 7. 切回 default 验证消息隔离(不包含 option-a/b 的对话) // 8. 列出所有 slot: default, option-a, option-b // 9. 删除 option-b,验证 list 不再包含 fn main() { // 使用 MockProvider(无需 API key) // 全程通过 assert_eq! 验证消息隔离 } ``` #### 验证 ```bash cargo run --example context_slot_demo # 预期:exit 0,控制台输出各 slot 的消息数及隔离验证结果 cargo test --all-targets # 预期:全量测试通过,clippy 0 警告 ``` --- ## 7. 否决项记录 | 方案 | 否决原因 | |------|---------| | **`llm/context.rs`(Roadmap 原案)** | 引入 `llm → memory` 新依赖方向(当前 llm 不依赖 memory);而 `agent → memory` 依赖已存在。模块归属改为 `agent/context.rs` | | **per-message 持久化** | 代码量多 5 倍(~150 vs ~30 行),v0.2 无按消息粒度操作场景。JSON blob + serde_json 已依赖,95% 代码减少 | | **submit_turn 签名改 `(slot_id, input)`** | 破坏向后兼容,内在概念泄漏到公共 API。每次调 `submit_turn` 都需传 slot 对简单场景不友好。改为内部 `current_slot_id` 状态 | | **观察者模式自动同步** | LlmCycle 内部消息通过 `Arc>>` 管理,无法从外部注册回调。手动写回足够简单且职责清晰 | | **ContextBudget 消费逻辑 v0.2 实现** | 半成品风险:当前没有 tokenizer 绑定(字符估算),消费逻辑需深层侵入 LlmCycle 消息组装路径。v0.2 仅做数据结构 + `Default` + `auto()` 占位 | | **`inject_summary` 自动生成 v0.2** | 半成品风险:自动摘要是 Hook 驱动(`OnTurnEnd`),涉及 LlmCycle 内部事件编排。v0.2 仅支持手动 `summary_override: Option`,自动生成推迟 v0.3 | | **fork/merge/branch API** | 语义复杂("分支合并"的冲突语义需精确定义),v0.2 无此场景。v0.3+ 考虑 | --- ## 8. 参考来源 | 来源 | 内容 | 用途 | |------|------|------| | `roadmap.md` §Phase 10 | ContextSlot 原始规划 | 范围定义、Step 划分 | | `docs/7-agent-runtime.md` §3.2 | AgentSession 设计 | submit_turn 改造基座 | | `docs/16-phase9-streaming-experience.md` | submit_turn_stream + finalize_turn 设计 | finalize_turn 扩展参考 | | `src/agent/session.rs` | AgentSession 当前实现 | 字段、方法签名、submit_turn 流程 | | `src/agent/runtime.rs` | RuntimeBundle 定义 | resolve_store fallback 链 | | `src/memory/store.rs` | MemoryStore trait | save/get/delete/list 方法签名 | | `src/memory/types.rs` | MemoryItem / MemoryFilter | 持久化数据格式 | | `src/llm/compact.rs` | CompactConfig | context_window 引用 | | `src/llm/types/message.rs` | Message 类型 | serde Serialize/Deserialize 确认 | ### 路线图同步 本方案实施后需同步更新 `roadmap.md`: | 更新项 | 内容 | |--------|------| | Phase 10 状态 | Step 10.1/10.2/10.3 逐项标记 ✅ | | 里程碑 M6 | ⏳ → ✅ + 日期 | | 依赖图 | `P10` 节点 class `p1` → `done` | | 下一步行动 | Phase 10 → Phase 11 | | 已完成列表 | 追加 `- ✅ Phase 10 ContextSlot — 多上下文分区管理` | | v0.3 展望 | ContextBudget 消费逻辑 + 摘要自动生成 + fork/merge 移至 v0.3 清单 | ## 9. 实施计划 ### 9.1 阶段总览 | 阶段 | 涉及文件 | 操作 | 预估行数 | 风险 | 工作量 | |------|---------|------|---------|------|--------| | 10.1 核心类型 | `agent/context.rs` / `agent.rs` / `agent/error.rs` | 1 新文件 + 2 修改 | ~150 | 低 | M | | 10.2 持久化 | `agent/context.rs` | 修改(+方法 + 测试) | ~200 | 中 | M | | 10.3 AgentSession 集成 | `agent/session.rs` + `examples/context_slot_demo.rs` | 修改 + 新示例 | ~300 | 高 | L | ### 9.2 任务拆解表 #### Step 10.1 任务 | 任务 | 描述 | 文件 | 依赖 | 工作量 | 风险 | 验收条件 | |------|------|------|------|--------|------|---------| | 10.1.1 | 新增 `AgentError` 变体:`SlotReadonly` / `SlotNotFound` / `SlotAlreadyExists` | `agent/error.rs` | 无 | S(3 行) | 低 | `cargo build` | | 10.1.2 | 新增 `agent/context.rs`:`ContextSlot` 核心类型 + `SlotConfig` / `SlotMode` / `FocusedConfig` / `SlotSource` / `DeriveStrategy` / `ContextBudget` / `SlotMeta` | `agent/context.rs` | 10.1.1 | M(120 行) | 低 | `cargo build` | | 10.1.3 | `Default`/`auto` 实现 + module 声明 | `agent/context.rs` + `agent.rs` | 10.1.2 | S(15 行) | 低 | `cargo build --all-targets` | #### Step 10.2 任务 | 任务 | 描述 | 文件 | 依赖 | 工作量 | 风险 | 验收条件 | |------|------|------|------|--------|------|---------| | 10.2.1 | Key 辅助函数 + `make_item()` + `save()` 方法(写 data / meta / config / rel) | `agent/context.rs` | 10.1.3 | S(45 行) | 低 | `cargo build` | | 10.2.2 | `load()` + `list()` + `delete()` 方法 | `agent/context.rs` | 10.2.1 | M(50 行) | 中(`MemoryStore` API 字段名需匹配) | `cargo build` | | 10.2.3 | `append_messages()` + `load_messages()` 方法(含 Focused 过滤逻辑) | `agent/context.rs` | 10.2.2 | M(40 行) | 中(Focused 过滤逻辑正确性) | `cargo build` | | 10.2.4 | 内联单元测试(11 个用例) | `agent/context.rs` | 10.2.3 | M(70 行) | 中 | `cargo test` — 211 + 11 passed | #### Step 10.3 任务 | 任务 | 描述 | 文件 | 依赖 | 工作量 | 风险 | 验收条件 | |------|------|------|------|--------|------|---------| | 10.3.1 | `AgentSession` 新增 `slots` / `current_slot_id` 字段 + `new()` 自动创建 default slot | `agent/session.rs` | 10.1.2 | S(30 行) | 低 | `cargo build` | | 10.3.2 | `resolve_store()` + `create_slot()` + `switch_slot()` + `list_slots()` + `derive_slot()` + `delete_slot()` | `agent/session.rs` | 10.3.1 | L(80 行) | 中(slot 恢复逻辑) | `cargo build` | | 10.3.3 | `submit_turn` 改造(Readonly 检查 → `load_messages()` → cycle → 增量追加 + save;注意大括号释放借用) | `agent/session.rs` | 10.3.2 | L(50 行) | 高(data flow 正确性) | `cargo test` 全绿 | | 10.3.4 | `finalize_turn` 扩展(`new_messages_from_cycle` 参数 → 增量追加 + Readonly 检查 + save;同步更新 2 个存量测试调用方) | `agent/session.rs` | 10.3.3 | M(25 行 + 2 测试适配) | 中 | `cargo test` 全绿 + 所有 finalize_turn 调用方已适配 | | 10.3.5 | `context_slot_demo` 分支对话示例(使用 `MockProvider::new(vec![...])` 预置响应,避免空队列) | `examples/context_slot_demo.rs` | 10.3.4 | M(80 行) | 低 | `cargo run --example` exit 0 | | 10.3.6 | 全量验证:`cargo test --all-targets` + clippy + 存量测试 0 回归 | — | 10.3.5 | S(5 min) | 低 | test 全绿 + clippy 0 | ### 9.3 并行机会分析 | 可并行组合 | 理由 | 风险 | |-----------|------|------| | 10.1.1 + 10.1.2 | 错误变体与核心类型无文件交集 | 无 | | 10.2.1 + 10.2.2 | `save()` 与 `load()` 可独立实现(只要 key 辅助函数就绪) | 低(确保 key 格式一致) | | 10.3.1 + 10.3.2 | 字段声明与方法新增可同步进行 | 无 | | 10.3.1 可提前到 10.1.2 后启动 | `new()` 只依赖类型定义(`ContextSlot` / `SlotConfig` / `SlotMeta`),不依赖持久化方法 | 无 | | **不可并行**:10.2 → 10.3.2 起 | `create_slot()`/`switch_slot()` 等需要 `ContextSlot::save()`/`load()` 就绪 | — | ### 9.4 风险与应对 | 风险 | 可能性 | 影响 | 应对策略 | |------|--------|------|---------| | `MemoryStore` trait 方法签名变化 | 低 | 高 | 验证代码中 `MemoryStore` API,必要时适配 | | `LlmCycle` API(`cycle.messages()` 等)无效 | 低 | 高 | 在实施前验证 `cycle` 的公共方法 | | 序列化失败导致 session 数据损坏 | 低 | 高 | 所有 `serde_json` 调用走 `?` 传播错误 | | `finalize_turn` 签名变更破坏存量测试 | 中 | 中 | 10.3.4 明确列出需适配的 2 个测试调用方,适配后立即运行 `cargo test` 验证 | | Focused 过滤逻辑边界条件遗漏 | 中 | 中 | 4 个 Focused 测试覆盖边界(0 turn / `keep_system` / `summary_override`) | | `new()` 同步限制导致 session 恢复困难 | 中 | 中 | 文档已说明限制,v0.3 解决 | | `MockProvider` 空队列导致示例 panic | 中 | 低 | 示例使用 `MockProvider::new(vec![...])` 预置响应,避免 `empty()` | | Hook 执行器在 slot 写回后触发,`HookContext` 不携带 `slot_id` | 低 | 低 | v0.2 已知局限,后续在 `HookContext` 上扩展 `slot_id: Option<&str>` | ### 9.5 测试策略 **单元测试覆盖**(11 个基础 + 5 个扩展): - **持久化**:save/load roundtrip、session 隔离、delete 后 load None、list 多 slot - **模式行为**:Readonly 拒绝写入、Focused 过滤(最近 N 条 / 摘要注入 / 0 边界) - **派生**:`DeriveStrategy::Full` 正确复制父 slot 消息、`DeriveStrategy::Focused` 正确裁剪、派生 slot 持久化后可独立加载 - **边界**:空消息列表 roundtrip、Readonly save 不影响 - **保护逻辑**:`delete_slot` 禁止删 `"default"`、禁止删最后一个 slot **集成测试覆盖**: - **端到端**:`context_slot_demo` 示例验证多 slot 隔离 - **回归**:存量 211 个测试不依赖 slot 行为 → 0 回归预期 ### 9.6 验证标准 | 门禁 | 命令 | 预期结果 | |------|------|---------| | 编译 | `cargo build --all-targets` | 0 error + 0 warning | | 测试 | `cargo test --all-targets` | 211 → 227 passed, 0 failed | | 静态检查 | `cargo clippy --all-targets -- -D warnings` | 0 warning | | 示例 | `cargo run --example context_slot_demo` | exit 0 | | 文档 | `cargo doc --no-deps` | 0 warning |