Compare commits
3
Commits
212cfcc916
...
2af92cd554
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2af92cd554 | ||
|
|
635942248b | ||
|
|
fe51961202 |
File diff suppressed because it is too large
Load Diff
+43
-14
@@ -1,13 +1,13 @@
|
||||
# AG Core Roadmap
|
||||
|
||||
> 定稿日期:2026-05-11
|
||||
> 最后更新:2026-07-05
|
||||
> 最后更新:2026-07-07
|
||||
|
||||
## 愿景
|
||||
|
||||
AG Core 定位为构建 AI 智能体的底层工具箱,通过模块化、可插拔的架构,提供大模型调用、提示词工程、工具系统、记忆检索四大核心能力,支持快速组合出符合业务需求的智能体应用。
|
||||
|
||||
**当前状态**:v0.1.0 已发布(2026-07-04)。Phase 0-8 全部完成,v0.2.0-rc.1 已打标签。Provider IR 重构 + LlmCycle 简化 + 10 个离线示例(含 `quick_start` 30 行最小示例与 `end_to_end` 完整集成示例)+ SqliteStore 持久化 + 14 个公开枚举 `#[non_exhaustive]` 护栏 + `StepStatus` IR 迁移已交付。下一步进入 Phase 9(流式体验增强 / `submit_turn_stream`)。
|
||||
**当前状态**:v0.1.0 已发布(2026-07-04)。Phase 0-10 全部完成,v0.2.0-rc.1 已打标签。Provider IR 重构 + LlmCycle 简化 + 11 个离线示例(含 `quick_start` 30 行最小示例、`end_to_end` 完整集成示例、`context_slot_demo` 分支对话示例)+ SqliteStore 持久化 + 14 个公开枚举 `#[non_exhaustive]` 护栏 + `StepStatus` IR 迁移 + `submit_turn_stream` 流式体验 + ContextSlot 多上下文分区管理已交付。下一步进入 Phase 11(测试与检索补强)。
|
||||
|
||||
---
|
||||
|
||||
@@ -460,13 +460,23 @@ pub struct ContextBudget { system, history, tools, tool_results, reserve }
|
||||
|
||||
| Step | 内容 | 文件 | 验证标准 |
|
||||
|------|------|-----|---------|
|
||||
| **9.1** | `AgentSession::submit_turn_stream(user_input) -> impl Stream<Item=StreamEvent>` | `agent/session.rs` | 单元测试验证流事件序列:`TextDelta → ... → MessageComplete` |
|
||||
| **9.1** ✅ | `AgentSession::submit_turn_stream(user_input) -> impl Stream<Item=StreamEvent>` | `agent/session.rs` | 单元测试验证流事件序列:`TextDelta → ... → MessageComplete` |
|
||||
|
||||
**注意**:tool 自动循环时流中插入 `ToolExecutionStarted` 事件,用户端 UI 显示"正在调用工具..."。
|
||||
|
||||
**依赖**:Phase 6(ToolDef)+ `LlmProvider.chat_stream`(v0.1 已有)
|
||||
**优先级**:P1
|
||||
|
||||
**实际新增**(2026-07-06 commit `212cfcc`,详见 `docs/16-phase9-streaming-experience.md`):
|
||||
- 方案文档:`docs/16-phase9-streaming-experience.md`(821 行,含状态机设计推演与边界情况)
|
||||
- 修改文件 3 个:`src/agent/session.rs`(+208,含 `submit_turn_stream` / `finalize_turn`)、`src/llm/cycle.rs`(+784,含 `submit_with_tools_stream` / `run_tool_loop` spawn + mpsc 状态机)、`src/llm/types/response_v2.rs`(+21,含 `StreamEvent::ToolExecutionStarted`/`Completed` 变体 + `apply_to` 元事件)
|
||||
- 关键设计:`CycleConfig` 加 `Clone` derive 以支持 spawn 跨 task;`finalize_turn` 手动同步状态(`submit_turn_stream` 返回流前不落库,避免半成品被 hook 误读)
|
||||
- 测试:新增 9 个单元测试 + 2 个集成测试(含 `submit_turn_stream_end_to_end` 端到端 mock provider 流消费 + `submit_turn_stream_triggers_turn_hooks` Hook 触发验证),全量 200 → 211(+11,0 失败)
|
||||
- clippy 0 警告
|
||||
- 无新增外部依赖
|
||||
|
||||
**状态**:✅ Phase 9 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
#### Phase 10: ContextSlot 上下文管理
|
||||
@@ -475,14 +485,31 @@ pub struct ContextBudget { system, history, tools, tool_results, reserve }
|
||||
|
||||
| Step | 内容 | 验证标准 |
|
||||
|------|------|---------|
|
||||
| **10.1** | `src/llm/context.rs`:`ContextSlot` + `SlotConfig` / `SlotMode` / `SlotSource` / `ContextBudget` 核心类型 | `cargo build` |
|
||||
| **10.2** | ContextSlot 持久化:基于 `MemoryStore` trait(不绑定 SqliteStore)实现 save/load/list + slot 命名空间 key 策略 | 单元测试:slot 创建/写入/读取/隔离(不串数据) |
|
||||
| **10.3** | `AgentSession` 扩展:`create_slot` / `switch_slot` / `list_slots` / `derive_slot` + `AgentBuilder` 默认创建 `"default"` slot | 集成测试 + 新示例 `context_slot_demo` |
|
||||
| **10.1** ✅ | `src/agent/context.rs`:`ContextSlot` + `SlotConfig` / `SlotMode` / `FocusedConfig` / `SlotSource` / `DeriveStrategy` / `ContextBudget` / `SlotMeta` 核心类型 | `cargo build --all-targets` |
|
||||
| **10.2** ✅ | ContextSlot 持久化:基于 `MemoryStore` trait(不绑定 SqliteStore)实现 save/load/list/delete + slot 命名空间 key 策略 + `load_messages()` Focused 读时过滤 + `append_messages()` Readonly 阻断 + colon 注入防护 | 单元测试:持久化 roundtrip / session 隔离 / Focused 边界 / delete 保护 / 派生 / load_messages() |
|
||||
| **10.3** ✅ | `AgentSession` 扩展:`create_slot` / `switch_slot` / `list_slots` / `derive_slot` / `delete_slot` + `new()` 自动创建 `"default"` slot + `submit_turn`/`finalize_turn` 改造为基于当前 slot 的增量追加写回 + 新示例 `context_slot_demo` | 集成测试 + `cargo run --example context_slot_demo` exit 0 |
|
||||
|
||||
**如何保证简单场景无感**:`AgentBuilder::build()` 内部检查,如果用户没手动 `create_slot`,自动创建 `"default"` slot → `submit_turn` 默认写到 default slot。
|
||||
**如何保证简单场景无感**:`AgentSession::new()` 内部检查,自动创建 `"default"` slot → `submit_turn` 默认写到 default slot。
|
||||
|
||||
**依赖**:Phase 7(SqliteStore 作为推荐持久化后端;`MemoryStore` trait 即可)
|
||||
**实际新增**(2026-07-07 commit `6359422`,详见 `docs/17-phase10-contextslot.md`):
|
||||
- 方案文档:`docs/17-phase10-contextslot.md`(1227 行,含 §5 推荐方案、§6 实施建议、§9 实施计划,经过 4 轮方案/计划/实施审查 + 1 轮非阻塞建议修复)
|
||||
- 新增文件 3 个:`src/agent/context.rs`(~430 行 ContextSlot 核心类型 + 持久化方法 + 22 个测试)、`src/agent/context.rs` 中的 `ContextSlot::filter_focused` 静态方法(被 `load_messages` 和 `derive_slot` 复用,消除代码重复)、`examples/context_slot_demo.rs`(~160 行分支对话示例:法律咨询 → 派生两个方向 → 切换 → 隔离验证 → 删除保护)
|
||||
- 修改文件 3 个:`src/agent.rs`(+5 行 module 声明 + re-export)、`src/agent/error.rs`(+56 行:3 个新变体 `SlotReadonly`/`SlotNotFound`/`SlotAlreadyExists` + 4 个测试)、`src/agent/session.rs`(+825/-197 行:slots 字段 + 6 个管理方法 + submit_turn/finalize_turn 改造 + 17 个测试)
|
||||
- 关键设计:
|
||||
- **模块归属**:`agent/context.rs`(零新依赖方向,遵循 `agent → memory` 已有依赖)
|
||||
- **持久化**:JSON blob 批次存储,每 slot 3-4 条 `MemoryItem`(`slot_data` / `slot_meta` / `slot_config` / `slot_rel`)
|
||||
- **submit_turn 签名不变**:方案 A(内部 `current_slot_id` 状态),向后兼容
|
||||
- **Focused 模式读时过滤**:`load_messages() -> Vec<Message>`,避免 Rust 借用检查问题
|
||||
- **增量追加写回**:`cycle.messages()[input_len..]` 提取本轮新增消息,确保 Focused 模式数据不丢失
|
||||
- **delete_slot 双重保护**:禁止删 `"default"` + 至少保留一个 slot
|
||||
- **colon 注入防护**:`assert_no_colon` 在 key 构造时 panic
|
||||
- **错误传播**:`serde_json` / `MemoryStore` 所有错误用 `?` 传播,无静默吞掉
|
||||
- 验证:211 → 254 测试(+43 新测试),clippy 0 警告,doc 0 warning,10 + 1 示例全部 exit 0
|
||||
- finalize_turn 签名变更(破坏性):新增 `new_messages_from_cycle: Vec<Message>` 参数,返回从 `()` 改为 `Result<(), AgentError>`——影响 Phase 9 的 `submit_turn_stream_triggers_turn_hooks` 和 `submit_turn_stream_end_to_end` 2 个测试,已适配
|
||||
|
||||
**依赖**:Phase 5(`#[non_exhaustive]` 预置 SlotMode 等枚举)、Phase 7(SqliteStore 推荐持久化后端;`MemoryStore` trait 即可)
|
||||
**优先级**:P1
|
||||
**状态**:✅ Phase 10 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
@@ -523,8 +550,8 @@ graph BT
|
||||
P6["<b>Phase 6: ToolDef IR</b><br/>Provider 无关工具定义"]:::done
|
||||
P7["<b>Phase 7: SqliteStore</b><br/>rusqlite + WAL<br/>9 个内联测试<br/>持久化 round-trip"]:::done
|
||||
P8["<b>Phase 8: MVP 出口</b><br/>rc.1 标签<br/>14 枚举 #[non_exhaustive]<br/>StepStatus IR 迁移<br/>quick_start + end_to_end"]:::done
|
||||
P9["Phase 9<br/>流式体验增强"]:::p1
|
||||
P10["Phase 10<br/>ContextSlot"]:::p1
|
||||
P9["<b>Phase 9: 流式体验增强</b><br/>submit_turn_stream<br/>submit_with_tools_stream<br/>9 单元测试 + 2 集成测试"]:::done
|
||||
P10["<b>Phase 10: ContextSlot</b><br/>ContextSlot 类型<br/>JSON blob 持久化<br/>AgentSession 集成<br/>43 个新测试"]:::done
|
||||
P11["Phase 11<br/>测试与检索"]:::p1
|
||||
P12["Phase 12<br/>P2 锦上添花"]:::p2
|
||||
|
||||
@@ -557,8 +584,8 @@ graph BT
|
||||
| **M2** | Phase 6 | `ToolDef` 全量切换,`cargo test --all-targets` 全绿 | ✅ 2026-07-05 |
|
||||
| **M3** | Phase 7 | SqliteStore CRUD + 并发测试通过,进程重启数据不丢 | ✅ 2026-07-05 |
|
||||
| **M4** | **Phase 8 (rc.1)** | P0 五项全部交付,`cargo run --example quick_start` 跑通 | ✅ 2026-07-05 |
|
||||
| **M5** | Phase 9 | `submit_turn_stream` 流式事件序列验证通过 | ⏳ |
|
||||
| **M6** | Phase 10 | ContextSlot 创建/切换/派生集成测试通过 | ⏳ |
|
||||
| **M5** | Phase 9 | `submit_turn_stream` 流式事件序列验证通过 | ✅ 2026-07-06 |
|
||||
| **M6** | Phase 10 | ContextSlot 创建/切换/派生集成测试通过 | ✅ 2026-07-07 |
|
||||
| **M7** | Phase 11 | wiremock + 并发测试补强,测试总量 200+ | ⏳ |
|
||||
| **M8** | Phase 12(可选) | P2 功能按需交付 | ⏳ |
|
||||
|
||||
@@ -603,9 +630,9 @@ graph BT
|
||||
|
||||
## 下一步行动
|
||||
|
||||
1. **Phase 9 启动**:流式体验增强(`AgentSession::submit_turn_stream` 流式事件序列),P1 功能
|
||||
1. **Phase 11 启动**:测试与检索补强(`VectorRetriever` trait + wiremock Provider roundtrip + 并发写入验证),P1 功能
|
||||
2. **示例先行**:每完成一个 Phase 立即更新对应示例,验证通过后再合入
|
||||
3. **里程碑追踪**:以 Phase 8(MVP 出口,v0.2.0-rc.1 已打标签)为节点,逐 Phase 验收
|
||||
3. **里程碑追踪**:以 Phase 10(ContextSlot,已完成)为最新节点,逐 Phase 验收
|
||||
4. **v0.2.0 正式版**:Phase 8-11 全部完成后,去掉 rc 后缀打 `v0.2.0` 正式版
|
||||
|
||||
**已完成 / 进行中阶段**:
|
||||
@@ -620,6 +647,8 @@ graph BT
|
||||
- ✅ Phase 6 ToolDefinition IR — `ToolDef` 新类型 + 双向 `From` 转换 + 别名彻底移除 + `#[allow(deprecated)]` 清理(cycle/registry/mcp/agent);Anthropic 零改动;roundtrip 测试覆盖
|
||||
- ✅ Phase 7 SqliteStore — `rusqlite 0.32` + WAL 模式 + `Arc<Mutex<Connection>>` + `spawn_blocking`;`memory/store.rs` → `store/{in_memory,sqlite_store}.rs` 模块化;9 个内联测试覆盖 CRUD/upsert/过滤/10×10 并发/持久化 round-trip;`InMemoryStore ↔ SqliteStore` trait-box 互换兼容
|
||||
- ✅ **Phase 8 MVP 集成出口** — 14 个公开枚举追加 `#[non_exhaustive]`(P0 核心 IR + P0 Error + P1 其他) + `StepStatus::Completed(ChatResponse)` → `Completed(MessageResponse)` 迁移 + CHANGELOG v0.2.0-rc.1 + 2 个新示例(`quick_start` 60 行 + `end_to_end` 246 行),10 个离线示例全部 exit 0;**v0.2.0-rc.1 标签已打**;实施后三方审查发现 6 项问题(1 🔴 + 2 🟡 + 3 💭)已全部修复
|
||||
- ✅ **Phase 9 流式体验增强** — `AgentSession::submit_turn_stream` 流式事件序列 + `LlmCycle::submit_with_tools_stream` spawn + mpsc 状态机 + `StreamEvent::ToolExecutionStarted`/`Completed` 新变体 + 9 单元测试 + 2 集成测试(含 `submit_turn_stream_end_to_end` 端到端 mock 验证 + `submit_turn_stream_triggers_turn_hooks` Hook 触发验证),全量 200 → 211;`CycleConfig` 加 `Clone` derive;方案文档 `docs/16-phase9-streaming-experience.md`(821 行)
|
||||
- ✅ **Phase 10 ContextSlot 上下文管理** — `src/agent/context.rs` 新增 `ContextSlot` 核心类型(Full / Focused / Readonly 三种模式,New / Derived / Static 三种来源)+ JSON blob 批次持久化(每 slot 3-4 条 MemoryItem,`slot_config` key 自恢复支持旧版本兼容);`AgentSession` 扩展 slots 字段 + 5 个管理方法(`create_slot` / `switch_slot` / `list_slots` / `derive_slot` / `delete_slot`,自动创建 `"default"` slot,`delete_slot` 双重保护禁止删 default/最后一个);`submit_turn`/`finalize_turn` 改造为基于当前 slot 的增量追加写回(`cycle.messages()[input_len..]` 提取本轮新增消息,确保 Focused 模式"读时过滤"语义不丢失数据);`finalize_turn` 签名变更(新增 `new_messages_from_cycle: Vec<Message>` 参数,返回 `Result<(), AgentError>`);`agent/error.rs` 新增 3 个 Slot 错误变体(`SlotReadonly` / `SlotNotFound` / `SlotAlreadyExists`);`examples/context_slot_demo.rs` 新增分支对话示例(法律咨询入口 → 两个派生方向 → 切换 → 隔离验证 → 删除保护);方案文档 `docs/17-phase10-contextslot.md`(1227 行,含 §5 推荐方案、§6 实施建议、§9 实施计划,经过 4 轮方案/计划/实施审查 + 1 轮非阻塞建议修复);全量 211 → 254(+43 新测试),clippy 0 警告,doc 0 warning,11 个离线示例全部 exit 0
|
||||
- ✅ Provider IR 重构 — 统一类型系统 + OpenAI/Anthropic/DeepSeek/Qwen/Ollama 适配
|
||||
- ✅ LlmCycle 简化 — IR 消息类型切换 + Phase 0 桥接层移除
|
||||
- ✅ v0.1 Release — 技术债扫清、MockProvider 公开化、8 个离线示例(含 `simple_visit`)、README + 错误消息友好化、CHANGELOG 初始化
|
||||
|
||||
@@ -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<dyn LlmProvider> = 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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Message>,
|
||||
/// 槽元数据。
|
||||
pub meta: SlotMeta,
|
||||
}
|
||||
|
||||
/// 槽配置。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SlotConfig {
|
||||
/// 槽模式(Full / Focused / Readonly)。
|
||||
pub mode: SlotMode,
|
||||
/// 槽来源(New / Derived / Static)。
|
||||
pub source: SlotSource,
|
||||
/// 上下文预算(v0.2 纯数据结构,无消费逻辑)。
|
||||
pub budget: ContextBudget,
|
||||
/// 是否启用自动压缩(v0.2 保留字段,LlmCycle 内部自行判断)。
|
||||
pub compact: bool,
|
||||
}
|
||||
|
||||
impl Default for SlotConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: SlotMode::Full,
|
||||
source: SlotSource::New,
|
||||
budget: ContextBudget::default(),
|
||||
compact: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 槽模式。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub enum SlotMode {
|
||||
/// 完整对话历史(全部消息)。
|
||||
Full,
|
||||
/// 聚焦模式 —— 读取时按策略过滤,保持 LLM 注意力。
|
||||
Focused(FocusedConfig),
|
||||
/// 只读参考上下文 —— 禁止写入。
|
||||
Readonly,
|
||||
}
|
||||
|
||||
/// 聚焦模式配置。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FocusedConfig {
|
||||
/// 是否保留 system prompt。
|
||||
pub keep_system: bool,
|
||||
/// 保留的最近消息条数(以消息条数而非对话轮次为单位,因为一轮对话可能包含多条 tool 消息)。
|
||||
pub recent_messages: usize,
|
||||
/// 摘要覆盖(v0.2 仅消费端:手动设置则注入,不自动生成)。
|
||||
/// v0.3 将支持 Hook 驱动的自动摘要生成。
|
||||
pub summary_override: Option<String>,
|
||||
}
|
||||
|
||||
/// 槽来源。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub enum SlotSource {
|
||||
/// 全新空槽。
|
||||
New,
|
||||
/// 从父 slot 派生(记录 parent_id)。
|
||||
Derived {
|
||||
parent_id: String,
|
||||
strategy: DeriveStrategy,
|
||||
},
|
||||
/// 预置静态消息(不持久化,随 session 生命周期存在)。
|
||||
Static(Vec<Message>),
|
||||
}
|
||||
|
||||
/// 派生策略。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum DeriveStrategy {
|
||||
/// 完整复制父 slot 的消息。
|
||||
Full,
|
||||
/// 按聚焦策略复制父 slot 的消息。
|
||||
Focused(FocusedConfig),
|
||||
}
|
||||
|
||||
/// 上下文预算(v0.2 纯数据结构,无消费逻辑)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ContextBudget {
|
||||
/// system prompt 预算。
|
||||
pub system: u32,
|
||||
/// 对话历史预算。
|
||||
pub history: u32,
|
||||
/// 工具定义预算。
|
||||
pub tools: u32,
|
||||
/// 工具结果预算。
|
||||
pub tool_results: u32,
|
||||
/// 预留 buffer。
|
||||
pub reserve: u32,
|
||||
}
|
||||
|
||||
impl Default for ContextBudget {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
system: 8_000,
|
||||
history: 80_000,
|
||||
tools: 10_000,
|
||||
tool_results: 20_000,
|
||||
reserve: 10_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ContextBudget {
|
||||
/// 自动分配:按上下文窗口的固定比例分配预算。
|
||||
/// v0.2 只做占位实现,v0.3 将根据实际 provider 的 context_window 计算。
|
||||
pub fn auto() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 槽元数据。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SlotMeta {
|
||||
/// 父 slot id(仅 Derived 来源有值)。
|
||||
pub parent_id: Option<String>,
|
||||
/// 消息总数。
|
||||
pub message_count: usize,
|
||||
/// 总 token 估算值(由 add_messages 时累计,v0.2 为近似值)。
|
||||
pub total_tokens: u32,
|
||||
/// 创建时间(Unix 时间戳,秒)。
|
||||
pub created_at: u64,
|
||||
}
|
||||
|
||||
impl SlotMeta {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
parent_id: None,
|
||||
message_count: 0,
|
||||
total_tokens: 0,
|
||||
created_at: std::time::SystemTime::now()
|
||||
.duration_since(std::time::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<String>,
|
||||
slot_id: impl Into<String>,
|
||||
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<Option<Self>, 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<Message> = serde_json::from_str(&d.content)
|
||||
.map_err(|e| AgentError::Other(e.to_string()))?;
|
||||
// config 从存储恢复;不存在则使用 default(兼容旧版本)
|
||||
let config = match config_item {
|
||||
Some(c) => serde_json::from_str(&c.content)
|
||||
.map_err(|e| AgentError::Other(e.to_string()))?,
|
||||
None => SlotConfig::default(),
|
||||
};
|
||||
Ok(Some(Self {
|
||||
id: id.to_string(),
|
||||
session_id: session_id.to_string(),
|
||||
config,
|
||||
messages,
|
||||
meta,
|
||||
}))
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// 列出某 session 下的所有 slot 元数据。
|
||||
pub async fn list(
|
||||
session_id: &str,
|
||||
store: &dyn MemoryStore,
|
||||
) -> Result<Vec<SlotMeta>, AgentError> {
|
||||
let prefix_str = format!("{}:{}:", Self::KEY_META, session_id);
|
||||
let filter = MemoryFilter {
|
||||
prefix: Some(prefix_str),
|
||||
..Default::default()
|
||||
};
|
||||
let items = store.list(&filter).await.map_err(AgentError::Memory)?;
|
||||
let mut metas = Vec::new();
|
||||
for item in items {
|
||||
if let Ok(meta) = serde_json::from_str::<SlotMeta>(&item.content) {
|
||||
metas.push(meta);
|
||||
}
|
||||
}
|
||||
Ok(metas)
|
||||
}
|
||||
|
||||
/// 删除 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<Message>) -> Result<(), AgentError> {
|
||||
if matches!(self.config.mode, SlotMode::Readonly) {
|
||||
return Err(AgentError::SlotReadonly(
|
||||
"Readonly slot does not allow writes".into(),
|
||||
));
|
||||
}
|
||||
let count = new_messages.len();
|
||||
self.messages.extend(new_messages);
|
||||
self.meta.message_count += count;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 按 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<Message> {
|
||||
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<Message> {
|
||||
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<dyn MemoryStore> {
|
||||
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: std::future::Future>(f: F) -> F::Output {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap()
|
||||
.block_on(f)
|
||||
}
|
||||
}
|
||||
+53
-3
@@ -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' 已存在"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+644
-207
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user