Files
agcore/design/pdd/17-phase10-contextslot.md
T
徐涛 28ca43ccb2 chore(docs): 将设计文档从 docs 移至 design 目录
将 note、pdd、prd、roadmap 四类文档分别归入 `design/` 下对应子目录中,并新增 `.gitkeep` 占位文件
2026-07-23 05:45:53 +08:00

51 KiB
Raw Blame History

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.1Phase 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<Mutex<Vec<Message>>> 管理 tool 循环迭代
  └─ turn_index += 1, cost_so_far.add(...)
  └─ 返回 MessageResponse

关键观察:AgentSession 当前不维护自身的 messages: Vec<Message>,每次 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_storesession_memory_backendconfig
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<Message>submit_turn 每次从空白开始
  2. 无上下文隔离:所有对话共享同一命名空间,无法分支或隔离
  3. 无显式消息写入LlmCycle 内部的 Arc<Mutex<Vec<Message>>> 无法被外部直接读取

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<String> 字段名暗示"注入",但消费端逻辑未实现。建议改名 summary_override: Option<String>,明确表达"覆盖而非自动生成"的语义,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<Message> 整体序列化 每条消息一个 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_slotsubmit_turn。零破坏,低侵入。

3.2.4 其他架构发现

维度 发现 结论
消息同步 LlmCycle 内部 Arc<Mutex<Vec<Message>>> 无法外部观测 手动写回:AgentSession 在 submit_turn 返回后显式覆盖 slot.messages
Compact 时机 压缩逻辑在 LlmCycle 内部下游触发(maybe_compact 保持现状,slot 只做纯存储,不干预压缩
Focused 模式 读取消息时按策略过滤(非持久化时过滤) 读时过滤:load_messages() 返回裁剪后的 Vec<Message>
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<Mutex<Vec>>> 无法外部观测,改造成本高

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 核心类型定义

/// 上下文槽 —— 一段带策略配置的消息列表。
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::UNIX_EPOCH)
                .unwrap_or_default()
                .as_secs(),
        }
    }
}

5.3 持久化 Key 设计

使用 session_memory_backendMemoryStore trait)存储,Key 命名规则:

Key 格式 内容 序列化方式
slot_data:{session_id}:{slot_id} Vec<Message> 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(纯文本) 直接字符串

每条 MemoryItemidcontent 字段使用上述 key 格式和序列化后的字符串。每 slot 对应 3-4 条 MemoryItem 记录。

⚠️ Key 编码约定session_idslot_id不应包含冒号 :,否则会导致 list 的 prefix 匹配(LIKE 'slot_data:{session_id}:%')返回异常结果。建议使用字母、数字、连字符和下划线。如果 session_id 来自外部系统(如 UUID),需确认不含 :

5.4 ContextSlot 方法

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<Option<Self>, AgentError> { ... }

    /// 列出某 session 下的所有 slot 元数据。
    pub async fn list(
        session_id: &str,
        store: &dyn MemoryStore,
    ) -> Result<Vec<SlotMeta>, 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<Message>) -> Result<(), AgentError> { ... }

    /// 读取当前 slot 的消息列表。
    /// Focused 模式下按策略过滤后返回;Full/Readonly 返回全量。
    /// 返回 `Vec<Message>`(而非 `&[Message]`),因为 Focused 模式需要分配新 Vec 来存储裁剪后的消息。
    pub fn load_messages(&self) -> Vec<Message> { ... }
}

5.5 AgentSession 扩展

pub struct AgentSession {
    pub session_id: String,
    pub agent: Arc<dyn Agent>,
    bundle: Arc<RuntimeBundle>,
    turn_index: u32,
    cost_so_far: CostTracker,
    pub session_memory: SessionMemory,

    // === Phase 10 新增字段 ===
    /// session 内的所有 slotid → ContextSlot)。
    slots: HashMap<String, ContextSlot>,
    /// 当前活跃 slot 的 id。
    current_slot_id: String,
}

impl AgentSession {
    /// 创建新 slot(config 可选,不传则使用默认值)。
    pub async fn create_slot(
        &mut self,
        id: impl Into<String>,
        config: Option<SlotConfig>,
    ) -> Result<(), AgentError> { ... }

    /// 切换到指定 slot(必须已存在,否则返回 `SlotNotFound` 错误)。
    pub async fn switch_slot(&mut self, id: &str) -> Result<(), AgentError> { ... }

    /// 列出所有 slot 的 id 列表。
    pub fn list_slots(&self) -> impl Iterator<Item = &String> { ... }

    /// 从父 slot 派生新 slot(继承父 slot 的全量或聚焦消息)。
    pub async fn derive_slot(
        &mut self,
        id: impl Into<String>,
        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<dyn MemoryStore> { ... }
}

new() 改动:自动创建 "default" slot,确保简单场景无感使用。

submit_turn 改造

pub async fn submit_turn(&mut self, user_input: impl Into<String>) -> Result<MessageResponse, AgentError> {
    // 1. 从 current slot 加载历史消息
    let history = self.load_current_slot_history();

    // 2. 组装 LlmCyclesystem_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:

pub async fn finalize_turn(
    &mut self,
    response: &MessageResponse,
    new_messages_from_cycle: Vec<Message>,  // ← 新增
) -> Result<(), AgentError> { ... }

5.6 向后兼容性

维度 状态 说明
submit_turn 签名 不变 仍是 (&mut self, user_input: impl Into<String>);新增 Readonly slot 前置检查(返回 SlotReadonly 错误)
AgentSession::new 签名 不变 内部自动创建 "default" slot
finalize_turn 签名 ⚠️ 变更 — 新增 new_messages_from_cycle: Vec<Message> 参数,返回从 () 改为 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 相关错误场景):

#[error("Readonly slot 不允许写入: {0}")]
SlotReadonly(String),
#[error("Slot '{0}' 不存在")]
SlotNotFound(String),
#[error("Slot '{0}' 已存在")]
SlotAlreadyExists(String),

核心代码

// 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<String> }

/// 槽来源。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub enum SlotSource { New, Derived { parent_id: String, strategy: DeriveStrategy }, Static(Vec<Message>) }

/// 派生策略。
#[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 修改

// 在 pub mod session_memory; 下一行插入
pub mod context;

验证

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

核心代码

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(())
    }

    /// 从存储加载 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?;
        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<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),
        }
    }

    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?;
        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)
    }

    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<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(())
    }

    /// 返回消息列表。Focused 模式下按策略过滤(裁剪到最近 recent_messages 条)。
    pub fn load_messages(&self) -> Vec<Message> {
        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)

验证

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 关键改动

use std::collections::HashMap;
use crate::agent::context::{ContextSlot, SlotConfig, SlotSource, DeriveStrategy};

pub struct AgentSession {
    // ... 原有字段不变 ...
    slots: HashMap<String, ContextSlot>,
    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<dyn Agent>, session_id: impl Into<String>, bundle: Arc<RuntimeBundle>) -> 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<dyn MemoryStore> {
        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<String>, config: Option<SlotConfig>) -> 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<Item = &String> {
        self.slots.keys()
    }

    /// 按 FocusedConfig 过滤消息(辅助函数,与 ContextSlot::load_messages 的 Focused 逻辑一致)。
    fn apply_focused_filter(messages: &[Message], cfg: &FocusedConfig) -> Vec<Message> {
        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<String>, 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 改造后的核心流程

pub async fn submit_turn(&mut self, user_input: impl Into<String>) -> Result<MessageResponse, AgentError> {
    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_toolsprompt 参数即为本轮 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<Message> = 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 扩展

/// 结束本轮流式对话,将新增消息追加到当前 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<Message>,
) -> 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(())
}

分支对话示例

// 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! 验证消息隔离
}

验证

cargo run --example context_slot_demo
# 预期:exit 0,控制台输出各 slot 的消息数及隔离验证结果

cargo test --all-targets
# 预期:全量测试通过,clippy 0 警告

7. 否决项记录

方案 否决原因
llm/context.rsRoadmap 原案) 引入 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<Mutex<Vec<Message>>> 管理,无法从外部注册回调。手动写回足够简单且职责清晰
ContextBudget 消费逻辑 v0.2 实现 半成品风险:当前没有 tokenizer 绑定(字符估算),消费逻辑需深层侵入 LlmCycle 消息组装路径。v0.2 仅做数据结构 + Default + auto() 占位
inject_summary 自动生成 v0.2 半成品风险:自动摘要是 Hook 驱动(OnTurnEnd),涉及 LlmCycle 内部事件编排。v0.2 仅支持手动 summary_override: Option<String>,自动生成推迟 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 p1done
下一步行动 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 S3 行) cargo build
10.1.2 新增 agent/context.rsContextSlot 核心类型 + SlotConfig / SlotMode / FocusedConfig / SlotSource / DeriveStrategy / ContextBudget / SlotMeta agent/context.rs 10.1.1 M120 行) cargo build
10.1.3 Default/auto 实现 + module 声明 agent/context.rs + agent.rs 10.1.2 S15 行) cargo build --all-targets

Step 10.2 任务

任务 描述 文件 依赖 工作量 风险 验收条件
10.2.1 Key 辅助函数 + make_item() + save() 方法(写 data / meta / config / rel agent/context.rs 10.1.3 S45 行) cargo build
10.2.2 load() + list() + delete() 方法 agent/context.rs 10.2.1 M50 行) 中(MemoryStore API 字段名需匹配) cargo build
10.2.3 append_messages() + load_messages() 方法(含 Focused 过滤逻辑) agent/context.rs 10.2.2 M40 行) 中(Focused 过滤逻辑正确性) cargo build
10.2.4 内联单元测试(11 个用例) agent/context.rs 10.2.3 M70 行) 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 S30 行) cargo build
10.3.2 resolve_store() + create_slot() + switch_slot() + list_slots() + derive_slot() + delete_slot() agent/session.rs 10.3.1 L80 行) 中(slot 恢复逻辑) cargo build
10.3.3 submit_turn 改造(Readonly 检查 → load_messages() → cycle → 增量追加 + save;注意大括号释放借用) agent/session.rs 10.3.2 L50 行) 高(data flow 正确性) cargo test 全绿
10.3.4 finalize_turn 扩展(new_messages_from_cycle 参数 → 增量追加 + Readonly 检查 + save;同步更新 2 个存量测试调用方) agent/session.rs 10.3.3 M25 行 + 2 测试适配) cargo test 全绿 + 所有 finalize_turn 调用方已适配
10.3.5 context_slot_demo 分支对话示例(使用 MockProvider::new(vec![...]) 预置响应,避免空队列) examples/context_slot_demo.rs 10.3.4 M80 行) cargo run --example exit 0
10.3.6 全量验证:cargo test --all-targets + clippy + 存量测试 0 回归 10.3.5 S5 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 APIcycle.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