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

1228 lines
51 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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_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<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_slot``submit_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 核心类型定义
```rust
/// 上下文槽 —— 一段带策略配置的消息列表。
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_backend``MemoryStore` 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`(纯文本) | 直接字符串 |
每条 `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<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 扩展
```rust
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` 改造**
```rust
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:
```rust
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 相关错误场景):
> ```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<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` 修改
```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(())
}
/// 从存储加载 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 |
#### 验证
```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<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` 改造后的核心流程**
```rust
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` 扩展**
```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<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(())
}
```
#### 分支对话示例
```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<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 `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` | 无 | S3 行) | 低 | `cargo build` |
| 10.1.2 | 新增 `agent/context.rs``ContextSlot` 核心类型 + `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` 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 |