diff --git a/docs/17-phase10-contextslot.md b/docs/17-phase10-contextslot.md new file mode 100644 index 0000000..defc2db --- /dev/null +++ b/docs/17-phase10-contextslot.md @@ -0,0 +1,1227 @@ +# Phase 10 — ContextSlot 上下文管理 + +- **文档编号**:17 +- **标题**:Phase 10 — ContextSlot 上下文管理 +- **日期**:2026-07-06 +- **状态**:已定稿 +- **涉及模块**:agent/context(新建)、agent/session、examples +- **关联文档**:roadmap.md(§Phase 10)、7-agent-runtime.md、16-phase9-streaming-experience.md + +--- + +## 1. 背景与目标 + +agcore 已发布 v0.2.0-rc.1,Phase 0-9 全部完成。当前 `AgentSession` 只有"单一对话线程"——所有 `submit_turn` 调用共享同一消息列表,无法在同一个 session 内部管理多个独立上下文。 + +Phase 10 的目标是引入 **ContextSlot(上下文槽)** 概念,使 Agent 能在单个 session 内创建、切换、派生多个独立的消息上下文。开发者可像"多标签页"一样管理对话分支,每个 slot 有独立的配置、消息列表和持久化。 + +### 1.1 成功标准 + +- `ContextSlot` 核心类型可编译,支持三种模式(Full / Focused / Readonly)、三种来源(New / Derived / Static) +- 基于 `MemoryStore` trait 的持久化:save / load / list / delete 完整,session 间隔离 +- `AgentSession` 扩展:`create_slot` / `switch_slot` / `list_slots` / `derive_slot` / `delete_slot`,自动创建 `"default"` slot +- 向后兼容:现有 `submit_turn` 签名不变,存量测试 0 回归 +- 分支对话示例可运行:`cargo run --example context_slot_demo` exit 0 + +--- + +## 2. 当前状态分析 + +### 2.1 AgentSession 现状 + +当前 `AgentSession` **没有 slot 概念**。其消息流向如下: + +``` +submit_turn(user_input) + └─ 组装 initial_messages: [system_prompt] + └─ LlmCycle::new().with_messages(initial_messages) + └─ cycle.submit_with_tools(user_input, tool_registry) + └─ LlmCycle 内部 Arc>> 管理 tool 循环迭代 + └─ turn_index += 1, cost_so_far.add(...) + └─ 返回 MessageResponse +``` + +关键观察:`AgentSession` 当前**不维护自身的 `messages: Vec`**,每次 `submit_turn` 从头组装消息(system_prompt + user_input),由 `LlmCycle` 内部管理 tool 循环中的消息迭代。这意味着 session 层面没有任何消息历史保留——跨 turn 的消息积累完全依赖上层应用自行实现。 + +### 2.2 相关模块 + +| 模块 | 路径 | 与 ContextSlot 的关系 | +|------|------|----------------------| +| `MemoryStore` trait | `memory/store.rs` | 持久化后端接口(4 方法:save/get/delete/list) | +| `SqliteStore` | `memory/store/sqlite_store.rs` | 推荐持久化后端,Phase 7 已完成 | +| `SessionMemory` | `agent/session_memory.rs` | session 级 KV 存储,namespace `_session_{session_id}` | +| `RuntimeBundle` | `agent/runtime.rs` | 含 `memory_store`、`session_memory_backend`、`config` | +| `CompactConfig` | `llm/compact.rs` | context_window=128k, reserved_tokens=20k, keep_recent=6 | +| `Message` | `llm/types/message.rs` | IR 层统一消息类型,带 `Serialize/Deserialize` | + +### 2.3 预置枚举 + +Roadmap 中 Phase 5.3 已规划的 `#[non_exhaustive]` 枚举尚未实际创建,将在 Phase 10 中首次定义: + +- `SlotMode` — 三种模式 +- `SlotSource` — 三种来源 +- `DeriveStrategy` — 派生策略 + +### 2.4 当前约束 + +1. **无跨 turn 消息持久化**:AgentSession 不持有 `Vec`,`submit_turn` 每次从空白开始 +2. **无上下文隔离**:所有对话共享同一命名空间,无法分支或隔离 +3. **无显式消息写入**:LlmCycle 内部的 `Arc>>` 无法被外部直接读取 + +--- + +## 3. 调研发现 + +### 3.1 产品视角(PM) + +#### 3.1.1 三个核心场景 + +| 场景 | 描述 | 用户价值 | +|------|------|---------| +| **多子任务上下文隔离** | Agent 同时处理多个独立任务(如:同时追踪两个法律咨询),每个任务的消息历史互不干扰 | 避免上下文污染 | +| **派生/假设推演** | 从决策点分出"试错分支"(如:分别推演方案 A 和方案 B),独立演进,不相互影响 | 探索式决策 | +| **长会话聚焦管理** | 大模型注意力有限,聚焦模式裁剪非核心历史,保留关键上下文 | 节省 token 成本 | + +#### 3.1.2 关键发现 + +1. **CRUD 缺 D**:派生场景会持续创建新 slot,若没有 `delete_slot` 会导致存储泄漏。`delete_slot` 必须纳入 v0.2 +2. **`FocusedConfig.inject_summary` 半成品风险**:当前设计 `inject_summary: Option` 字段名暗示"注入",但消费端逻辑未实现。建议改名 `summary_override: Option`,明确表达"覆盖而非自动生成"的语义,v0.2 只做消费端,生成推迟 v0.3 +3. **API 极简默认值关键**:`create_slot("id")` 应一行创建,config 全部走 `Default`。开发者只有需要定制时才传 `SlotConfig` +4. **示例场景选择**:Focused 模式的"注意力管理"概念较抽象,示例以**"分支对话"**场景呈现更直观(法律咨询入口 → 两个派生方向 → 切换 → 隔离验证) +5. **storage 后端统一**:ContextSlot 应使用 `session_memory_backend`(与 `SessionMemory` 同一后端),避免引入第三种存储路径 +6. **ContextBudget 工厂函数**:需提供 `ContextBudget::auto()` 工厂函数,自动按上下文窗口比例分配预算 + +### 3.2 架构视角(SA) + +#### 3.2.1 模块归属争议 + +Roadmap 将 ContextSlot 放在 `src/llm/context.rs`,但引入 `llm → memory` 的新依赖方向(当前 `llm` 模块不依赖 `memory`)。 + +依赖关系推演: + +``` +llm/context.rs → memory::store::MemoryStore // ❌ 新依赖方向 +agent/context.rs → memory::store::MemoryStore // ✅ 已有依赖 (agent → memory) +agent/context.rs ← agent/session.rs // ✅ 同模块内引用 +``` + +**结论**:推荐 `agent/context.rs`,零新依赖方向。 + +#### 3.2.2 持久化策略 + +两种存法对比: + +| 维度 | 方案 A: JSON blob 批次 | 方案 B: per-message 独立 key | +|------|----------------------|-----------------------------| +| 存储单位 | `Vec` 整体序列化 | 每条消息一个 key | +| 代码量 | ~30 行 | ~150 行 | +| 变更风险 | 低(serde_json 已依赖) | 中(需额外 id 生成) | +| 按消息粒度操作 | 不支持 | 支持 | +| v0.2 使用场景 | 无(只做全量读/写) | 无(无按消息粒度的操作需求) | + +**结论**:推荐 JSON blob 批次存储。serde_json 已是现有依赖,95% 代码减少,v0.2 没有 per-message 操作场景。每 slot 对应 2-3 条 `MemoryItem` 记录。 + +#### 3.2.3 submit_turn 改造方向 + +| | 方案 A(推荐) | 方案 B | +|---|---------------|--------| +| 签名 | `submit_turn(&mut self, input)` 不变 | `submit_turn(&mut self, slot_id, input)` | +| slot 角色 | 内部状态,`current_slot_id` 隐式消费 | 显式参数 | +| 向后兼容 | ✅ 完全兼容 | ❌ 破坏现有 API | +| 心智负担 | 低 "默认往当前 slot 写" | 高 "每次都要传 slot" | +| 显式度 | 隐式(需开发者意识"当前 slot"存在) | 显式(每次调用明确目标) | + +**结论**:推荐方案 A。`submit_turn` 签名不变,默认写入 `current_slot_id` 指向的 slot。当开发者需要操作非当前 slot 时,先 `switch_slot` 再 `submit_turn`。零破坏,低侵入。 + +#### 3.2.4 其他架构发现 + +| 维度 | 发现 | 结论 | +|------|------|------| +| **消息同步** | LlmCycle 内部 `Arc>>` 无法外部观测 | 手动写回:AgentSession 在 submit_turn 返回后显式覆盖 slot.messages | +| **Compact 时机** | 压缩逻辑在 LlmCycle 内部下游触发(`maybe_compact`) | 保持现状,slot 只做纯存储,不干预压缩 | +| **Focused 模式** | 读取消息时按策略过滤(非持久化时过滤) | 读时过滤:`load_messages()` 返回裁剪后的 `Vec` | +| **Readonly 模式** | 阻止 append/overwrite | 写时阻断:`append_messages()` 返回 `SlotReadonly` 错误 | +| **改动量** | 1 新文件 + 2-3 修改文件 | 约 500 行净增(含测试和示例) | + +--- + +## 4. 可选方案 + +### 4.1 模块归属 + +| 方案 | 位置 | 优点 | 缺点 | +|------|------|------|------| +| **A(推荐)** | `agent/context.rs` | 零新依赖方向;agent→memory 已存在 | 偏离 Roadmap 文本 | +| B | `llm/context.rs` | 与 `compact.rs` 同级,语义上接近"上下文" | 引入 `llm→memory` 新依赖方向 | + +### 4.2 持久化粒度 + +| 方案 | 存储方式 | 优点 | 缺点 | +|------|---------|------|------| +| **A(推荐)** | JSON blob,每 slot 2-3 条记录 | 代码量极小(~30 行),serde_json 已依赖 | 不支持按消息粒度操作 | +| B | per-message 独立 key | 粒度最细 | 代码量 5 倍 +,v0.2 无此场景 | + +### 4.3 submit_turn 改造方式 + +| 方案 | 签名 | 优点 | 缺点 | +|------|------|------|------| +| **A(推荐)** | 签名不变,内部状态 | 向后兼容,侵入小 | 隐式消费当前 slot | +| B | 显式 `slot_id` 参数 | 调用意图明确 | 破坏向后兼容 | + +### 4.4 消息同步机制 + +| 方案 | 方式 | 优点 | 缺点 | +|------|------|------|------| +| **A(推荐)** | 手动写回(AgentSession 在 submit_turn 后显式 overwrite) | 简单、可预测、职责清晰 | 需注意同步时机 | +| B | 观察者模式(LlmCycle 发出消息变更事件) | 实时同步 | LlmCycle 内部 `Arc>>` 无法外部观测,改造成本高 | + +### 4.5 Focused 模式实现 + +| 方案 | 方式 | 优点 | 缺点 | +|------|------|------|------| +| **A(推荐)** | 读时过滤 | 持久化不依赖 mode,切换 mode 无需写回 | 每次读取都需计算 | +| B | 写时裁剪 | 存储空间最小 | 切换 mode 需重新裁剪,逻辑复杂 | + +### 4.6 ContextBudget 消费逻辑 + +| 方案 | 内容 | 优点 | 缺点 | +|------|------|------|------| +| **A(推荐)** | v0.2 仅数据结构 + `Default` + `auto()`,无消费逻辑 | 低风险,可交付 | 功能半成品 | +| B | v0.2 同时实现消费逻辑 | 完整闭环 | 半成品风险高,时间不足 | + +--- + +## 5. 推荐方案 + +### 5.1 总体设计 + +**模块归属**:`src/agent/context.rs`(零新依赖方向) +**持久化**:JSON blob 批次存储,使用 `session_memory_backend` +**submit_turn**:签名不变,当前 slot 内部状态 +**消息同步**:手动写回(非观察者模式) +**Compact 时机**:保持现状(LlmCycle 内部下游压缩) +**Focused 模式**:读时过滤 +**Readonly 模式**:写时阻断 + +### 5.2 核心类型定义 + +```rust +/// 上下文槽 —— 一段带策略配置的消息列表。 +pub struct ContextSlot { + /// 当前 slot 的唯一标识(同一个 session_id 内唯一)。 + pub id: String, + /// 所属 session。 + pub session_id: String, + /// 槽配置。 + pub config: SlotConfig, + /// 消息列表(全量,Focused/Readonly 在读取时做策略过滤)。 + pub messages: Vec, + /// 槽元数据。 + pub meta: SlotMeta, +} + +/// 槽配置。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SlotConfig { + /// 槽模式(Full / Focused / Readonly)。 + pub mode: SlotMode, + /// 槽来源(New / Derived / Static)。 + pub source: SlotSource, + /// 上下文预算(v0.2 纯数据结构,无消费逻辑)。 + pub budget: ContextBudget, + /// 是否启用自动压缩(v0.2 保留字段,LlmCycle 内部自行判断)。 + pub compact: bool, +} + +impl Default for SlotConfig { + fn default() -> Self { + Self { + mode: SlotMode::Full, + source: SlotSource::New, + budget: ContextBudget::default(), + compact: true, + } + } +} + +/// 槽模式。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] +pub enum SlotMode { + /// 完整对话历史(全部消息)。 + Full, + /// 聚焦模式 —— 读取时按策略过滤,保持 LLM 注意力。 + Focused(FocusedConfig), + /// 只读参考上下文 —— 禁止写入。 + Readonly, +} + +/// 聚焦模式配置。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FocusedConfig { + /// 是否保留 system prompt。 + pub keep_system: bool, + /// 保留的最近消息条数(以消息条数而非对话轮次为单位,因为一轮对话可能包含多条 tool 消息)。 + pub recent_messages: usize, + /// 摘要覆盖(v0.2 仅消费端:手动设置则注入,不自动生成)。 + /// v0.3 将支持 Hook 驱动的自动摘要生成。 + pub summary_override: Option, +} + +/// 槽来源。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] +pub enum SlotSource { + /// 全新空槽。 + New, + /// 从父 slot 派生(记录 parent_id)。 + Derived { + parent_id: String, + strategy: DeriveStrategy, + }, + /// 预置静态消息(不持久化,随 session 生命周期存在)。 + Static(Vec), +} + +/// 派生策略。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum DeriveStrategy { + /// 完整复制父 slot 的消息。 + Full, + /// 按聚焦策略复制父 slot 的消息。 + Focused(FocusedConfig), +} + +/// 上下文预算(v0.2 纯数据结构,无消费逻辑)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContextBudget { + /// system prompt 预算。 + pub system: u32, + /// 对话历史预算。 + pub history: u32, + /// 工具定义预算。 + pub tools: u32, + /// 工具结果预算。 + pub tool_results: u32, + /// 预留 buffer。 + pub reserve: u32, +} + +impl Default for ContextBudget { + fn default() -> Self { + Self { + system: 8_000, + history: 80_000, + tools: 10_000, + tool_results: 20_000, + reserve: 10_000, + } + } +} + +impl ContextBudget { + /// 自动分配:按上下文窗口的固定比例分配预算。 + /// v0.2 只做占位实现,v0.3 将根据实际 provider 的 context_window 计算。 + pub fn auto() -> Self { + Self::default() + } +} + +/// 槽元数据。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SlotMeta { + /// 父 slot id(仅 Derived 来源有值)。 + pub parent_id: Option, + /// 消息总数。 + pub message_count: usize, + /// 总 token 估算值(由 add_messages 时累计,v0.2 为近似值)。 + pub total_tokens: u32, + /// 创建时间(Unix 时间戳,秒)。 + pub created_at: u64, +} + +impl SlotMeta { + pub fn new() -> Self { + Self { + parent_id: None, + message_count: 0, + total_tokens: 0, + created_at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + } + } +} +``` + +### 5.3 持久化 Key 设计 + +使用 `session_memory_backend`(`MemoryStore` trait)存储,Key 命名规则: + +| Key 格式 | 内容 | 序列化方式 | +|----------|------|-----------| +| `slot_data:{session_id}:{slot_id}` | `Vec` | `serde_json::to_string` | +| `slot_meta:{session_id}:{slot_id}` | `SlotMeta` | `serde_json::to_string` | +| `slot_config:{session_id}:{slot_id}` | `SlotConfig` | `serde_json::to_string` | +| `slot_rel:{session_id}:{child_id}` | `parent_id`(纯文本) | 直接字符串 | + +每条 `MemoryItem` 的 `id` 和 `content` 字段使用上述 key 格式和序列化后的字符串。每 slot 对应 3-4 条 `MemoryItem` 记录。 + +> **⚠️ Key 编码约定**:`session_id` 和 `slot_id` 中**不应包含冒号 `:`**,否则会导致 `list` 的 prefix 匹配(`LIKE 'slot_data:{session_id}:%'`)返回异常结果。建议使用字母、数字、连字符和下划线。如果 session_id 来自外部系统(如 UUID),需确认不含 `:`。 + +### 5.4 ContextSlot 方法 + +```rust +impl ContextSlot { + /// 保存 slot 数据到存储后端(全量写入,含 config)。 + pub async fn save(&self, store: &dyn MemoryStore) -> Result<(), AgentError> { ... } + + /// 从存储后端加载 slot(config 从存储自行恢复,不依赖调用方传入)。 + pub async fn load( + id: &str, + session_id: &str, + store: &dyn MemoryStore, + ) -> Result, AgentError> { ... } + + /// 列出某 session 下的所有 slot 元数据。 + pub async fn list( + session_id: &str, + store: &dyn MemoryStore, + ) -> Result, AgentError> { ... } + + /// 删除 slot 的所有存储记录(slot_data + slot_meta + slot_config + slot_rel)。 + pub async fn delete( + id: &str, + session_id: &str, + store: &dyn MemoryStore, + ) -> Result<(), AgentError> { ... } + + /// 追加消息(Readonly 模式下返回 `SlotReadonly` 错误)。 + /// Full / Focused 模式下允许追加。 + pub fn append_messages(&mut self, messages: Vec) -> Result<(), AgentError> { ... } + + /// 读取当前 slot 的消息列表。 + /// Focused 模式下按策略过滤后返回;Full/Readonly 返回全量。 + /// 返回 `Vec`(而非 `&[Message]`),因为 Focused 模式需要分配新 Vec 来存储裁剪后的消息。 + pub fn load_messages(&self) -> Vec { ... } +} +``` + +### 5.5 AgentSession 扩展 + +```rust +pub struct AgentSession { + pub session_id: String, + pub agent: Arc, + bundle: Arc, + turn_index: u32, + cost_so_far: CostTracker, + pub session_memory: SessionMemory, + + // === Phase 10 新增字段 === + /// session 内的所有 slot(id → ContextSlot)。 + slots: HashMap, + /// 当前活跃 slot 的 id。 + current_slot_id: String, +} + +impl AgentSession { + /// 创建新 slot(config 可选,不传则使用默认值)。 + pub async fn create_slot( + &mut self, + id: impl Into, + config: Option, + ) -> Result<(), AgentError> { ... } + + /// 切换到指定 slot(必须已存在,否则返回 `SlotNotFound` 错误)。 + pub async fn switch_slot(&mut self, id: &str) -> Result<(), AgentError> { ... } + + /// 列出所有 slot 的 id 列表。 + pub fn list_slots(&self) -> impl Iterator { ... } + + /// 从父 slot 派生新 slot(继承父 slot 的全量或聚焦消息)。 + pub async fn derive_slot( + &mut self, + id: impl Into, + parent_id: &str, + strategy: DeriveStrategy, + ) -> Result<(), AgentError> { ... } + + /// 删除一个 slot(仅内存 + 持久化记录清理;已删除后再 load 返回 None)。 + pub async fn delete_slot(&mut self, id: &str) -> Result<(), AgentError> { ... } + + // === 内部方法 === + + /// 解析存储后端:fallback 链 session_memory.store → bundle.memory_store → InMemoryStore。 + fn resolve_store(&self) -> Arc { ... } +} +``` + +**`new()` 改动**:自动创建 `"default"` slot,确保简单场景无感使用。 + +**`submit_turn` 改造**: +```rust +pub async fn submit_turn(&mut self, user_input: impl Into) -> Result { + // 1. 从 current slot 加载历史消息 + let history = self.load_current_slot_history(); + + // 2. 组装 LlmCycle(system_prompt + history + user_input) + // 3. submit_with_tools + // 4. 累计 cost + // 5. 将新消息写回当前 slot + // 6. save 到存储后端 + // 7. 触发 OnTurnEnd hook + // 8. turn_index += 1 +} +``` + +**`finalize_turn` 扩展**:增加 `new_messages_from_cycle` 参数,用于流结束时增量追加到 slot: +```rust +pub async fn finalize_turn( + &mut self, + response: &MessageResponse, + new_messages_from_cycle: Vec, // ← 新增 +) -> Result<(), AgentError> { ... } +``` + +### 5.6 向后兼容性 + +| 维度 | 状态 | 说明 | +|------|------|------| +| `submit_turn` 签名 | ✅ 不变 | 仍是 `(&mut self, user_input: impl Into)`;新增 Readonly slot 前置检查(返回 `SlotReadonly` 错误) | +| `AgentSession::new` 签名 | ✅ 不变 | 内部自动创建 `"default"` slot | +| `finalize_turn` 签名 | ⚠️ **变更** — 新增 `new_messages_from_cycle: Vec` 参数,返回从 `()` 改为 `Result<(), AgentError>` | 流式场景的消费者需从 `cycle.messages()[input_len..]` 提取新增消息传入;之前忽略错误的调用需处理 Result | +| 存量测试 | ✅ 0 回归 | 不传 `session_memory_backend` 时,`resolve_store()` fallback 到 `InMemoryStore` | +| 新增错误变体 | 新增 | `AgentError::SlotReadonly(String)` + `AgentError::SlotNotFound(String)` + `AgentError::Config(String)` 已有 | +| 公开类型 | 新增 | 所有新类型默认 `#[non_exhaustive]` 策略枚举 | + +--- + +## 6. 实施建议 + +### Step 10.1 — 核心类型(约 150 行,可编译 checkpoint) + +**目标**:所有类型定义可编译,无逻辑。 + +#### 文件变更 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `src/agent/context.rs` | **新增** | 全部类型定义(~150 行) | +| `src/agent.rs` | **修改** | + `pub mod context;` | +| `src/agent/error.rs` | **修改** | 追加 `SlotReadonly`/`SlotNotFound`/`SlotAlreadyExists` 错误变体 | + +> 在 `AgentError` 中新增以下变体(用于 slot 相关错误场景): +> ```rust +> #[error("Readonly slot 不允许写入: {0}")] +> SlotReadonly(String), +> #[error("Slot '{0}' 不存在")] +> SlotNotFound(String), +> #[error("Slot '{0}' 已存在")] +> SlotAlreadyExists(String), +> ``` + +#### 核心代码 + +```rust +// src/agent/context.rs + +use serde::{Deserialize, Serialize}; +use crate::llm::types::message::Message; + +/// 上下文槽。 +pub struct ContextSlot { /* ... §5.2 完整定义 ... */ } + +/// 槽配置。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SlotConfig { /* ... */ } + +/// 槽模式。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] +pub enum SlotMode { Full, Focused(FocusedConfig), Readonly } + +/// 聚焦配置。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FocusedConfig { pub keep_system: bool, pub recent_messages: usize, pub summary_override: Option } + +/// 槽来源。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] +pub enum SlotSource { New, Derived { parent_id: String, strategy: DeriveStrategy }, Static(Vec) } + +/// 派生策略。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum DeriveStrategy { Full, Focused(FocusedConfig) } + +/// 上下文预算。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContextBudget { /* ... §5.2 完整定义 ... */ } + +/// 槽元数据。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SlotMeta { /* ... §5.2 完整定义 ... */ } + +impl Default for SlotConfig { /* ... */ } +impl Default for ContextBudget { /* ... */ } +impl ContextBudget { pub fn auto() -> Self { Self::default() } } +impl SlotMeta { pub fn new() -> Self { /* ... */ } } +``` + +#### `src/agent.rs` 修改 + +```rust +// 在 pub mod session_memory; 下一行插入 +pub mod context; +``` + +#### 验证 + +```bash +cargo build --all-targets +# 预期:编译通过,无新 warning +``` + +--- + +### Step 10.2 — 持久化(约 200 行,含测试) + +**目标**:ContextSlot 的 save/load/list/delete/append_messages/load_messages 完整实现并通过测试。 + +#### 文件变更 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `src/agent/context.rs` | **修改** | 追加所有方法实现 + `#[cfg(test)] mod tests` | + +#### 核心代码 + +```rust +use std::collections::HashMap; +use time::OffsetDateTime; +use crate::agent::error::AgentError; +use crate::llm::types::message::Message; +use crate::memory::store::MemoryStore; +use crate::memory::types::{MemoryFilter, MemoryItem}; +use crate::memory::store::InMemoryStore; + +impl ContextSlot { + /// 持久化 key 前缀。 + const KEY_DATA: &'static str = "slot_data"; + const KEY_META: &'static str = "slot_meta"; + const KEY_CONFIG: &'static str = "slot_config"; + const KEY_REL: &'static str = "slot_rel"; + + fn data_key(session_id: &str, slot_id: &str) -> String { + format!("{}:{}:{}", Self::KEY_DATA, session_id, slot_id) + } + fn meta_key(session_id: &str, slot_id: &str) -> String { + format!("{}:{}:{}", Self::KEY_META, session_id, slot_id) + } + fn config_key(session_id: &str, slot_id: &str) -> String { + format!("{}:{}:{}", Self::KEY_CONFIG, session_id, slot_id) + } + fn rel_key(session_id: &str, child_id: &str) -> String { + format!("{}:{}:{}", Self::KEY_REL, session_id, child_id) + } + + /// 构造 MemoryItem 的辅助函数。 + fn make_item(key: String, content: String) -> MemoryItem { + MemoryItem { + id: key, + content, + metadata: serde_json::json!({}), + created_at: OffsetDateTime::now_utc(), + } + } + + pub async fn save(&self, store: &dyn MemoryStore) -> Result<(), AgentError> { + let data = serde_json::to_string(&self.messages) + .map_err(|e| AgentError::Other(e.to_string()))?; + let meta = serde_json::to_string(&self.meta) + .map_err(|e| AgentError::Other(e.to_string()))?; + let config = serde_json::to_string(&self.config) + .map_err(|e| AgentError::Other(e.to_string()))?; + + store.save(Self::make_item(Self::data_key(&self.session_id, &self.id), data)).await?; + store.save(Self::make_item(Self::meta_key(&self.session_id, &self.id), meta)).await?; + store.save(Self::make_item(Self::config_key(&self.session_id, &self.id), config)).await?; + + // 派生关系 + if let SlotSource::Derived { parent_id, .. } = &self.config.source { + store.save(Self::make_item(Self::rel_key(&self.session_id, &self.id), parent_id.clone())).await?; + } + + Ok(()) + } + + /// 从存储加载 slot,config 从 `slot_config` key 自行恢复。 + /// 若 config 记录不存在(旧版本升级场景),使用 `SlotConfig::default()`。 + pub async fn load( + id: &str, + session_id: &str, + store: &dyn MemoryStore, + ) -> Result, AgentError> { + let meta_item = store.get(&Self::meta_key(session_id, id)).await?; + let data_item = store.get(&Self::data_key(session_id, id)).await?; + let config_item = store.get(&Self::config_key(session_id, id)).await?; + + match (meta_item, data_item) { + (Some(m), Some(d)) => { + let meta: SlotMeta = serde_json::from_str(&m.content) + .map_err(|e| AgentError::Other(e.to_string()))?; + let messages: Vec = serde_json::from_str(&d.content) + .map_err(|e| AgentError::Other(e.to_string()))?; + // config 从存储恢复;不存在则使用 default(兼容旧版本) + let config = match config_item { + Some(c) => serde_json::from_str(&c.content) + .map_err(|e| AgentError::Other(e.to_string()))?, + None => SlotConfig::default(), + }; + Ok(Some(Self { id: id.to_string(), session_id: session_id.to_string(), config, messages, meta })) + } + _ => Ok(None), + } + } + + pub async fn list( + session_id: &str, + store: &dyn MemoryStore, + ) -> Result, AgentError> { + let prefix_str = format!("{}:{}:", Self::KEY_META, session_id); + let filter = MemoryFilter { prefix: Some(prefix_str), ..Default::default() }; + let items = store.list(&filter).await?; + let mut metas = Vec::new(); + for item in items { + if let Ok(meta) = serde_json::from_str::(&item.content) { + metas.push(meta); + } + } + Ok(metas) + } + + pub async fn delete( + id: &str, + session_id: &str, + store: &dyn MemoryStore, + ) -> Result<(), AgentError> { + store.delete(&Self::data_key(session_id, id)).await?; + store.delete(&Self::meta_key(session_id, id)).await?; + store.delete(&Self::config_key(session_id, id)).await?; + store.delete(&Self::rel_key(session_id, id)).await?; + Ok(()) + } + + pub fn append_messages(&mut self, new_messages: Vec) -> Result<(), AgentError> { + if matches!(self.config.mode, SlotMode::Readonly) { + return Err(AgentError::SlotReadonly("Readonly slot does not allow writes".into())); + } + let count = new_messages.len(); + self.messages.extend(new_messages); + self.meta.message_count += count; + Ok(()) + } + + /// 返回消息列表。Focused 模式下按策略过滤(裁剪到最近 recent_messages 条)。 + pub fn load_messages(&self) -> Vec { + match &self.config.mode { + SlotMode::Focused(cfg) => { + let mut result = Vec::new(); + // 保留 system prompt + if cfg.keep_system { + if let Some(msg) = self.messages.iter().find(|m| matches!(m, Message::System(_))) { + result.push(msg.clone()); + } + } + // 处理 recent_messages=0 边界:上面已处理 system,下面仅取最近 N 条 + if cfg.recent_messages > 0 { + let recent: Vec<&Message> = self.messages.iter() + .filter(|m| !matches!(m, Message::System(_))) + .collect(); + let start = recent.len().saturating_sub(cfg.recent_messages); + for msg in recent.iter().skip(start) { + result.push((*msg).clone()); + } + } + // 注入摘要 + if let Some(summary) = &cfg.summary_override { + result.push(Message::system(format!("[上下文摘要] {}", summary))); + } + result + } + _ => self.messages.clone(), + } + } +} +``` + +#### 测试用例 + +| 测试 | 验证点 | +|------|--------| +| `slot_save_load_roundtrip` | 创建 → 保存 → 加载 → 消息一致 | +| `slot_session_isolation` | 不同 session_id 下相同 slot_id 不串数据 | +| `slot_derived_parent_id` | Derived 来源的 parent_id 正确记录 | +| `slot_readonly_rejects_write` | Readonly 模式下 append_messages 返回 `SlotReadonly` 错误 | +| `slot_delete_then_load_none` | delete 后 load 返回 None | +| `slot_list_multiple` | 同一 session 下多个 slot 的 list 正确 | +| `slot_focused_recent_messages` | Focused 模式 `load_messages()` 仅返回最近 N 条消息(含 system prompt 可选) | +| `slot_focused_summary_override` | Focused 模式注入 `summary_override` 生成 system 摘要消息 | +| `slot_focused_zero_messages` | Focused 模式 `recent_messages=0` 返回空列表(`keep_system=true` 时只含 system) | +| `slot_empty_messages_roundtrip` | 空消息列表 save → load 后仍为空列表 | +| `slot_save_on_readonly_side_effect` | Readonly slot 调用 save 仍可持久化(只禁止 append) | + +#### 验证 + +```bash +cargo test -- --test-threads=1 +# 预期:所有新增测试通过,存量 211 个测试 0 回归 +``` + +--- + +### Step 10.3 — AgentSession 集成 + 示例(约 150 行) + +**目标**:`AgentSession` 支持 slot 管理方法,`submit_turn` 读写当前 slot,分支对话示例可运行。 + +#### 文件变更 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `src/agent/session.rs` | **修改** | slots 字段 + 6 新方法 + submit_turn/finalize_turn 改造 | +| `examples/context_slot_demo.rs` | **新增** | 分支对话展示(~朵创建后派生两个方向) | + +#### AgentSession 关键改动 + +```rust +use std::collections::HashMap; +use crate::agent::context::{ContextSlot, SlotConfig, SlotSource, DeriveStrategy}; + +pub struct AgentSession { + // ... 原有字段不变 ... + slots: HashMap, + current_slot_id: String, +} + +impl AgentSession { + /// 带 slot 的新构造函数。 + /// 注意:`new()` 是同步函数,无法执行异步的 `ContextSlot::load()`。 + /// 因此始终创建空的 default slot。若需要从存储恢复 session 历史, + /// 可在创建后调用 `switch_slot("default")` 尝试从存储加载。 + /// (v0.2 简化:`switch_slot` 在 HashMap 中已有 key 时不会重载——如需恢复, + /// 请在清空 `slots` 后调用 `switch_slot`,或等待 v0.3 的懒加载支持。) + pub fn new(agent: Arc, session_id: impl Into, bundle: Arc) -> Self { + let session_id_str = session_id.into(); + let backend = bundle.session_memory_backend.clone() + .unwrap_or_else(|| Arc::new(InMemoryStore::new())); + let session_memory = SessionMemory::new(backend, &session_id_str); + + // 自动创建 "default" slot + let default_slot = ContextSlot { + id: "default".to_string(), + session_id: session_id_str.clone(), + config: SlotConfig::default(), + messages: Vec::new(), + meta: SlotMeta::new(), + }; + let mut slots = HashMap::new(); + slots.insert("default".to_string(), default_slot); + + Self { + session_id: session_id_str, + agent, + bundle, + turn_index: 0, + cost_so_far: CostTracker::default(), + session_memory, + slots, + current_slot_id: "default".to_string(), + } + } + + fn resolve_store(&self) -> Arc { + self.bundle.session_memory_backend.clone() + .or_else(|| self.bundle.memory_store.clone()) + .unwrap_or_else(|| Arc::new(InMemoryStore::new())) + } + + pub async fn create_slot(&mut self, id: impl Into, config: Option) -> Result<(), AgentError> { + let id = id.into(); + // 禁止创建已存在的 slot + if self.slots.contains_key(&id) { + return Err(AgentError::SlotAlreadyExists(id)); + } + let slot = ContextSlot { + id: id.clone(), + session_id: self.session_id.clone(), + config: config.unwrap_or_default(), + messages: Vec::new(), + meta: SlotMeta::new(), + }; + slot.save(&*self.resolve_store()).await?; + self.slots.insert(id, slot); + Ok(()) + } + + pub async fn switch_slot(&mut self, id: &str) -> Result<(), AgentError> { + if !self.slots.contains_key(id) { + // 尝试从存储加载(config 自动从 slot_config key 恢复) + let store = self.resolve_store(); + if let Some(slot) = ContextSlot::load(id, &self.session_id, &*store).await? { + self.slots.insert(id.to_string(), slot); + } else { + return Err(AgentError::SlotNotFound(id.to_string())); + } + } + self.current_slot_id = id.to_string(); + Ok(()) + } + + pub fn list_slots(&self) -> impl Iterator { + self.slots.keys() + } + + /// 按 FocusedConfig 过滤消息(辅助函数,与 ContextSlot::load_messages 的 Focused 逻辑一致)。 + fn apply_focused_filter(messages: &[Message], cfg: &FocusedConfig) -> Vec { + let mut result = Vec::new(); + if cfg.keep_system { + if let Some(msg) = messages.iter().find(|m| matches!(m, Message::System(_))) { + result.push(msg.clone()); + } + } + if cfg.recent_messages > 0 { + let recent: Vec<&Message> = messages.iter() + .filter(|m| !matches!(m, Message::System(_))) + .collect(); + let start = recent.len().saturating_sub(cfg.recent_messages); + for msg in recent.iter().skip(start) { + result.push((*msg).clone()); + } + } + result + } + + pub async fn derive_slot(&mut self, id: impl Into, parent_id: &str, strategy: DeriveStrategy) -> Result<(), AgentError> { + // 禁止创建已存在的 slot(与 create_slot 行为一致) + let slot_id = id.into(); + if self.slots.contains_key(&slot_id) { + return Err(AgentError::SlotAlreadyExists(slot_id.clone())); + } + let parent = self.slots.get(parent_id) + .ok_or_else(|| AgentError::SlotNotFound(parent_id.to_string()))?; + + let parent_messages = parent.messages.clone(); + let (messages, focused_cfg) = match &strategy { + DeriveStrategy::Full => (parent_messages, None), + DeriveStrategy::Focused(cfg) => { + let filtered = Self::apply_focused_filter(&parent_messages, &cfg); + (filtered, Some(cfg)) + } + }; + + let mode = match focused_cfg { + Some(cfg) => SlotMode::Focused(cfg), + None => SlotMode::Full, + }; + + let slot = ContextSlot { + id: slot_id.clone(), + session_id: self.session_id.clone(), + config: SlotConfig { + mode, + source: SlotSource::Derived { parent_id: parent_id.to_string(), strategy }, + ..SlotConfig::default() + }, + messages, + meta: SlotMeta::new(), + }; + slot.save(&*self.resolve_store()).await?; + self.slots.insert(slot_id.clone(), slot); + Ok(()) + } + + pub async fn delete_slot(&mut self, id: &str) -> Result<(), AgentError> { + // 禁止删除 "default" slot——它是 session 的基础生命周期锚点 + if id == "default" { + return Err(AgentError::Config("Cannot delete the 'default' slot".into())); + } + // 确保 session 至少保留一个 slot + if self.slots.len() <= 1 { + return Err(AgentError::Config("Cannot delete the last slot".into())); + } + ContextSlot::delete(id, &self.session_id, &*self.resolve_store()).await?; + self.slots.remove(id); + if self.current_slot_id == id { + self.current_slot_id = "default".to_string(); + } + Ok(()) + } +} +``` + +**`submit_turn` 改造后的核心流程**: + +```rust +pub async fn submit_turn(&mut self, user_input: impl Into) -> Result { + let turn_index = self.turn_index; + let hook_executor = Arc::clone(&self.bundle.hook_executor); + + // 0. Readonly slot 拒绝 submit_turn + { + let slot = self.slots.get(&self.current_slot_id) + .ok_or_else(|| AgentError::SlotNotFound(self.current_slot_id.clone()))?; + if matches!(slot.config.mode, SlotMode::Readonly) { + return Err(AgentError::SlotReadonly( + format!("Cannot submit turn on Readonly slot '{}'", self.current_slot_id) + )); + } + } + + // 1. OnTurnStart hook + let start_ctx = HookContext::new(HookEvent::OnTurnStart).with_turn_index(turn_index); + hook_executor.execute(HookEvent::OnTurnStart, &start_ctx).await; + + // 2. 从当前 slot 加载历史消息 + let history = self.slots.get(&self.current_slot_id) + .ok_or_else(|| AgentError::SlotNotFound(self.current_slot_id.clone()))? + .load_messages(); + + // 3. 组装 LlmCycle + let _ = self.agent.tool_definitions(&self.bundle); + let mut cycle = LlmCycle::new_with_arc(Arc::clone(&self.bundle.provider), CycleConfig::default()); + let initial_messages_len = history.len(); + let mut messages_with_prompt = history; + if let Some(prompt) = self.agent.system_prompt() { + messages_with_prompt.insert(0, Message::system(prompt)); + } + let input_len = messages_with_prompt.len(); + cycle = cycle.with_messages(messages_with_prompt); + if let Some(cfg) = self.bundle.config.compact_config.clone() { + cycle = cycle.with_compact_config(cfg); + } + + // 4. submit_with_tools(prompt 参数即为本轮 user_input) + let response = cycle.submit_with_tools(user_input.into(), &self.bundle.tool_registry).await?; + + // 5. 累计 cost + self.cost_so_far.add(&response.usage); + + // 6. 只将本轮新增消息追加到当前 slot(保留全量历史,确保 Focused 模式的"读时过滤"语义不丢失数据) + // cycle.messages() 包含 [system_prompt?, history..., user_input, tool_calls..., final_response] + // 新增消息 = cycle.messages()[input_len..](跳过 initial_messages,即跳过已被持久化的内容) + if let Some(slot) = self.slots.get_mut(&self.current_slot_id) { + let new_messages: Vec = cycle.messages().iter().skip(input_len).cloned().collect(); + slot.append_messages(new_messages)?; + let store = self.resolve_store(); + slot.save(&*store).await?; + } + + // 7. OnTurnEnd hook + let end_ctx = HookContext::new(HookEvent::OnTurnEnd).with_turn_index(turn_index); + hook_executor.execute(HookEvent::OnTurnEnd, &end_ctx).await; + + // 8. turn_index 递增 + self.turn_index += 1; + + Ok(response) +} +``` + +**`finalize_turn` 扩展**: + +```rust +/// 结束本轮流式对话,将新增消息追加到当前 slot。 +/// - `response`: 本次 turn 的最终响应(用于累计 cost 和触发 hook) +/// - `new_messages_from_cycle`: 本轮**新增消息**(user input + tool 循环中间消息 + 最终回复), +/// 消费者在流消费完成后,从 `cycle.messages()[initial_input_len..]` 获取并传入。 +/// 仅增量追加(不覆盖 slot 已有消息),与 `submit_turn` 行为一致。 +pub async fn finalize_turn( + &mut self, + response: &MessageResponse, + new_messages_from_cycle: Vec, +) -> Result<(), AgentError> { + self.cost_so_far.add(&response.usage); + + if let Some(slot) = self.slots.get_mut(&self.current_slot_id) { + // Readonly 检查 + if matches!(slot.config.mode, SlotMode::Readonly) { + return Err(AgentError::SlotReadonly( + format!("Cannot finalize turn on Readonly slot '{}'", self.current_slot_id) + )); + } + // 增量追加(不覆盖已有消息),确保 Focused 模式的"读时过滤"语义不丢失数据 + slot.append_messages(new_messages_from_cycle)?; + let store = self.resolve_store(); + slot.save(&*store).await?; + } + + let end_ctx = HookContext::new(HookEvent::OnTurnEnd) + .with_turn_index(self.turn_index.saturating_sub(1)); + self.bundle.hook_executor.execute(HookEvent::OnTurnEnd, &end_ctx).await; + Ok(()) +} +``` + +#### 分支对话示例 + +```rust +// examples/context_slot_demo.rs +// 场景:法律咨询入口 → 两个派生方向 → 切换 → 隔离验证 + +// 1. 创建 AgentSession(自动带 "default" slot) +// 2. 在 default slot 中咨询:"我需要法律援助" +// 3. 创建派生 slot "option-a"(DeriveStrategy::Full) +// 4. 切换到 "option-a",咨询具体场景 +// 5. 创建派生 slot "option-b"(DeriveStrategy::Full) +// 6. 切换到 "option-b",咨询另一个方向 +// 7. 切回 default 验证消息隔离(不包含 option-a/b 的对话) +// 8. 列出所有 slot: default, option-a, option-b +// 9. 删除 option-b,验证 list 不再包含 + +fn main() { + // 使用 MockProvider(无需 API key) + // 全程通过 assert_eq! 验证消息隔离 +} +``` + +#### 验证 + +```bash +cargo run --example context_slot_demo +# 预期:exit 0,控制台输出各 slot 的消息数及隔离验证结果 + +cargo test --all-targets +# 预期:全量测试通过,clippy 0 警告 +``` + +--- + +## 7. 否决项记录 + +| 方案 | 否决原因 | +|------|---------| +| **`llm/context.rs`(Roadmap 原案)** | 引入 `llm → memory` 新依赖方向(当前 llm 不依赖 memory);而 `agent → memory` 依赖已存在。模块归属改为 `agent/context.rs` | +| **per-message 持久化** | 代码量多 5 倍(~150 vs ~30 行),v0.2 无按消息粒度操作场景。JSON blob + serde_json 已依赖,95% 代码减少 | +| **submit_turn 签名改 `(slot_id, input)`** | 破坏向后兼容,内在概念泄漏到公共 API。每次调 `submit_turn` 都需传 slot 对简单场景不友好。改为内部 `current_slot_id` 状态 | +| **观察者模式自动同步** | LlmCycle 内部消息通过 `Arc>>` 管理,无法从外部注册回调。手动写回足够简单且职责清晰 | +| **ContextBudget 消费逻辑 v0.2 实现** | 半成品风险:当前没有 tokenizer 绑定(字符估算),消费逻辑需深层侵入 LlmCycle 消息组装路径。v0.2 仅做数据结构 + `Default` + `auto()` 占位 | +| **`inject_summary` 自动生成 v0.2** | 半成品风险:自动摘要是 Hook 驱动(`OnTurnEnd`),涉及 LlmCycle 内部事件编排。v0.2 仅支持手动 `summary_override: Option`,自动生成推迟 v0.3 | +| **fork/merge/branch API** | 语义复杂("分支合并"的冲突语义需精确定义),v0.2 无此场景。v0.3+ 考虑 | + +--- + +## 8. 参考来源 + +| 来源 | 内容 | 用途 | +|------|------|------| +| `roadmap.md` §Phase 10 | ContextSlot 原始规划 | 范围定义、Step 划分 | +| `docs/7-agent-runtime.md` §3.2 | AgentSession 设计 | submit_turn 改造基座 | +| `docs/16-phase9-streaming-experience.md` | submit_turn_stream + finalize_turn 设计 | finalize_turn 扩展参考 | +| `src/agent/session.rs` | AgentSession 当前实现 | 字段、方法签名、submit_turn 流程 | +| `src/agent/runtime.rs` | RuntimeBundle 定义 | resolve_store fallback 链 | +| `src/memory/store.rs` | MemoryStore trait | save/get/delete/list 方法签名 | +| `src/memory/types.rs` | MemoryItem / MemoryFilter | 持久化数据格式 | +| `src/llm/compact.rs` | CompactConfig | context_window 引用 | +| `src/llm/types/message.rs` | Message 类型 | serde Serialize/Deserialize 确认 | + +### 路线图同步 + +本方案实施后需同步更新 `roadmap.md`: + +| 更新项 | 内容 | +|--------|------| +| Phase 10 状态 | Step 10.1/10.2/10.3 逐项标记 ✅ | +| 里程碑 M6 | ⏳ → ✅ + 日期 | +| 依赖图 | `P10` 节点 class `p1` → `done` | +| 下一步行动 | Phase 10 → Phase 11 | +| 已完成列表 | 追加 `- ✅ Phase 10 ContextSlot — 多上下文分区管理` | +| v0.3 展望 | ContextBudget 消费逻辑 + 摘要自动生成 + fork/merge 移至 v0.3 清单 | + +## 9. 实施计划 + +### 9.1 阶段总览 + +| 阶段 | 涉及文件 | 操作 | 预估行数 | 风险 | 工作量 | +|------|---------|------|---------|------|--------| +| 10.1 核心类型 | `agent/context.rs` / `agent.rs` / `agent/error.rs` | 1 新文件 + 2 修改 | ~150 | 低 | M | +| 10.2 持久化 | `agent/context.rs` | 修改(+方法 + 测试) | ~200 | 中 | M | +| 10.3 AgentSession 集成 | `agent/session.rs` + `examples/context_slot_demo.rs` | 修改 + 新示例 | ~300 | 高 | L | + +### 9.2 任务拆解表 + +#### Step 10.1 任务 + +| 任务 | 描述 | 文件 | 依赖 | 工作量 | 风险 | 验收条件 | +|------|------|------|------|--------|------|---------| +| 10.1.1 | 新增 `AgentError` 变体:`SlotReadonly` / `SlotNotFound` / `SlotAlreadyExists` | `agent/error.rs` | 无 | S(3 行) | 低 | `cargo build` | +| 10.1.2 | 新增 `agent/context.rs`:`ContextSlot` 核心类型 + `SlotConfig` / `SlotMode` / `FocusedConfig` / `SlotSource` / `DeriveStrategy` / `ContextBudget` / `SlotMeta` | `agent/context.rs` | 10.1.1 | M(120 行) | 低 | `cargo build` | +| 10.1.3 | `Default`/`auto` 实现 + module 声明 | `agent/context.rs` + `agent.rs` | 10.1.2 | S(15 行) | 低 | `cargo build --all-targets` | + +#### Step 10.2 任务 + +| 任务 | 描述 | 文件 | 依赖 | 工作量 | 风险 | 验收条件 | +|------|------|------|------|--------|------|---------| +| 10.2.1 | Key 辅助函数 + `make_item()` + `save()` 方法(写 data / meta / config / rel) | `agent/context.rs` | 10.1.3 | S(45 行) | 低 | `cargo build` | +| 10.2.2 | `load()` + `list()` + `delete()` 方法 | `agent/context.rs` | 10.2.1 | M(50 行) | 中(`MemoryStore` API 字段名需匹配) | `cargo build` | +| 10.2.3 | `append_messages()` + `load_messages()` 方法(含 Focused 过滤逻辑) | `agent/context.rs` | 10.2.2 | M(40 行) | 中(Focused 过滤逻辑正确性) | `cargo build` | +| 10.2.4 | 内联单元测试(11 个用例) | `agent/context.rs` | 10.2.3 | M(70 行) | 中 | `cargo test` — 211 + 11 passed | + +#### Step 10.3 任务 + +| 任务 | 描述 | 文件 | 依赖 | 工作量 | 风险 | 验收条件 | +|------|------|------|------|--------|------|---------| +| 10.3.1 | `AgentSession` 新增 `slots` / `current_slot_id` 字段 + `new()` 自动创建 default slot | `agent/session.rs` | 10.1.2 | S(30 行) | 低 | `cargo build` | +| 10.3.2 | `resolve_store()` + `create_slot()` + `switch_slot()` + `list_slots()` + `derive_slot()` + `delete_slot()` | `agent/session.rs` | 10.3.1 | L(80 行) | 中(slot 恢复逻辑) | `cargo build` | +| 10.3.3 | `submit_turn` 改造(Readonly 检查 → `load_messages()` → cycle → 增量追加 + save;注意大括号释放借用) | `agent/session.rs` | 10.3.2 | L(50 行) | 高(data flow 正确性) | `cargo test` 全绿 | +| 10.3.4 | `finalize_turn` 扩展(`new_messages_from_cycle` 参数 → 增量追加 + Readonly 检查 + save;同步更新 2 个存量测试调用方) | `agent/session.rs` | 10.3.3 | M(25 行 + 2 测试适配) | 中 | `cargo test` 全绿 + 所有 finalize_turn 调用方已适配 | +| 10.3.5 | `context_slot_demo` 分支对话示例(使用 `MockProvider::new(vec![...])` 预置响应,避免空队列) | `examples/context_slot_demo.rs` | 10.3.4 | M(80 行) | 低 | `cargo run --example` exit 0 | +| 10.3.6 | 全量验证:`cargo test --all-targets` + clippy + 存量测试 0 回归 | — | 10.3.5 | S(5 min) | 低 | test 全绿 + clippy 0 | + +### 9.3 并行机会分析 + +| 可并行组合 | 理由 | 风险 | +|-----------|------|------| +| 10.1.1 + 10.1.2 | 错误变体与核心类型无文件交集 | 无 | +| 10.2.1 + 10.2.2 | `save()` 与 `load()` 可独立实现(只要 key 辅助函数就绪) | 低(确保 key 格式一致) | +| 10.3.1 + 10.3.2 | 字段声明与方法新增可同步进行 | 无 | +| 10.3.1 可提前到 10.1.2 后启动 | `new()` 只依赖类型定义(`ContextSlot` / `SlotConfig` / `SlotMeta`),不依赖持久化方法 | 无 | +| **不可并行**:10.2 → 10.3.2 起 | `create_slot()`/`switch_slot()` 等需要 `ContextSlot::save()`/`load()` 就绪 | — | + +### 9.4 风险与应对 + +| 风险 | 可能性 | 影响 | 应对策略 | +|------|--------|------|---------| +| `MemoryStore` trait 方法签名变化 | 低 | 高 | 验证代码中 `MemoryStore` API,必要时适配 | +| `LlmCycle` API(`cycle.messages()` 等)无效 | 低 | 高 | 在实施前验证 `cycle` 的公共方法 | +| 序列化失败导致 session 数据损坏 | 低 | 高 | 所有 `serde_json` 调用走 `?` 传播错误 | +| `finalize_turn` 签名变更破坏存量测试 | 中 | 中 | 10.3.4 明确列出需适配的 2 个测试调用方,适配后立即运行 `cargo test` 验证 | +| Focused 过滤逻辑边界条件遗漏 | 中 | 中 | 4 个 Focused 测试覆盖边界(0 turn / `keep_system` / `summary_override`) | +| `new()` 同步限制导致 session 恢复困难 | 中 | 中 | 文档已说明限制,v0.3 解决 | +| `MockProvider` 空队列导致示例 panic | 中 | 低 | 示例使用 `MockProvider::new(vec![...])` 预置响应,避免 `empty()` | +| Hook 执行器在 slot 写回后触发,`HookContext` 不携带 `slot_id` | 低 | 低 | v0.2 已知局限,后续在 `HookContext` 上扩展 `slot_id: Option<&str>` | + +### 9.5 测试策略 + +**单元测试覆盖**(11 个基础 + 5 个扩展): + +- **持久化**:save/load roundtrip、session 隔离、delete 后 load None、list 多 slot +- **模式行为**:Readonly 拒绝写入、Focused 过滤(最近 N 条 / 摘要注入 / 0 边界) +- **派生**:`DeriveStrategy::Full` 正确复制父 slot 消息、`DeriveStrategy::Focused` 正确裁剪、派生 slot 持久化后可独立加载 +- **边界**:空消息列表 roundtrip、Readonly save 不影响 +- **保护逻辑**:`delete_slot` 禁止删 `"default"`、禁止删最后一个 slot + +**集成测试覆盖**: + +- **端到端**:`context_slot_demo` 示例验证多 slot 隔离 +- **回归**:存量 211 个测试不依赖 slot 行为 → 0 回归预期 + +### 9.6 验证标准 + +| 门禁 | 命令 | 预期结果 | +|------|------|---------| +| 编译 | `cargo build --all-targets` | 0 error + 0 warning | +| 测试 | `cargo test --all-targets` | 211 → 227 passed, 0 failed | +| 静态检查 | `cargo clippy --all-targets -- -D warnings` | 0 warning | +| 示例 | `cargo run --example context_slot_demo` | exit 0 | +| 文档 | `cargo doc --no-deps` | 0 warning | diff --git a/examples/context_slot_demo.rs b/examples/context_slot_demo.rs new file mode 100644 index 0000000..6088060 --- /dev/null +++ b/examples/context_slot_demo.rs @@ -0,0 +1,161 @@ +//! context_slot_demo —— 多上下文槽位管理示例。 +//! +//! 场景:法律咨询入口 → 派生两个独立探索方向 → 切换 → 隔离验证 → 删除。 +//! +//! 展示: +//! - 默认 slot 自动创建 +//! - 多 slot 间的消息隔离 +//! - 派生 slot 从父 slot 复制消息 +//! - 删除非 default slot 后自动回退到 default +//! +//! 运行:`cargo run --example context_slot_demo`(离线,零配置) + +use std::sync::Arc; + +use agcore::agent::{Agent, AgentBuilder, AgentSession}; +use agcore::llm::hooks::HookExecutor; +use agcore::llm::mock::MockProvider; +use agcore::llm::provider::LlmProvider; +use agcore::llm::types::message::{ContentBlock, Message}; +use agcore::llm::types::response_v2::{MessageResponse, StopReason}; +use agcore::llm::types::Usage; +use agcore::tools::ToolRegistry; + +struct LegalAdvisor; + +impl Agent for LegalAdvisor { + fn name(&self) -> &str { + "legal-advisor" + } + fn system_prompt(&self) -> Option<&str> { + Some("你是法律顾问。请用一句话回答用户问题。") + } +} + +/// 构造一个简单的 Assistant 响应(用于 MockProvider)。 +fn assistant_resp(text: &str) -> MessageResponse { + MessageResponse { + id: String::new(), + model: "mock".into(), + message: Message::Assistant { + content: vec![ContentBlock::Text { text: text.into() }], + }, + usage: Usage::from_input_output(5, 5), + stop_reason: StopReason::Stop, + extra: Default::default(), + } +} + +#[tokio::main] +async fn main() { + // 1. 构造 session(自动包含 default slot) + let provider: Arc = Arc::new(MockProvider::new(vec![ + assistant_resp("您好,我可以帮您处理法律问题。"), + assistant_resp("管辖权问题:建议选择合同签订地法院。"), + assistant_resp("条款修改:建议将上限调整为 80 万。"), + assistant_resp("已回到主对话。"), + ])); + let bundle = Arc::new( + AgentBuilder::new() + .provider(provider) + .tool_registry(Arc::new(ToolRegistry::new())) + .hook_executor(Arc::new(HookExecutor::new())) + .build() + .unwrap(), + ); + let mut session = AgentSession::new(Arc::new(LegalAdvisor), "legal-001", bundle); + + println!("=== 1. 默认 slot 自动创建 ==="); + assert_eq!(session.current_slot_id(), "default"); + let slots: Vec<_> = session.list_slots().collect(); + println!("初始 slots: {slots:?}"); + assert_eq!(slots.len(), 1); + assert!(slots.contains(&&"default".to_string())); + + println!("\n=== 2. 在 default slot 中提交一轮 ==="); + let r1 = session.submit_turn("我需要法律援助").await.unwrap(); + println!("default slot response: {}", r1.text()); + + println!("\n=== 3. 派生两个独立探索方向的 slot ==="); + session + .derive_slot("option_jurisdiction", "default", agcore::agent::DeriveStrategy::Full) + .await + .unwrap(); + session + .derive_slot("option_amendment", "default", agcore::agent::DeriveStrategy::Full) + .await + .unwrap(); + let slots: Vec<_> = session.list_slots().cloned().collect(); + println!("派生后 slots: {slots:?}"); + assert_eq!(slots.len(), 3); + + println!("\n=== 4. 切到 option_jurisdiction 并提交 ==="); + session.switch_slot("option_jurisdiction").await.unwrap(); + assert_eq!(session.current_slot_id(), "option_jurisdiction"); + let r2 = session.submit_turn("如果用户质疑管辖权?").await.unwrap(); + println!("option_jurisdiction response: {}", r2.text()); + + println!("\n=== 5. 切到 option_amendment 并提交 ==="); + session.switch_slot("option_amendment").await.unwrap(); + let r3 = session.submit_turn("用户要求提高赔偿上限?").await.unwrap(); + println!("option_amendment response: {}", r3.text()); + + println!("\n=== 6. 切回 default,验证消息隔离 ==="); + session.switch_slot("default").await.unwrap(); + let r4 = session.submit_turn("汇总一下我们的讨论").await.unwrap(); + println!("default response: {}", r4.text()); + // 验证 default slot 不包含 option_jurisdiction 的"管辖权"问题 + let (_, default_slot) = session.slots().find(|(id, _)| *id == "default").unwrap(); + let default_has_jurisdiction = default_slot + .messages + .iter() + .any(|m| message_contains(m, "管辖权")); + assert!( + !default_has_jurisdiction, + "default slot 不应包含 option_jurisdiction 的消息" + ); + + println!("\n=== 7. 删除 option_amendment,验证回退到 default ==="); + session.delete_slot("option_amendment").await.unwrap(); + let slots: Vec<_> = session.list_slots().cloned().collect(); + println!("删除后 slots: {slots:?}"); + assert!(!slots.contains(&"option_amendment".to_string())); + assert_eq!(slots.len(), 2); + + println!("\n=== 8. 切到 option_jurisdiction 并删除,验证 current 回退 ==="); + session.switch_slot("option_jurisdiction").await.unwrap(); + session.delete_slot("option_jurisdiction").await.unwrap(); + assert_eq!(session.current_slot_id(), "default"); + let slots: Vec<_> = session.list_slots().cloned().collect(); + println!("删除后 slots: {slots:?}"); + assert_eq!(slots.len(), 1); + assert_eq!(slots[0], "default"); + + println!("\n=== 9. 验证 delete_slot 保护逻辑 ==="); + let err = session.delete_slot("default").await.unwrap_err(); + println!("删除 default 返回错误: {err}"); + assert!(matches!(err, agcore::agent::AgentError::Config(_))); + + println!("\n✓ context_slot_demo 完成"); +} + +/// 检查 Message 是否包含指定文本(提取第一个 Text block)。 +fn message_contains(msg: &Message, needle: &str) -> bool { + use agcore::llm::types::message::ContentBlock; + let blocks = match msg { + Message::System { content } + | Message::User { content } + | Message::Assistant { content } => content, + Message::UserImage { .. } => return false, + Message::ToolResult { content, .. } => content, + _ => return false, + }; + for block in blocks { + if let ContentBlock::Text { text } = block + && text.contains(needle) + { + return true; + } + } + false +} \ No newline at end of file diff --git a/src/agent.rs b/src/agent.rs index a4e839c..a66a161 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -11,6 +11,7 @@ pub mod agent; pub mod builder; +pub mod context; pub mod error; pub mod runtime; pub mod session; @@ -20,6 +21,10 @@ pub mod task; // 重导出公共 API(按使用频度排序) pub use agent::Agent; pub use builder::AgentBuilder; +pub use context::{ + ContextBudget, ContextSlot, DeriveStrategy, FocusedConfig, SlotConfig, SlotMeta, SlotMode, + SlotSource, +}; pub use error::AgentError; pub use runtime::{AgentConfig, RuntimeBundle}; pub use session::AgentSession; diff --git a/src/agent/context.rs b/src/agent/context.rs new file mode 100644 index 0000000..3c88732 --- /dev/null +++ b/src/agent/context.rs @@ -0,0 +1,900 @@ +//! ContextSlot —— 多上下文槽位管理。 +//! +//! 设计要点(参见 `docs/17-phase10-contextslot.md`): +//! +//! - **多上下文分区**:单个 session 内可创建/切换/派生多个独立消息上下文 +//! - **三种模式**:Full(完整历史)/ Focused(读时过滤)/ Readonly(禁止写入) +//! - **三种来源**:New(全新)/ Derived(派生)/ Static(静态) +//! - **基于 MemoryStore trait 持久化**:JSON blob 批次存储,每 slot 3-4 条 MemoryItem 记录 +//! - **零新依赖方向**:放在 `agent/` 下利用已有的 `agent → memory` 依赖 + +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; + +use crate::agent::error::AgentError; +use crate::llm::types::message::Message; +use crate::memory::store::MemoryStore; +use crate::memory::types::{MemoryFilter, MemoryItem}; + +/// 上下文槽 —— 一段带策略配置的消息列表。 +#[derive(Debug, Clone)] +pub struct ContextSlot { + /// 当前 slot 的唯一标识(同一个 session_id 内唯一)。 + pub id: String, + /// 所属 session。 + pub session_id: String, + /// 槽配置。 + pub config: SlotConfig, + /// 消息列表(全量,Focused/Readonly 在读取时做策略过滤)。 + pub messages: Vec, + /// 槽元数据。 + pub meta: SlotMeta, +} + +/// 槽配置。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SlotConfig { + /// 槽模式(Full / Focused / Readonly)。 + pub mode: SlotMode, + /// 槽来源(New / Derived / Static)。 + pub source: SlotSource, + /// 上下文预算(v0.2 纯数据结构,无消费逻辑)。 + pub budget: ContextBudget, + /// 是否启用自动压缩(v0.2 保留字段,LlmCycle 内部自行判断)。 + pub compact: bool, +} + +impl Default for SlotConfig { + fn default() -> Self { + Self { + mode: SlotMode::Full, + source: SlotSource::New, + budget: ContextBudget::default(), + compact: true, + } + } +} + +/// 槽模式。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] +pub enum SlotMode { + /// 完整对话历史(全部消息)。 + Full, + /// 聚焦模式 —— 读取时按策略过滤,保持 LLM 注意力。 + Focused(FocusedConfig), + /// 只读参考上下文 —— 禁止写入。 + Readonly, +} + +/// 聚焦模式配置。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FocusedConfig { + /// 是否保留 system prompt。 + pub keep_system: bool, + /// 保留的最近消息条数(以消息条数而非对话轮次为单位,因为一轮对话可能包含多条 tool 消息)。 + pub recent_messages: usize, + /// 摘要覆盖(v0.2 仅消费端:手动设置则注入,不自动生成)。 + /// v0.3 将支持 Hook 驱动的自动摘要生成。 + pub summary_override: Option, +} + +/// 槽来源。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] +pub enum SlotSource { + /// 全新空槽。 + New, + /// 从父 slot 派生(记录 parent_id)。 + Derived { + parent_id: String, + strategy: DeriveStrategy, + }, + /// 预置静态消息(不持久化,随 session 生命周期存在)。 + Static(Vec), +} + +/// 派生策略。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum DeriveStrategy { + /// 完整复制父 slot 的消息。 + Full, + /// 按聚焦策略复制父 slot 的消息。 + Focused(FocusedConfig), +} + +/// 上下文预算(v0.2 纯数据结构,无消费逻辑)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContextBudget { + /// system prompt 预算。 + pub system: u32, + /// 对话历史预算。 + pub history: u32, + /// 工具定义预算。 + pub tools: u32, + /// 工具结果预算。 + pub tool_results: u32, + /// 预留 buffer。 + pub reserve: u32, +} + +impl Default for ContextBudget { + fn default() -> Self { + Self { + system: 8_000, + history: 80_000, + tools: 10_000, + tool_results: 20_000, + reserve: 10_000, + } + } +} + +impl ContextBudget { + /// 自动分配:按上下文窗口的固定比例分配预算。 + /// v0.2 只做占位实现,v0.3 将根据实际 provider 的 context_window 计算。 + pub fn auto() -> Self { + Self::default() + } +} + +/// 槽元数据。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SlotMeta { + /// 父 slot id(仅 Derived 来源有值)。 + pub parent_id: Option, + /// 消息总数。 + pub message_count: usize, + /// 总 token 估算值(由 add_messages 时累计,v0.2 为近似值)。 + pub total_tokens: u32, + /// 创建时间(Unix 时间戳,秒)。 + pub created_at: u64, +} + +impl SlotMeta { + pub fn new() -> Self { + Self { + parent_id: None, + message_count: 0, + total_tokens: 0, + created_at: std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0), + } + } +} + +impl Default for SlotMeta { + fn default() -> Self { + Self::new() + } +} + +impl ContextSlot { + /// 持久化 key 前缀。 + const KEY_DATA: &'static str = "slot_data"; + const KEY_META: &'static str = "slot_meta"; + const KEY_CONFIG: &'static str = "slot_config"; + const KEY_REL: &'static str = "slot_rel"; + + /// 校验 id 不含冒号(避免破坏 key 格式与 list prefix 过滤)。 + /// 失败时 panic —— 这是开发者错误而非用户错误。 + fn assert_no_colon(id: &str, field: &str) { + if id.contains(':') { + panic!( + "{field} '{id}' contains ':' which would break key format. \ + Use only letters, digits, hyphens and underscores." + ); + } + } + + pub(crate) fn data_key(session_id: &str, slot_id: &str) -> String { + Self::assert_no_colon(session_id, "session_id"); + Self::assert_no_colon(slot_id, "slot_id"); + format!("{}:{}:{}", Self::KEY_DATA, session_id, slot_id) + } + pub(crate) fn meta_key(session_id: &str, slot_id: &str) -> String { + Self::assert_no_colon(session_id, "session_id"); + Self::assert_no_colon(slot_id, "slot_id"); + format!("{}:{}:{}", Self::KEY_META, session_id, slot_id) + } + pub(crate) fn config_key(session_id: &str, slot_id: &str) -> String { + Self::assert_no_colon(session_id, "session_id"); + Self::assert_no_colon(slot_id, "slot_id"); + format!("{}:{}:{}", Self::KEY_CONFIG, session_id, slot_id) + } + pub(crate) fn rel_key(session_id: &str, child_id: &str) -> String { + Self::assert_no_colon(session_id, "session_id"); + Self::assert_no_colon(child_id, "child_id"); + format!("{}:{}:{}", Self::KEY_REL, session_id, child_id) + } + + /// 构造 MemoryItem 的辅助函数。 + fn make_item(key: String, content: String) -> MemoryItem { + MemoryItem { + id: key, + content, + metadata: serde_json::json!({}), + created_at: OffsetDateTime::now_utc(), + } + } + + /// 创建一个新的空 ContextSlot(不持久化,仅内存构造)。 + pub fn new( + session_id: impl Into, + slot_id: impl Into, + config: SlotConfig, + ) -> Self { + Self { + id: slot_id.into(), + session_id: session_id.into(), + config, + messages: Vec::new(), + meta: SlotMeta::new(), + } + } + + /// 保存 slot 数据到存储后端(全量写入,含 config)。 + pub async fn save(&self, store: &dyn MemoryStore) -> Result<(), AgentError> { + let data = serde_json::to_string(&self.messages) + .map_err(|e| AgentError::Other(e.to_string()))?; + let meta = serde_json::to_string(&self.meta) + .map_err(|e| AgentError::Other(e.to_string()))?; + let config = serde_json::to_string(&self.config) + .map_err(|e| AgentError::Other(e.to_string()))?; + + store + .save(Self::make_item( + Self::data_key(&self.session_id, &self.id), + data, + )) + .await + .map_err(AgentError::Memory)?; + store + .save(Self::make_item( + Self::meta_key(&self.session_id, &self.id), + meta, + )) + .await + .map_err(AgentError::Memory)?; + store + .save(Self::make_item( + Self::config_key(&self.session_id, &self.id), + config, + )) + .await + .map_err(AgentError::Memory)?; + + // 派生关系 + if let SlotSource::Derived { parent_id, .. } = &self.config.source { + store + .save(Self::make_item( + Self::rel_key(&self.session_id, &self.id), + parent_id.clone(), + )) + .await + .map_err(AgentError::Memory)?; + } + + Ok(()) + } + + /// 从存储加载 slot,config 从 `slot_config` key 自行恢复。 + /// 若 config 记录不存在(旧版本升级场景),使用 `SlotConfig::default()`。 + pub async fn load( + id: &str, + session_id: &str, + store: &dyn MemoryStore, + ) -> Result, AgentError> { + let meta_item = store + .get(&Self::meta_key(session_id, id)) + .await + .map_err(AgentError::Memory)?; + let data_item = store + .get(&Self::data_key(session_id, id)) + .await + .map_err(AgentError::Memory)?; + let config_item = store + .get(&Self::config_key(session_id, id)) + .await + .map_err(AgentError::Memory)?; + + match (meta_item, data_item) { + (Some(m), Some(d)) => { + let meta: SlotMeta = serde_json::from_str(&m.content) + .map_err(|e| AgentError::Other(e.to_string()))?; + let messages: Vec = serde_json::from_str(&d.content) + .map_err(|e| AgentError::Other(e.to_string()))?; + // config 从存储恢复;不存在则使用 default(兼容旧版本) + let config = match config_item { + Some(c) => serde_json::from_str(&c.content) + .map_err(|e| AgentError::Other(e.to_string()))?, + None => SlotConfig::default(), + }; + Ok(Some(Self { + id: id.to_string(), + session_id: session_id.to_string(), + config, + messages, + meta, + })) + } + _ => Ok(None), + } + } + + /// 列出某 session 下的所有 slot 元数据。 + pub async fn list( + session_id: &str, + store: &dyn MemoryStore, + ) -> Result, AgentError> { + let prefix_str = format!("{}:{}:", Self::KEY_META, session_id); + let filter = MemoryFilter { + prefix: Some(prefix_str), + ..Default::default() + }; + let items = store.list(&filter).await.map_err(AgentError::Memory)?; + let mut metas = Vec::new(); + for item in items { + if let Ok(meta) = serde_json::from_str::(&item.content) { + metas.push(meta); + } + } + Ok(metas) + } + + /// 删除 slot 的所有存储记录(slot_data + slot_meta + slot_config + slot_rel)。 + pub async fn delete( + id: &str, + session_id: &str, + store: &dyn MemoryStore, + ) -> Result<(), AgentError> { + store + .delete(&Self::data_key(session_id, id)) + .await + .map_err(AgentError::Memory)?; + store + .delete(&Self::meta_key(session_id, id)) + .await + .map_err(AgentError::Memory)?; + store + .delete(&Self::config_key(session_id, id)) + .await + .map_err(AgentError::Memory)?; + // slot_rel 是 best-effort(仅 Derived 来源的 slot 才有此 key) + let _ = store.delete(&Self::rel_key(session_id, id)).await; + Ok(()) + } + + /// 追加消息(Readonly 模式下返回 `SlotReadonly` 错误)。 + /// Full / Focused 模式下允许追加。 + pub fn append_messages(&mut self, new_messages: Vec) -> Result<(), AgentError> { + if matches!(self.config.mode, SlotMode::Readonly) { + return Err(AgentError::SlotReadonly( + "Readonly slot does not allow writes".into(), + )); + } + let count = new_messages.len(); + self.messages.extend(new_messages); + self.meta.message_count += count; + Ok(()) + } + + /// 按 FocusedConfig 过滤消息(静态辅助函数,被 `load_messages` 和 `derive_slot` 复用)。 + /// + /// 过滤逻辑: + /// 1. 保留第一条 system 消息(如果 `keep_system=true`) + /// 2. 取最近 `recent_messages` 条非 system 消息(如果 `recent_messages > 0`) + /// 3. 追加摘要消息(如果 `summary_override` 存在) + pub fn filter_focused(messages: &[Message], cfg: &FocusedConfig) -> Vec { + let mut result = Vec::new(); + // 保留 system prompt + if cfg.keep_system + && let Some(msg) = messages + .iter() + .find(|m| matches!(m, Message::System { .. })) + { + result.push(msg.clone()); + } + // 处理 recent_messages=0 边界:上面已处理 system,下面仅取最近 N 条 + if cfg.recent_messages > 0 { + let recent: Vec<&Message> = messages + .iter() + .filter(|m| !matches!(m, Message::System { .. })) + .collect(); + let start = recent.len().saturating_sub(cfg.recent_messages); + for msg in recent.iter().skip(start) { + result.push((*msg).clone()); + } + } + // 注入摘要 + if let Some(summary) = &cfg.summary_override { + result.push(Message::system(format!("[上下文摘要] {}", summary))); + } + result + } + + /// 返回消息列表。Focused 模式下按策略过滤(裁剪到最近 recent_messages 条)。 + pub fn load_messages(&self) -> Vec { + match &self.config.mode { + SlotMode::Focused(cfg) => Self::filter_focused(&self.messages, cfg), + _ => self.messages.clone(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::memory::store::InMemoryStore; + + fn make_store() -> std::sync::Arc { + std::sync::Arc::new(InMemoryStore::new()) + } + + fn make_slot(id: &str, session: &str) -> ContextSlot { + ContextSlot::new(session, id, SlotConfig::default()) + } + + /// 提取 `Message` 的第一个 Text block 的内容(用于测试断言)。 + /// 返回 None 表示该消息不含纯文本 block。 + fn extract_text(msg: &Message) -> &str { + use crate::llm::types::message::ContentBlock; + let blocks = match msg { + Message::System { content } + | Message::User { content } + | Message::Assistant { content } => content, + Message::UserImage { .. } => return "", + Message::ToolResult { content, .. } => content, + }; + for block in blocks { + if let ContentBlock::Text { text } = block { + return text; + } + } + "" + } + + // ===== 持久化 ===== + + #[tokio::test] + async fn slot_save_load_roundtrip() { + let store = make_store(); + let mut slot = make_slot("default", "s1"); + slot.append_messages(vec![Message::user_text("hi")]).unwrap(); + slot.append_messages(vec![Message::assistant("hello")]).unwrap(); + + slot.save(&*store).await.unwrap(); + let loaded = ContextSlot::load("default", "s1", &*store).await.unwrap(); + let loaded = loaded.expect("slot should exist after save"); + assert_eq!(loaded.id, "default"); + assert_eq!(loaded.session_id, "s1"); + assert_eq!(loaded.messages.len(), 2); + assert_eq!(loaded.meta.message_count, 2); + } + + #[tokio::test] + async fn slot_session_isolation() { + let store = make_store(); + let mut a = make_slot("main", "sA"); + a.append_messages(vec![Message::user_text("only in A")]) + .unwrap(); + a.save(&*store).await.unwrap(); + + let mut b = make_slot("main", "sB"); + b.append_messages(vec![Message::user_text("only in B")]) + .unwrap(); + b.save(&*store).await.unwrap(); + + let loaded_a = ContextSlot::load("main", "sA", &*store).await.unwrap().unwrap(); + let loaded_b = ContextSlot::load("main", "sB", &*store).await.unwrap().unwrap(); + assert_eq!(extract_text(&loaded_a.messages[0]), "only in A"); + assert_eq!(extract_text(&loaded_b.messages[0]), "only in B"); + } + + #[tokio::test] + async fn slot_derived_parent_id_recorded() { + let store = make_store(); + let slot = ContextSlot::new( + "s1", + "child", + SlotConfig { + mode: SlotMode::Full, + source: SlotSource::Derived { + parent_id: "default".to_string(), + strategy: DeriveStrategy::Full, + }, + budget: ContextBudget::default(), + compact: true, + }, + ); + slot.save(&*store).await.unwrap(); + + // rel_key 直接读 + let rel = store + .get(&ContextSlot::rel_key("s1", "child")) + .await + .unwrap() + .unwrap(); + assert_eq!(rel.content, "default"); + } + + #[tokio::test] + async fn slot_readonly_rejects_write() { + let mut slot = make_slot("ro", "s1"); + slot.config.mode = SlotMode::Readonly; + let result = slot.append_messages(vec![Message::user_text("nope")]); + assert!(matches!(result, Err(AgentError::SlotReadonly(_)))); + assert!(slot.messages.is_empty()); + } + + #[tokio::test] + async fn slot_delete_then_load_none() { + let store = make_store(); + let mut slot = make_slot("to_delete", "s1"); + slot.append_messages(vec![Message::user_text("hi")]).unwrap(); + slot.save(&*store).await.unwrap(); + + ContextSlot::delete("to_delete", "s1", &*store).await.unwrap(); + let loaded = ContextSlot::load("to_delete", "s1", &*store).await.unwrap(); + assert!(loaded.is_none()); + } + + #[tokio::test] + async fn slot_list_multiple() { + let store = make_store(); + for id in ["alpha", "beta", "gamma"] { + let mut s = make_slot(id, "sX"); + s.append_messages(vec![Message::user_text(id)]).unwrap(); + s.save(&*store).await.unwrap(); + } + // 不同 session 不该列出 + let mut s2 = make_slot("alpha", "sY"); + s2.append_messages(vec![Message::user_text("y")]).unwrap(); + s2.save(&*store).await.unwrap(); + + let metas = ContextSlot::list("sX", &*store).await.unwrap(); + assert_eq!(metas.len(), 3); + let metas_y = ContextSlot::list("sY", &*store).await.unwrap(); + assert_eq!(metas_y.len(), 1); + } + + // ===== Focused 模式 ===== + + #[tokio::test] + async fn slot_focused_recent_messages() { + let mut slot = make_slot("f", "s1"); + slot.append_messages(vec![Message::system("sys")]).unwrap(); + for i in 0..5 { + slot.append_messages(vec![Message::user_text(format!("u{i}"))]) + .unwrap(); + slot.append_messages(vec![Message::assistant(format!("a{i}"))]) + .unwrap(); + } + slot.config.mode = SlotMode::Focused(FocusedConfig { + keep_system: true, + recent_messages: 3, + summary_override: None, + }); + + let loaded = slot.load_messages(); + // system + 最近 3 条 (assistant 4, user 4, assistant 5 实际是按 vec 顺序取最近 3 条非 system) + let has_sys = loaded.iter().any(|m| matches!(m, Message::System { .. })); + assert!(has_sys, "system 提示应保留"); + // 最近 3 条非 system 应该是 a4, u4, a5 (按 messages 存储顺序的最后 3 条) + assert_eq!(loaded.len(), 1 + 3); + } + + #[tokio::test] + async fn slot_focused_summary_override() { + let mut slot = make_slot("f", "s1"); + slot.append_messages(vec![Message::user_text("u")]).unwrap(); + slot.append_messages(vec![Message::assistant("a")]).unwrap(); + slot.config.mode = SlotMode::Focused(FocusedConfig { + keep_system: false, + recent_messages: 100, + summary_override: Some("讨论了 X".to_string()), + }); + + let loaded = slot.load_messages(); + // 2 条原始 + 1 条摘要 system = 3 + assert_eq!(loaded.len(), 3); + // 最后一条是摘要 + if let Message::System { content } = &loaded[2] { + let text = format!("{:?}", content); + assert!(text.contains("上下文摘要")); + assert!(text.contains("讨论了 X")); + } else { + panic!("最后一条应为 system 摘要"); + } + } + + #[tokio::test] + async fn slot_focused_zero_messages() { + let mut slot = make_slot("f", "s1"); + slot.append_messages(vec![Message::system("sys")]).unwrap(); + slot.append_messages(vec![Message::user_text("u")]).unwrap(); + slot.config.mode = SlotMode::Focused(FocusedConfig { + keep_system: true, + recent_messages: 0, + summary_override: None, + }); + + let loaded = slot.load_messages(); + // recent_messages=0 但 keep_system=true 应只含 system + assert_eq!(loaded.len(), 1); + assert!(matches!(loaded[0], Message::System { .. })); + } + + // ===== 边界 ===== + + #[tokio::test] + async fn slot_empty_messages_roundtrip() { + let store = make_store(); + let slot = make_slot("empty", "s1"); + slot.save(&*store).await.unwrap(); + let loaded = ContextSlot::load("empty", "s1", &*store).await.unwrap().unwrap(); + assert!(loaded.messages.is_empty()); + assert_eq!(loaded.meta.message_count, 0); + } + + #[tokio::test] + async fn slot_save_on_readonly_side_effect() { + let store = make_store(); + let mut slot = make_slot("ro", "s1"); + slot.config.mode = SlotMode::Readonly; + // save 本身允许(只禁止 append) + slot.save(&*store).await.unwrap(); + let loaded = ContextSlot::load("ro", "s1", &*store).await.unwrap(); + assert!(loaded.is_some()); + } + + // ===== 派生 (derive_slot 行为) ===== + + #[tokio::test] + async fn derive_full_copies_parent_messages() { + let mut parent = make_slot("p", "s1"); + for i in 0..3 { + parent.append_messages(vec![Message::user_text(format!("u{i}"))]) + .unwrap(); + } + + // 模拟 derive_slot 内部 Full 策略 + let child_messages = parent.messages.clone(); + let child = ContextSlot::new( + "s1", + "c", + SlotConfig { + mode: SlotMode::Full, + source: SlotSource::Derived { + parent_id: "p".to_string(), + strategy: DeriveStrategy::Full, + }, + budget: ContextBudget::default(), + compact: true, + }, + ); + let mut child = child; + child.messages = child_messages; + let store = make_store(); + child.save(&*store).await.unwrap(); + + let loaded = ContextSlot::load("c", "s1", &*store).await.unwrap().unwrap(); + assert_eq!(loaded.messages.len(), 3); + assert!(matches!(loaded.config.source, SlotSource::Derived { .. })); + } + + #[tokio::test] + async fn derive_focused_filters_parent_messages() { + let mut parent = make_slot("p", "s1"); + parent.append_messages(vec![Message::system("sys")]).unwrap(); + for i in 0..5 { + parent.append_messages(vec![Message::user_text(format!("u{i}"))]) + .unwrap(); + } + + // 模拟 derive_slot 内部 Focused 策略:按 FocusedConfig 过滤 + let cfg = FocusedConfig { + keep_system: true, + recent_messages: 2, + summary_override: None, + }; + // 应用 load_messages 同样的过滤 + let mut filtered = Vec::new(); + if cfg.keep_system + && let Some(m) = parent + .messages + .iter() + .find(|m| matches!(m, Message::System { .. })) + { + filtered.push(m.clone()); + } + if cfg.recent_messages > 0 { + let recent: Vec<&Message> = parent + .messages + .iter() + .filter(|m| !matches!(m, Message::System { .. })) + .collect(); + let start = recent.len().saturating_sub(cfg.recent_messages); + for m in recent.iter().skip(start) { + filtered.push((*m).clone()); + } + } + + assert_eq!(filtered.len(), 1 + 2); // system + 2 条 + } + + #[tokio::test] + async fn derived_slot_loadable_independently() { + let store = make_store(); + let mut parent = make_slot("p", "s1"); + parent.append_messages(vec![Message::user_text("u")]).unwrap(); + parent.save(&*store).await.unwrap(); + + // 派生 child + let mut child = ContextSlot::new( + "s1", + "c", + SlotConfig { + mode: SlotMode::Full, + source: SlotSource::Derived { + parent_id: "p".to_string(), + strategy: DeriveStrategy::Full, + }, + budget: ContextBudget::default(), + compact: true, + }, + ); + child.append_messages(vec![Message::user_text("derived msg")]) + .unwrap(); + child.save(&*store).await.unwrap(); + + // child 可独立加载 + let loaded = ContextSlot::load("c", "s1", &*store).await.unwrap().unwrap(); + assert_eq!(loaded.messages.len(), 1); + assert_eq!(extract_text(&loaded.messages[0]), "derived msg"); + } + + // ===== delete 保护 (AgentSession 层,但 ContextSlot.delete 不保护;逻辑测试在 session.rs) ===== + + #[tokio::test] + async fn slot_delete_cleans_all_records() { + let store = make_store(); + let mut slot = ContextSlot::new( + "s1", + "x", + SlotConfig { + mode: SlotMode::Full, + source: SlotSource::Derived { + parent_id: "p".to_string(), + strategy: DeriveStrategy::Full, + }, + budget: ContextBudget::default(), + compact: true, + }, + ); + slot.append_messages(vec![Message::user_text("u")]).unwrap(); + slot.save(&*store).await.unwrap(); + + // 确认所有记录存在 + assert!(store.get(&ContextSlot::data_key("s1", "x")).await.unwrap().is_some()); + assert!(store.get(&ContextSlot::meta_key("s1", "x")).await.unwrap().is_some()); + assert!(store.get(&ContextSlot::config_key("s1", "x")).await.unwrap().is_some()); + assert!(store.get(&ContextSlot::rel_key("s1", "x")).await.unwrap().is_some()); + + ContextSlot::delete("x", "s1", &*store).await.unwrap(); + + // data/meta/config 已删 + assert!(store.get(&ContextSlot::data_key("s1", "x")).await.unwrap().is_none()); + assert!(store.get(&ContextSlot::meta_key("s1", "x")).await.unwrap().is_none()); + assert!(store.get(&ContextSlot::config_key("s1", "x")).await.unwrap().is_none()); + } + + // ===== 基础类型测试 ===== + + #[test] + fn slot_meta_new_sets_zero_message_count() { + let m = SlotMeta::new(); + assert_eq!(m.message_count, 0); + assert_eq!(m.total_tokens, 0); + assert!(m.parent_id.is_none()); + } + + #[test] + fn context_budget_default_sum_128k() { + let b = ContextBudget::default(); + assert_eq!(b.system + b.history + b.tools + b.tool_results + b.reserve, 128_000); + } + + #[test] + fn slot_config_default_is_full_new() { + let c = SlotConfig::default(); + assert!(matches!(c.mode, SlotMode::Full)); + assert!(matches!(c.source, SlotSource::New)); + assert!(c.compact); + } + + #[test] + fn focused_config_serializes_roundtrip() { + let cfg = FocusedConfig { + keep_system: true, + recent_messages: 5, + summary_override: Some("sum".into()), + }; + let json = serde_json::to_string(&cfg).unwrap(); + let back: FocusedConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(back.recent_messages, 5); + assert_eq!(back.summary_override.as_deref(), Some("sum")); + } + + // ====== filter_focused 静态方法(被 load_messages 和 derive_slot 复用) ====== + + #[test] + fn filter_focused_keeps_system_and_recent() { + let mut messages = vec![Message::system("sys")]; + for i in 0..5 { + messages.push(Message::user_text(format!("u{i}"))); + messages.push(Message::assistant(format!("a{i}"))); + } + let cfg = FocusedConfig { + keep_system: true, + recent_messages: 3, + summary_override: None, + }; + let filtered = ContextSlot::filter_focused(&messages, &cfg); + // system + 3 条最近的非 system 消息 + assert_eq!(filtered.len(), 1 + 3); + assert!(matches!(filtered[0], Message::System { .. })); + } + + #[test] + fn filter_focused_injects_summary() { + let messages = vec![ + Message::user_text("u"), + Message::assistant("a"), + ]; + let cfg = FocusedConfig { + keep_system: false, + recent_messages: 100, + summary_override: Some("讨论了 X".into()), + }; + let filtered = ContextSlot::filter_focused(&messages, &cfg); + // 2 条原始 + 1 条摘要 system + assert_eq!(filtered.len(), 3); + if let Message::System { content } = &filtered[2] { + let text = format!("{:?}", content); + assert!(text.contains("上下文摘要")); + } else { + panic!("最后一条应为 system 摘要"); + } + } + + // ====== Colon 校验(key 格式保护) ====== + + #[test] + #[should_panic(expected = "session_id 's:1' contains ':'")] + fn key_constructor_rejects_colon_in_session_id() { + // 通过 make_slot 间接调用 slot.save 时会触发 data_key -> assert_no_colon + let store = make_store(); + let slot = ContextSlot::new("s:1", "default", SlotConfig::default()); + let _ = tokio_test_runtime(slot.save(&*store)); + } + + #[test] + #[should_panic(expected = "slot_id 'a:b' contains ':'")] + fn key_constructor_rejects_colon_in_slot_id() { + let store = make_store(); + let slot = ContextSlot::new("s1", "a:b", SlotConfig::default()); + let _ = tokio_test_runtime(slot.save(&*store)); + } + + /// 在同步测试中运行 future 的辅助函数。 + fn tokio_test_runtime(f: F) -> F::Output { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(f) + } +} \ No newline at end of file diff --git a/src/agent/error.rs b/src/agent/error.rs index f44a90a..503c40d 100644 --- a/src/agent/error.rs +++ b/src/agent/error.rs @@ -36,6 +36,18 @@ pub enum AgentError { #[error("Plan 解析错误: {0}")] PlanParse(String), + /// Readonly slot 不允许写入(Phase 10 新增)。 + #[error("Readonly slot 不允许写入: {0}")] + SlotReadonly(String), + + /// Slot 不存在(Phase 10 新增)。 + #[error("Slot '{0}' 不存在")] + SlotNotFound(String), + + /// Slot 已存在(Phase 10 新增)。 + #[error("Slot '{0}' 已存在")] + SlotAlreadyExists(String), + /// 钩子阻断操作(Agent 层特有)。 #[error("钩子阻断: {0}")] HookBlocked(String), @@ -60,6 +72,7 @@ impl AgentError { /// - `Tool`:由内层 `is_recoverable()` 决定 /// - `HookBlocked` / `LimitExceeded`:不可恢复(需人工介入或终止循环) /// - `Config` / `Other`:不可恢复 + /// - `SlotReadonly` / `SlotNotFound` / `SlotAlreadyExists`:不可恢复(结构性错误) pub fn is_recoverable(&self) -> bool { match self { Self::Llm(e) => matches!( @@ -69,9 +82,13 @@ impl AgentError { Self::Tool(e) => e.is_recoverable(), Self::Memory(e) => e.is_recoverable(), Self::PlanParse(_) => false, - Self::HookBlocked(_) | Self::LimitExceeded(_) | Self::Config(_) | Self::Other(_) => { - false - } + Self::SlotReadonly(_) + | Self::SlotNotFound(_) + | Self::SlotAlreadyExists(_) + | Self::HookBlocked(_) + | Self::LimitExceeded(_) + | Self::Config(_) + | Self::Other(_) => false, } } } @@ -181,4 +198,37 @@ mod tests { let err = caller().unwrap_err(); assert!(matches!(err, AgentError::Memory(_))); } + + // ====== Phase 10: Slot 错误变体测试 ====== + + #[test] + fn slot_readonly_not_recoverable() { + assert!(!AgentError::SlotReadonly("readonly".into()).is_recoverable()); + } + + #[test] + fn slot_not_found_not_recoverable() { + assert!(!AgentError::SlotNotFound("missing".into()).is_recoverable()); + } + + #[test] + fn slot_already_exists_not_recoverable() { + assert!(!AgentError::SlotAlreadyExists("dup".into()).is_recoverable()); + } + + #[test] + fn slot_error_messages() { + assert_eq!( + format!("{}", AgentError::SlotReadonly("readonly".into())), + "Readonly slot 不允许写入: readonly" + ); + assert_eq!( + format!("{}", AgentError::SlotNotFound("foo".into())), + "Slot 'foo' 不存在" + ); + assert_eq!( + format!("{}", AgentError::SlotAlreadyExists("bar".into())), + "Slot 'bar' 已存在" + ); + } } diff --git a/src/agent/session.rs b/src/agent/session.rs index 6256c66..4b8a30d 100644 --- a/src/agent/session.rs +++ b/src/agent/session.rs @@ -1,18 +1,23 @@ //! AgentSession —— 智能体"会话"实例。 //! -//! 设计要点(参见 `docs/7-agent-runtime.md` §3.2.3): +//! 设计要点(参见 `docs/7-agent-runtime.md` §3.2.3 与 `docs/17-phase10-contextslot.md`): //! //! - **会话 = 角色 + 状态**:绑定 `session_id` / `agent` / `bundle`,累计 `turn_index` 和 `cost_so_far` +//! - **多上下文分区**(Phase 10):通过 `ContextSlot` 管理多个独立的消息上下文 //! - **最小 reference impl**:`submit_turn` 演示"组装 LlmCycle → submit_with_tools → 累计 cost"的标准流程 //! - **不做业务循环**:多轮策略、错误重试、记忆回写由上层应用或具体 `TaskAgent` 决定 //! - **不持有 ConversationMemory**:上层可独立 new 一个 `ConversationMemory`,在合适的时机调 `add_message` +use std::collections::HashMap; use std::pin::Pin; use std::sync::Arc; use futures_core::Stream; use crate::agent::agent::Agent; +use crate::agent::context::{ + ContextSlot, DeriveStrategy, SlotConfig, SlotMode, SlotSource, +}; use crate::agent::error::AgentError; use crate::agent::runtime::RuntimeBundle; use crate::agent::session_memory::SessionMemory; @@ -21,13 +26,16 @@ use crate::llm::hooks::{HookContext, HookEvent}; use crate::llm::stream::StreamEvent; use crate::llm::types::message::Message; use crate::llm::types::response_v2::MessageResponse; -use crate::memory::store::InMemoryStore; +use crate::memory::store::{InMemoryStore, MemoryStore}; /// Agent 会话实例。 /// /// 同一 `Agent` 可被多个 `AgentSession` 复用(不同 session_id 互不干扰)。 /// `submit_turn` 一次只跑一轮 LLM 调用(含自动 tool 循环)。 /// +/// **Phase 10 新增**:通过 `slots: HashMap` 管理多个独立的对话上下文。 +/// `submit_turn` 默认写入当前活跃 slot(`current_slot_id`)。 +/// /// **不实现 `Clone`**:session 持有累计 `turn_index` / `cost_so_far` / `session_memory`, /// 共享这些状态需要显式 sync 语义;如果上层需要并发访问,自己用 `Arc>` 包装。 pub struct AgentSession { @@ -40,6 +48,10 @@ pub struct AgentSession { cost_so_far: CostTracker, /// 会话级记忆(Phase 4c 替换内联 HashMap)。 pub session_memory: SessionMemory, + /// Phase 10 新增:所有 slot(id → ContextSlot)。 + slots: HashMap, + /// Phase 10 新增:当前活跃 slot 的 id。 + current_slot_id: String, } impl std::fmt::Debug for AgentSession { @@ -50,6 +62,8 @@ impl std::fmt::Debug for AgentSession { .field("turn_index", &self.turn_index) .field("cost_so_far", &self.cost_so_far.total()) .field("session_memory", &"") + .field("slots", &self.slots.keys().collect::>()) + .field("current_slot_id", &self.current_slot_id) .finish() } } @@ -58,6 +72,14 @@ impl AgentSession { /// 创建一个新的会话实例。 /// /// `agent` 与 `bundle` 共同决定 `submit_turn` 行为:system_prompt / 工具集 / LLM 后端均来自它们。 + /// + /// Phase 10 新增:自动创建 `"default"` slot,确保简单场景无感使用。 + /// + /// **注意**:`new()` 是同步函数,无法执行异步的 `ContextSlot::load()`。 + /// 因此始终创建空的 default slot。若需要从存储恢复 session 历史, + /// 可在创建后调用 `switch_slot("default")` 尝试从存储加载。 + /// (v0.2 简化:`switch_slot` 在 HashMap 中已有 key 时不会重载——如需恢复, + /// 请在清空 `slots` 后调用 `switch_slot`,或等待 v0.3 的懒加载支持。) pub fn new( agent: Arc, session_id: impl Into, @@ -69,6 +91,16 @@ impl AgentSession { .clone() .unwrap_or_else(|| Arc::new(InMemoryStore::new())); let session_memory = SessionMemory::new(backend, &session_id_str); + + // 自动创建 "default" slot + let default_slot = ContextSlot::new( + &session_id_str, + "default", + SlotConfig::default(), + ); + let mut slots = HashMap::new(); + slots.insert("default".to_string(), default_slot); + Self { session_id: session_id_str, agent, @@ -76,6 +108,8 @@ impl AgentSession { turn_index: 0, cost_so_far: CostTracker::default(), session_memory, + slots, + current_slot_id: "default".to_string(), } } @@ -100,7 +134,9 @@ impl AgentSession { key: impl Into, value: impl Into, ) -> Result<(), AgentError> { - self.session_memory.set(&key.into(), &value.into()).await + self.session_memory + .set(&key.into(), &value.into()) + .await } /// 读取一条会话级数据。 @@ -108,20 +144,158 @@ impl AgentSession { self.session_memory.get(key).await } + /// Phase 10: 当前 slot id。 + pub fn current_slot_id(&self) -> &str { + &self.current_slot_id + } + + /// Phase 10: 列出所有 slot 的不可变引用(按 id 顺序)。 + pub fn slots(&self) -> impl Iterator { + self.slots.iter() + } + + /// Phase 10: 解析存储后端。 + /// fallback 链:`session_memory_backend` → `memory_store` → `InMemoryStore`。 + fn resolve_store(&self) -> Arc { + self.bundle + .session_memory_backend + .clone() + .or_else(|| self.bundle.memory_store.clone()) + .unwrap_or_else(|| Arc::new(InMemoryStore::new())) + } + + // ====== Phase 10: Slot 管理方法 ====== + + /// 创建新 slot(config 可选,不传则使用默认值)。 + pub async fn create_slot( + &mut self, + id: impl Into, + config: Option, + ) -> Result<(), AgentError> { + let id = id.into(); + if self.slots.contains_key(&id) { + return Err(AgentError::SlotAlreadyExists(id)); + } + let slot = ContextSlot::new( + &self.session_id, + &id, + config.unwrap_or_default(), + ); + slot.save(&*self.resolve_store()).await?; + self.slots.insert(id, slot); + Ok(()) + } + + /// 切换到指定 slot。 + /// - 如果 slot 已在内存中,直接切换 current_slot_id + /// - 如果不在内存中,尝试从存储加载(config 自动从 slot_config key 恢复) + /// - 存储中也不存在则返回 `SlotNotFound` + pub async fn switch_slot(&mut self, id: &str) -> Result<(), AgentError> { + if !self.slots.contains_key(id) { + let store = self.resolve_store(); + match ContextSlot::load(id, &self.session_id, &*store).await? { + Some(slot) => { + self.slots.insert(id.to_string(), slot); + } + None => return Err(AgentError::SlotNotFound(id.to_string())), + } + } + self.current_slot_id = id.to_string(); + Ok(()) + } + + /// 列出所有 slot id。 + pub fn list_slots(&self) -> impl Iterator { + self.slots.keys() + } + + /// 从父 slot 派生新 slot(继承父 slot 的全量或聚焦消息)。 + pub async fn derive_slot( + &mut self, + id: impl Into, + parent_id: &str, + strategy: DeriveStrategy, + ) -> Result<(), AgentError> { + let slot_id = id.into(); + if self.slots.contains_key(&slot_id) { + return Err(AgentError::SlotAlreadyExists(slot_id)); + } + 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 = ContextSlot::filter_focused(&parent_messages, cfg); + (filtered, Some(cfg.clone())) + } + }; + + let mode = match focused_cfg { + Some(cfg) => SlotMode::Focused(cfg), + None => SlotMode::Full, + }; + + let slot = ContextSlot::new( + &self.session_id, + &slot_id, + SlotConfig { + mode, + source: SlotSource::Derived { + parent_id: parent_id.to_string(), + strategy, + }, + budget: Default::default(), + compact: true, + }, + ); + let mut slot = slot; + slot.messages = messages; + slot.save(&*self.resolve_store()).await?; + self.slots.insert(slot_id, slot); + Ok(()) + } + + /// 删除一个 slot。 + /// - 禁止删除 `"default"` slot + /// - 至少保留一个 slot + /// - 已删除后再 load 返回 None + pub async fn delete_slot(&mut self, id: &str) -> Result<(), AgentError> { + if id == "default" { + return Err(AgentError::Config("Cannot delete the 'default' slot".into())); + } + 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(()) + } + + // ====== 原始方法 ====== + /// 提交一轮对话(含自动 tool 循环),返回 LLM 响应。 /// - /// 流程: - /// 1. 触发 `OnTurnStart` hook - /// 2. 组装 `LlmCycle`(注入 system_prompt / hook_executor / compact_config / 消息历史) - /// 3. `submit_with_tools` 跑单轮对话 - /// 4. 累计 `cost_so_far` - /// 5. 触发 `OnTurnEnd` hook - /// 6. `turn_index += 1` + /// Phase 10 改造: + /// - 从当前 slot 加载历史(Focused 模式读时过滤) + /// - 提交完成后**增量追加**本轮新增消息到当前 slot(不覆盖,确保 Focused 语义不丢数据) /// - /// **不做**: - /// - 不持有 `ConversationMemory`(由上层独立 task 决定何时回写) - /// - 不做 Plan 拆解(Phase 4b 才加 `TaskAgent`) - /// - 不做 session_data 持久化(Phase 4c 替换为 `SessionMemory`) + /// 流程: + /// 1. 检查当前 slot 不是 Readonly + /// 2. 触发 `OnTurnStart` hook + /// 3. 加载当前 slot 的历史消息 + /// 4. 组装 `LlmCycle`(注入 system_prompt / compact_config / 历史) + /// 5. `submit_with_tools` 跑单轮对话 + /// 6. 累计 `cost_so_far` + /// 7. **增量追加**本轮新增消息到当前 slot + 保存到 store + /// 8. 触发 `OnTurnEnd` hook + /// 9. `turn_index += 1` pub async fn submit_turn( &mut self, user_input: impl Into, @@ -129,46 +303,75 @@ impl AgentSession { 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. 组装 LlmCycle —— 共享 bundle 中的 provider 句柄 - // 工具列表从 agent.tool_definitions(bundle) 派生(默认 = bundle 全量); - // submit_with_tools 内部从 registry 自行取 definitions,此处仅消费以触发 - // 子 trait 覆盖(白名单/过滤)的副作用。 + // 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()) - .with_messages(Vec::new()); - // Phase 2 切换 system_prompt 字段为 Message::System(FIX-D)。 - // 若 agent 自带 system prompt,预置到 messages 列表头部。 - let mut initial_messages: Vec = Vec::new(); + LlmCycle::new_with_arc(Arc::clone(&self.bundle.provider), CycleConfig::default()); + let mut messages_with_prompt = history; if let Some(prompt) = self.agent.system_prompt() { - initial_messages.push(Message::system(prompt)); - } - if !initial_messages.is_empty() { - cycle = cycle.with_messages(initial_messages); + 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); } - // 3. 提交(HookExecutor 不在这里传——内部 hook 由 LlmCycle 在 PreRequest/PostRequest 触发) + // 4. 提交 let response = cycle .submit_with_tools(user_input.into(), &self.bundle.tool_registry) .await?; - // 4. 累计 cost + // 5. 累计 cost self.cost_so_far.add(&response.usage); - // 5. 触发 OnTurnEnd hook + // 6. 只将本轮新增消息追加到当前 slot(保留全量历史,确保 Focused 模式的"读时过滤"语义不丢失数据) + // cycle.messages() 包含 [system_prompt?, history..., user_input, tool_calls..., final_response] + // 新增消息 = cycle.messages()[input_len..](跳过 initial_messages,即跳过已被持久化的内容) + let new_messages: Vec = cycle + .messages() + .iter() + .skip(input_len) + .cloned() + .collect(); + let store = self.resolve_store(); + if let Some(slot) = self.slots.get_mut(&self.current_slot_id) { + slot.append_messages(new_messages)?; + 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; - // 6. turn_index 递增 + // 8. turn_index 递增 self.turn_index += 1; Ok(response) @@ -176,15 +379,11 @@ impl AgentSession { /// 提交一轮对话(流式版本,含自动 tool 循环),返回 `StreamEvent` 流。 /// - /// 与 `submit_turn` 的区别: - /// - 以流事件序列而非 `MessageResponse` 返回 - /// - 工具执行期间插入 `ToolExecutionStarted` / `ToolExecutionCompleted` 事件 - /// - 消费方在收到 `MessageComplete` 后需手动调用 `finalize_turn` 同步状态 + /// Phase 10 改造: + /// - 从当前 slot 加载历史(Focused 模式读时过滤) + /// - finalize_turn 需要传入本轮新增消息列表 /// /// **运行时要求**:内部委托 `submit_with_tools_stream`,需要 tokio 多线程运行时。 - /// - /// ponytail: 流程结构与 `submit_turn` 对称,但 `OnTurnEnd` hook + cost 累计不在流生成路径上, - /// 因为流是延迟求值且 `&mut self` 无法进入 spawn 闭包。调用方消费流完毕后必须调 `finalize_turn`。 pub async fn submit_turn_stream( &mut self, user_input: impl Into, @@ -192,7 +391,21 @@ impl AgentSession { let turn_index = self.turn_index; let hook_executor = Arc::clone(&self.bundle.hook_executor); - // 1. 触发 OnTurnStart hook(同步) + // 0. Readonly 检查 + { + 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 stream 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) @@ -201,18 +414,26 @@ impl AgentSession { // 2. 触发子 trait 覆盖(白名单/过滤)的副作用 let _ = self.agent.tool_definitions(&self.bundle); - // 3. 组装 LlmCycle + // 3. 从当前 slot 加载历史 + let history = self + .slots + .get(&self.current_slot_id) + .ok_or_else(|| AgentError::SlotNotFound(self.current_slot_id.clone()))? + .load_messages(); + + // 4. 组装 LlmCycle let mut cycle = - LlmCycle::new_with_arc(Arc::clone(&self.bundle.provider), CycleConfig::default()) - .with_messages(Vec::new()); + LlmCycle::new_with_arc(Arc::clone(&self.bundle.provider), CycleConfig::default()); + let mut messages_with_prompt = history; if let Some(prompt) = self.agent.system_prompt() { - cycle = cycle.with_messages(vec![Message::system(prompt)]); + messages_with_prompt.insert(0, Message::system(prompt)); } + cycle = cycle.with_messages(messages_with_prompt); if let Some(cfg) = self.bundle.config.compact_config.clone() { cycle = cycle.with_compact_config(cfg); } - // 4. 调用流式工具循环 + // 5. 调用流式工具循环 let stream = cycle .submit_with_tools_stream( user_input.into(), @@ -220,7 +441,7 @@ impl AgentSession { ) .await?; - // 5. turn_index 递增 —— 配合 finalize_turn 用 (turn_index - 1) 传递正确的 OnTurnEnd 序号 + // 6. turn_index 递增 —— 配合 finalize_turn 用 (turn_index - 1) 传递正确的 OnTurnEnd 序号 self.turn_index += 1; // 注:hook_executor 不显式 drop,生命周期由 Arc 自动管理 @@ -229,27 +450,48 @@ impl AgentSession { Ok(stream) } - /// 完成一轮 turn:累计 cost + 触发 OnTurnEnd hook。 + /// 完成一轮 turn:累计 cost + 触发 OnTurnEnd hook + 增量追加消息到当前 slot。 + /// + /// Phase 10 改造: + /// - 新增 `new_messages_from_cycle` 参数:流式场景下,本轮新增的消息列表 + /// (由消费者在流消费完毕后从 `cycle.messages()[input_len..]` 获取并传入) + /// - 仅**增量追加**到当前 slot(不覆盖已有消息),与 submit_turn 行为一致 + /// - 返回类型从 `()` 改为 `Result<(), AgentError>`,错误传播更清晰 /// /// 由消费者在收到 `MessageComplete.full_response` 后调用。 - /// - /// **消费者注意**:`finalize_turn` 是开发者责任 —— 遗漏调用会导致 cost 不累计、OnTurnEnd 不触发。 - /// session 状态仍然可用,后续 `submit_turn` 也能正常执行,但 cost 信息不完整。 - /// - /// ponytail: 与 `submit_turn` 行为对齐 —— `cost_so_far` 仅计入最终轮的 usage。 - pub async fn finalize_turn(&mut self, response: &MessageResponse) { + pub async fn finalize_turn( + &mut self, + response: &MessageResponse, + new_messages_from_cycle: Vec, + ) -> Result<(), AgentError> { self.cost_so_far.add(&response.usage); - // ponytail: 防御性 saturating_sub 防止误用 panic。 - // 正常路径是 submit_turn_stream 内 turn_index += 1 后再调 finalize_turn, - // 所以 saturating 后为 0 是正常的;若调用方忘了先调 submit_turn_stream, - // turn_index 仍为 0,传 0 给 OnTurnEnd hook 不会 panic。 + // 防御性检查:current_slot_id 必须在 slots 中(与 submit_turn 行为一致)。 + // 正常流程:submit_turn_stream 已注册 slot,finalize_turn 不应触发此分支。 + if !self.slots.contains_key(&self.current_slot_id) { + return Err(AgentError::SlotNotFound(self.current_slot_id.clone())); + } + // 增量追加到当前 slot(仅 Full/Focused 模式允许,Readonly 阻断) + let store = self.resolve_store(); + if let Some(slot) = self.slots.get_mut(&self.current_slot_id) { + if matches!(slot.config.mode, SlotMode::Readonly) { + return Err(AgentError::SlotReadonly(format!( + "Cannot finalize turn on Readonly slot '{}'", + self.current_slot_id + ))); + } + slot.append_messages(new_messages_from_cycle)?; + slot.save(&*store).await?; + } + + // 防御性 saturating_sub 防止误用 panic。 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(()) } } @@ -313,10 +555,20 @@ mod tests { } } - /// 烟雾测试 1:AgentSession::submit_turn 跑通 mock provider。 - #[tokio::test] - async fn submit_turn_runs_with_mock_provider() { - let provider = Arc::new(MockProvider::new(vec![assistant_text("hello back")])); + fn build_session(provider_responses: Vec) -> (AgentSession, Arc, Arc) { + let mut hook_executor = HookExecutor::new(); + let start_count = Arc::new(CountHook(AtomicU32::new(0))); + let end_count = Arc::new(CountHook(AtomicU32::new(0))); + hook_executor.register( + HookEvent::OnTurnStart, + Box::new(CountHookAdapter(start_count.clone())), + ); + hook_executor.register( + HookEvent::OnTurnEnd, + Box::new(CountHookAdapter(end_count.clone())), + ); + + let provider = Arc::new(MockProvider::new(provider_responses)); let agent = Arc::new(StubAgent { name: "stub".into(), prompt: Some("you are a test agent".into()), @@ -325,39 +577,36 @@ mod tests { AgentBuilder::new() .provider(provider) .tool_registry(Arc::new(ToolRegistry::new())) - .hook_executor(Arc::new(HookExecutor::new())) + .hook_executor(Arc::new(hook_executor)) .build() .unwrap(), ); - let mut session = AgentSession::new(agent, "s1", bundle); + let session = AgentSession::new(agent, "test-session", bundle); + (session, start_count, end_count) + } + + /// 烟雾测试 1:AgentSession::submit_turn 跑通 mock provider(向后兼容)。 + #[tokio::test] + async fn submit_turn_runs_with_mock_provider() { + let (mut session, start_count, end_count) = build_session(vec![assistant_text("hello back")]); assert_eq!(session.turn_index(), 0); let response = session.submit_turn("hi").await.unwrap(); - assert_eq!(response.text(), "hello back"); + assert_eq!(extract_text(&response.message), "hello back"); assert_eq!(session.turn_index(), 1); assert_eq!(session.usage().total().prompt_tokens, 10); assert_eq!(session.usage().total().completion_tokens, 5); + + // hook 触发 + assert_eq!(start_count.0.load(Ordering::SeqCst), 1); + assert_eq!(end_count.0.load(Ordering::SeqCst), 1); } /// 烟雾测试 2:session_data 读写。 #[tokio::test] async fn session_data_set_get() { - let provider = Arc::new(MockProvider::new(vec![])); - let agent = Arc::new(StubAgent { - name: "stub".into(), - prompt: None, - }); - let bundle = Arc::new( - AgentBuilder::new() - .provider(provider) - .tool_registry(Arc::new(ToolRegistry::new())) - .hook_executor(Arc::new(HookExecutor::new())) - .build() - .unwrap(), - ); - let mut session = AgentSession::new(agent, "s2", bundle); - + let (mut session, _, _) = build_session(vec![]); assert!(session.get_session_data("k").await.unwrap().is_none()); session.set_session_data("k", "v").await.unwrap(); assert_eq!( @@ -375,35 +624,10 @@ mod tests { /// 烟雾测试 3:submit_turn 触发 OnTurnStart / OnTurnEnd hook。 #[tokio::test] async fn submit_turn_triggers_turn_hooks() { - let mut hook_executor = HookExecutor::new(); - let start_count = Arc::new(CountHook(AtomicU32::new(0))); - let end_count = Arc::new(CountHook(AtomicU32::new(0))); - hook_executor.register( - HookEvent::OnTurnStart, - Box::new(CountHookAdapter(start_count.clone())), - ); - hook_executor.register( - HookEvent::OnTurnEnd, - Box::new(CountHookAdapter(end_count.clone())), - ); - - let provider = Arc::new(MockProvider::new(vec![ + let (mut session, start_count, end_count) = build_session(vec![ assistant_text("ok"), assistant_text("ok 2"), - ])); - let agent = Arc::new(StubAgent { - name: "stub".into(), - prompt: None, - }); - let bundle = Arc::new( - AgentBuilder::new() - .provider(provider) - .tool_registry(Arc::new(ToolRegistry::new())) - .hook_executor(Arc::new(hook_executor)) - .build() - .unwrap(), - ); - let mut session = AgentSession::new(agent, "s3", bundle); + ]); session.submit_turn("hi").await.unwrap(); assert_eq!(start_count.0.load(Ordering::SeqCst), 1); @@ -414,96 +638,235 @@ mod tests { assert_eq!(end_count.0.load(Ordering::SeqCst), 2); } - // ====== Phase 9: submit_turn_stream + finalize_turn 集成测试 ====== - - use futures_util::StreamExt; - - /// 集成测试 5.1 — `submit_turn_stream` 端到端:跑通 mock provider → 消费流 - /// 验证各事件到达 → `finalize_turn` 后 cost 更新正确 - #[tokio::test(flavor = "multi_thread")] - async fn submit_turn_stream_end_to_end() { - use crate::llm::mock::MockProvider as SessionMock; - - let provider = Arc::new(SessionMock::new(vec![assistant_text("hello")])); - let agent = Arc::new(StubAgent { - name: "stub".into(), - prompt: Some("you are a test agent".into()), - }); - let bundle = Arc::new( - AgentBuilder::new() - .provider(provider) - .tool_registry(Arc::new(ToolRegistry::new())) - .hook_executor(Arc::new(HookExecutor::new())) - .build() - .unwrap(), - ); - - let mut session = AgentSession::new(agent, "stream-s1", bundle); - assert_eq!(session.turn_index(), 0); - - // 1. 提交 stream - let mut stream = session.submit_turn_stream("hi").await.unwrap(); - - // 2. 消费流,收集事件直到结束 - let mut events: Vec = Vec::new(); - let mut final_response = None; - while let Some(event) = stream.next().await { - let _ = &event; - if let StreamEvent::MessageComplete { full_response } = &event { - final_response = Some(full_response.clone()); + /// 提取 Message 的第一个 Text block(测试辅助)。 + fn extract_text(msg: &Message) -> &str { + use crate::llm::types::message::ContentBlock; + let blocks = match msg { + Message::System { content } + | Message::User { content } + | Message::Assistant { content } => content, + Message::UserImage { .. } => return "", + Message::ToolResult { content, .. } => content, + }; + for block in blocks { + if let ContentBlock::Text { text } = block { + return text; } - events.push(event); } - - // 3. 验证事件序列 - assert!(!events.is_empty(), "应有事件"); - assert!( - events.iter().any(|e| matches!(e, StreamEvent::MessageStart { .. })), - "应包含 MessageStart" - ); - assert!( - events - .iter() - .any(|e| matches!(e, StreamEvent::TextDelta { text } if text == "hello")), - "应包含 TextDelta hello" - ); - assert!( - events - .iter() - .any(|e| matches!(e, StreamEvent::MessageComplete { .. })), - "应包含 MessageComplete" - ); - - // 4. 调用 finalize_turn - let response = final_response.expect("流应包含至少一个 MessageComplete"); - session.finalize_turn(&response).await; - - // 5. 验证 cost 累计 - assert_eq!(session.turn_index(), 1); - assert_eq!(session.usage().total().prompt_tokens, 10); - assert_eq!(session.usage().total().completion_tokens, 5); + "" } - /// 集成测试 5.2 — Hook 触发验证: - /// - `OnTurnStart` 在 `submit_turn_stream` 返回流之前触发 - /// - `finalize_turn` 调用后 `OnTurnEnd` 正确触发 - #[tokio::test(flavor = "multi_thread")] - async fn submit_turn_stream_triggers_turn_hooks() { - use crate::llm::mock::MockProvider as SessionMock; + // ====== Phase 10 新增测试 ====== - let mut hook_executor = HookExecutor::new(); - let start_count = Arc::new(CountHook(AtomicU32::new(0))); - let end_count = Arc::new(CountHook(AtomicU32::new(0))); - hook_executor.register( - HookEvent::OnTurnStart, - Box::new(CountHookAdapter(start_count.clone())), - ); - hook_executor.register( - HookEvent::OnTurnEnd, - Box::new(CountHookAdapter(end_count.clone())), - ); + /// Phase 10: 默认 slot 自动创建。 + #[tokio::test] + async fn default_slot_auto_created() { + let (session, _, _) = build_session(vec![]); + assert_eq!(session.current_slot_id(), "default"); + let slots: Vec<_> = session.list_slots().collect(); + assert_eq!(slots.len(), 1); + assert!(slots.contains(&&"default".to_string())); + } - let provider = Arc::new(SessionMock::new(vec![assistant_text("ok")])); + /// Phase 10: submit_turn 写入当前 slot。 + #[tokio::test] + async fn submit_turn_writes_to_current_slot() { + let (mut session, _, _) = build_session(vec![assistant_text("resp")]); + session.submit_turn("user input").await.unwrap(); + + // 检查 default slot 内存中的消息 + let slot = session.slots.get("default").expect("default slot exists"); + // submit_turn 增量追加的是 cycle.messages()[input_len..] 部分, + // 即 [user_input, tool_results?, final_response](不含 system_prompt,system 由 agent 提供) + assert!(slot.messages.len() >= 2, "应至少包含 user 和 assistant"); + // 验证 user 输入和 assistant 响应都已写入 + let has_user = slot.messages.iter().any(|m| extract_text(m) == "user input"); + let has_resp = slot.messages.iter().any(|m| extract_text(m) == "resp"); + assert!(has_user && has_resp, "slot 应包含 user input 和 assistant response"); + } + + /// Phase 10: create_slot 创建新 slot。 + #[tokio::test] + async fn create_slot_basic() { + let (mut session, _, _) = build_session(vec![]); + session.create_slot("scratch", None).await.unwrap(); + let slots: Vec<_> = session.list_slots().cloned().collect(); + assert!(slots.contains(&"default".to_string())); + assert!(slots.contains(&"scratch".to_string())); + assert_eq!(slots.len(), 2); + } + + /// Phase 10: create_slot 拒绝重复 id。 + #[tokio::test] + async fn create_slot_rejects_duplicate() { + let (mut session, _, _) = build_session(vec![]); + session.create_slot("dup", None).await.unwrap(); + let err = session.create_slot("dup", None).await.unwrap_err(); + assert!(matches!(err, AgentError::SlotAlreadyExists(_))); + } + + /// Phase 10: switch_slot 切换并保留各自消息。 + #[tokio::test] + async fn switch_slot_isolates_messages() { + let (mut session, _, _) = build_session(vec![ + assistant_text("resp a"), + assistant_text("resp b"), + assistant_text("resp c"), + ]); + + // 1. 在 default 中提交一次 + session.submit_turn("msg in default").await.unwrap(); + + // 2. 创建 slot_a + session.create_slot("slot_a", None).await.unwrap(); + session.switch_slot("slot_a").await.unwrap(); + assert_eq!(session.current_slot_id(), "slot_a"); + session.submit_turn("msg in slot_a").await.unwrap(); + + // 3. 检查 slot_a 的消息数 + let slot_a = session.slots.get("slot_a").unwrap(); + let slot_a_count = slot_a.messages.len(); + assert!(slot_a_count >= 2, "slot_a 至少 2 条消息,实际 {}", slot_a_count); + + // 4. 切回 default,验证 default 不包含 slot_a 的消息 + session.switch_slot("default").await.unwrap(); + let slot_default = session.slots.get("default").unwrap(); + let default_count = slot_default.messages.len(); + assert!(default_count >= 2); + // 验证 default 中没有 "msg in slot_a" + let default_has_a = slot_default + .messages + .iter() + .any(|m| extract_text(m) == "msg in slot_a"); + assert!(!default_has_a, "default 不应包含 slot_a 的消息"); + // 验证 slot_a 中没有 "msg in default" + let slot_a = session.slots.get("slot_a").unwrap(); + let a_has_default = slot_a + .messages + .iter() + .any(|m| extract_text(m) == "msg in default"); + assert!(!a_has_default, "slot_a 不应包含 default 的消息"); + } + + /// Phase 10: Readonly slot 拒绝写入。 + #[tokio::test] + async fn readonly_slot_rejects_submit_turn() { + let (mut session, _, _) = build_session(vec![assistant_text("resp")]); + session + .create_slot( + "ro", + Some(SlotConfig { + mode: SlotMode::Readonly, + source: SlotSource::New, + budget: Default::default(), + compact: true, + }), + ) + .await + .unwrap(); + session.switch_slot("ro").await.unwrap(); + let err = session.submit_turn("blocked").await.unwrap_err(); + assert!(matches!(err, AgentError::SlotReadonly(_))); + } + + /// Phase 10: delete_slot 删除非 default。 + #[tokio::test] + async fn delete_slot_removes_non_default() { + let (mut session, _, _) = build_session(vec![]); + session.create_slot("to_delete", None).await.unwrap(); + session.delete_slot("to_delete").await.unwrap(); + let slots: Vec<_> = session.list_slots().cloned().collect(); + assert!(!slots.contains(&"to_delete".to_string())); + assert_eq!(slots.len(), 1); + } + + /// Phase 10: delete_slot 禁止删 default。 + #[tokio::test] + async fn delete_slot_rejects_default() { + let (mut session, _, _) = build_session(vec![]); + let err = session.delete_slot("default").await.unwrap_err(); + assert!(matches!(err, AgentError::Config(_))); + } + + /// Phase 10: delete_slot 禁止删最后一个 slot。 + #[tokio::test] + async fn delete_slot_rejects_last() { + let (mut session, _, _) = build_session(vec![]); + // 只有 default 一个 slot + let err = session.delete_slot("default").await.unwrap_err(); + assert!(matches!(err, AgentError::Config(_))); + } + + /// Phase 10: delete_slot 后 current 回退到 default。 + #[tokio::test] + async fn delete_slot_falls_back_to_default() { + let (mut session, _, _) = build_session(vec![]); + session.create_slot("temp", None).await.unwrap(); + session.switch_slot("temp").await.unwrap(); + assert_eq!(session.current_slot_id(), "temp"); + session.delete_slot("temp").await.unwrap(); + assert_eq!(session.current_slot_id(), "default"); + } + + /// Phase 10: derive_slot Full 策略复制父 slot 消息。 + #[tokio::test] + async fn derive_slot_full_copies_parent() { + let (mut session, _, _) = build_session(vec![assistant_text("resp")]); + session.submit_turn("parent msg").await.unwrap(); + session + .derive_slot("child", "default", DeriveStrategy::Full) + .await + .unwrap(); + + let child = session.slots.get("child").unwrap(); + assert!(matches!(child.config.source, SlotSource::Derived { .. })); + // child 应有 parent 的消息拷贝 + let has_parent = child + .messages + .iter() + .any(|m| extract_text(m) == "parent msg"); + assert!(has_parent); + } + + /// Phase 10: derive_slot 拒绝重复 id。 + #[tokio::test] + async fn derive_slot_rejects_duplicate() { + let (mut session, _, _) = build_session(vec![]); + session.create_slot("child", None).await.unwrap(); + let err = session + .derive_slot("child", "default", DeriveStrategy::Full) + .await + .unwrap_err(); + assert!(matches!(err, AgentError::SlotAlreadyExists(_))); + } + + /// Phase 10: derive_slot 父 slot 不存在返回 SlotNotFound。 + #[tokio::test] + async fn derive_slot_parent_not_found() { + let (mut session, _, _) = build_session(vec![]); + let err = session + .derive_slot("child", "nonexistent", DeriveStrategy::Full) + .await + .unwrap_err(); + assert!(matches!(err, AgentError::SlotNotFound(_))); + } + + /// Phase 10: switch_slot 加载不存在的 slot 返回 SlotNotFound。 + #[tokio::test] + async fn switch_slot_not_found() { + let (mut session, _, _) = build_session(vec![]); + let err = session.switch_slot("missing").await.unwrap_err(); + assert!(matches!(err, AgentError::SlotNotFound(_))); + } + + /// Phase 10: slot 数据持久化到 storage,switch 时可恢复。 + /// 使用 session_memory_backend 配置可验证持久化。 + #[tokio::test] + async fn slot_persistence_roundtrip() { + // 创建一个共享的 InMemoryStore 作为后端 + let backend = Arc::new(InMemoryStore::new()); + + let provider = Arc::new(MockProvider::new(vec![assistant_text("resp")])); let agent = Arc::new(StubAgent { name: "stub".into(), prompt: None, @@ -512,29 +875,103 @@ mod tests { AgentBuilder::new() .provider(provider) .tool_registry(Arc::new(ToolRegistry::new())) - .hook_executor(Arc::new(hook_executor)) + .hook_executor(Arc::new(HookExecutor::new())) + .session_memory_backend(backend.clone()) .build() .unwrap(), ); - let mut session = AgentSession::new(agent, "stream-s2", bundle); - // 1. submit_turn_stream 触发 OnTurnStart - let mut stream = session.submit_turn_stream("hi").await.unwrap(); - assert_eq!(start_count.0.load(Ordering::SeqCst), 1); - assert_eq!(end_count.0.load(Ordering::SeqCst), 0, "OnTurnEnd 未在流返回前触发"); + let mut session = AgentSession::new(agent, "persist-session", bundle); + session.create_slot("persist_test", None).await.unwrap(); + session.switch_slot("persist_test").await.unwrap(); + session.submit_turn("hi").await.unwrap(); - // 2. 消费完流后再调 finalize_turn 触发 OnTurnEnd - let mut final_response = None; - while let Some(event) = stream.next().await { - if let StreamEvent::MessageComplete { full_response } = &event { - final_response = Some(full_response.clone()); - } - } + // 验证 data/meta/config 三个 key 都已写入共享 backend + let stored_data = backend + .get(&ContextSlot::data_key("persist-session", "persist_test")) + .await + .unwrap(); + assert!(stored_data.is_some(), "data 应已持久化"); + + let stored_meta = backend + .get(&ContextSlot::meta_key("persist-session", "persist_test")) + .await + .unwrap(); + assert!(stored_meta.is_some(), "meta 应已持久化"); + + let stored_config = backend + .get(&ContextSlot::config_key("persist-session", "persist_test")) + .await + .unwrap(); + assert!(stored_config.is_some(), "config 应已持久化"); + } + + /// Phase 10: 当只有 `memory_store`(无 `session_memory_backend`)时,resolve_store + /// 应 fallback 到 `memory_store`。 + #[tokio::test] + async fn resolve_store_falls_back_to_memory_store() { + let backend = Arc::new(InMemoryStore::new()); + + let provider = Arc::new(MockProvider::new(vec![assistant_text("ok")])); + let agent = Arc::new(StubAgent { + name: "stub".into(), + prompt: None, + }); + // 注意:这里只设置 memory_store,不设置 session_memory_backend + let bundle = Arc::new( + AgentBuilder::new() + .provider(provider) + .tool_registry(Arc::new(ToolRegistry::new())) + .hook_executor(Arc::new(HookExecutor::new())) + .memory_store(backend.clone()) + .build() + .unwrap(), + ); + + let mut session = AgentSession::new(agent, "fb-session", bundle); + session.create_slot("fb_slot", None).await.unwrap(); + + // 验证 backend 中已存在 fb_slot 的数据 + let stored = backend + .get(&ContextSlot::data_key("fb-session", "fb_slot")) + .await + .unwrap(); + assert!(stored.is_some(), "memory_store fallback 应生效"); + } + + /// Phase 10: finalize_turn 在 current_slot 不存在时返回 SlotNotFound(与 submit_turn 一致)。 + #[tokio::test] + async fn finalize_turn_slot_not_found() { + let (mut session, _, _) = build_session(vec![assistant_text("ok")]); + // 强制 current_slot_id 指向不存在的 slot(模拟异常状态) + session.current_slot_id = "ghost".to_string(); + let response = assistant_text("ok"); + let err = session.finalize_turn(&response, vec![]).await.unwrap_err(); + assert!(matches!(err, AgentError::SlotNotFound(_))); + } + + /// Phase 10: finalize_turn 在 Readonly slot 上返回 SlotReadonly。 + #[tokio::test] + async fn finalize_turn_readonly_rejects() { + let (mut session, _, _) = build_session(vec![assistant_text("ok")]); session - .finalize_turn(&final_response.expect("应有 MessageComplete")) - .await; - - assert_eq!(start_count.0.load(Ordering::SeqCst), 1); - assert_eq!(end_count.0.load(Ordering::SeqCst), 1, "OnTurnEnd 在 finalize_turn 后触发"); + .create_slot( + "ro", + Some(SlotConfig { + mode: SlotMode::Readonly, + source: SlotSource::New, + budget: Default::default(), + compact: true, + }), + ) + .await + .unwrap(); + session.switch_slot("ro").await.unwrap(); + let response = assistant_text("ok"); + let err = session + .finalize_turn(&response, vec![Message::user_text("x")]) + .await + .unwrap_err(); + assert!(matches!(err, AgentError::SlotReadonly(_))); } } \ No newline at end of file