清理过期文档
This commit is contained in:
parent
0c3e740d15
commit
0d66536e90
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,4 +1,5 @@
|
|||||||
/target
|
/target
|
||||||
|
docker_build/
|
||||||
reference/**
|
reference/**
|
||||||
.env
|
.env
|
||||||
*.env
|
*.env
|
||||||
|
|||||||
@ -1,128 +0,0 @@
|
|||||||
# 架构审查报告
|
|
||||||
|
|
||||||
> 生成时间: 2026-04-26
|
|
||||||
> 更新时间: 2026-04-26
|
|
||||||
|
|
||||||
## 审查摘要
|
|
||||||
|
|
||||||
本报告识别了当前代码库中的架构不合理、冗余和无效代码的问题。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 问题清单
|
|
||||||
|
|
||||||
### 已修复
|
|
||||||
|
|
||||||
#### ✅ #1 OutboundDispatcher 重复维护 Channel 注册表
|
|
||||||
|
|
||||||
**修复方案**: `OutboundDispatcher` 现在从 `ChannelManager` 获取 channels,而不是自己维护一份注册表。
|
|
||||||
|
|
||||||
**修改文件**:
|
|
||||||
- `src/bus/dispatcher.rs` - 移除 `channels` 字段,改用 `ChannelManager`
|
|
||||||
- `src/channels/manager.rs` - 添加 `register_channel` 方法
|
|
||||||
- `src/gateway/mod.rs` - 简化 dispatcher 初始化
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### ✅ #2 CliChatChannel 持有独立的 SessionStore
|
|
||||||
|
|
||||||
**修复方案**: `CliChatChannel` 的 `SessionStore` 通过依赖注入从 `ChannelManager` 获取,而不是独立持有。
|
|
||||||
|
|
||||||
**修改文件**:
|
|
||||||
- `src/channels/cli_chat.rs` - 添加 `set_store()` 方法
|
|
||||||
- `src/channels/manager.rs` - 添加 `cli_chat_channel` 字段
|
|
||||||
- `src/gateway/mod.rs` - 重构 channel 初始化流程
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### ✅ #3 MessageBus 被创建两次引用
|
|
||||||
|
|
||||||
**修复方案**: 移除 `GatewayState.bus` 字段,直接使用 `channel_manager.bus()`。
|
|
||||||
|
|
||||||
**修改文件**:
|
|
||||||
- `src/gateway/mod.rs` - 移除冗余的 `bus` 字段
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### ✅ #4 GatewayState 同时持有 channel_manager 和 cli_chat_channel
|
|
||||||
|
|
||||||
**修复方案**: `cli_chat_channel` 只通过 `ChannelManager` 管理,`GatewayState` 不再单独持有。
|
|
||||||
|
|
||||||
**修改文件**:
|
|
||||||
- `src/gateway/mod.rs` - 移除 `cli_chat_channel` 字段,添加 `cli_chat_channel()` getter 方法
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 高优先级(待修复)
|
|
||||||
|
|
||||||
#### ❌ Session 每次重建都创建新的 LLM Provider
|
|
||||||
|
|
||||||
**文件**: `src/gateway/session.rs:349-361`
|
|
||||||
|
|
||||||
**问题**: 每当 session TTL 过期(默认4小时),就会销毁并重建 session,同时创建新的 LLM provider 连接。
|
|
||||||
|
|
||||||
**建议**: Provider 应该池化复用,不随 session 销毁而重建。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### ❌ CliChatChannel::send 广播给所有客户端
|
|
||||||
|
|
||||||
**文件**: `src/channels/cli_chat.rs:279-289`
|
|
||||||
|
|
||||||
**问题**: `OutboundMessage` 有 `chat_id` 字段用于路由,但实现广播给所有客户端,而不是只发给对应 chat_id 的客户端。
|
|
||||||
|
|
||||||
**建议**: 根据 `chat_id` 过滤客户端,只发送给对应的客户端。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 中优先级(待修复)
|
|
||||||
|
|
||||||
#### ❌ default_tools() 每次调用创建新 ToolRegistry
|
|
||||||
|
|
||||||
**文件**: `src/gateway/session.rs:212-227`
|
|
||||||
|
|
||||||
**建议**: 如果工具列表是只读的,直接 clone Arc;如果需要修改,需要澄清设计意图。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 低优先级(待修复)
|
|
||||||
|
|
||||||
#### ❌ FeishuChannel::new 接收未使用的 provider_config
|
|
||||||
|
|
||||||
**文件**: `src/channels/feishu.rs:175-178`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### ❌ OutboundDispatcher::send_with_retry 永不执行的 unreachable
|
|
||||||
|
|
||||||
**文件**: `src/bus/dispatcher.rs:81`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### ❌ Channel trait 的 `is_running` 使用 std::sync::Mutex
|
|
||||||
|
|
||||||
**文件**: `src/channels/base.rs:38` vs `src/channels/cli_chat.rs:265-267`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### ❌ LoopDetector 硬编码在 AgentLoop 中
|
|
||||||
|
|
||||||
**文件**: `src/agent/agent_loop.rs:88-172`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### ❌ InboundMessage 和 OutboundMessage 结构重复
|
|
||||||
|
|
||||||
**文件**: `src/bus/message.rs`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 问题统计
|
|
||||||
|
|
||||||
| 状态 | 优先级 | 数量 |
|
|
||||||
|------|--------|------|
|
|
||||||
| ✅ 已修复 | - | 4 |
|
|
||||||
| ❌ 待修复 | 高 | 2 |
|
|
||||||
| ❌ 待修复 | 中 | 1 |
|
|
||||||
| ❌ 待修复 | 低 | 5 |
|
|
||||||
| **总计** | - | **12** |
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
# 客户端代码整合设计
|
|
||||||
|
|
||||||
## 目标
|
|
||||||
|
|
||||||
将分散在 `src/cli/` 和 `src/client/` 的客户端代码整合到 `src/client/` 目录。
|
|
||||||
|
|
||||||
## 变更
|
|
||||||
|
|
||||||
### 目录结构
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── client/ # 整合后的客户端模块
|
|
||||||
│ ├── mod.rs # 主程序入口 (run 函数)
|
|
||||||
│ ├── input.rs # InputHandler + InputCommand (从 cli/input.rs 合并)
|
|
||||||
│ └── channel.rs # CliChannel (从 cli/channel.rs 合并)
|
|
||||||
├── cli/ # 删除
|
|
||||||
└── protocol.rs # 保留
|
|
||||||
```
|
|
||||||
|
|
||||||
### 关键变更
|
|
||||||
|
|
||||||
| 变更 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `InputEvent::Message(String)` | 简化为只携带文本内容,不再使用 `ChatMessage` |
|
|
||||||
| `cli` 模块删除 | 代码合并到 `client` |
|
|
||||||
| 解耦 | `client` 不再依赖 `bus::ChatMessage` |
|
|
||||||
|
|
||||||
## 实施步骤
|
|
||||||
|
|
||||||
1. 创建 `src/client/input.rs` - 从 `cli/input.rs` 合并,修改 `InputEvent::Message` 为 `String`
|
|
||||||
2. 创建 `src/client/channel.rs` - 从 `cli/channel.rs` 直接复制
|
|
||||||
3. 更新 `src/client/mod.rs` - 更新 import
|
|
||||||
4. 更新 `src/lib.rs` - 删除 `pub mod cli;`
|
|
||||||
5. 删除 `src/cli/` 目录
|
|
||||||
|
|
||||||
## 验证
|
|
||||||
|
|
||||||
- `cargo build` 通过
|
|
||||||
- 功能保持不变
|
|
||||||
@ -1,877 +0,0 @@
|
|||||||
# Phase 1: Storage 基础 实现计划
|
|
||||||
|
|
||||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
||||||
|
|
||||||
**Goal:** 创建 `src/storage/` 模块,实现 SQLite 持久化层,为后续 Session 扩展提供 Storage 基础设施。
|
|
||||||
|
|
||||||
**Architecture:** 使用 `sqlx` + `sqlite`,通过 `SqlitePool` 实现异步连接池,所有 Storage 操作均为 async,在 `Storage` 内部管理连接池的生命周期。
|
|
||||||
|
|
||||||
**Tech Stack:** `sqlx` (sqlite, tokio), `serde`, `chrono` (时间戳), `tokio::time::sleep` (重试退避)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 1: 添加依赖
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `Cargo.toml:36` (在 `[dependencies]` 末尾添加)
|
|
||||||
|
|
||||||
**Step 1: 添加 sqlx + sqlite 依赖**
|
|
||||||
|
|
||||||
在 `Cargo.toml` 末尾添加:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
sqlx = { version = "0.8", features = ["sqlite", "tokio", "macros", "chrono"] }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: 运行 cargo check 验证依赖**
|
|
||||||
|
|
||||||
Run: `cargo check 2>&1`
|
|
||||||
Expected: 无报错,依赖解析成功
|
|
||||||
|
|
||||||
**Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add Cargo.toml
|
|
||||||
git commit -m "deps: 添加 sqlx + sqlite 依赖"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 2: 创建 Storage Error 类型
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/storage/error.rs`
|
|
||||||
|
|
||||||
**Step 1: 编写 StorageError 枚举和测试**
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
|
||||||
pub enum StorageError {
|
|
||||||
#[error("session not found: {0}")]
|
|
||||||
NotFound(String),
|
|
||||||
|
|
||||||
#[error("session already exists: {0}")]
|
|
||||||
AlreadyExists(String),
|
|
||||||
|
|
||||||
#[error("database error: {0}")]
|
|
||||||
Database(#[from] sqlx::Error),
|
|
||||||
|
|
||||||
#[error("serialization error: {0}")]
|
|
||||||
Serialization(String),
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: 验证编译**
|
|
||||||
|
|
||||||
Run: `cargo build --lib 2>&1 | head -30`
|
|
||||||
Expected: 报错 "cannot find module `storage`"(因为模块未创建),这是预期的
|
|
||||||
|
|
||||||
**Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/storage/error.rs
|
|
||||||
git commit -m "feat(storage): 添加 StorageError 类型"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 3: 创建 Storage 模块骨架
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/storage/mod.rs`
|
|
||||||
- Create: `src/storage/session.rs`
|
|
||||||
- Create: `src/storage/message.rs`
|
|
||||||
|
|
||||||
**Step 1: 创建 `src/storage/mod.rs`**
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub mod error;
|
|
||||||
pub mod session;
|
|
||||||
pub mod message;
|
|
||||||
|
|
||||||
pub use error::StorageError;
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: 创建 `src/storage/session.rs`(空壳)**
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// Session CRUD 操作占位符
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 3: 创建 `src/storage/message.rs`(空壳)**
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// Message CRUD 操作占位符
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 4: 在 `src/lib.rs` 中添加 storage 模块**
|
|
||||||
|
|
||||||
在 `src/lib.rs` 末尾添加:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub mod storage;
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 5: 验证编译**
|
|
||||||
|
|
||||||
Run: `cargo build --lib 2>&1`
|
|
||||||
Expected: 编译成功(空壳模块)
|
|
||||||
|
|
||||||
**Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/storage/ src/lib.rs
|
|
||||||
git commit -m "feat(storage): 创建 storage 模块骨架"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 4: 实现 Storage 主结构
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/storage/mod.rs`
|
|
||||||
|
|
||||||
**Step 1: 编写 Storage 结构和初始化逻辑**
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use sqlx::{Pool, Sqlite, SqlitePool};
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
pub struct Storage {
|
|
||||||
pool: Pool<Sqlite>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Storage {
|
|
||||||
/// 打开或创建数据库
|
|
||||||
pub async fn new(db_path: &Path) -> Result<Self, StorageError> {
|
|
||||||
let database_url = format!("sqlite:{}?mode=rwc", db_path.display());
|
|
||||||
let pool = SqlitePool::connect(&database_url).await?;
|
|
||||||
|
|
||||||
let storage = Self { pool };
|
|
||||||
storage.init_schema().await?;
|
|
||||||
Ok(storage)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 初始化数据库 schema
|
|
||||||
async fn init_schema(&self) -> Result<(), StorageError> {
|
|
||||||
sqlx::query(
|
|
||||||
r#"
|
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
channel TEXT NOT NULL,
|
|
||||||
chat_id TEXT NOT NULL,
|
|
||||||
dialog_id TEXT NOT NULL,
|
|
||||||
title TEXT NOT NULL DEFAULT '新对话',
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
last_active_at INTEGER NOT NULL,
|
|
||||||
message_count INTEGER DEFAULT 0,
|
|
||||||
routing_info TEXT,
|
|
||||||
deleted_at INTEGER,
|
|
||||||
UNIQUE(channel, chat_id, dialog_id)
|
|
||||||
)
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
r#"
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_sessions_chat
|
|
||||||
ON sessions(channel, chat_id, deleted_at)
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
r#"
|
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
session_id TEXT NOT NULL,
|
|
||||||
seq INTEGER NOT NULL,
|
|
||||||
role TEXT NOT NULL,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
media_refs TEXT,
|
|
||||||
tool_call_id TEXT,
|
|
||||||
tool_name TEXT,
|
|
||||||
tool_calls TEXT,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
r#"
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_session_seq
|
|
||||||
ON messages(session_id, seq)
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取连接池引用(供内部 CRUD 使用)
|
|
||||||
pub(crate) fn pool(&self) -> &Pool<Sqlite> {
|
|
||||||
&self.pool
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: 验证编译**
|
|
||||||
|
|
||||||
Run: `cargo build --lib 2>&1`
|
|
||||||
Expected: 编译成功
|
|
||||||
|
|
||||||
**Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/storage/mod.rs
|
|
||||||
git commit -m "feat(storage): 实现 Storage 主结构和初始化"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 5: 定义 SessionMeta 和 MessageMeta 数据结构
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/storage/session.rs`
|
|
||||||
- Modify: `src/storage/message.rs`
|
|
||||||
|
|
||||||
**Step 1: 在 `session.rs` 中定义 SessionMeta**
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct SessionMeta {
|
|
||||||
pub id: String,
|
|
||||||
pub channel: String,
|
|
||||||
pub chat_id: String,
|
|
||||||
pub dialog_id: String,
|
|
||||||
pub title: String,
|
|
||||||
pub created_at: i64,
|
|
||||||
pub last_active_at: i64,
|
|
||||||
pub message_count: i64,
|
|
||||||
pub routing_info: Option<String>,
|
|
||||||
pub deleted_at: Option<i64>,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: 在 `message.rs` 中定义 MessageMeta**
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct MessageMeta {
|
|
||||||
pub id: String,
|
|
||||||
pub session_id: String,
|
|
||||||
pub seq: i64,
|
|
||||||
pub role: String,
|
|
||||||
pub content: String,
|
|
||||||
pub media_refs: Option<String>,
|
|
||||||
pub tool_call_id: Option<String>,
|
|
||||||
pub tool_name: Option<String>,
|
|
||||||
pub tool_calls: Option<String>,
|
|
||||||
pub created_at: i64,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 3: 验证编译**
|
|
||||||
|
|
||||||
Run: `cargo build --lib 2>&1`
|
|
||||||
Expected: 编译成功
|
|
||||||
|
|
||||||
**Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/storage/session.rs src/storage/message.rs
|
|
||||||
git commit -m "feat(storage): 定义 SessionMeta 和 MessageMeta 数据结构"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 6: 实现 Session CRUD 操作
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/storage/session.rs`
|
|
||||||
|
|
||||||
**Step 1: 编写 upsert_session**
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use sqlx::Row;
|
|
||||||
use super::SessionMeta;
|
|
||||||
|
|
||||||
impl Storage {
|
|
||||||
pub async fn upsert_session(&self, meta: &SessionMeta) -> Result<(), StorageError> {
|
|
||||||
sqlx::query(
|
|
||||||
r#"
|
|
||||||
INSERT INTO sessions (id, channel, chat_id, dialog_id, title, created_at, last_active_at, message_count, routing_info, deleted_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
|
||||||
title = excluded.title,
|
|
||||||
last_active_at = excluded.last_active_at,
|
|
||||||
message_count = excluded.message_count,
|
|
||||||
routing_info = excluded.routing_info,
|
|
||||||
deleted_at = excluded.deleted_at
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(&meta.id)
|
|
||||||
.bind(&meta.channel)
|
|
||||||
.bind(&meta.chat_id)
|
|
||||||
.bind(&meta.dialog_id)
|
|
||||||
.bind(&meta.title)
|
|
||||||
.bind(meta.created_at)
|
|
||||||
.bind(meta.last_active_at)
|
|
||||||
.bind(meta.message_count)
|
|
||||||
.bind(&meta.routing_info)
|
|
||||||
.bind(meta.deleted_at)
|
|
||||||
.execute(self.pool())
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_session(&self, id: &str) -> Result<SessionMeta, StorageError> {
|
|
||||||
let row = sqlx::query(
|
|
||||||
r#"
|
|
||||||
SELECT id, channel, chat_id, dialog_id, title, created_at, last_active_at, message_count, routing_info, deleted_at
|
|
||||||
FROM sessions WHERE id = ? AND deleted_at IS NULL
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.fetch_optional(self.pool())
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| StorageError::NotFound(id.to_string()))?;
|
|
||||||
|
|
||||||
Ok(SessionMeta {
|
|
||||||
id: row.get("id"),
|
|
||||||
channel: row.get("channel"),
|
|
||||||
chat_id: row.get("chat_id"),
|
|
||||||
dialog_id: row.get("dialog_id"),
|
|
||||||
title: row.get("title"),
|
|
||||||
created_at: row.get("created_at"),
|
|
||||||
last_active_at: row.get("last_active_at"),
|
|
||||||
message_count: row.get("message_count"),
|
|
||||||
routing_info: row.get("routing_info"),
|
|
||||||
deleted_at: row.get("deleted_at"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_sessions(
|
|
||||||
&self,
|
|
||||||
channel: &str,
|
|
||||||
chat_id: &str,
|
|
||||||
limit: i64,
|
|
||||||
) -> Result<Vec<SessionMeta>, StorageError> {
|
|
||||||
let rows = sqlx::query(
|
|
||||||
r#"
|
|
||||||
SELECT id, channel, chat_id, dialog_id, title, created_at, last_active_at, message_count, routing_info, deleted_at
|
|
||||||
FROM sessions
|
|
||||||
WHERE channel = ? AND chat_id = ? AND deleted_at IS NULL
|
|
||||||
ORDER BY last_active_at DESC
|
|
||||||
LIMIT ?
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(channel)
|
|
||||||
.bind(chat_id)
|
|
||||||
.bind(limit)
|
|
||||||
.fetch_all(self.pool())
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(rows
|
|
||||||
.into_iter()
|
|
||||||
.map(|row| SessionMeta {
|
|
||||||
id: row.get("id"),
|
|
||||||
channel: row.get("channel"),
|
|
||||||
chat_id: row.get("chat_id"),
|
|
||||||
dialog_id: row.get("dialog_id"),
|
|
||||||
title: row.get("title"),
|
|
||||||
created_at: row.get("created_at"),
|
|
||||||
last_active_at: row.get("last_active_at"),
|
|
||||||
message_count: row.get("message_count"),
|
|
||||||
routing_info: row.get("routing_info"),
|
|
||||||
deleted_at: row.get("deleted_at"),
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn touch_session(
|
|
||||||
&self,
|
|
||||||
id: &str,
|
|
||||||
message_count: i64,
|
|
||||||
last_active_at: i64,
|
|
||||||
) -> Result<(), StorageError> {
|
|
||||||
sqlx::query(
|
|
||||||
r#"
|
|
||||||
UPDATE sessions SET message_count = ?, last_active_at = ?
|
|
||||||
WHERE id = ?
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(message_count)
|
|
||||||
.bind(last_active_at)
|
|
||||||
.bind(id)
|
|
||||||
.execute(self.pool())
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn soft_delete_session(&self, id: &str) -> Result<(), StorageError> {
|
|
||||||
let now = chrono::Utc::now().timestamp_millis();
|
|
||||||
sqlx::query(
|
|
||||||
r#"UPDATE sessions SET deleted_at = ? WHERE id = ?"#,
|
|
||||||
)
|
|
||||||
.bind(now)
|
|
||||||
.bind(id)
|
|
||||||
.execute(self.pool())
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 查找 channel:chat_id 下最近活跃且未过期的 session
|
|
||||||
pub async fn find_active_session(
|
|
||||||
&self,
|
|
||||||
channel: &str,
|
|
||||||
chat_id: &str,
|
|
||||||
ttl_millis: i64,
|
|
||||||
) -> Result<Option<SessionMeta>, StorageError> {
|
|
||||||
let cutoff = chrono::Utc::now().timestamp_millis() - ttl_millis;
|
|
||||||
let row = sqlx::query(
|
|
||||||
r#"
|
|
||||||
SELECT id, channel, chat_id, dialog_id, title, created_at, last_active_at, message_count, routing_info, deleted_at
|
|
||||||
FROM sessions
|
|
||||||
WHERE channel = ? AND chat_id = ? AND deleted_at IS NULL AND last_active_at > ?
|
|
||||||
ORDER BY last_active_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(channel)
|
|
||||||
.bind(chat_id)
|
|
||||||
.bind(cutoff)
|
|
||||||
.fetch_optional(self.pool())
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
match row {
|
|
||||||
Some(row) => Ok(Some(SessionMeta {
|
|
||||||
id: row.get("id"),
|
|
||||||
channel: row.get("channel"),
|
|
||||||
chat_id: row.get("chat_id"),
|
|
||||||
dialog_id: row.get("dialog_id"),
|
|
||||||
title: row.get("title"),
|
|
||||||
created_at: row.get("created_at"),
|
|
||||||
last_active_at: row.get("last_active_at"),
|
|
||||||
message_count: row.get("message_count"),
|
|
||||||
routing_info: row.get("routing_info"),
|
|
||||||
deleted_at: row.get("deleted_at"),
|
|
||||||
})),
|
|
||||||
None => Ok(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> 注意:`Storage` 的 CRUD 方法需要能访问 `pool()`,但目前 `pool()` 是 `pub(crate)`。在 `mod.rs` 中为 `session.rs` 实现 `Storage` 的 CRUD,所以同模块内可访问。
|
|
||||||
|
|
||||||
**Step 2: 验证编译**
|
|
||||||
|
|
||||||
Run: `cargo build --lib 2>&1`
|
|
||||||
Expected: 编译成功
|
|
||||||
|
|
||||||
**Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/storage/session.rs
|
|
||||||
git commit -m "feat(storage): 实现 Session CRUD 操作"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 7: 实现 Message CRUD 操作
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/storage/message.rs`
|
|
||||||
|
|
||||||
**Step 1: 编写 Message CRUD**
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use sqlx::Row;
|
|
||||||
use super::MessageMeta;
|
|
||||||
|
|
||||||
impl Storage {
|
|
||||||
pub async fn append_message(&self, session_id: &str, msg: &MessageMeta) -> Result<i64, StorageError> {
|
|
||||||
sqlx::query(
|
|
||||||
r#"
|
|
||||||
INSERT INTO messages (id, session_id, seq, role, content, media_refs, tool_call_id, tool_name, tool_calls, created_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(&msg.id)
|
|
||||||
.bind(session_id)
|
|
||||||
.bind(msg.seq)
|
|
||||||
.bind(&msg.role)
|
|
||||||
.bind(&msg.content)
|
|
||||||
.bind(&msg.media_refs)
|
|
||||||
.bind(&msg.tool_call_id)
|
|
||||||
.bind(&msg.tool_name)
|
|
||||||
.bind(&msg.tool_calls)
|
|
||||||
.bind(msg.created_at)
|
|
||||||
.execute(self.pool())
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(msg.seq)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn append_messages(
|
|
||||||
&self,
|
|
||||||
session_id: &str,
|
|
||||||
msgs: &[MessageMeta],
|
|
||||||
) -> Result<Vec<i64>, StorageError> {
|
|
||||||
let mut seqs = Vec::with_capacity(msgs.len());
|
|
||||||
for msg in msgs {
|
|
||||||
let seq = self.append_message(session_id, msg).await?;
|
|
||||||
seqs.push(seq);
|
|
||||||
}
|
|
||||||
Ok(seqs)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn load_messages(
|
|
||||||
&self,
|
|
||||||
session_id: &str,
|
|
||||||
from_seq: i64,
|
|
||||||
) -> Result<Vec<MessageMeta>, StorageError> {
|
|
||||||
let rows = sqlx::query(
|
|
||||||
r#"
|
|
||||||
SELECT id, session_id, seq, role, content, media_refs, tool_call_id, tool_name, tool_calls, created_at
|
|
||||||
FROM messages
|
|
||||||
WHERE session_id = ? AND seq >= ?
|
|
||||||
ORDER BY seq ASC
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(session_id)
|
|
||||||
.bind(from_seq)
|
|
||||||
.fetch_all(self.pool())
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(rows
|
|
||||||
.into_iter()
|
|
||||||
.map(|row| MessageMeta {
|
|
||||||
id: row.get("id"),
|
|
||||||
session_id: row.get("session_id"),
|
|
||||||
seq: row.get("seq"),
|
|
||||||
role: row.get("role"),
|
|
||||||
content: row.get("content"),
|
|
||||||
media_refs: row.get("media_refs"),
|
|
||||||
tool_call_id: row.get("tool_call_id"),
|
|
||||||
tool_name: row.get("tool_name"),
|
|
||||||
tool_calls: row.get("tool_calls"),
|
|
||||||
created_at: row.get("created_at"),
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn clear_messages(&self, session_id: &str) -> Result<(), StorageError> {
|
|
||||||
sqlx::query(r#"DELETE FROM messages WHERE session_id = ?"#)
|
|
||||||
.bind(session_id)
|
|
||||||
.execute(self.pool())
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> 注意:同样在 `mod.rs` 中实现,这样 `Storage` 的方法对 `message.rs` 中的 impl 可见。
|
|
||||||
|
|
||||||
**Step 2: 验证编译**
|
|
||||||
|
|
||||||
Run: `cargo build --lib 2>&1`
|
|
||||||
Expected: 编译成功
|
|
||||||
|
|
||||||
**Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/storage/message.rs
|
|
||||||
git commit -m "feat(storage): 实现 Message CRUD 操作"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 8: 实现写入重试逻辑
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/storage/mod.rs`
|
|
||||||
|
|
||||||
**Step 1: 在 Storage 中添加带重试的 append_message**
|
|
||||||
|
|
||||||
在 `mod.rs` 的 `Storage` impl 块中添加:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use tokio::time::{sleep, Duration};
|
|
||||||
|
|
||||||
impl Storage {
|
|
||||||
/// 追加消息,带重试逻辑
|
|
||||||
/// 重试 3 次(100/200/300ms 退避),仍失败返回错误
|
|
||||||
pub async fn append_message_with_retry(
|
|
||||||
&self,
|
|
||||||
session_id: &str,
|
|
||||||
msg: &MessageMeta,
|
|
||||||
) -> Result<i64, StorageError> {
|
|
||||||
let delays = [100, 200, 300];
|
|
||||||
|
|
||||||
for (i, delay) in delays.iter().enumerate() {
|
|
||||||
match self.append_message(session_id, msg).await {
|
|
||||||
Ok(seq) => return Ok(seq),
|
|
||||||
Err(e) if i < delays.len() - 1 => {
|
|
||||||
sleep(Duration::from_millis(*delay)).await;
|
|
||||||
tracing::warn!("Storage write failed, retrying: {}", e);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Storage write failed after retries: {}", e);
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unreachable!()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> 注意:需要 `use sqlx::Row;` 在 `mod.rs` 中。
|
|
||||||
|
|
||||||
**Step 2: 验证编译**
|
|
||||||
|
|
||||||
Run: `cargo build --lib 2>&1`
|
|
||||||
Expected: 编译成功
|
|
||||||
|
|
||||||
**Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/storage/mod.rs
|
|
||||||
git commit -m "feat(storage): 添加写入重试逻辑"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 9: 编写 Storage 单元测试
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/storage/mod.rs`(添加测试模块)
|
|
||||||
|
|
||||||
**Step 1: 编写 Storage 集成测试**
|
|
||||||
|
|
||||||
在 `src/storage/mod.rs` 末尾添加:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use tempfile::tempdir;
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
async fn create_test_storage() -> (Storage, impl Fn()) {
|
|
||||||
let dir = tempdir().unwrap();
|
|
||||||
let db_path = dir.path().join("test.db");
|
|
||||||
let storage = Storage::new(&db_path).await.unwrap();
|
|
||||||
|
|
||||||
let cleanup = || {
|
|
||||||
drop(dir);
|
|
||||||
};
|
|
||||||
|
|
||||||
(storage, cleanup)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_upsert_and_get_session() {
|
|
||||||
let (storage, cleanup) = create_test_storage().await;
|
|
||||||
defer { cleanup(); }
|
|
||||||
|
|
||||||
let meta = SessionMeta {
|
|
||||||
id: "cli_chat:sid123:dialog1".to_string(),
|
|
||||||
channel: "cli_chat".to_string(),
|
|
||||||
chat_id: "sid123".to_string(),
|
|
||||||
dialog_id: "dialog1".to_string(),
|
|
||||||
title: "测试会话".to_string(),
|
|
||||||
created_at: 1000,
|
|
||||||
last_active_at: 1000,
|
|
||||||
message_count: 0,
|
|
||||||
routing_info: Some(r#"{"type":"cli"}"#.to_string()),
|
|
||||||
deleted_at: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
storage.upsert_session(&meta).await.unwrap();
|
|
||||||
|
|
||||||
let loaded = storage.get_session(&meta.id).await.unwrap();
|
|
||||||
assert_eq!(loaded.title, "测试会话");
|
|
||||||
assert_eq!(loaded.channel, "cli_chat");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_get_nonexistent_session() {
|
|
||||||
let (storage, cleanup) = create_test_storage().await;
|
|
||||||
defer { cleanup(); }
|
|
||||||
|
|
||||||
let result = storage.get_session("nonexistent").await;
|
|
||||||
assert!(result.is_err());
|
|
||||||
matches!(result.unwrap_err(), StorageError::NotFound(_));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_list_sessions() {
|
|
||||||
let (storage, cleanup) = create_test_storage().await;
|
|
||||||
defer { cleanup(); }
|
|
||||||
|
|
||||||
for i in 0..5 {
|
|
||||||
let meta = SessionMeta {
|
|
||||||
id: format!("cli_chat:sid123:dialog{}", i),
|
|
||||||
channel: "cli_chat".to_string(),
|
|
||||||
chat_id: "sid123".to_string(),
|
|
||||||
dialog_id: format!("dialog{}", i),
|
|
||||||
title: format!("会话{}", i),
|
|
||||||
created_at: i as i64 * 1000,
|
|
||||||
last_active_at: i as i64 * 1000,
|
|
||||||
message_count: i,
|
|
||||||
routing_info: None,
|
|
||||||
deleted_at: None,
|
|
||||||
};
|
|
||||||
storage.upsert_session(&meta).await.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
let sessions = storage.list_sessions("cli_chat", "sid123", 10).await.unwrap();
|
|
||||||
assert_eq!(sessions.len(), 5);
|
|
||||||
// 按 last_active_at DESC 排序
|
|
||||||
assert_eq!(sessions[0].dialog_id, "dialog4");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_soft_delete() {
|
|
||||||
let (storage, cleanup) = create_test_storage().await;
|
|
||||||
defer { cleanup(); }
|
|
||||||
|
|
||||||
let meta = SessionMeta {
|
|
||||||
id: "cli_chat:sid123:dialog1".to_string(),
|
|
||||||
channel: "cli_chat".to_string(),
|
|
||||||
chat_id: "sid123".to_string(),
|
|
||||||
dialog_id: "dialog1".to_string(),
|
|
||||||
title: "测试".to_string(),
|
|
||||||
created_at: 1000,
|
|
||||||
last_active_at: 1000,
|
|
||||||
message_count: 0,
|
|
||||||
routing_info: None,
|
|
||||||
deleted_at: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
storage.upsert_session(&meta).await.unwrap();
|
|
||||||
storage.soft_delete_session(&meta.id).await.unwrap();
|
|
||||||
|
|
||||||
let result = storage.get_session(&meta.id).await;
|
|
||||||
assert!(result.is_err());
|
|
||||||
matches!(result.unwrap_err(), StorageError::NotFound(_));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_append_and_load_messages() {
|
|
||||||
let (storage, cleanup) = create_test_storage().await;
|
|
||||||
defer { cleanup(); }
|
|
||||||
|
|
||||||
let session_meta = SessionMeta {
|
|
||||||
id: "cli_chat:sid123:dialog1".to_string(),
|
|
||||||
channel: "cli_chat".to_string(),
|
|
||||||
chat_id: "sid123".to_string(),
|
|
||||||
dialog_id: "dialog1".to_string(),
|
|
||||||
title: "测试".to_string(),
|
|
||||||
created_at: 1000,
|
|
||||||
last_active_at: 1000,
|
|
||||||
message_count: 0,
|
|
||||||
routing_info: None,
|
|
||||||
deleted_at: None,
|
|
||||||
};
|
|
||||||
storage.upsert_session(&session_meta).await.unwrap();
|
|
||||||
|
|
||||||
let msg = MessageMeta {
|
|
||||||
id: "msg1".to_string(),
|
|
||||||
session_id: session_meta.id.clone(),
|
|
||||||
seq: 1,
|
|
||||||
role: "user".to_string(),
|
|
||||||
content: "你好".to_string(),
|
|
||||||
media_refs: None,
|
|
||||||
tool_call_id: None,
|
|
||||||
tool_name: None,
|
|
||||||
tool_calls: None,
|
|
||||||
created_at: 1000,
|
|
||||||
};
|
|
||||||
|
|
||||||
let seq = storage.append_message(&session_meta.id, &msg).await.unwrap();
|
|
||||||
assert_eq!(seq, 1);
|
|
||||||
|
|
||||||
let loaded = storage.load_messages(&session_meta.id, 0).await.unwrap();
|
|
||||||
assert_eq!(loaded.len(), 1);
|
|
||||||
assert_eq!(loaded[0].content, "你好");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_touch_session() {
|
|
||||||
let (storage, cleanup) = create_test_storage().await;
|
|
||||||
defer { cleanup(); }
|
|
||||||
|
|
||||||
let meta = SessionMeta {
|
|
||||||
id: "cli_chat:sid123:dialog1".to_string(),
|
|
||||||
channel: "cli_chat".to_string(),
|
|
||||||
chat_id: "sid123".to_string(),
|
|
||||||
dialog_id: "dialog1".to_string(),
|
|
||||||
title: "测试".to_string(),
|
|
||||||
created_at: 1000,
|
|
||||||
last_active_at: 1000,
|
|
||||||
message_count: 0,
|
|
||||||
routing_info: None,
|
|
||||||
deleted_at: None,
|
|
||||||
};
|
|
||||||
storage.upsert_session(&meta).await.unwrap();
|
|
||||||
|
|
||||||
storage.touch_session(&meta.id, 5, 2000).await.unwrap();
|
|
||||||
|
|
||||||
let loaded = storage.get_session(&meta.id).await.unwrap();
|
|
||||||
assert_eq!(loaded.message_count, 5);
|
|
||||||
assert_eq!(loaded.last_active_at, 2000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> 需要在 `Cargo.toml` 中添加 `tempfile` 依赖(已存在)。`defer` 宏可自己实现一个简单的:`fn defer<F: FnOnce()>(f: F) { f() }`
|
|
||||||
|
|
||||||
**Step 2: 运行测试**
|
|
||||||
|
|
||||||
Run: `cargo test storage::tests --lib 2>&1`
|
|
||||||
Expected: 所有 7 个测试 PASS
|
|
||||||
|
|
||||||
**Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/storage/mod.rs
|
|
||||||
git commit -m "test(storage): 编写 Storage 单元测试"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 汇总
|
|
||||||
|
|
||||||
| Task | 改动文件 | 关键交付物 |
|
|
||||||
|------|----------|-----------|
|
|
||||||
| 1 | `Cargo.toml` | sqlx 依赖 |
|
|
||||||
| 2 | `src/storage/error.rs` | StorageError |
|
|
||||||
| 3 | `src/storage/{mod.rs,session.rs,message.rs}`, `src/lib.rs` | 模块骨架 |
|
|
||||||
| 4 | `src/storage/mod.rs` | Storage 结构 + init_schema |
|
|
||||||
| 5 | `src/storage/session.rs`, `message.rs` | SessionMeta, MessageMeta |
|
|
||||||
| 6 | `src/storage/session.rs` | Session CRUD |
|
|
||||||
| 7 | `src/storage/message.rs` | Message CRUD |
|
|
||||||
| 8 | `src/storage/mod.rs` | append_message_with_retry |
|
|
||||||
| 9 | `src/storage/mod.rs` | 7 个单元测试 |
|
|
||||||
|
|
||||||
**Phase 1 完成后:** Storage 模块可独立使用,具备完整的持久化能力,可安全地集成到 Session 和 SessionManager 中。
|
|
||||||
@ -1,278 +0,0 @@
|
|||||||
# Session 持久化设计方案
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
|
|
||||||
为 PicoBot 添加 SQLite 持久化层,实现 Session 数据的持久化、完整 Dialog 生命周期管理、消息实时落盘、以及基于 TTL 的自动内存清理。
|
|
||||||
|
|
||||||
## 核心概念
|
|
||||||
|
|
||||||
```
|
|
||||||
UnifiedSessionId = {channel}:{chat_id}:{dialog_id}
|
|
||||||
Session = Dialog(两者等价,不再分层)
|
|
||||||
```
|
|
||||||
|
|
||||||
每个 Session 独立管理自己的消息历史、LLM 配置和路由信息。
|
|
||||||
|
|
||||||
## 数据库 Schema
|
|
||||||
|
|
||||||
### sessions 表
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE sessions (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
channel TEXT NOT NULL,
|
|
||||||
chat_id TEXT NOT NULL,
|
|
||||||
dialog_id TEXT NOT NULL,
|
|
||||||
title TEXT NOT NULL DEFAULT '新对话',
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
last_active_at INTEGER NOT NULL,
|
|
||||||
message_count INTEGER DEFAULT 0,
|
|
||||||
routing_info TEXT,
|
|
||||||
deleted_at INTEGER,
|
|
||||||
UNIQUE(channel, chat_id, dialog_id)
|
|
||||||
);
|
|
||||||
CREATE INDEX idx_sessions_chat ON sessions(channel, chat_id, deleted_at);
|
|
||||||
```
|
|
||||||
|
|
||||||
### messages 表
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE messages (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
session_id TEXT NOT NULL,
|
|
||||||
seq INTEGER NOT NULL,
|
|
||||||
role TEXT NOT NULL,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
media_refs TEXT,
|
|
||||||
tool_call_id TEXT,
|
|
||||||
tool_name TEXT,
|
|
||||||
tool_calls TEXT,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE INDEX idx_messages_session_seq ON messages(session_id, seq);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Storage API
|
|
||||||
|
|
||||||
### Session 操作
|
|
||||||
|
|
||||||
| 方法 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `new(db_path) -> Storage` | 打开/创建数据库 |
|
|
||||||
| `upsert_session(meta) -> Result<(), StorageError>` | 插入或更新 session 元数据 |
|
|
||||||
| `get_session(id) -> Result<SessionMeta, StorageError>` | 获取单个 session |
|
|
||||||
| `list_sessions(channel, chat_id, limit) -> Result<Vec<SessionMeta>>` | 最近 N 条 |
|
|
||||||
| `touch_session(id, message_count, last_active_at)` | 更新计数和最后活跃时间 |
|
|
||||||
| `soft_delete_session(id) -> Result<(), StorageError>` | 软删除 |
|
|
||||||
|
|
||||||
### Message 操作
|
|
||||||
|
|
||||||
| 方法 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `append_message(session_id, msg) -> Result<i64, StorageError>` | 追加单条消息,返回 seq |
|
|
||||||
| `append_messages(session_id, msgs) -> Result<Vec<i64>, StorageError>` | 批量追加 |
|
|
||||||
| `load_messages(session_id, from_seq) -> Result<Vec<MessageMeta>>` | 从指定 seq 加载 |
|
|
||||||
| `clear_messages(session_id) -> Result<(), StorageError>` | 清除消息(保留 session) |
|
|
||||||
|
|
||||||
### 写入失败处理
|
|
||||||
|
|
||||||
重试 3 次(100/200/300ms 退避),仍失败则发送系统通知告警。
|
|
||||||
|
|
||||||
## Session 结构
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct Session {
|
|
||||||
pub id: UnifiedSessionId,
|
|
||||||
pub title: String,
|
|
||||||
pub created_at: i64,
|
|
||||||
pub last_active_at: i64,
|
|
||||||
pub message_count: i64, // 用户消息计数
|
|
||||||
pub total_message_count: i64, // 含系统消息
|
|
||||||
|
|
||||||
messages: Vec<ChatMessage>, // 内存消息历史
|
|
||||||
seq_counter: i64, // 下一个消息的 seq
|
|
||||||
|
|
||||||
provider_config: LLMProviderConfig,
|
|
||||||
provider: Arc<dyn LLMProvider>,
|
|
||||||
tools: Arc<ToolRegistry>,
|
|
||||||
compressor: ContextCompressor,
|
|
||||||
user_tx: mpsc::Sender<WsOutbound>,
|
|
||||||
storage: Arc<Storage>, // 持久化 sink
|
|
||||||
routing_info: String, // JSON 路由信息
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 初始化流程
|
|
||||||
|
|
||||||
```
|
|
||||||
new() 或 from_storage()
|
|
||||||
↓
|
|
||||||
注入 storage 引用
|
|
||||||
↓
|
|
||||||
创建 provider, tools, compressor
|
|
||||||
↓
|
|
||||||
从 Storage 加载 messages(from_seq = 0)
|
|
||||||
↓
|
|
||||||
设置 seq_counter = messages.len() + 1
|
|
||||||
↓
|
|
||||||
返回 Session 实例
|
|
||||||
```
|
|
||||||
|
|
||||||
## handle_message 流程
|
|
||||||
|
|
||||||
```
|
|
||||||
handle_message(channel, chat_id, sender_id, content, media)
|
|
||||||
│
|
|
||||||
├── 1. 确定 dialog_id
|
|
||||||
│ │
|
|
||||||
│ ├── 显式传入 dialog_id → 使用
|
|
||||||
│ └── 无 dialog_id
|
|
||||||
│ ├── 查找 channel:chat_id 下最近活跃且未过期的 session
|
|
||||||
│ ├── 找到 → 使用该 session
|
|
||||||
│ └── 未找到 → 创建新 session(dialog_id = 新随机 ID)
|
|
||||||
│
|
|
||||||
├── 2. 获取或创建 Session
|
|
||||||
│ 有 → 更新 session_timestamps
|
|
||||||
│ 无 → 从 Storage 恢复 或 创建新 Session
|
|
||||||
│
|
|
||||||
├── 3. 追加用户消息并持久化
|
|
||||||
│ seq = seq_counter; seq_counter += 1
|
|
||||||
│ Storage.append_message()(失败重试 → 告警)
|
|
||||||
│ messages.push(user_msg)
|
|
||||||
│ message_count += 1
|
|
||||||
│
|
|
||||||
├── 4. 检查 title 自动生成
|
|
||||||
│ message_count == 10 且 title == 默认值 → LLM 生成 → 更新 title → 写回 Storage
|
|
||||||
│
|
|
||||||
├── 5. 注入 skills_prompt
|
|
||||||
│
|
|
||||||
├── 6. 新 session 注入欢迎消息(系统消息,不计入 message_count)
|
|
||||||
│
|
|
||||||
├── 7. 上下文压缩(如需要)
|
|
||||||
│
|
|
||||||
├── 8. 调用 AgentLoop
|
|
||||||
│
|
|
||||||
├── 9. 持久化 Agent 响应
|
|
||||||
│
|
|
||||||
└── 10. 返回响应
|
|
||||||
```
|
|
||||||
|
|
||||||
## Dialog 生命周期命令
|
|
||||||
|
|
||||||
| 命令 | 行为 |
|
|
||||||
|------|------|
|
|
||||||
| `/new [标题]` | 创建新 dialog(新随机 dialog_id),新建 Session |
|
|
||||||
| `/sessions` | 列出 channel:chat_id 下最近 10 条 session(按 last_active_at 倒序) |
|
|
||||||
| `/switch <dialog_id>` | 切换到指定 session(从 Storage 恢复或内存命中) |
|
|
||||||
| `/rename <新标题>` | 重命名当前 session |
|
|
||||||
| `/delete` | 软删除当前 session(内存移除 + Storage 标记 deleted_at) |
|
|
||||||
| `/info` | 显示当前 session 信息 |
|
|
||||||
| `/compact` | 手动触发上下文压缩 |
|
|
||||||
|
|
||||||
## 路由信息
|
|
||||||
|
|
||||||
每种 Channel 在创建 Session 时注入路由信息:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// CLI
|
|
||||||
routing_info = json!({"type": "cli", "ws_sender_id": "xxx"})
|
|
||||||
|
|
||||||
// Feishu
|
|
||||||
routing_info = json!({"type": "feishu", "open_conversation_id": "oc_xxx", "tenant_key": "xxx"})
|
|
||||||
```
|
|
||||||
|
|
||||||
## Title 自动生成
|
|
||||||
|
|
||||||
调用时机:
|
|
||||||
1. Session 首次创建时(初始 title = "新对话")
|
|
||||||
2. `message_count` 达到 10 且 title 仍为默认值时,自动更新
|
|
||||||
|
|
||||||
生成 Prompt:
|
|
||||||
```
|
|
||||||
给定以下对话历史,生成一个简短的会话标题(5-15 个中文字符),
|
|
||||||
概括这个对话的核心内容或用户的主要需求。只返回一个标题,不要解释。
|
|
||||||
|
|
||||||
历史:
|
|
||||||
{messages}
|
|
||||||
```
|
|
||||||
|
|
||||||
## TTL 清理
|
|
||||||
|
|
||||||
- 内存 session 超时 → 释放内存,Storage 记录保留
|
|
||||||
- 用户切换回该 session → 从 Storage 重新加载到内存
|
|
||||||
- Storage 中的 session 记录通过 `deleted_at` 软删除,不会物理删除
|
|
||||||
|
|
||||||
## 文件结构
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── storage/
|
|
||||||
│ ├── mod.rs # Storage 主模块
|
|
||||||
│ ├── session.rs # Session CRUD
|
|
||||||
│ ├── message.rs # Message CRUD
|
|
||||||
│ └── error.rs # StorageError
|
|
||||||
│
|
|
||||||
└── session/
|
|
||||||
├── mod.rs # 导出 Session, SessionManager
|
|
||||||
├── session.rs # Session, SessionManager 实现
|
|
||||||
├── session_id.rs # UnifiedSessionId
|
|
||||||
├── commands.rs # SessionCommand
|
|
||||||
├── events.rs # SessionEvent, DialogInfo
|
|
||||||
└── error.rs # SessionError
|
|
||||||
```
|
|
||||||
|
|
||||||
## 实现顺序
|
|
||||||
|
|
||||||
### Phase 1: Storage 基础
|
|
||||||
1. 添加 `sqlx` + `sqlite` 依赖
|
|
||||||
2. 实现 `Storage` 结构(连接池、初始化)
|
|
||||||
3. Session CRUD + Message CRUD
|
|
||||||
4. 写入重试逻辑
|
|
||||||
5. 单元测试
|
|
||||||
|
|
||||||
### Phase 2: Session 扩展
|
|
||||||
1. 扩展 `Session` 结构(添加 storage、routing_info、计数字段、seq_counter)
|
|
||||||
2. `from_storage()` 恢复逻辑
|
|
||||||
3. `add_message` 持久化集成
|
|
||||||
4. `send_system_notification` 接口
|
|
||||||
5. Title 自动生成
|
|
||||||
|
|
||||||
### Phase 3: SessionManager 完善
|
|
||||||
1. 注入 `Arc<Storage>`
|
|
||||||
2. 实现 `list_dialogs()`
|
|
||||||
3. 实现 `switch_dialog()`
|
|
||||||
4. 实现 `delete_dialog()` / `rename_dialog()`
|
|
||||||
5. 后台 TTL 清理任务
|
|
||||||
6. 集成测试
|
|
||||||
|
|
||||||
### Phase 4: 斜杠命令
|
|
||||||
1. 实现 `/sessions`
|
|
||||||
2. 实现 `/switch`
|
|
||||||
3. 实现 `/rename`
|
|
||||||
4. 实现 `/delete`
|
|
||||||
5. 端到端测试
|
|
||||||
|
|
||||||
## 配置项
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"session": {
|
|
||||||
"ttl_hours": 24,
|
|
||||||
"cleanup_interval_minutes": 60,
|
|
||||||
"auto_title_after_n_messages": 10,
|
|
||||||
"storage_retry_delays_ms": [100, 200, 300]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 与现有代码的冲突点
|
|
||||||
|
|
||||||
| 冲突 | 处理方式 |
|
|
||||||
|------|----------|
|
|
||||||
| `DialogInfo` 有 `archived_at` | 删除该字段,改用 `deleted_at` |
|
|
||||||
| `SessionCommand::ArchiveDialog` | 删除 |
|
|
||||||
| `/new` 现有行为 | 改为创建新 session(新 dialog_id) |
|
|
||||||
| 现有 `Session` 无 storage/routing_info | 扩展结构,新增 `from_storage()` |
|
|
||||||
| `SessionManager` 需注入 `Arc<Storage>` | 扩展构造方法 |
|
|
||||||
| stub 方法 | 实现 |
|
|
||||||
@ -1,226 +0,0 @@
|
|||||||
# PicoBot Memory System Design
|
|
||||||
|
|
||||||
Date: 2026-05-07
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
|
|
||||||
Introduce a memory system that allows PicoBot agents to remember user preferences, project context, facts, and conversation history across sessions. The memory system is **unified with the existing context compression pipeline**: compression automatically produces `timeline` memory entries and advances a `last_consolidated_at` pointer to avoid redundant reprocessing.
|
|
||||||
|
|
||||||
### Design Principles
|
|
||||||
|
|
||||||
- **Compression is memory** (inspired by nanobot): when old messages are compressed, the summary is persisted — not discarded
|
|
||||||
- **FTS5 only** (no vector embeddings): keyword search via SQLite FTS5, sufficient for current scale
|
|
||||||
- **Extend existing infrastructure**: reuse `Storage` connection pool, `ContextCompressor`, `SystemPromptBuilder`
|
|
||||||
- **YAGNI**: no knowledge graph, no response cache, no namespace isolation, no audit trail
|
|
||||||
|
|
||||||
## 2. Core Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
ContextCompressor (existing) MemoryManager (new)
|
|
||||||
│ │
|
|
||||||
│ compress_if_needed() │ store / recall / forget
|
|
||||||
│ ├─ LLM summary → inject │
|
|
||||||
│ └─ store(timeline entry) ──────┘
|
|
||||||
│ └─ advance last_consolidated_at
|
|
||||||
│
|
|
||||||
SystemPromptBuilder ── recall(knowledge, limit=5) ──→ inject into system prompt
|
|
||||||
AgentLoop ── after_turn ──→ memory_store / memory_recall / memory_forget tools
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Memory Categories
|
|
||||||
|
|
||||||
| Category | Purpose | Written By | Retrieved By |
|
|
||||||
|----------|---------|-----------|--------------|
|
|
||||||
| `knowledge` | Long-term facts, preferences, patterns, insights | Agent via `memory_store` tool | FTS5 → injected into system prompt every turn |
|
|
||||||
| `timeline` | Compressed conversation summaries | ContextCompressor automatically | FTS5 + time-range queries |
|
|
||||||
|
|
||||||
## 4. Storage Schema
|
|
||||||
|
|
||||||
### New table: `memories`
|
|
||||||
|
|
||||||
Added to the existing `Storage` initialization in `src/storage/mod.rs`:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE IF NOT EXISTS memories (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
key TEXT NOT NULL UNIQUE,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
category TEXT NOT NULL DEFAULT 'knowledge',
|
|
||||||
importance REAL NOT NULL DEFAULT 0.5,
|
|
||||||
session_id TEXT,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5(
|
|
||||||
key,
|
|
||||||
content,
|
|
||||||
content=memories,
|
|
||||||
content_rowid=rowid
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Modified table: `sessions`
|
|
||||||
|
|
||||||
```sql
|
|
||||||
ALTER TABLE sessions ADD COLUMN last_consolidated_at INTEGER;
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. Unified Compression-Memory Pipeline
|
|
||||||
|
|
||||||
### Trigger Conditions
|
|
||||||
|
|
||||||
Compression/consolidation fires when **any** of these conditions is met:
|
|
||||||
|
|
||||||
| Condition | Value | Rationale |
|
|
||||||
|-----------|-------|-----------|
|
|
||||||
| Token budget exceeds 50% threshold | `context_window / 2` | Primary trigger — context is getting full |
|
|
||||||
| Accumulated N turns without consolidation | 3 (configurable) | Catch-up for short messages that don't hit token threshold |
|
|
||||||
| Session idle | 10 minutes (configurable) | Important for async channels like Feishu |
|
|
||||||
|
|
||||||
### Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
compress_if_needed(history, session_id):
|
|
||||||
1. Read last_consolidated_at from session
|
|
||||||
→ Only compress messages after that timestamp
|
|
||||||
2. If no messages to compress → return history unchanged
|
|
||||||
3. FTS5 recall(user_input, limit=recall_limit, category=knowledge)
|
|
||||||
→ Inject relevant facts into system prompt
|
|
||||||
4. LLM summarization of old messages → [Context Summary]
|
|
||||||
→ Inject into current conversation
|
|
||||||
5. Store summary as timeline entry:
|
|
||||||
key: "ctx_{session_id}_{uuid}"
|
|
||||||
content: "[YYYY-MM-DD HH:MM] summary text..."
|
|
||||||
category: timeline
|
|
||||||
6. UPDATE sessions.last_consolidated_at = now()
|
|
||||||
7. Return compressed history
|
|
||||||
```
|
|
||||||
|
|
||||||
### timeline Entry Format
|
|
||||||
|
|
||||||
Each timeline entry follows nanobot's convention:
|
|
||||||
```
|
|
||||||
[2026-05-07 14:30] User asked about Rust async patterns. Discussed tokio::select!,
|
|
||||||
semaphore-based rate limiting, and backpressure strategies. No code was written.
|
|
||||||
```
|
|
||||||
|
|
||||||
This format is grep-friendly and human-readable.
|
|
||||||
|
|
||||||
## 6. Retrieval Strategy
|
|
||||||
|
|
||||||
### Automatic Retrieval (every turn)
|
|
||||||
|
|
||||||
`SystemPromptBuilder.build_system_prompt()` calls:
|
|
||||||
```rust
|
|
||||||
memory.recall(query=user_message, limit=recall_limit, category=knowledge)
|
|
||||||
```
|
|
||||||
|
|
||||||
Results sorted by FTS5 BM25 score, injected as:
|
|
||||||
```
|
|
||||||
## Memory Context
|
|
||||||
|
|
||||||
- user_prefers_rust: User prefers Rust for all backend projects
|
|
||||||
- project_picobot_stack: PicoBot uses Rust, axum, sqlx, ratatui, tokio
|
|
||||||
- user_workflow: User prefers TDD workflow with cargo test --lib
|
|
||||||
```
|
|
||||||
|
|
||||||
### Agent-Initiated Retrieval
|
|
||||||
|
|
||||||
Agent uses `memory_recall` tool with optional `category`, `since`, `until` parameters.
|
|
||||||
|
|
||||||
### Fallback
|
|
||||||
|
|
||||||
If FTS5 returns empty results, fallback to `LIKE '%keyword%'` on `key` and `content` columns.
|
|
||||||
|
|
||||||
## 7. Agent Tools
|
|
||||||
|
|
||||||
| Tool | Parameters | Description |
|
|
||||||
|------|-----------|-------------|
|
|
||||||
| `memory_store` | `key: str`, `content: str`, `category: str`, `importance?: f64` | Write or update a memory entry. Key is semantic identifier (e.g., "user_language_pref") |
|
|
||||||
| `memory_recall` | `query: str`, `category?: str`, `since?: i64`, `until?: i64`, `limit?: usize` | Search memories by keyword and optional filters |
|
|
||||||
| `memory_forget` | `key: str` | Delete a memory entry by key |
|
|
||||||
|
|
||||||
## 8. Error Handling & Degradation
|
|
||||||
|
|
||||||
| Scenario | Strategy |
|
|
||||||
|----------|----------|
|
|
||||||
| Consolidation LLM call fails | Log warning, increment failure counter, do NOT block main flow |
|
|
||||||
| Consecutive failures >= 3 | Degrade: append raw message dump to timeline with `[RAW]` prefix, reset counter |
|
|
||||||
| FTS5 recall returns empty | Fallback to `LIKE '%keyword%'` query |
|
|
||||||
| `memory.enabled = false` | ContextCompressor works normally, no memory writes |
|
|
||||||
| MemoryManager uninitialized | ContextCompressor works with feature-gated memory write path |
|
|
||||||
|
|
||||||
## 9. Configuration
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"memory": {
|
|
||||||
"enabled": true,
|
|
||||||
"consolidation_provider": "openai",
|
|
||||||
"consolidation_model": "gpt-4o-mini",
|
|
||||||
"recall_limit": 5,
|
|
||||||
"consolidation_turn_threshold": 3,
|
|
||||||
"idle_consolidation_minutes": 10,
|
|
||||||
"timeline_retention_days": 90,
|
|
||||||
"max_failures_before_degrade": 3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Key | Type | Default | Description |
|
|
||||||
|-----|------|---------|-------------|
|
|
||||||
| `enabled` | bool | `false` | Master switch for memory system |
|
|
||||||
| `consolidation_provider` | string | — | Provider name for consolidation LLM calls |
|
|
||||||
| `consolidation_model` | string | — | Model name for consolidation |
|
|
||||||
| `recall_limit` | usize | `5` | Max knowledge entries injected into system prompt |
|
|
||||||
| `consolidation_turn_threshold` | usize | `3` | Turns before forced consolidation |
|
|
||||||
| `idle_consolidation_minutes` | u64 | `10` | Idle time before consolidation trigger |
|
|
||||||
| `timeline_retention_days` | u64 | `90` | Auto-cleanup age for timeline entries |
|
|
||||||
| `max_failures_before_degrade` | usize | `3` | Consecutive failures before raw archive fallback |
|
|
||||||
|
|
||||||
## 10. New Module Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── memory/
|
|
||||||
│ ├── mod.rs # MemoryManager, MemoryConfig
|
|
||||||
│ ├── types.rs # MemoryEntry, MemoryCategory, ConsolidationResult
|
|
||||||
│ └── consolidation.rs # Consolidation prompt + LLM call logic
|
|
||||||
├── storage/
|
|
||||||
│ └── memory.rs # SQLite CRUD for memories table + FTS5
|
|
||||||
├── tools/
|
|
||||||
│ ├── memory_store.rs # memory_store tool
|
|
||||||
│ ├── memory_recall.rs # memory_recall tool
|
|
||||||
│ └── memory_forget.rs # memory_forget tool
|
|
||||||
```
|
|
||||||
|
|
||||||
## 11. Integration Points (Existing Files Modified)
|
|
||||||
|
|
||||||
| File | Change |
|
|
||||||
|------|--------|
|
|
||||||
| `src/lib.rs` | Add `pub mod memory;` |
|
|
||||||
| `src/config/mod.rs` | Add `MemoryConfig` struct and deserialization |
|
|
||||||
| `src/storage/mod.rs` | Add `pub mod memory;`, init `memories` table and FTS5 in `init_schema()` |
|
|
||||||
| `src/storage/session.rs` | Add `last_consolidated_at` column read/write |
|
|
||||||
| `src/session/session.rs` | Add `last_consolidated_at: Option<i64>` field to Session |
|
|
||||||
| `src/agent/context_compressor.rs` | Add `memory: Option<Arc<MemoryManager>>` field, write timeline on compress |
|
|
||||||
| `src/agent/system_prompt.rs` | Add `memory_context` section via `MemoryManager::recall()` |
|
|
||||||
| `src/agent/agent_loop.rs` | No changes (tools registered via ToolRegistry) |
|
|
||||||
| `src/tools/mod.rs` | Register `memory_store`, `memory_recall`, `memory_forget` in `create_default_tools()` |
|
|
||||||
| `src/gateway/mod.rs` | Initialize `MemoryManager` in `GatewayState::new()`, pass to ContextCompressor |
|
|
||||||
|
|
||||||
## 12. Implementation Order
|
|
||||||
|
|
||||||
| # | Task | Dependencies |
|
|
||||||
|---|------|-------------|
|
|
||||||
| 1 | Types: `MemoryEntry`, `MemoryCategory`, `ConsolidationResult` | — |
|
|
||||||
| 2 | Config: `MemoryConfig` + deserialization | — |
|
|
||||||
| 3 | Storage: `memories` table + FTS5 + CRUD + search | #1 |
|
|
||||||
| 4 | `MemoryManager` API | #1, #2, #3 |
|
|
||||||
| 5 | Session: `last_consolidated_at` field | — |
|
|
||||||
| 6 | `ContextCompressor` memory integration | #4, #5 |
|
|
||||||
| 7 | `SystemPromptBuilder` memory context injection | #4 |
|
|
||||||
| 8 | Agent tools: `memory_store`, `memory_recall`, `memory_forget` | #4 |
|
|
||||||
| 9 | `GatewayState` initialization wiring | #4, #5, #6 |
|
|
||||||
| 10 | Unit tests | #1-#9 |
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,90 +0,0 @@
|
|||||||
# 启动增量恢复设计
|
|
||||||
|
|
||||||
## 问题
|
|
||||||
|
|
||||||
PicoBot 重启后,`Session::from_storage()` 全量加载 `messages` 表,恢复的 history 可能直接超出上下文窗口,首次 LLM 调用即触发压缩,浪费 token。
|
|
||||||
|
|
||||||
## 设计
|
|
||||||
|
|
||||||
### 核心思路
|
|
||||||
|
|
||||||
用 `last_compressed_message_at` 标记最后压缩时刻。恢复时:
|
|
||||||
- 加载该标记之后的原始消息
|
|
||||||
- 用该 session 的 Timeline 条目替代已压缩部分
|
|
||||||
- `seq_counter` 统一从 SQLite 查 `MAX(seq) + 1`
|
|
||||||
|
|
||||||
```
|
|
||||||
messages 表 memories(timeline)
|
|
||||||
┌──────────────────────────┐ ┌───────────────────────────┐
|
|
||||||
│ created_at = T1..T5 │ ← 跳过 │ session = feishu:oc:dialog │
|
|
||||||
│ (压缩已覆盖,用Timeline替代)│ │ created_at 降序 │
|
|
||||||
├──────────────────────────┤ ├───────────────────────────┤
|
|
||||||
│ created_at > T6 │ ← 加载 │ 只取最近 3 条 │
|
|
||||||
└──────────────────────────┘ └───────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 数据变更
|
|
||||||
|
|
||||||
**`sessions` 表加列:**
|
|
||||||
```sql
|
|
||||||
last_compressed_message_at INTEGER
|
|
||||||
```
|
|
||||||
|
|
||||||
**`SessionMeta` / `Session` 加字段:** `last_compressed_message_at: Option<i64>`
|
|
||||||
|
|
||||||
### Storage 层新增方法
|
|
||||||
|
|
||||||
| 方法 | SQL |
|
|
||||||
|------|-----|
|
|
||||||
| `get_max_message_seq(session_id)` | `SELECT MAX(seq) FROM messages WHERE session_id = ?` |
|
|
||||||
| `load_messages_after_timestamp(session_id, after_ts)` | `WHERE created_at > ?` |
|
|
||||||
| `load_session_timelines(session_id, limit)` | `WHERE session_id = ? AND category = 'timeline' ORDER BY created_at DESC LIMIT ?` |
|
|
||||||
|
|
||||||
### 压缩跟踪
|
|
||||||
|
|
||||||
`compress_if_needed()` 返回值改为 `CompressionResult { history, created_timelines: bool }`。
|
|
||||||
`compress_once()` 中 LLM 摘要路径才置 `true`(Tier 2),Tier 1/3 不产生 Timeline。
|
|
||||||
|
|
||||||
**记录时机**(`handle_message` 正常流、溢出重试流、`/compact` 统一):
|
|
||||||
```rust
|
|
||||||
if result.created_timelines {
|
|
||||||
session.last_compressed_message_at = Some(now());
|
|
||||||
session.persist_session_meta().await;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Session::from_storage() 恢复逻辑
|
|
||||||
|
|
||||||
有压缩标记时:
|
|
||||||
1. `load_session_timelines(limit=4)` → 取 3 条给 LLM,第 4 条判"有更多"
|
|
||||||
2. 有更多 → 插入提示 user 消息
|
|
||||||
3. 逐条插入 Timeline 为 `[Previous Context]` user 消息
|
|
||||||
4. `load_messages_after_timestamp(after_ts)` → 原始尾消息
|
|
||||||
5. `repair_tool_call_chains`
|
|
||||||
|
|
||||||
无压缩标记 → 全量加载(现有行为)。
|
|
||||||
|
|
||||||
统一:`seq_counter = MAX(seq) + 1`
|
|
||||||
|
|
||||||
### 系统提示词
|
|
||||||
|
|
||||||
`Session.last_compressed_message_at` 非空时追加:
|
|
||||||
```
|
|
||||||
## 历史会话
|
|
||||||
之前的对话摘要已归档。如需回顾历史上下文,使用 `timeline_recall` 工具搜索。
|
|
||||||
```
|
|
||||||
|
|
||||||
## 改动清单
|
|
||||||
|
|
||||||
| # | 文件 | 改动 |
|
|
||||||
|---|------|------|
|
|
||||||
| 1 | `storage/session.rs` | `SessionMeta` 加 `last_compressed_message_at` |
|
|
||||||
| 2 | `storage/mod.rs` | DDL migration + upsert/get_session 加列 |
|
|
||||||
| 3 | `storage/mod.rs` | 新增 `get_max_message_seq`, `load_messages_after_timestamp` |
|
|
||||||
| 4 | `storage/memory.rs` | 新增 `load_session_timelines` |
|
|
||||||
| 5 | `agent/context_compressor.rs` | 返回值改为 `CompressionResult` 含 `created_timelines` |
|
|
||||||
| 6 | `session/session.rs` | `Session` 加字段,`persist_session_meta` 加字段 |
|
|
||||||
| 7 | `session/session.rs` | `from_storage()` 重写恢复逻辑 |
|
|
||||||
| 8 | `session/session.rs` | `handle_message()` 压缩后记录标记 |
|
|
||||||
| 9 | `session/session.rs` | `/compact` 命令压缩后记录标记 |
|
|
||||||
| 10 | `session/session.rs` | `build_system_prompt()` 注入 `last_compressed_message_at` |
|
|
||||||
@ -1,674 +0,0 @@
|
|||||||
# 启动增量恢复 Implementation Plan
|
|
||||||
|
|
||||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
||||||
|
|
||||||
**Goal:** PicoBot 重启后不再全量加载 messages 表,而是基于 `last_compressed_message_at` 标记增量恢复,用 Timeline 替代已压缩部分。
|
|
||||||
|
|
||||||
**Architecture:** 在 `sessions` 表加 `last_compressed_message_at` 列,`compress_if_needed` 返回值增加 `created_timelines` 标志,恢复时按时间戳增量加载消息并用近 3 条 Timeline 前置,`seq_counter` 统一从 SQLite 查 MAX(seq)。
|
|
||||||
|
|
||||||
**Tech Stack:** Rust, sqlx (SQLite), tokio
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: SessionMeta 和数据库 DDL 加列
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/storage/session.rs:15`
|
|
||||||
- Modify: `src/storage/mod.rs:44-45` (DDL), `:172-180` (migration)
|
|
||||||
- Modify: `src/storage/mod.rs:317-326` (upsert_session SQL + ON CONFLICT)
|
|
||||||
- Modify: `src/storage/mod.rs:345-369` (get_session SELECT + struct)
|
|
||||||
- Modify: `src/storage/mod.rs:380-406`, `:454-479`, `:564-588`, `:728`, `:754` (list_sessions 及测试 mock)
|
|
||||||
|
|
||||||
**Step 1: 在 `src/storage/session.rs` SessionMeta 加字段**
|
|
||||||
|
|
||||||
在 `last_consolidated_at: Option<i64>` 后加一行:
|
|
||||||
```rust
|
|
||||||
pub last_compressed_message_at: Option<i64>,
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: DDL schema 加列**
|
|
||||||
|
|
||||||
在 `src/storage/mod.rs` 的 CREATE TABLE sessions 中 (line 44),`last_consolidated_at INTEGER` 后加逗号和:
|
|
||||||
```sql
|
|
||||||
last_compressed_message_at INTEGER
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 3: migration 加列**
|
|
||||||
|
|
||||||
在 `src/storage/mod.rs` line 182 之后(现有 migration 的 `); .ok();` 之后),添加新 migration:
|
|
||||||
```rust
|
|
||||||
// Migration: add last_compressed_message_at column if not exists
|
|
||||||
sqlx::query(
|
|
||||||
r#"ALTER TABLE sessions ADD COLUMN last_compressed_message_at INTEGER"#,
|
|
||||||
)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.ok();
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 4: upsert_session SQL 加列**
|
|
||||||
|
|
||||||
`src/storage/mod.rs` line 317: INSERT 列列表加 `last_compressed_message_at`,VALUES 加 `?`,ON CONFLICT DO UPDATE SET 加 `last_compressed_message_at = excluded.last_compressed_message_at`。line 338 后加 `.bind(meta.last_compressed_message_at)`。
|
|
||||||
|
|
||||||
**Step 5: get_session SELECT 加列**
|
|
||||||
|
|
||||||
`src/storage/mod.rs` line 348: SELECT 列加 `last_compressed_message_at`。line 368 后加:
|
|
||||||
```rust
|
|
||||||
last_compressed_message_at: row.get("last_compressed_message_at"),
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 6: 其他 SELECT sessions 的地方(list_sessions 多个变体)**
|
|
||||||
|
|
||||||
同样补 `last_compressed_message_at` 到 SELECT 列和 struct 构造。以及测试中的 mock SessionMeta 构造(line 728, 754 等)。
|
|
||||||
|
|
||||||
**Step 7: 编译检查**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo check 2>&1
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 8: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/storage/session.rs src/storage/mod.rs
|
|
||||||
git commit -m "feat(storage): add last_compressed_message_at column to sessions"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: Storage 新增加载方法
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/storage/mod.rs` (在 load_messages 之后)
|
|
||||||
- Modify: `src/storage/memory.rs` (在 cleanup_old_timelines 之后)
|
|
||||||
|
|
||||||
**Step 1: `get_max_message_seq`**
|
|
||||||
|
|
||||||
在 `src/storage/mod.rs` 中 `load_messages` 函数后面添加:
|
|
||||||
```rust
|
|
||||||
pub async fn get_max_message_seq(&self, session_id: &str) -> Result<i64, StorageError> {
|
|
||||||
let row = sqlx::query(
|
|
||||||
"SELECT COALESCE(MAX(seq), 0) as max_seq FROM messages WHERE session_id = ?",
|
|
||||||
)
|
|
||||||
.bind(session_id)
|
|
||||||
.fetch_one(self.pool())
|
|
||||||
.await?;
|
|
||||||
Ok(row.get::<i64, _>("max_seq"))
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: `load_messages_after_timestamp`**
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub async fn load_messages_after_timestamp(
|
|
||||||
&self,
|
|
||||||
session_id: &str,
|
|
||||||
after_ts: i64,
|
|
||||||
) -> Result<Vec<crate::storage::message::MessageMeta>, StorageError> {
|
|
||||||
let rows = sqlx::query(
|
|
||||||
r#"
|
|
||||||
SELECT id, session_id, seq, role, content, media_refs, tool_call_id, tool_name, tool_calls, source, created_at
|
|
||||||
FROM messages
|
|
||||||
WHERE session_id = ? AND created_at > ?
|
|
||||||
ORDER BY seq ASC
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(session_id)
|
|
||||||
.bind(after_ts)
|
|
||||||
.fetch_all(self.pool())
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(rows.into_iter().map(|row| crate::storage::message::MessageMeta {
|
|
||||||
id: row.get("id"),
|
|
||||||
session_id: row.get("session_id"),
|
|
||||||
seq: row.get("seq"),
|
|
||||||
role: row.get("role"),
|
|
||||||
content: row.get("content"),
|
|
||||||
media_refs: row.get("media_refs"),
|
|
||||||
tool_call_id: row.get("tool_call_id"),
|
|
||||||
tool_name: row.get("tool_name"),
|
|
||||||
tool_calls: row.get("tool_calls"),
|
|
||||||
source: row.get("source"),
|
|
||||||
created_at: row.get("created_at"),
|
|
||||||
}).collect())
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 3: `load_session_timelines`**
|
|
||||||
|
|
||||||
在 `src/storage/memory.rs` 的 `cleanup_old_timelines` 之后(line 252 的 `}` 之前)添加:
|
|
||||||
```rust
|
|
||||||
pub async fn load_session_timelines(
|
|
||||||
&self,
|
|
||||||
session_id: &str,
|
|
||||||
limit: usize,
|
|
||||||
) -> Result<Vec<MemoryEntry>, StorageError> {
|
|
||||||
let rows = sqlx::query(
|
|
||||||
r#"
|
|
||||||
SELECT id, key, content, category, importance,
|
|
||||||
session_id, created_at, updated_at
|
|
||||||
FROM memories
|
|
||||||
WHERE session_id = ? AND category = 'timeline'
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT ?
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(session_id)
|
|
||||||
.bind(limit as i64)
|
|
||||||
.fetch_all(self.pool())
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
parse_memory_rows(&rows)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 4: 编译检查**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo check 2>&1
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/storage/mod.rs src/storage/memory.rs
|
|
||||||
git commit -m "feat(storage): add load_messages_after_timestamp, load_session_timelines, get_max_message_seq"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: ContextCompressor 引入 CompressionResult
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/agent/context_compressor.rs:172-274` (compress_if_needed)
|
|
||||||
- Modify: `src/agent/context_compressor.rs:320-402` (compress_once)
|
|
||||||
|
|
||||||
**Step 1: 定义 CompressionResult**
|
|
||||||
|
|
||||||
在 context_compressor.rs 中 `ContextCompressor` struct 定义之后添加:
|
|
||||||
```rust
|
|
||||||
pub struct CompressionResult {
|
|
||||||
pub history: Vec<ChatMessage>,
|
|
||||||
pub created_timelines: bool,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: 修改 compress_if_needed 签名和返回**
|
|
||||||
|
|
||||||
将 `pub async fn compress_if_needed(&self, mut history: Vec<ChatMessage>) -> Result<Vec<ChatMessage>, AgentError>` 改为:
|
|
||||||
```rust
|
|
||||||
pub async fn compress_if_needed(
|
|
||||||
&self,
|
|
||||||
mut history: Vec<ChatMessage>,
|
|
||||||
) -> Result<CompressionResult, AgentError> {
|
|
||||||
```
|
|
||||||
|
|
||||||
内部的 `return Ok(history)` 改为 `return Ok(CompressionResult { history, created_timelines: false })`(Tier 1 fast trim 和不需要压缩时)。
|
|
||||||
|
|
||||||
**Step 3: 修改 LLM summarization pass 部分**
|
|
||||||
|
|
||||||
在压缩循环中维护一个 `created_timelines` 标志:
|
|
||||||
```rust
|
|
||||||
let mut created_timelines = false;
|
|
||||||
for pass in 0..self.config.max_passes {
|
|
||||||
// ...
|
|
||||||
match self.compress_once(...).await {
|
|
||||||
Ok(Some(compressed)) => {
|
|
||||||
current_history = compressed;
|
|
||||||
created_timelines = true;
|
|
||||||
}
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
最后返回:
|
|
||||||
```rust
|
|
||||||
Ok(CompressionResult { history: current_history, created_timelines })
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 4: 更新所有 compress_if_needed 调用方**
|
|
||||||
|
|
||||||
所有 `compress_if_needed(history)` 改为 `compress_if_needed(history).await?.history`。在 `handle_message` 和 `/compact` 中还需要用到 `created_timelines`。
|
|
||||||
|
|
||||||
**Step 5: 编译检查**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo check 2>&1
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/agent/context_compressor.rs src/session/session.rs
|
|
||||||
git commit -m "feat(compressor): return CompressionResult with created_timelines flag"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4: Session 结构体和持久化
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/session/session.rs:52-74` (Session struct)
|
|
||||||
- Modify: `src/session/session.rs:76-121` (Session::new)
|
|
||||||
- Modify: `src/session/session.rs:298-320` (persist_session_meta)
|
|
||||||
|
|
||||||
**Step 1: Session struct 加字段**
|
|
||||||
|
|
||||||
在 `pub last_consolidated_at: Option<i64>` 后加:
|
|
||||||
```rust
|
|
||||||
pub last_compressed_message_at: Option<i64>,
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Session::new 初始化**
|
|
||||||
|
|
||||||
在 `last_consolidated_at: None` 后加:
|
|
||||||
```rust
|
|
||||||
last_compressed_message_at: None,
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 3: persist_session_meta 加字段**
|
|
||||||
|
|
||||||
在 `last_consolidated_at: self.last_consolidated_at` 后加:
|
|
||||||
```rust
|
|
||||||
last_compressed_message_at: self.last_compressed_message_at,
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 4: 编译检查**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo check 2>&1
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/session/session.rs
|
|
||||||
git commit -m "feat(session): add last_compressed_message_at field to Session and persist"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 5: Session::from_storage() 增量恢复
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/session/session.rs:124-189` (from_storage)
|
|
||||||
|
|
||||||
**Step 1: 重写 from_storage**
|
|
||||||
|
|
||||||
替换现有实现为:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub async fn from_storage(
|
|
||||||
id: UnifiedSessionId,
|
|
||||||
provider_config: LLMProviderConfig,
|
|
||||||
tools: Arc<ToolRegistry>,
|
|
||||||
storage: StdArc<Storage>,
|
|
||||||
memory_manager: Arc<crate::memory::MemoryManager>,
|
|
||||||
) -> Result<Self, AgentError> {
|
|
||||||
let session_meta = storage.get_session(&id.to_string()).await
|
|
||||||
.map_err(|e| AgentError::Other(format!("failed to load session from storage: {}", e)))?;
|
|
||||||
|
|
||||||
let mut provider_box = create_provider(provider_config.clone())
|
|
||||||
.map_err(|e| AgentError::Other(format!("provider creation error: {}", e)))?;
|
|
||||||
provider_box.set_storage(storage.clone());
|
|
||||||
let provider: Arc<dyn LLMProvider> = Arc::from(provider_box);
|
|
||||||
|
|
||||||
let compressor_config = ContextCompressionConfig {
|
|
||||||
protect_first_n: 2,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut compressor = ContextCompressor::with_config(provider.clone(), provider_config.token_limit, compressor_config, memory_manager.clone());
|
|
||||||
compressor.set_session_id(Some(id.to_string()));
|
|
||||||
|
|
||||||
// Determine recovery strategy
|
|
||||||
let mut chat_messages: Vec<ChatMessage> = Vec::new();
|
|
||||||
|
|
||||||
if let Some(after_ts) = session_meta.last_compressed_message_at {
|
|
||||||
// Load last 4 timelines to determine if there are > 3
|
|
||||||
let timelines = storage
|
|
||||||
.load_session_timelines(&id.to_string(), 4)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let has_more_timelines = timelines.len() > 3;
|
|
||||||
|
|
||||||
// Insert hint if more timelines exist
|
|
||||||
if has_more_timelines {
|
|
||||||
chat_messages.push(ChatMessage::user(
|
|
||||||
"[Earlier conversation summaries exist. \
|
|
||||||
Use `timeline_recall` to search if needed.]"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert latest 3 timelines as context (reversed: oldest first)
|
|
||||||
for tl in timelines.iter().take(3).rev() {
|
|
||||||
chat_messages.push(ChatMessage::user(format!(
|
|
||||||
"[Previous Context]\n{}", tl.content
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load raw messages after compressed timestamp
|
|
||||||
let tail = storage
|
|
||||||
.load_messages_after_timestamp(&id.to_string(), after_ts)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let mut tail_msgs: Vec<ChatMessage> = tail.into_iter().map(|m| {
|
|
||||||
ChatMessage {
|
|
||||||
id: m.id,
|
|
||||||
role: m.role,
|
|
||||||
content: m.content,
|
|
||||||
media_refs: m.media_refs.map(|refs| serde_json::from_str(&refs).unwrap_or_default()).unwrap_or_default(),
|
|
||||||
timestamp: m.created_at,
|
|
||||||
tool_call_id: m.tool_call_id,
|
|
||||||
tool_name: m.tool_name,
|
|
||||||
tool_calls: m.tool_calls
|
|
||||||
.and_then(|tc| serde_json::from_str::<Vec<crate::providers::ToolCall>>(&tc).ok())
|
|
||||||
.filter(|v| !v.is_empty()),
|
|
||||||
source: m.source.and_then(|s| serde_json::from_str(&s).ok()),
|
|
||||||
}
|
|
||||||
}).collect();
|
|
||||||
|
|
||||||
repair_tool_call_chains(&mut tail_msgs);
|
|
||||||
chat_messages.extend(tail_msgs);
|
|
||||||
} else {
|
|
||||||
// No prior compression — load all messages (existing behavior)
|
|
||||||
let messages = storage.load_messages(&id.to_string(), 0).await
|
|
||||||
.map_err(|e| AgentError::Other(format!("failed to load messages from storage: {}", e)))?;
|
|
||||||
|
|
||||||
chat_messages = messages.into_iter().map(|m| {
|
|
||||||
ChatMessage {
|
|
||||||
id: m.id,
|
|
||||||
role: m.role,
|
|
||||||
content: m.content,
|
|
||||||
media_refs: m.media_refs.map(|refs| serde_json::from_str(&refs).unwrap_or_default()).unwrap_or_default(),
|
|
||||||
timestamp: m.created_at,
|
|
||||||
tool_call_id: m.tool_call_id,
|
|
||||||
tool_name: m.tool_name,
|
|
||||||
tool_calls: m.tool_calls
|
|
||||||
.and_then(|tc| serde_json::from_str::<Vec<crate::providers::ToolCall>>(&tc).ok())
|
|
||||||
.filter(|v| !v.is_empty()),
|
|
||||||
source: m.source.and_then(|s| serde_json::from_str(&s).ok()),
|
|
||||||
}
|
|
||||||
}).collect();
|
|
||||||
|
|
||||||
repair_tool_call_chains(&mut chat_messages);
|
|
||||||
}
|
|
||||||
|
|
||||||
// seq_counter from actual DB max
|
|
||||||
let max_seq = storage
|
|
||||||
.get_max_message_seq(&id.to_string())
|
|
||||||
.await
|
|
||||||
.unwrap_or(0);
|
|
||||||
let seq_counter = max_seq + 1;
|
|
||||||
let total_message_count = session_meta.message_count;
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
id: id.clone(),
|
|
||||||
title: session_meta.title,
|
|
||||||
created_at: session_meta.created_at,
|
|
||||||
last_active_at: session_meta.last_active_at,
|
|
||||||
message_count: session_meta.message_count,
|
|
||||||
total_message_count,
|
|
||||||
messages: chat_messages,
|
|
||||||
seq_counter,
|
|
||||||
provider_config: provider_config.clone(),
|
|
||||||
provider: provider.clone(),
|
|
||||||
tools,
|
|
||||||
compressor,
|
|
||||||
storage: Some(storage),
|
|
||||||
routing_info: session_meta.routing_info.unwrap_or_default(),
|
|
||||||
last_consolidated_at: session_meta.last_consolidated_at,
|
|
||||||
last_compressed_message_at: session_meta.last_compressed_message_at,
|
|
||||||
memory_manager,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: 编译检查**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo check 2>&1
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/session/session.rs
|
|
||||||
git commit -m "feat(session): incremental recovery from storage using compressed timeline"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 6: 系统提示词加历史会话提示
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/agent/system_prompt.rs:289-304` (MemorySection)
|
|
||||||
- Modify: `src/agent/system_prompt.rs:16-23` (PromptContext)
|
|
||||||
- Modify: `src/agent/system_prompt.rs:343-358` (build_system_prompt free function)
|
|
||||||
- Modify: `src/session/session.rs:411-426` (build_system_prompt)
|
|
||||||
|
|
||||||
**Step 1: PromptContext 加 has_compressed_history 字段**
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct PromptContext<'a> {
|
|
||||||
pub workspace_dir: &'a Path,
|
|
||||||
pub model_name: &'a str,
|
|
||||||
pub tools: &'a ToolRegistry,
|
|
||||||
pub session_id: Option<&'a str>,
|
|
||||||
pub memory_context: Option<&'a str>,
|
|
||||||
pub has_compressed_history: bool,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: 加 HistorySection**
|
|
||||||
|
|
||||||
在 MemorySection 后面添加:
|
|
||||||
```rust
|
|
||||||
pub struct HistorySection;
|
|
||||||
|
|
||||||
impl PromptSection for HistorySection {
|
|
||||||
fn name(&self) -> &str {
|
|
||||||
"history"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build(&self, ctx: &PromptContext<'_>) -> String {
|
|
||||||
if ctx.has_compressed_history {
|
|
||||||
"## 历史会话\n之前的对话摘要已归档。如需回顾历史上下文,使用 `timeline_recall` 工具搜索。".to_string()
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 3: 注册到 SystemPromptBuilder::with_defaults**
|
|
||||||
|
|
||||||
在 `with_defaults()` 的 sections vec 中 `Box::new(MemorySection)` 后加 `Box::new(HistorySection)`。
|
|
||||||
|
|
||||||
**Step 4: 更新 build_system_prompt 签名和调用**
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub fn build_system_prompt(
|
|
||||||
workspace_dir: &Path,
|
|
||||||
model_name: &str,
|
|
||||||
tools: &ToolRegistry,
|
|
||||||
session_id: Option<&str>,
|
|
||||||
memory_context: Option<&str>,
|
|
||||||
has_compressed_history: bool,
|
|
||||||
) -> String {
|
|
||||||
let ctx = PromptContext {
|
|
||||||
workspace_dir,
|
|
||||||
model_name,
|
|
||||||
tools,
|
|
||||||
session_id,
|
|
||||||
memory_context,
|
|
||||||
has_compressed_history,
|
|
||||||
};
|
|
||||||
SystemPromptBuilder::with_defaults().build(&ctx)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 5: 更新 Session::build_system_prompt**
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub fn build_system_prompt(&self, skills_prompt: &str, memory_context: Option<&str>) -> String {
|
|
||||||
let base_prompt = build_system_prompt(
|
|
||||||
&self.provider_config.workspace_dir,
|
|
||||||
&self.provider_config.model_id,
|
|
||||||
&self.tools,
|
|
||||||
Some(&self.id.to_string()),
|
|
||||||
memory_context,
|
|
||||||
self.last_compressed_message_at.is_some(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if skills_prompt.trim().is_empty() {
|
|
||||||
base_prompt
|
|
||||||
} else {
|
|
||||||
format!("{}\n\n## Skills\n\n{}\n\nUse the `get_skill` tool to load a skill's full content when needed.", base_prompt, skills_prompt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 6: 更新所有其他 build_system_prompt 调用方**
|
|
||||||
|
|
||||||
搜索 `build_system_prompt(` 的所有调用位置,每个都要加 `false` 参数。主要有 `agent/agent_loop.rs` 中的两个调用。
|
|
||||||
|
|
||||||
**Step 7: 编译检查**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo check 2>&1
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 8: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/agent/system_prompt.rs src/session/session.rs src/agent/agent_loop.rs
|
|
||||||
git commit -m "feat(system-prompt): add history section for archived conversation context"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 7: handle_message 和 /compact 记录压缩标记
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/session/session.rs:1339-1355` (handle_message 压缩后)
|
|
||||||
- Modify: `src/session/session.rs:1372-1376` (handle_message 溢出重试)
|
|
||||||
- Modify: `src/session/session.rs:851-872` (/compact 命令)
|
|
||||||
|
|
||||||
**Step 1: handle_message 正常流**
|
|
||||||
|
|
||||||
在 `compress_if_needed(history).await?` 之后(line 1346),改为:
|
|
||||||
```rust
|
|
||||||
let result = session_guard.compressor
|
|
||||||
.compress_if_needed(history)
|
|
||||||
.await?;
|
|
||||||
if result.created_timelines {
|
|
||||||
session_guard.last_compressed_message_at = Some(chrono::Utc::now().timestamp_millis());
|
|
||||||
if let Err(e) = session_guard.persist_session_meta().await {
|
|
||||||
tracing::warn!(error = %e, "Failed to persist compressed message marker");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut history = result.history;
|
|
||||||
```
|
|
||||||
|
|
||||||
同时删除后面(line 1350-1355)单独的 `persist_session_meta` 调用(现在已合入上面的逻辑)。
|
|
||||||
|
|
||||||
**Step 2: handle_message 溢出重试流**
|
|
||||||
|
|
||||||
```rust
|
|
||||||
let raw = session_guard.get_history().to_vec();
|
|
||||||
let result = session_guard.compressor.compress_if_needed(raw).await?;
|
|
||||||
if result.created_timelines {
|
|
||||||
session_guard.last_compressed_message_at = Some(chrono::Utc::now().timestamp_millis());
|
|
||||||
let _ = session_guard.persist_session_meta().await;
|
|
||||||
}
|
|
||||||
let mut retry = result.history;
|
|
||||||
retry.insert(0, ChatMessage::system(system_prompt));
|
|
||||||
agent.process(retry).await?
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 3: /compact 命令**
|
|
||||||
|
|
||||||
```rust
|
|
||||||
let result = session_guard.compressor
|
|
||||||
.compress_if_needed(history)
|
|
||||||
.await?;
|
|
||||||
let compressed_count = result.history.len();
|
|
||||||
if result.created_timelines {
|
|
||||||
session_guard.last_compressed_message_at = Some(chrono::Utc::now().timestamp_millis());
|
|
||||||
let _ = session_guard.persist_session_meta().await;
|
|
||||||
}
|
|
||||||
session_guard.clear_history();
|
|
||||||
for msg in result.history {
|
|
||||||
session_guard.add_message(msg, false).await
|
|
||||||
.map_err(|e| AgentError::Other(format!("persist error: {}", e)))?;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
同时确认 `compress_if_needed` 的 import 正常(已在 scope 中)。
|
|
||||||
|
|
||||||
**Step 4: 编译检查**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo check 2>&1
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/session/session.rs
|
|
||||||
git commit -m "feat(session): record last_compressed_message_at after compression"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 8: 全局编译和测试
|
|
||||||
|
|
||||||
**Step 1: 全局编译**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo check 2>&1
|
|
||||||
```
|
|
||||||
|
|
||||||
修复所有编译错误,确保全部文件一致。
|
|
||||||
|
|
||||||
**Step 2: 运行单元测试**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo test --lib 2>&1
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 3: 测试通过后 commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add -A
|
|
||||||
git commit -m "chore: fix remaining compilation and test issues for incremental recovery"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 4: 运行 lint**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo clippy --lib 2>&1 | head -50
|
|
||||||
```
|
|
||||||
|
|
||||||
修复任何 warning。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 9: 验证 & 提交设计文档
|
|
||||||
|
|
||||||
**Step 1: 最终验证**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo test --lib 2>&1
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Commit 设计文档**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add docs/plans/2026-05-10-incremental-session-recovery-design.md
|
|
||||||
git commit -m "docs: add incremental session recovery design doc"
|
|
||||||
```
|
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user