28ca43ccb2
将 note、pdd、prd、roadmap 四类文档分别归入 `design/` 下对应子目录中,并新增 `.gitkeep` 占位文件
1228 lines
51 KiB
Markdown
1228 lines
51 KiB
Markdown
# 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<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 内的所有 slot(id → 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. 组装 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<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(())
|
||
}
|
||
|
||
/// 从存储加载 slot,config 从 `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_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<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` | 无 | 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 |
|