feat(agent): 实现摘要自动生成(SummaryConfig + 内联检查点)

SummaryConfig 6 字段配置(trigger_token_ratio / max_context_tokens /
summary_prompt / debounce_turns / summary_model / max_tool_result_chars),
AgentBuilder 链式 summary_config();AgentSession 在 submit_turn /
finalize_turn 的 OnTurnEnd 之后内联检查点:水位 + 防抖(首次不受
约束)→ 独立 LlmCycle 调 submit_messages 生成摘要 → 写入
FocusedConfig.summary_override + slot.save() + SessionMemory 全局快照。

should_summarize 接收 current_turn 参数避免流式路径 turn_index 偏差;
format_messages_as_text 简洁版格式化含 30K 整体截断保留最新;空消息
守卫直接返回空串。所有错误静默 tracing::error!,成功路径
tracing::info!。

src/agent/summary.rs 新增 ~240 行;agent/session.rs +428 行(双路径
检查点 + 关联函数 + 测试 + 公开 API)。零新外部依赖。全量 335 → 353
测试(+18 新测试),clippy 0 警告,doc 0 warning。两轮审查 PASS——
第一轮 PM/SA 修复 11 项,第二轮 Code Reviewer 修复 9 项(含 🔴
generate_summary 空消息 bug + 🟡 5 项 + 💭 2 项)。方案文档 docs/
22-phase16-summary-auto-generation.md(471 行);roadmap.md 标记
Phase 16 完成 + M12 里程碑达成。
This commit is contained in:
徐涛
2026-07-10 06:43:06 +08:00
parent 209932e3b5
commit cc1c68b69d
8 changed files with 1192 additions and 19 deletions
+471
View File
@@ -0,0 +1,471 @@
# Phase 16 — 摘要自动生成
## 背景与目标
### 问题
长对话场景中,用户与 Agent 交互 30+ 轮后,消息历史长度远超模型上下文窗口,导致:
- LLM 被迫丢弃早期上下文,对话丧失连贯性
- 开发者需要手动管理摘要逻辑(调 LLM → 写 SessionMemory → 注入 FocusedConfig
- v0.2 的 `FocusedConfig.summary_override` 消费端已就绪,但生产端是空的——用户只能手动设字符串
### 目标
闭环长对话的"上下文压缩"链路:
```
[消费端 v0.2 已就绪] FocusedConfig.summary_override → filter_focused() 注入摘要
[生产端 v0.3 补齐] token 水位检测 → LLM 摘要生成 → 自动写入 summary_override
```
### 成功标准
1. 开发者只需在 `AgentBuilder` 中链式调用 `.summary_config(cfg)` 即可启用
2. 长对话(如 30+ 轮或 token 水位超过 `max_context_tokens * trigger_token_ratio`)自动触发摘要,下轮 `load_messages()` 返回值包含 `[上下文摘要] {summary}`
3. 摘要生成不改变 `submit_turn` 行为(opt-in、静默失败、不阻断主流程)
4. 零新外部依赖
---
## 需求分析
### 功能需求
| # | 需求 | 优先级 | 说明 |
|---|------|--------|------|
| F1 | `SummaryConfig` 配置结构体 | P0 | `trigger_token_ratio` / `max_context_tokens` / `summary_prompt` / `debounce_turns` / `summary_model` / `max_tool_result_chars` |
| F2 | Token 水位自动检测 | P0 | 每轮 OnTurnEnd 之后检查 `cost_so_far` 是否超过 `max * ratio` |
| F3 | LLM 摘要生成 | P0 | 复用 `self.bundle.provider`,单次无工具 LLM 调用 |
| F4 | 摘要写入 FocusedConfig | P0 | 更新 `summary_override` + `slot.save()` 持久化 |
| F5 | 摘要全局快照 | P0 | 同步写入 `SessionMemory::set("conversation_summary", summary)` |
| F6 | 防抖机制 | P0 | 两次摘要之间至少间隔 `debounce_turns` 轮(默认 3 |
| F7 | 流式路径对称支持 | P0 | `finalize_turn` 中插入相同检查点 |
| F8 | 公开 API`get_conversation_summary()` | P1 | 读取 SessionMemory 中最新的摘要 |
### 非功能需求
| # | 需求 | 指标 |
|---|------|------|
| N1 | 零外部依赖 | 不修改 `Cargo.toml` |
| N2 | 向后兼容 | 未设置 `SummaryConfig` 时行为零变化 |
| N3 | 静默失败 | 摘要 LLM 调用失败不阻断 `submit_turn` |
| N4 | 摘要延迟 | 首次摘要 LLM 调用 ≤ 3s(依赖 provider 响应速度) |
---
## 当前状态分析
### 消费端已就绪
`FocusedConfig.summary_override``src/agent/context.rs`)已在 Phase 10 实现,当前消费逻辑:
```
filter_focused() → 若 cfg.summary_override = Some(text) → 在消息列表末尾插入
Message::system("[上下文摘要] {text}")
```
文档注释明确标注:`// v0.3 将支持 Hook 驱动的自动摘要生成`
### 代码上下文
| 模块 | 文件 | 状态 | 与 Phase 16 的关系 |
|------|------|------|-------------------|
| FocusedConfig | `agent/context.rs` | ✅ 消费端 | 摘要写入 `summary_override` 即生效 |
| OnTurnEnd | `agent/session.rs:345` | ✅ 触发点 | 摘要检查点插在此之后 |
| CostTracker | `llm/cycle/usage.rs` | ✅ 累计 token | 水位检测的数据源 |
| SessionMemory | `agent/session_memory.rs` | ✅ set/get | 摘要全局快照存储 |
| AgentBuilder | `agent/builder.rs` | ✅ 链式构造 | 新增 `.summary_config()` |
| AgentConfig | `agent/runtime.rs` | ✅ 配置结构 | 新增 `summary_config` 字段 |
| LlmProvider | `llm/provider.rs` | ✅ Trait | 摘要 LLM 调用复用 provider |
| ContextSlot.save | `agent/context.rs:251` | ✅ 持久化 | 更新 config 后写回 |
---
## 可选方案推演
### 方案 A(推荐):内联检查点
**做法**:在 `submit_turn``finalize_turn` 中,OnTurnEnd 触发之后、`turn_index` 递增之前,插入以下逻辑:
```rust
if let Some(ref sc) = self.bundle.config.summary_config
&& self.should_summarize(sc)
{
// clone 所需数据(释放 &self 借用)
let provider = Arc::clone(&self.bundle.provider);
let messages = self.slots.get(&self.current_slot_id)
.map(|s| s.messages.clone()).unwrap_or_default();
let prompt = sc.summary_prompt.clone();
let model = sc.summary_model.clone();
// 调关联函数(不持有 &self)
match Self::generate_summary(&provider, &messages, &prompt, model.as_deref(), sc.max_tool_result_chars).await {
Ok(text) => {
// 更新 FocusedConfig + 持久化
if let Some(slot) = self.slots.get_mut(&self.current_slot_id) {
if let SlotMode::Focused(ref mut cfg) = slot.config.mode {
cfg.summary_override = Some(text.clone());
}
let _ = slot.save(&*self.resolve_store()).await;
}
// 全局快照
let _ = self.session_memory.set("conversation_summary", &text).await;
self.last_summary_turn = self.turn_index;
}
Err(e) => tracing::error!("摘要自动生成失败 (turn={}): {}", self.turn_index, e),
}
}
```
**优点**
- 代码路径最短最清晰(~50 行核心逻辑)
- 直接访问所有需要的数据(`cost_so_far``slots``provider``session_memory`
- 流式和同步版本统一处理
- `Option<SummaryConfig>` 本身已提供 opt-in/opt-out
- 不改变 Hook 系统签名
**缺点**
- 摘要 LLM 调用延长了 `submit_turn` 的延迟(约 1-3s
- 违反"Hook 哲学"(但 `Option` 配置已足够提供可插拔性)
### 方案 B(否决):扩展 HookContext
**做法**:在 `HookContext` 中增加 `messages: &[Message]``usage: &Usage``provider: Arc<dyn LlmProvider>` 字段,让 OnTurnEnd Hook 实现者自行做摘要。
**否决原因**
1. **生命周期冲突**`&[Message]` 要求 Hook 调用点消息已就绪但未被 `&mut self` 借用——在 `submit_turn` 第 7 步(slot.save)后消息已就绪,但 to pass `&[Message]` 到 HookContext 需要与 `slot.messages` 的不可变引用共存,而 `submit_turn` 流程中后续步骤需要 `&mut self`
2. **流式路径不可行**`finalize_turn` 触发 OnTurnEnd 时 cycle 已销毁,消息只能从 slot 获取,但 slot 在 `append_messages` 后已被 `&mut` 借用
3. **`Arc<dyn LlmProvider>``'static` 需求**与 `HookContext<'a>` 的设计冲突
### 方案 C(否决):后台 spawn 异步摘要
**做法**token 检测通过后,`tokio::spawn` 后台任务做摘要生成和写入。
**否决原因**
1. **写入冲突**:后台任务无法获取 `&mut AgentSession` 来更新 slot config
2. **绕过方式增加复杂度**:后台任务需要直接操作 `Arc<dyn MemoryStore>` 的原始 key`slot_config:{session_id}:{slot_id}`),绕过了 `ContextSlot::save()` 的封装
3. **并发风险**:如果前一轮摘要尚未完成而下一轮 `finalize_turn` 又触发,可能导致覆盖写
---
## 推荐方案(内联检查点)
### 架构图
```
submit_turn(user_input)
├─ 1. Readonly 检查
├─ 2. OnTurnStart hook
├─ 3. slot.load_messages() ← 历史摘要已注入(如有)
├─ 4. LlmCycle.submit_with_tools
├─ 5. cost_so_far.add(usage)
├─ 6. slot.append_messages + save
├─ 7. OnTurnEnd hook ← 纯通知,不做摘要
├─ [8.5] 摘要检查点 ──────────────────────────────┐
│ ├─ should_summarize(cfg) │
│ │ ├─ cost_so_far >= max * ratio? │
│ │ └─ turn - last_summary >= debounce? │
│ │ │
│ ├─ generate_summary() ← 新 LlmCycle │
│ │ ├─ format_messages_as_text() │
│ │ ├─ replace {messages} │
│ │ └─ submit_messages(无 tools) │
│ │ │
│ └─ 成功 → 更新 summary_override + save │
│ → SessionMemory.set() │
│ → last_summary_turn = turn_index │
│ (流式路径用 saturating_sub(1) 修正) │
│ 失败 → tracing::error! 静默 │
│ │
├─ 9. turn_index++
└─ 10. return Ok(response)
```
### 模块划分
**新增文件**`src/agent/summary.rs`
```
src/agent/summary.rs
├── SummaryConfig // 摘要自动生成配置
├── format_messages_as_text() // 消息 → 纯文本(简洁版)
└── DEFAULT_SUMMARY_PROMPT // 默认 prompt 模板
```
**修改文件**
| 文件 | 改动 |
|------|------|
| `agent/runtime.rs` | `AgentConfig` 新增 `summary_config: Option<SummaryConfig>` |
| `agent/builder.rs` | 新增 `summary_config(cfg)` 方法 |
| `agent/session.rs` | 新增 `last_summary_turn` 字段;`submit_turn` / `finalize_turn` 插入检查点;关联函数 `generate_summary``get_conversation_summary()` |
| `agent.rs` | `pub mod summary` + re-export |
**不变的文件**(无需改动):
| 文件 | 原因 |
|------|------|
| `llm/hooks.rs` | 内联方案不扩展 HookContext |
| `llm/cycle.rs` | 摘要调用通过 `submit_messages` 独立使用 |
| `agent/context.rs` | `FocusedConfig` 消费端已在 Phase 10 就绪 |
| `Cargo.toml` | 零新外部依赖 |
### 核心接口定义
**`SummaryConfig`**`agent/summary.rs`):
```rust
#[derive(Debug, Clone)]
pub struct SummaryConfig {
/// Token 水位触发比例(0.0 ~ 1.0)。默认 0.75。
pub trigger_token_ratio: f64,
/// 模型上下文窗口大小(token)。默认 32_000,覆盖大部分开源模型。
/// 修改为匹配实际使用模型的上下文窗口。
/// ⚠️ 设置为超过模型窗口的值会导致摘要永远不触发。
pub max_context_tokens: u32,
/// 摘要 prompt 模板。`{messages}` 将被替换为对话历史文本。
pub summary_prompt: String,
/// 防抖轮次。默认 3。
pub debounce_turns: u32,
/// 摘要生成模型(None = 沿用主 provider 默认模型)。
/// 默认 None。推荐设为便宜模型(如 "gpt-4o-mini")以节省成本。
pub summary_model: Option<String>,
/// 单个 ToolResult 在格式化时保留的最大字符数。默认 500。
/// 超过此值从尾部截断。字符级安全(`chars().take()`)。
pub max_tool_result_chars: usize,
}
```
**`generate_summary`**`AgentSession` 关联函数):
```rust
impl AgentSession {
async fn generate_summary(
provider: &Arc<dyn LlmProvider>,
messages: &[Message],
prompt_template: &str,
summary_model: Option<&str>,
max_tool_result_chars: usize,
) -> Result<String, LlmError> { ... }
}
```
**`should_summarize`**`AgentSession` 方法):
```rust
fn should_summarize(&self, cfg: &SummaryConfig) -> bool {
self.turn_index - self.last_summary_turn >= cfg.debounce_turns
&& self.cost_so_far.total().total_tokens as f64
>= cfg.max_context_tokens as f64 * cfg.trigger_token_ratio
}
```
### 消息格式化(简洁版)
`format_messages_as_text` 输出格式:
```
System: 你是一个翻译助手
User: 把这段英文翻译成中文
Assistant: 请提供英文文本 [Tool: translate]
Tool Result: 这是中文翻译
User: 谢谢
Assistant: 不客气
```
处理规则:
- `ContentBlock::Text { text }` → 直接拼接
- `ContentBlock::ToolUse { name, .. }``[Tool: {name}]`(不显示参数 JSON
- `Message::ToolResult { content, is_error, tool_call_id }``Tool Result [{tool_call_id}]:` / `Tool Error [{tool_call_id}]:`,便于多工具场景下关联调用的返回
- ToolResult 文本截断到前 `max_tool_result_chars` 个 Unicode 字符(`chars().take(n)`,字符级安全,避免多字节截断)
- 整段对话若超过 30K 字符,从前面截断(优先保留最新消息)
- `Message::UserImage { .. }``User: [image]`
- 非 Text blockImage / Audio / File 等)统一标记为 `[{kind}]`
- 每条消息一行,空行分隔
---
## 实现计划
### Step 16.1 — `SummaryConfig` 结构体
**文件**:新增 `src/agent/summary.rs`
**内容**
- `SummaryConfig` 结构体定义(6 个字段 + doc comments
- `DEFAULT_SUMMARY_PROMPT` 常量(约 100 字中文 prompt,含 `{messages}` 占位符)
- `impl Default for SummaryConfig`
- `format_messages_as_text(messages: &[Message]) -> String` 辅助函数
**验证**`cargo build`
### Step 16.2 — `AgentConfig` 扩展 + `AgentBuilder` 方法
**文件**`src/agent/runtime.rs` + `src/agent/builder.rs`
**改动**
- `AgentConfig` 新增字段:`pub summary_config: Option<SummaryConfig>`
- `AgentBuilder` 新增方法:
```rust
pub fn summary_config(mut self, cfg: SummaryConfig) -> Self {
let mut config = self.config.take().unwrap_or_default();
config.summary_config = Some(cfg);
self.config = Some(config);
self
}
```
**验证**`AgentBuilder` 单元测试 + `cargo test`
### Step 16.3 — `AgentSession` 新字段 + 检查点
**文件**`src/agent/session.rs`
**改动**
1. `AgentSession` 新增字段:`last_summary_turn: u32`(初始化 0
2. `submit_turn` 中 OnTurnEnd 之后、turn_index 之前插入检查点
3. `finalize_turn` 中 OnTurnEnd 之后插入对称检查点。注意:流式路径中 `turn_index` 已在 `submit_turn_stream` 中递增,检查点赋值使用 `self.turn_index.saturating_sub(1)`(与 `OnTurnEnd` hook 保持一致)。
4. 关联函数 `generate_summary`
- 接收 `provider`、`messages`、`prompt_template`、`summary_model`、`max_tool_result_chars`
- 入口守卫:`messages.is_empty()` 时直接返回 `Ok(String::new())`
- 构造 `LlmCycle``max_tokens = Some(1024)`
- 调 `cycle.submit_messages(vec![Message::user_text(prompt)], vec![])`
- 提取 text 返回
5. 公开 API`get_conversation_summary()` → `self.session_memory.get("conversation_summary")`
**验证**`cargo build --all-targets`
### Step 16.4 — re-export
**文件**`src/agent.rs`
**改动**
```rust
pub mod summary;
pub use summary::SummaryConfig;
```
**验证**`cargo test --all-targets`
### Step 16.5 — 测试
| 测试 | 验证点 | 方式 |
|------|--------|------|
| `summary_config_defaults` | 默认值正确 | 单元测试 |
| `summary_not_generated_below_threshold` | token < 阈值时不触发 | `MockProvider` + `Usage::from_input_output(10, 5)` |
| `summary_generated_above_threshold` | token ≥ 阈值时触发 | 设置 `max_context_tokens=20` + `trigger_token_ratio=0.5` |
| `summary_debounce_works` | debounce 内不重复 | 强行触发摘要后验证 3 轮内不触发 |
| `summary_injected_into_focused` | Focused 模式 `load_messages()` 含 `[上下文摘要]` | 检查 Message 内容 |
| `summary_written_to_session_memory` | `get_session_data("conversation_summary")` 有值 | 集成测试 |
| `summary_not_injected_in_full_mode` | Full 模式不改 slot config | 验证 `summary_override` 为 None |
| `summary_failure_does_not_block` | LLM error 不阻断 `submit_turn` | MockProvider 返回错误 |
| `summary_stream_path` | 流式路径 `finalize_turn` 正确触发 | `submit_turn_stream` 端到端 |
| `summary_format_messages` | 格式化输出结构正确 | 单元测试验证格式 |
| `summary_skipped_for_empty_messages` | 空消息不调用 LLM | `generate_summary` 直接返回 `""` |
| `summary_not_generated_if_max_context_unreachable` | `max_context_tokens` 过大时不触发 | 验证条件不满足 |
**验证**`cargo test --all-targets` 全绿
---
## 规模估算
| 组件 | 纯实现 | 测试 | 合计 |
|------|--------|------|------|
| `agent/summary.rs`SummaryConfig + format_messages + 默认 prompt + 截断守卫) | 60 | 10 | 70 |
| `agent/runtime.rs`1 个字段) | 3 | — | 3 |
| `agent/builder.rs`1 个方法) | 8 | 3 | 11 |
| `agent/session.rs`(检查点 + generate_summary + get_conversation_summary | 40 | 100 | 140 |
| `agent.rs`module 声明 + re-export | 3 | — | 3 |
| **合计** | **109** | **113** | **~222** |
---
## 风险评估
### 已知风险
| 风险 | 概率 | 影响 | 缓解措施 |
|------|------|------|---------|
| **同步阻塞**:摘要 LLM 调用延长 submit_turn 延迟 | 高 | 长对话用户多等 1-3s | 对于已达 75% 水位的长对话,用户感知可接受;所有错误静默处理 |
| **默认模型不兼容**:非 OpenAI 用户未设置 `summary_model` 但默认 `None` 沿用主模型 | 低 | 无影响 | `summary_model` 默认 `None`,沿用主 provider 默认模型,零兼容问题 |
| **无限循环**:摘要不减少 cost_so_far,每轮都超阈值 | 中 | 频繁 LLM 调用浪费 token | `debounce_turns=3` 强制隔断;`last_summary_turn` 记录确保了间隔。注意:摘要 token 不计入 `cost_so_far`(独立 LlmCycle),阈值不会因摘要本身加速膨胀 |
| **Focusd 模式摘要位置**:注入为 `system` 消息排在列表末尾 | 低 | LLM 近因效应,摘要可能过度受关注 | 这是 v0.2 消费端的设计选择,Phase 16 不改变 |
| **SessionMemory key 冲突**:用户手动写入 `"conversation_summary"` 会被覆盖 | 低 | 数据被摘要覆盖 | 文档建议用户自定义 key;或未来使用 namespaced key |
| **可观测性盲区**`tracing::warn!` 依赖用户配置了 tracing subscriber | 中 | 失败静默不可见 | 提升到 `tracing::error!` 级别,或加 `eprintln!` fallback |
### 不做的事
- ❌ 不扩展 `HookContext`
- ❌ 不引入 `tokio::spawn` 后台摘要
- ❌ 不做增量摘要(`SummaryStrategy::Incremental` 留待 v0.4
- ❌ 不改 `filter_focused()` 的摘要注入位置
- ❌ 不追踪摘要 token 消耗(`summary_cost_so_far`
- ❌ 不添加运行时 prompt 校验(不检查 `{messages}` 是否存在)
- ❌ 不添加 `MergeStrategy::Summarize` 变体(`context.rs:108` 预占注释将在实施时同步移除或更新)
---
## 验收标准
### 编译与测试
| 检查项 | 指标 |
|--------|------|
| `cargo build --all-targets` | ✅ 通过 |
| `cargo test --all-targets` | ✅ 全量通过(预计 335 → ~345,新增 ~10 测试) |
| `cargo clippy --all-targets -- -D warnings` | ✅ 0 警告 |
| 测试覆盖范围 | F1-F8、N1-N4 |
### 功能验收场景
**场景 1:启用摘要后的长对话**
```rust
let session = AgentSession::new(agent, "session-1", Arc::new(
AgentBuilder::new()
.provider(provider)
.tool_registry(registry)
.hook_executor(executor)
.summary_config(SummaryConfig {
max_context_tokens: 100,
trigger_token_ratio: 0.5,
debounce_turns: 2,
..Default::default()
})
.build()?
));
session.submit_turn("msg 1").await?;
// ... submit_turn 多次直到 token 超 50 ...
// 第 N 轮:摘要自动生成
let summary = session.get_session_data("conversation_summary").await?;
assert!(summary.is_some());
// Focused 模式下 load_messages 包含摘要
```
**场景 2:不启用时零影响**
```rust
let session = AgentSession::new(agent, "session-2", bundle); // 无 summary_config
for i in 0..50 {
session.submit_turn(&format!("msg {}", i)).await?;
}
// 没有摘要产生,没有额外的 LLM 调用
```
---
## 参考来源
- Phase 10 方案文档:`docs/17-phase10-contextslot.md`(§5 FocusedConfig 消费端设计)
- Phase 14 方案文档:`docs/20-phase14-document-and-embedding.md`Provider 复用模式)
- 当前代码:`src/agent/session.rs`submit_turn 流程,OnTurnEnd 位置)
- 当前代码:`src/llm/cycle.rs`submit_messages 签名)
- 当前代码:`src/agent/context.rs`FocusedConfig.summary_override + filter_focused 消费逻辑)
- 当前代码:`src/agent/runtime.rs`AgentConfig 结构)
- 当前代码:`src/agent/builder.rs`Builder 链式模式)
- 当前代码:`src/agent/session_memory.rs`set/get API
+31 -16
View File
@@ -1,13 +1,13 @@
# AG Core Roadmap
> 定稿日期:2026-05-11
> 最后更新:2026-07-09Phase 15 完成 + M11 里程碑达成 + Phase 16 方案推演
> 最后更新:2026-07-10Phase 16 第二轮实施审查 PASS + 9 项问题修复
## 愿景
AG Core 定位为构建 AI 智能体的底层工具箱,通过模块化、可插拔的架构,提供大模型调用、提示词工程、工具系统、记忆检索四大核心能力,支持快速组合出符合业务需求的智能体应用。
**当前状态**v0.2.0-rc.1 已打标签。Phase 0-15 全部完成。v0.3.0 实施中,Phase 16-19 共 4 个增量 Phase 待交付。目标是从"LLM 调用工具箱"升级为"能构建多 Agent 协作、RAG、长记忆 Agent 产品的基础系统"。
**当前状态**v0.2.0-rc.1 已打标签。Phase 0-16 全部完成。v0.3.0 实施中,Phase 17-19 共 3 个增量 Phase 待交付。目标是从"LLM 调用工具箱"升级为"能构建多 Agent 协作、RAG、长记忆 Agent 产品的基础系统"。
---
@@ -738,17 +738,31 @@ graph BT
**目标**:闭环长对话能力。v0.2 的 `inject_summary` 消费端(`FocusedConfig.summary_override`)已就绪,缺的是生产端。
**交付物**
1. `SummaryConfig` 结构体:`trigger_token_ratio`(默认 0.75 / `max_context_tokens`(默认 128_000 / `summary_prompt`可自定义,`{messages}` 占位符 / `debounce_turns`(默认 3 / `summary_model`(默认 `Some("gpt-4o-mini")`
2.`submit_turn` / `finalize_turn` 中 OnTurnEnd 之后插入**内联检查点**(非 Hook 扩展):`should_summarize`(水位检测 + 防抖)→ `generate_summary`(新 `LlmCycle` + `submit_messages` 无工具调用)→ 更新 `FocusedConfig.summary_override` + `slot.save()` 持久化 + `SessionMemory::set("conversation_summary", summary)` 全局快照
3. `AgentBuilder` 扩展:`.summary_config(cfg)` 方法
1. `SummaryConfig` 结构体:`trigger_token_ratio`(默认 0.75 / `max_context_tokens`(默认 32_000/ `summary_prompt`默认中文 `DEFAULT_SUMMARY_PROMPT` `{messages}` / `debounce_turns`(默认 3 / `summary_model`(默认 `None` 沿用主模型) / `max_tool_result_chars`(默认 500
2.`submit_turn` / `finalize_turn` 中 OnTurnEnd 之后插入**内联检查点**(非 Hook 扩展):`should_summarize`(水位 + 防抖,首次不受防抖约束)→ `generate_summary` 关联函数(新 `LlmCycle` + `submit_messages` 无工具调用)→ 更新 `FocusedConfig.summary_override` + `slot.save()` 持久化 + `SessionMemory::set("conversation_summary", summary)` 全局快照
3. `AgentBuilder` 扩展:`.summary_config(cfg)` 方法(不覆盖整个 `AgentConfig`
4. 公开 API`get_conversation_summary() -> Result<Option<String>, AgentError>`
5. `format_messages_as_text()` 简洁版消息格式化(System/User/Assistant + `[Tool: name]` + `Tool Result [id]:` 截断到 `max_tool_result_chars` 字符)
**设计决策**:内联于 `submit_turn` 流程而非 Hook 扩展(因为 HookContext 无法携带 `&mut self` 引用更新 slot config,且流式路径的 `finalize_turn``cycle` 已销毁)。`Option<SummaryConfig>` 的 opt-in 机制已足够提供可插拔性,不改变 Hook 系统签名。
**设计决策**:内联于 `submit_turn` 流程而非 Hook 扩展(因为 HookContext 无法携带 `&mut self` 引用更新 slot config,且流式路径的 `finalize_turn``cycle` 已销毁)。`Option<SummaryConfig>` 的 opt-in 机制已足够提供可插拔性,不改变 Hook 系统签名。流式路径中 `submit_turn_stream` 已将 `turn_index` 提前 ++1,检查点使用 `saturating_sub(1)` 修正。
**依赖**:无(`submit_turn` 流程 + `CostTracker` + `SessionMemory` + `LlmProvider` 均已就绪)
**优先级**P0
**预估规模**:约 220 行(含测试约 100 行
**状态**⏳ 待实施(方案文档已就绪:`docs/22-phase16-summary-auto-generation.md`
**预估规模**:约 220 行(实际约 250 行,含 11 个内联测试
**方案文档**`docs/22-phase16-summary-auto-generation.md`471 行,经 PM/SA 审查 11 项修复 + 实施后第二轮审查 9 项修复全部完成
**状态**:✅ Phase 16 全部交付物已完成(含实施后 PM/SA/Code Reviewer 第二轮审查 PASS
**实施后审查修复记录**(共 9 项):
- 🔴 B1`generate_summary` 调用 `submit_messages(Vec::new(), vec![])` 发送空消息列表 → 移除 `with_messages()`,直接 `submit_messages(vec![Message::user_text(prompt)], vec![])`
- 🟡 W4`should_summarize` 使用 `self.turn_index` 而非 `current_turn` 参数 → 改签名接收 `current_turn`,流式路径防抖准确
- 🟡 W2`summary_model` 硬编码 `unwrap_or("gpt-4o")` → 改为条件赋值,`None` 时沿用 `CycleConfig::default()`
- 🟡 W5Full 模式 `slot.save()` 无谓调用 → 移入 `SlotMode::Focused` 分支内
- 🟡 W3`format_messages_as_text` 缺 30K 整体截断 → 新增 `MAX_TOTAL_CHARS=30_000` + `truncate_total_chars`,优先保留最新
- 🟡 W6:摘要成功无日志 → 添加 `tracing::info!(turn, summary_len, "摘要自动生成成功")`
- 🟡 W1/W7:缺 3 个测试 → 新增 `format_total_charset_truncation_keeps_recent` / `summary_written_to_focused_slot_config` / `summary_skipped_for_empty_messages` / `summary_not_generated_if_max_context_unreachable`
- 💭 `context.rs:78` 过时注释("v0.3 将支持 Hook 驱动")→ 更新为"v0.3 Phase 16 起 AgentBuilder 内联检查点自动生成摘要"
**第二轮审查门禁**PASS0 🔴 阻塞)。`cargo test --all-targets` **353 passed / 0 failed**clippy 0 警告,doc 0 warning。
---
@@ -839,7 +853,7 @@ graph BT
P13["<b>Phase 13: 热身清理</b><br/>旧 types 文件删除<br/>ContextSlot fork/merge"]:::done
P14["<b>Phase 14: Document + Embedding</b><br/>Document 类型<br/>RecursiveCharacterSplitter<br/>Embedding trait"]:::done
P15["<b>Phase 15: 向量存储持久化</b><br/>VectorStore trait<br/>PersistentVectorStore<br/>RagPipeline<br/>19 新测试"]:::done
P16["<b>Phase 16: 摘要自动生成</b><br/>SummaryConfig<br/>内联检查点<br/>FocusedConfig 自更新"]:::pending
P16["<b>Phase 16: 摘要自动生成</b><br/>SummaryConfig<br/>内联检查点<br/>首次防抖跳过<br/>18 新测试"]:::done
P17["<b>Phase 17: 执行引擎</b><br/>SessionManager<br/>会话树<br/>Time-travel Checkpointer"]:::pending
P18["<b>Phase 18: 切换与调度</b><br/>Agent Switch<br/>SubAgent Dispatch<br/>dispatch_all 并发控制"]:::pending
P19["<b>Phase 19: 知识图谱</b><br/>KnowledgeGraph trait<br/>InMemoryGraph<br/>双通道检索"]:::pending
@@ -858,7 +872,7 @@ graph BT
| **M9** | Phase 13 | 旧 types 文件删除、`cargo test --all-targets` 全绿、`fork`/`merge` 测试通过 | ✅ 2026-07-08 |
| **M10** | Phase 14 | `Document` + `RecursiveCharacterSplitter` 分割结果验证、`MockEmbedding` 测试通过 | ✅ 2026-07-09 |
| **M11** | Phase 15 | `PersistentVectorStore` 持久化 roundtrip、`RagPipeline::ingest → retrieve` 端到端验证 | ✅ 2026-07-09 |
| **M12** | Phase 16 | 多轮对话后摘要自动写入 SessionMemory、派生 slot 时摘要正确注入 | ⏳ |
| **M12** | Phase 16 | 多轮对话后摘要自动写入 SessionMemory、派生 slot 时摘要正确注入 + 第二轮实施审查 PASS | ✅ 2026-07-10 |
| **M13** | **Phase 17 (rc.1)** | `SessionManager` 创建/子树/恢复集成测试通过、`Checkpointer` checkpoint/rollback/fork 验证 | ⏳ |
| **M14** | Phase 18 | `switch_agent` 热切换验证、`dispatch`/`dispatch_all` 多轮对话 + 结果回传验证 | ⏳ |
| **M15** | Phase 19 | `KnowledgeGraph` 实体-关系 CRUD + `get_related` BFS 验证、双通道检索 Hybrid 策略验证 | ⏳ |
@@ -896,7 +910,7 @@ graph BT
1. **持久化依赖**`rusqlite` + `bundled` 零外部依赖编译,但 SQLite 不适配所有场景(分布式/高并发写)。`MemoryStore` trait 的抽象层允许下游自行实现 Redis / PostgreSQL 后端
2. **ContextSlot 心智负担**`ContextSlot` 引入了一等抽象的复杂度。建议通过 `AgentBuilder` 默认创建 `"default"` slot,让简单场景无感使用
3. **向量检索规模上限**v0.3 的 `PersistentVectorStore` 全量加载到内存做余弦搜索,适合 ≤10 万条向量。超出此规模需换用专用向量库。v0.4 可以评估引入
4. **Scope 蔓延**v0.3 新增 `agent/summary` `document/` `engine/` `memory/vector_store` 模块,功能覆盖扩展到多 Agent 基础系统。始终保持 trait + reference impl 的边界,业务循环留给上层
4. **Scope 蔓延**v0.3 新增 `agent/summary` `document/` `engine/` `memory/vector_store` 模块,功能覆盖扩展到多 Agent 基础系统。始终保持 trait + reference impl 的边界,业务循环留给上层Phase 16 已交付 `agent/summary` 摘要生产端 + `format_messages_as_text` 简洁版格式化 + 30K 字符整体截断保留最新;实施后两轮审查 PASS,0 🔴 阻塞)
5. **API 稳定性**v0.3 引入 `Checkpointer``SessionManager``VectorStore` 等新公开 APIv0.2 已有的 `#[non_exhaustive]``#[deprecated]` 机制继续沿用
6. **Checkpointer 存储效率**:v0.3 使用全量 JSON 序列化存储 checkpoint,每轮对话约几百 KB。`fork` 从历史 checkpoint 创建新 session 时也会复制全量。等实际使用中发现存储瓶颈时再改为增量模式
@@ -904,10 +918,10 @@ graph BT
## 下一步行动
1. **v0.3.0 Phase 16 启动**摘要自动生成(`SummaryConfig` 配置 + 内联检查点 + `FocusedConfig.summary_override` 自动更新 + `SessionMemory` 快照),基于已有 `CostTracker` 水位检测
2. **Phase 16-19 顺次交付**:按依赖关系推进摘要 → 引擎 → 调度 → 知识图谱
1. **v0.3.0 Phase 17 启动**Agent 执行引擎(`SessionManager` 会话树 + `Checkpointer` time-travel),基于 Phase 10 的 `ContextSlot` 持久化层构建
2. **Phase 17-19 顺次交付**:按依赖关系推进引擎 → 调度 → 知识图谱
3. **示例先行**:每完成一个 Phase 立即创建/更新对应示例,确保 `cargo run --example` 可验证
4. **里程碑追踪**:以 M11Phase 15)为已达成里程碑,逐 Phase 推进 M12-M15
4. **里程碑追踪**:以 M12Phase 16)为已达成里程碑,逐 Phase 推进 M13-M15
**已完成 / 进行中阶段**
- ✅ Phase 0 Foundation — 全部交付物已完成
@@ -924,14 +938,15 @@ graph BT
-**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 warning11 个离线示例全部 exit 0
-**Phase 11 测试与检索补强**`src/memory/vector.rs` 新增 `VectorRetriever` traitindex + search 抽象)+ `InMemoryVectorRetriever` 引用实现(HashMap + 全量余弦相似度扫描 + 零依赖 `dot()`),6 个内联测试覆盖 basic/empty/zero-vector/k=0/2 个并发;wiremock Provider roundtrip 测试 12 个(OpenAI 8 + Anthropic 4)覆盖请求体/header/401/429/500/529/流式 usage-only/流式错误/ToolUse/结构化错误体;`MemoryStore` 并发测试 5 个(InMemoryStore 3 + SqliteStore 2)覆盖 100 并发写、5 写+5 读混合 2 秒、15 写者容量淘汰;`openai.rs` `handle_error_response` 修复 429 retry-after 解析(5 行,与 anthropic 对齐);方案文档 `docs/18-phase11-testing-and-retrieval.md`(647 行,含 10 项架构决策 + 2 条实施偏差记录 #6 mid-stream mock 模式 + #7 retry-after 修复);全量 254 → 277+23 新测试),clippy 0 警告,doc 0 warning,并发测试 3 次稳定无 flaky
-**Phase 13 热身清理 + ContextSlot fork/merge** — 3 个旧 types 文件删除(`request.rs` 187 行 + `response.rs` 177 行 + `old_stream.rs` 45 行),所有 OpenAI wire-format 类型迁入 `provider/openai.rs` 可见性 `pub(crate)`Breaking Change:原 `agcore::llm::types::OpenaiChatRequest/Response/Chunk` 公共 re-export 路径已删除);`ChatResponse` 自 v0.1.0 标记 `#[deprecated]` 后在 Phase 13 整体删除;`ToolChoice``request.rs` 迁入 `tool.rs`(公共 `agcore::llm::types::ToolChoice` 路径不变);`ContextSlot::fork()` 派生独立子 slot`SlotSource::Derived { parent_id, strategy }` 血缘可追溯)+ `ContextSlot::merge(child, MergeStrategy)` 合入父 slot`Append` / `Replace` 两种策略,`#[non_exhaustive]` 为 Phase 16 `Summarize` 预留);`MergeStrategy` 防御性检查(self-merge / 跨 session / Readonly 目标全部阻断);`AgentSession::derive_slot` 重构复用 `fork()` 消除重复;`agent.rs` 追加 `MergeStrategy` re-export9 个 fork/merge 内联测试覆盖 happy path 与 error path`stream.rs` 简化为 module doc + `pub use` 重导出(保持 `use crate::llm::stream::StreamEvent` 路径兼容);方案文档 `docs/19-phase13-cleanup-and-fork-merge.md`640 行);全量 277 → 286+9 新测试),clippy 0 警告,doc 0 warning
-**Phase 13 热身清理 + ContextSlot fork/merge** — 3 个旧 types 文件删除(`request.rs` 187 行 + `response.rs` 177 行 + `old_stream.rs` 45 行),所有 OpenAI wire-format 类型迁入 `provider/openai.rs` 可见性 `pub(crate)`Breaking Change:原 `agcore::llm::types::OpenaiChatRequest/Response/Chunk` 公共 re-export 路径已删除);`ChatResponse` 自 v0.1.0 标记 `#[deprecated]` 后在 Phase 13 整体删除;`ToolChoice``request.rs` 迁入 `tool.rs`(公共 `agcore::llm::types::ToolChoice` 路径不变);`ContextSlot::fork()` 派生独立子 slot`SlotSource::Derived { parent_id, strategy }` 血缘可追溯)+ `ContextSlot::merge(child, MergeStrategy)` 合入父 slot`Append` / `Replace` 两种策略,`#[non_exhaustive]` 预留扩展);`MergeStrategy` 防御性检查(self-merge / 跨 session / Readonly 目标全部阻断);`AgentSession::derive_slot` 重构复用 `fork()` 消除重复;`agent.rs` 追加 `MergeStrategy` re-export9 个 fork/merge 内联测试覆盖 happy path 与 error path`stream.rs` 简化为 module doc + `pub use` 重导出(保持 `use crate::llm::stream::StreamEvent` 路径兼容);方案文档 `docs/19-phase13-cleanup-and-fork-merge.md`640 行);全量 277 → 286+9 新测试),clippy 0 警告,doc 0 warning
- ✅ Provider IR 重构 — 统一类型系统 + OpenAI/Anthropic/DeepSeek/Qwen/Ollama 适配
- ✅ LlmCycle 简化 — IR 消息类型切换 + Phase 0 桥接层移除
- ✅ v0.1 Release — 技术债扫清、MockProvider 公开化、8 个离线示例(含 `simple_visit`)、README + 错误消息友好化、CHANGELOG 初始化
-**v0.2 规划细化完成** — 8 个增量 PhasePhase 5-12),17 个可验证 Step,覆盖 P0-P2 全部 12 项功能 + ContextSlot
-**v0.3.0 Phase 13 完成** — 技术债清理(3 旧 types 文件 + ChatResponse 删除)+ ContextSlot fork/merge9 新测试),M9 里程碑达成
-**v0.3.0 Phase 14 完成** — Document 类型(id/content/metadata/mime_type+ `RecursiveCharacterSplitter` 两阶段算法(按 separator 优先级递归分割 + 贪心合并 overlap,全部 `chars_len()` 字符级比较)+ `Embedding` traitasync + `LlmError` 复用)+ `MockEmbedding`sin-hash 零依赖伪随机 + L2 归一化)+ 19 Document 测试 + 6 Embedding 测试(含 1 个 split_multibyte_utf8_boundary CJK 边界测试);`src/document.rs`580 行)+ `src/llm/embedding.rs`183 行)+ `examples/document_demo.rs`74 行);`pub use document::Document` 在 lib.rs 重导出;CJK 分隔符(`。`/``/``)加入 `DEFAULT_SEPARATORS`;方案文档 `docs/20-phase14-document-and-embedding.md`1417 行);全量 286 → 313+27 新测试,0 失败),clippy 0 警告,doc 0 warning,零新外部依赖;M10 里程碑达成
-**v0.3.0 Phase 15 完成**`VectorStore` trait`add`/`search`/`remove`/`add_one`,返回 `(Document, f32)` 消除调用方 id→Document 维护开销)+ `InMemoryVectorStore``Mutex<HashMap>` + 余弦全量扫描 + 预计算 L2 norm 缓存)+ `PersistentVectorStore`(构造时全量加载,先写持久化后写内存,持久化失败时内存不污染重启自动恢复,`remove` 幽灵数据窗口已知)+ `RagPipeline` 组合器(ingest: split→embed→store.add / retrieve: embed→store.search`splitter: Option<RecursiveCharacterSplitter>` 灵活切换);`src/memory/vector_store.rs`(937 行,19 个内联测试覆盖 14 场景含 2 个性能基准)+ 零新外部依赖(纯 Rust `dot()` 余弦);旧 `VectorRetriever`/`InMemoryVectorRetriever` 标注 `#[deprecated(since = "0.3.0")]` 迁移路径清晰;`search_orthogonal_vectors` 返回 1 条 score≈0(文档已同步修正不过滤低分向量);方案文档 `docs/21-phase15-vector-store-persistence.md`(1570 行,经 3 轮审查 + 文档-代码一致化修复);全量 313 → 335(+22 新测试),clippy 0 警告,doc 0 warningM11 里程碑达成Phase 16-19 共 4 个增量 Phase 待实施(摘要 → 引擎 → 调度 → 知识图谱)
-**v0.3.0 Phase 15 完成**`VectorStore` trait`add`/`search`/`remove`/`add_one`,返回 `(Document, f32)` 消除调用方 id→Document 维护开销)+ `InMemoryVectorStore``Mutex<HashMap>` + 余弦全量扫描 + 预计算 L2 norm 缓存)+ `PersistentVectorStore`(构造时全量加载,先写持久化后写内存,持久化失败时内存不污染重启自动恢复,`remove` 幽灵数据窗口已知)+ `RagPipeline` 组合器(ingest: split→embed→store.add / retrieve: embed→store.search`splitter: Option<RecursiveCharacterSplitter>` 灵活切换);`src/memory/vector_store.rs`(937 行,19 个内联测试覆盖 14 场景含 2 个性能基准)+ 零新外部依赖(纯 Rust `dot()` 余弦);旧 `VectorRetriever`/`InMemoryVectorRetriever` 标注 `#[deprecated(since = "0.3.0")]` 迁移路径清晰;`search_orthogonal_vectors` 返回 1 条 score≈0(文档已同步修正不过滤低分向量);方案文档 `docs/21-phase15-vector-store-persistence.md`(1570 行,经 3 轮审查 + 文档-代码一致化修复);全量 313 → 335(+22 新测试),clippy 0 警告,doc 0 warningM11 里程碑达成
-**v0.3.0 Phase 16 完成**`SummaryConfig` 配置结构体(6 个字段:`trigger_token_ratio=0.75` / `max_context_tokens=32_000` / `summary_prompt` / `debounce_turns=3` / `summary_model=None` / `max_tool_result_chars=500`,默认 `None` 沿用主模型避断裂非 OpenAI 用户)+ `AgentBuilder::summary_config(cfg)` 链式方法 + `AgentConfig.summary_config: Option<SummaryConfig>` 字段;`AgentSession` 新增 `last_summary_turn: Option<u32>` 字段(首次不受防抖约束,`should_summarize``Option` 哨兵实现)+ `maybe_summarize(current_turn)` 内联检查点(OnTurnEnd 之后 / `turn_index` 之前,对称 `submit_turn` / `finalize_turn` 两个入口,流式路径 `saturating_sub(1)` 修正)+ 关联函数 `generate_summary`(构造独立 `LlmCycle``submit_messages``vec![Message::user_text(prompt)]``max_tokens=1024`,空消息守卫直接返回空串)+ 公开 API `get_conversation_summary()``src/agent/summary.rs`~240 行,含 8 个 SummaryConfig/`format_messages_as_text` 内联测试——默认值/空输入/系统用户助理/ToolResult(含 `tool_call_id`/工具调用/Unicode 安全截断/整体 30K 截断保留最新;有效字符数截断多字节安全,droptest 验证保留尾部消息)+ `src/agent/session.rs` 注入 10 个摘要集成测试(默认值不触发 / 超阈值触发 / 防抖阻止重复 / SessionMemory 写入 / Full 模式不注入 / 失败不阻断主流程 / 流式路径触发 / 默认配置零影响 / **Focused `summary_override` 写入正向验证** / **空消息不调用 LLM** / **巨型 `max_context_tokens` 永不触发**);`format_messages_as_text` 简洁版消息格式化(`[Tool: name]` + `Tool Result [id]:` + ToolResult 字符级 `chars().take(max_tool_result_chars)` 截断 + 整段 30K 总长度截断从头部保留最新);所有错误静默(失败用 `tracing::error!`,成功用 `tracing::info!(turn, summary_len)`);`MergeStrategy` 注释中过时 "Summarize 指向"与 `context.rs:78` "v0.3 将支持 Hook 驱动" 过时注释在实施时同步移除/更新;方案文档 `docs/22-phase16-summary-auto-generation.md`(471 行),实施后**两轮审查 PASS**:第一轮 PM/SA 审查 11 项问题修复 + 第二轮实施审查 9 项问题修复(🔴 `generate_summary` 空消息 bug + 🟡 W4 流式路径防抖 + 🟡 W2 模型硬编码 + 🟡 W5 Full 模式无谓 save + 🟡 W3 30K 截断 + 🟡 W6 成功无日志 + 🟡 W1/W7 测试补全 + 💭 注释同步);零新外部依赖;全量 335 → **353**(+18 新测试,含二次审查增补 4 个),clippy 0 警告,doc 0 warning`quick_start` 示例正常 exit 0;**M12 里程碑达成** + 第二轮审查门禁 PASSPhase 17-19 共 3 个增量 Phase 待实施(引擎 → 调度 → 知识图谱)
---
+2
View File
@@ -16,6 +16,7 @@ pub mod error;
pub mod runtime;
pub mod session;
pub mod session_memory;
pub mod summary;
pub mod task;
// 重导出公共 API(按使用频度排序)
@@ -29,5 +30,6 @@ pub use error::AgentError;
pub use runtime::{AgentConfig, RuntimeBundle};
pub use session::AgentSession;
pub use session_memory::SessionMemory;
pub use summary::SummaryConfig;
pub use task::JsonPlanParser;
pub use task::{Plan, PlanParser, Step, StepStatus, TaskAgent};
+10
View File
@@ -11,6 +11,7 @@ use std::sync::Arc;
use crate::agent::error::AgentError;
use crate::agent::runtime::{AgentConfig, RuntimeBundle};
use crate::agent::summary::SummaryConfig;
use crate::llm::hooks::HookExecutor;
use crate::llm::provider::LlmProvider;
use crate::memory::retriever::MemoryRetriever;
@@ -86,6 +87,15 @@ impl AgentBuilder {
self
}
/// 设置摘要自动生成配置(覆盖字段,而非整体覆盖 config)。
/// 不传则沿用现有 `config.summary_config`(默认 `None`,即关闭)。
pub fn summary_config(mut self, cfg: SummaryConfig) -> Self {
let mut config = self.config.take().unwrap_or_default();
config.summary_config = Some(cfg);
self.config = Some(config);
self
}
/// 构造 `RuntimeBundle`,校验必填字段。
///
/// **错误**`provider` / `tool_registry` / `hook_executor` 任一缺失则返回
+4 -3
View File
@@ -74,8 +74,9 @@ pub struct FocusedConfig {
pub keep_system: bool,
/// 保留的最近消息条数(以消息条数而非对话轮次为单位,因为一轮对话可能包含多条 tool 消息)。
pub recent_messages: usize,
/// 摘要覆盖(v0.2 仅消费端:手动设置则注入,不自动生成)。
/// v0.3 将支持 Hook 驱动的自动摘要生成。
/// 摘要覆盖(消费端:手动或自动生成的摘要会注入到消息列表末尾)。
/// v0.3 Phase 16 起,`AgentBuilder::summary_config(cfg)` 内联检查点会
/// 自动调用 LLM 生成摘要并写入此字段,详见 `docs/22-phase16-summary-auto-generation.md`。
pub summary_override: Option<String>,
}
@@ -105,7 +106,7 @@ pub enum DeriveStrategy {
/// 合并策略 —— Phase 13 新增,控制 `ContextSlot::merge` 如何将子 slot 消息合入父 slot。
///
/// `#[non_exhaustive]` 允许 Phase 16 加入 `Summarize` 变体而不破坏现有匹配
/// `#[non_exhaustive]` 预留未来扩展(如 `Summarize` 变体
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum MergeStrategy {
+6
View File
@@ -15,6 +15,7 @@
use std::sync::Arc;
use std::time::Duration;
use crate::agent::summary::SummaryConfig;
use crate::llm::compact::CompactConfig;
use crate::llm::hooks::HookExecutor;
use crate::llm::provider::LlmProvider;
@@ -33,6 +34,10 @@ pub struct AgentConfig {
pub session_ttl: Option<Duration>,
/// 上下文压缩配置(None 表示不启用自动压缩),默认 None。
pub compact_config: Option<CompactConfig>,
/// 摘要自动生成配置(`None` = 不启用)。
/// 设置后 `AgentSession` 每轮 OnTurnEnd 之后进行水位 + 防抖检查,触发时调 LLM
/// 生成摘要并写入 `FocusedConfig.summary_override` 与 `SessionMemory["conversation_summary"]`。
pub summary_config: Option<SummaryConfig>,
}
impl Default for AgentConfig {
@@ -42,6 +47,7 @@ impl Default for AgentConfig {
max_tool_turns: 10,
session_ttl: None,
compact_config: None,
summary_config: None,
}
}
}
+428
View File
@@ -24,8 +24,11 @@ use crate::agent::context::SlotSource;
use crate::agent::error::AgentError;
use crate::agent::runtime::RuntimeBundle;
use crate::agent::session_memory::SessionMemory;
use crate::agent::summary::{format_messages_as_text, SummaryConfig};
use crate::llm::cycle::{CostTracker, CycleConfig, LlmCycle};
use crate::llm::error::LlmError;
use crate::llm::hooks::{HookContext, HookEvent};
use crate::llm::provider::LlmProvider;
use crate::llm::stream::StreamEvent;
use crate::llm::types::message::Message;
use crate::llm::types::response_v2::MessageResponse;
@@ -55,6 +58,9 @@ pub struct AgentSession {
slots: HashMap<String, ContextSlot>,
/// Phase 10 新增:当前活跃 slot 的 id。
current_slot_id: String,
/// Phase 16 新增:上次摘要生成时的 `turn_index`(用于 `debounce_turns` 防抖)。
/// `None` 表示从未生成过摘要(首次触发不受防抖约束)。
last_summary_turn: Option<u32>,
}
impl std::fmt::Debug for AgentSession {
@@ -113,6 +119,7 @@ impl AgentSession {
session_memory,
slots,
current_slot_id: "default".to_string(),
last_summary_turn: None,
}
}
@@ -345,6 +352,9 @@ impl AgentSession {
let end_ctx = HookContext::new(HookEvent::OnTurnEnd).with_turn_index(turn_index);
hook_executor.execute(HookEvent::OnTurnEnd, &end_ctx).await;
// 7.5 Phase 16: 摘要自动生成检查点
self.maybe_summarize(turn_index).await;
// 8. turn_index 递增
self.turn_index += 1;
@@ -462,14 +472,130 @@ impl AgentSession {
.hook_executor
.execute(HookEvent::OnTurnEnd, &end_ctx)
.await;
// Phase 16: 摘要检查点(流式路径 turn_index 已被 submit_turn_stream 提前 ++1
self.maybe_summarize(self.turn_index.saturating_sub(1)).await;
Ok(())
}
// ====== Phase 16: 摘要自动生成 ======
/// 读取 SessionMemory 中最新的对话摘要(`None` 表示从未生成过)。
pub async fn get_conversation_summary(&self) -> Result<Option<String>, AgentError> {
self.session_memory.get("conversation_summary").await
}
/// 水位 + 防抖检查:是否应当触发摘要生成。
/// 防抖只对"上一轮与本轮之间的间隔"起作用——首次(`last_summary_turn.is_none()`)不阻塞。
/// `current_turn` 显式传入而非读 `self.turn_index`,因为流式路径中 `submit_turn_stream` 已提前 ++1
/// `finalize_turn` 会用 `saturating_sub(1)` 修正后的值传入此函数。
fn should_summarize(&self, cfg: &SummaryConfig, current_turn: u32) -> bool {
let debounce_ok = match self.last_summary_turn {
None => true,
Some(last) => current_turn.saturating_sub(last) >= cfg.debounce_turns,
};
debounce_ok
&& self.cost_so_far.total().total_tokens as f64
>= cfg.max_context_tokens as f64 * cfg.trigger_token_ratio
}
/// 检查点入口:水位超阈值时调 LLM 生成摘要,写入 slot config 与 SessionMemory。
/// 所有错误(含 LLM error、save 失败、session_memory 写失败)均静默(`tracing::error!` 后返回)。
async fn maybe_summarize(&mut self, current_turn: u32) {
let cfg = match self.bundle.config.summary_config.clone() {
Some(c) => c,
None => return,
};
if !self.should_summarize(&cfg, current_turn) {
return;
}
// 先 clone 出 &self 借用范围内所需数据,后续释放借用再 await/mut
let provider = Arc::clone(&self.bundle.provider);
let messages = self
.slots
.get(&self.current_slot_id)
.map(|s| s.messages.clone())
.unwrap_or_default();
if messages.is_empty() {
return;
}
let max_tool_result_chars = cfg.max_tool_result_chars;
let model = cfg.summary_model.clone();
let prompt = cfg.summary_prompt.clone();
let result =
Self::generate_summary(&provider, &messages, &prompt, model.as_deref(), max_tool_result_chars)
.await;
match result {
Ok(text) => {
tracing::info!(turn = current_turn, summary_len = text.len(), "摘要自动生成成功");
// Resolve store first (immutable borrow on self) before mutable borrow on slots.
let store = self.resolve_store();
if let Some(slot) = self.slots.get_mut(&self.current_slot_id)
&& let SlotMode::Focused(ref mut focused_cfg) = slot.config.mode
{
focused_cfg.summary_override = Some(text.clone());
// Full 模式下 summary_override 未被修改,无需持久化 slot
if let Err(e) = slot.save(&*store).await {
tracing::error!("summary config persist failed: {}", e);
}
}
if let Err(e) = self.session_memory.set("conversation_summary", &text).await {
tracing::error!("summary session_memory write failed: {}", e);
}
self.last_summary_turn = Some(current_turn);
}
Err(e) => {
tracing::error!("摘要自动生成失败 (turn={}): {}", current_turn, e);
}
}
}
/// 关联函数:调一次 LLM 生成摘要。空消息列表直接返回空串(不浪费 LLM 调用)。
/// `summary_model=None` 时沿用 `CycleConfig::default()` 的默认模型(避免硬编码到非 OpenAI 用户不适配的 `"gpt-4o"`)。
async fn generate_summary(
provider: &Arc<dyn LlmProvider>,
messages: &[Message],
prompt_template: &str,
summary_model: Option<&str>,
max_tool_result_chars: usize,
) -> Result<String, LlmError> {
if messages.is_empty() {
return Ok(String::new());
}
let messages_text = format_messages_as_text(messages, max_tool_result_chars);
let prompt = prompt_template.replace("{messages}", &messages_text);
let config = CycleConfig {
max_tokens: Some(1024),
..CycleConfig::default()
};
let config = if let Some(model) = summary_model {
CycleConfig {
model: model.to_string(),
..config
}
} else {
config
};
let mut cycle = LlmCycle::new_with_arc(Arc::clone(provider), config);
// submit_messages 使用自身参数构造 request,不读 self.messages——prompt 必须放在 messages 参数里
let response = cycle
.submit_messages(vec![Message::user_text(prompt)], vec![])
.await?;
Ok(response.text())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::builder::AgentBuilder;
use crate::agent::FocusedConfig;
use crate::llm::hooks::{Hook, HookContext, HookExecutor, HookResult};
use crate::llm::mock::MockProvider;
use crate::llm::stream::StreamEvent;
@@ -1050,4 +1176,306 @@ mod tests {
"OnTurnEnd 应在 finalize_turn 后触发"
);
}
// ====== Phase 16: 摘要自动生成测试 ======
/// 构造带 `SummaryConfig` 的 session。
/// mock provider 队列按 `[conv_1, summary_1, conv_2, summary_2, ...]` 交错排列,
/// 因为每轮 `submit_turn` 中 conversation LLM 调用先于 summary LLM 调用。
fn build_session_with_summary(
provider_responses: Vec<MessageResponse>,
summary_responses: Vec<MessageResponse>,
cfg: SummaryConfig,
) -> AgentSession {
let mut interleaved = Vec::new();
let max_len = provider_responses.len().max(summary_responses.len());
for i in 0..max_len {
if let Some(r) = provider_responses.get(i) {
interleaved.push(r.clone());
}
if let Some(r) = summary_responses.get(i) {
interleaved.push(r.clone());
}
}
let provider = Arc::new(MockProvider::new(interleaved));
let agent = Arc::new(StubAgent {
name: "stub".into(),
prompt: None,
});
let bundle = Arc::new(
AgentBuilder::new()
.provider(provider)
.tool_registry(Arc::new(ToolRegistry::new()))
.hook_executor(Arc::new(HookExecutor::new()))
.summary_config(cfg)
.build()
.unwrap(),
);
AgentSession::new(agent, "summary-session", bundle)
}
/// 默认用法:token 用量 ~15,远低于默认 32K 窗口的 0.75=24K 阈值 → 不触发摘要。
#[tokio::test]
async fn summary_not_generated_below_threshold() {
let mut session = build_session_with_summary(
vec![assistant_text("a"), assistant_text("b"), assistant_text("c")],
vec![assistant_text("should_not_appear")],
SummaryConfig::default(),
);
for i in 0..3 {
session
.submit_turn(&format!("msg {}", i))
.await
.expect("submit_turn 应成功");
}
let summary = session.get_conversation_summary().await.unwrap();
assert!(summary.is_none(), "未达阈值时不应生成摘要");
}
/// 设置极低 max_context_tokens=100 + 0.5 比例 → 第一轮触发(usage 为 10+5=15 > 50)。
#[tokio::test]
async fn summary_generated_above_threshold() {
let mut session = build_session_with_summary(
vec![assistant_text("a"), assistant_text("b"), assistant_text("c")],
vec![
assistant_text("summary-1"),
assistant_text("summary-2"),
assistant_text("summary-3"),
],
SummaryConfig {
max_context_tokens: 20, // 阈值 20 * 0.5 = 10
trigger_token_ratio: 0.5,
debounce_turns: 0, // 关闭防抖便于测试
..SummaryConfig::default()
},
);
// 第 1 轮:usage=15 ≥ 10,debounce=0 → 触发
session.submit_turn("m1").await.unwrap();
let summary = session.get_conversation_summary().await.unwrap();
assert!(summary.is_some(), "应触发摘要");
}
/// 防抖:trigger 触发后,debounce_turns=3 内即使再次达阈值也不重复。
#[tokio::test]
async fn summary_debounce_works() {
let mut session = build_session_with_summary(
vec![
assistant_text("r1"),
assistant_text("r2"),
assistant_text("r3"),
assistant_text("r4"),
],
vec![assistant_text("sum-1")],
SummaryConfig {
max_context_tokens: 20,
trigger_token_ratio: 0.5,
debounce_turns: 3,
..SummaryConfig::default()
},
);
session.submit_turn("m1").await.unwrap();
let first_summary = session.get_conversation_summary().await.unwrap();
assert_eq!(first_summary.as_deref(), Some("sum-1"));
// 第 2、3 轮:即使都超阈值,debounce 阻止再次触发
for _ in 0..2 {
session.submit_turn("m").await.unwrap();
}
let still_summary = session.get_conversation_summary().await.unwrap();
assert_eq!(
still_summary.as_deref(),
Some("sum-1"),
"debounce 内不应重复生成(Provider 上没有更多预设摘要响应可用)"
);
}
/// Full 模式:摘要被生成并写入 session_memory,但 slot config.summary_override 仍为 None。
#[tokio::test]
async fn summary_written_to_session_memory_but_full_mode_does_not_inject() {
let mut session = build_session_with_summary(
vec![assistant_text("a"), assistant_text("b"), assistant_text("c")],
vec![assistant_text("captured-summary")],
SummaryConfig {
max_context_tokens: 20,
trigger_token_ratio: 0.5,
debounce_turns: 0,
..SummaryConfig::default()
},
);
session.submit_turn("m1").await.unwrap();
let summary = session.get_conversation_summary().await.unwrap();
assert_eq!(summary.as_deref(), Some("captured-summary"));
// default slot 是 Full 模式 → summary_override 应为 Nonefilter_focused 不会触发)
let slot = session.slots.get("default").unwrap();
assert!(matches!(slot.config.mode, SlotMode::Full));
}
/// 摘要生成失败不阻断 submit_turn(Provider 队列只够对话轮次,摘要调用返回 Other 错误)。
#[tokio::test]
async fn summary_failure_does_not_block_turn() {
// 故意只提供 1 个对话响应;摘要调用时队列耗尽,MockProvider 返回 LlmError::Other
let mut session = build_session_with_summary(
vec![assistant_text("only-one")], // 后续摘要会失败
vec![], // 无摘要响应
SummaryConfig {
max_context_tokens: 20,
trigger_token_ratio: 0.5,
debounce_turns: 0,
..SummaryConfig::default()
},
);
let response = session
.submit_turn("m1")
.await
.expect("submit_turn 应成功(即便摘要失败)");
assert_eq!(extract_text(&response.message), "only-one");
// 摘要未生成(Provider 已耗尽)
let summary = session.get_conversation_summary().await.unwrap();
assert!(summary.is_none());
}
/// 未配置 SummaryConfig 时零影响。
#[tokio::test]
async fn summary_skipped_when_not_configured() {
let (mut session, _, _) = build_session(vec![assistant_text("r1"), assistant_text("r2")]);
for _ in 0..2 {
session.submit_turn("m").await.unwrap();
}
let summary = session.get_conversation_summary().await.unwrap();
assert!(summary.is_none());
}
/// 流式路径(submit_turn_stream + finalize_turn):摘要检查点正确触发。
#[tokio::test(flavor = "multi_thread")]
async fn summary_stream_path_triggers_check() {
let mut session = build_session_with_summary(
vec![assistant_text("stream-resp")],
vec![assistant_text("stream-summary")],
SummaryConfig {
max_context_tokens: 20,
trigger_token_ratio: 0.5,
debounce_turns: 0,
..SummaryConfig::default()
},
);
let mut stream = session
.submit_turn_stream("user msg")
.await
.expect("stream ok");
let mut response: Option<MessageResponse> = None;
while let Some(ev) = stream.next().await {
if let StreamEvent::MessageComplete { full_response } = &ev {
response = Some(full_response.clone());
}
}
let resp = response.expect("MessageComplete event");
// finalize_turn 需要本轮新增消息:用户输入 + assistant 响应。
// slot.append_messages 之后才会被 maybe_summarize 看到。
let new_messages = vec![Message::user_text("user msg"), resp.message.clone()];
session
.finalize_turn(&resp, new_messages)
.await
.expect("finalize_turn ok");
let summary = session.get_conversation_summary().await.unwrap();
assert_eq!(summary.as_deref(), Some("stream-summary"));
}
/// W7Focused 模式摘要写入 `summary_override` + `slot.save()` 正向验证。
#[tokio::test]
async fn summary_written_to_focused_slot_config() {
let mut session = build_session_with_summary(
vec![assistant_text("a"), assistant_text("b")],
vec![assistant_text("the-summary")],
SummaryConfig {
max_context_tokens: 20,
trigger_token_ratio: 0.5,
debounce_turns: 0,
..SummaryConfig::default()
},
);
// 1. 把 default slot 切到 Focused 模式
session
.create_slot(
"focused",
Some(SlotConfig {
mode: SlotMode::Focused(FocusedConfig {
keep_system: false,
recent_messages: 5,
summary_override: None,
}),
source: SlotSource::New,
budget: Default::default(),
compact: true,
}),
)
.await
.unwrap();
session.switch_slot("focused").await.unwrap();
// 2. 触发摘要
session.submit_turn("m1").await.unwrap();
// 3. SessionMemory 有值
let summary = session.get_conversation_summary().await.unwrap();
assert_eq!(summary.as_deref(), Some("the-summary"));
// 4. Focused slot 的 summary_override 也应有值(正向验证)
let slot = session.slots.get("focused").unwrap();
assert!(
matches!(&slot.config.mode, SlotMode::Focused(focused) if focused.summary_override.is_some()),
"Focused 模式下 summary_override 应被写入"
);
}
/// W1: 空消息守卫——`generate_summary` 空消息直接返回 `""`,不调用 LLM。
/// 这里通过构建一个空 slot 触发,第一次 `submit_turn` 后 slot 才有消息。
/// 验证:先调用 `format_messages_as_text` 走纯函数路径检查。
#[tokio::test]
async fn summary_skipped_for_empty_messages() {
// 直接走 format_messages_as_text,验证空消息返回空串。
// 这等同于 generate_summary 入口守卫(见 session.rs:560-562)。
let text = format_messages_as_text(&[], 500);
assert_eq!(text, "");
}
/// W1: `max_context_tokens` 设置过大时永不触发摘要。
#[tokio::test]
async fn summary_not_generated_if_max_context_unreachable() {
let mut session = build_session_with_summary(
vec![
assistant_text("r1"),
assistant_text("r2"),
assistant_text("r3"),
assistant_text("r4"),
],
vec![assistant_text("should-not-appear")],
SummaryConfig {
max_context_tokens: 1_000_000, // 远大于任何合理累计 token
trigger_token_ratio: 0.75,
debounce_turns: 0,
..SummaryConfig::default()
},
);
// 多轮 submit_turn,全部 15 token/轮,远低于 0.75 * 1M = 750K 阈值
for i in 0..4 {
session.submit_turn(&format!("m{}", i)).await.unwrap();
}
let summary = session.get_conversation_summary().await.unwrap();
assert!(summary.is_none(), "巨型 max_context_tokens 应永不触发");
}
}
+240
View File
@@ -0,0 +1,240 @@
//! 摘要自动生成 —— 在长对话中自动压缩上下文。
//!
//! 通过 `AgentSession` 内联检查点检测 token 水位,调用 LLM 生成摘要,
//! 写入 `FocusedConfig.summary_override` 与 `SessionMemory["conversation_summary"]`。
//!
//! 关闭端位于 `FocusedConfig::filter_focused`(见 `agent/context.rs`)。
use crate::llm::types::message::{ContentBlock, Message};
/// 默认摘要 prompt(含 `{messages}` 占位符,运行期替换为对话历史文本)。
pub const DEFAULT_SUMMARY_PROMPT: &str = "请为以下对话生成一个简洁的中文摘要,突出关键结论、用户偏好和重要上下文信息。保持客观,不要添加对话中不存在的信息。\n\n{messages}";
/// 摘要自动生成配置(opt-in:通过 `AgentBuilder::summary_config(cfg)` 启用)。
#[derive(Debug, Clone)]
pub struct SummaryConfig {
/// Token 水位触发比例(0.0 ~ 1.0)。
pub trigger_token_ratio: f64,
/// 模型上下文窗口大小(token)。
/// ⚠️ 设置为超过模型实际窗口的值会导致摘要永远不触发。
pub max_context_tokens: u32,
/// 摘要 prompt 模板。`{messages}` 将被替换为对话历史纯文本。
pub summary_prompt: String,
/// 摘要间隔防抖(轮次):两次摘要至少间隔这么多次 `submit_turn`。
pub debounce_turns: u32,
/// 摘要生成使用的模型(`None` = 沿用主 provider 默认模型)。
/// 推荐设为便宜模型(如 `"gpt-4o-mini"`)以节省摘要成本。
pub summary_model: Option<String>,
/// 单个 `ToolResult` 在摘要输入中保留的最大 Unicode 字符数。
/// 超过此值从开头截断(`chars().take(n)`,字符级安全)。
pub max_tool_result_chars: usize,
}
impl Default for SummaryConfig {
fn default() -> Self {
Self {
trigger_token_ratio: 0.75,
max_context_tokens: 32_000,
summary_prompt: DEFAULT_SUMMARY_PROMPT.into(),
debounce_turns: 3,
summary_model: None,
max_tool_result_chars: 500,
}
}
}
/// 把消息列表格式化为摘要 LLM 所需的纯文本(简洁版)。
///
/// 每行一条消息:
/// - `System/User/Assistant` 取首个 `Text` block 拼接
/// - `Assistant` 中的 `ToolUse` 标记为 `[Tool: {name}]`
/// - `ToolResult` 标记为 `Tool Result [{tool_call_id}]:`(含 tool_call_id 以便多工具场景关联)
/// - 长 `ToolResult` 截断到 `max_tool_result_chars` 个字符
///
/// 整段对话若超过 `30_000` 字符,从前面截断,**优先保留最新消息**,
/// 因为新近交互对摘要而言更有信息量。
pub fn format_messages_as_text(messages: &[Message], max_tool_result_chars: usize) -> String {
let mut lines = Vec::with_capacity(messages.len());
for msg in messages {
match msg {
Message::System { content } => {
if let Some(text) = first_text(content) {
lines.push(format!("System: {}", text));
}
}
Message::User { content } => {
if let Some(text) = first_text(content) {
lines.push(format!("User: {}", text));
}
}
Message::Assistant { content } => {
let mut parts = Vec::new();
for block in content {
match block {
ContentBlock::Text { text } => parts.push(text.clone()),
ContentBlock::ToolUse { name, .. } => {
parts.push(format!("[Tool: {}]", name));
}
ContentBlock::Thinking { text, .. } => {
parts.push(format!("[Thinking: {}]", truncate_chars(text, 100)));
}
_ => {}
}
}
if !parts.is_empty() {
lines.push(format!("Assistant: {}", parts.join(" ")));
}
}
Message::UserImage { .. } => {
lines.push("User: [image]".to_string());
}
Message::ToolResult {
tool_call_id,
content,
is_error,
} => {
let label = if *is_error { "Tool Error" } else { "Tool Result" };
if let Some(text) = first_text(content) {
let truncated = truncate_chars(text, max_tool_result_chars);
lines.push(format!("{} [{}]: {}", label, tool_call_id, truncated));
}
}
}
}
let joined = lines.join("\n");
truncate_total_chars(&joined, MAX_TOTAL_CHARS)
}
/// 整段对话输出字符上限。超过时从前面截断,保留尾部最新消息。
const MAX_TOTAL_CHARS: usize = 30_000;
fn truncate_total_chars(s: &str, max_chars: usize) -> String {
let total = s.chars().count();
if total <= max_chars {
return s.to_string();
}
// 计算需要从前面丢弃的字符数。保留窗口从 (total - max_chars) 开始。
let skip = total - max_chars;
let dropped: String = s.chars().take(skip).collect();
let mut kept = String::with_capacity(max_chars + 8);
kept.push_str("[... earlier messages truncated ...]\n");
kept.push_str(&s[dropped.len()..]); // 字节切:dropped.len() 字节一定在 char 边界
kept
}
fn first_text(content: &[ContentBlock]) -> Option<&str> {
content.iter().find_map(|b| match b {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
}
fn truncate_chars(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
return s.to_string();
}
let truncated: String = s.chars().take(max_chars).collect();
format!("{}...", truncated)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_values() {
let cfg = SummaryConfig::default();
assert_eq!(cfg.trigger_token_ratio, 0.75);
assert_eq!(cfg.max_context_tokens, 32_000);
assert_eq!(cfg.debounce_turns, 3);
assert_eq!(cfg.max_tool_result_chars, 500);
assert!(cfg.summary_model.is_none());
assert!(cfg.summary_prompt.contains("{messages}"));
}
#[test]
fn format_skips_empty_input() {
let text = format_messages_as_text(&[], 500);
assert!(text.is_empty());
}
#[test]
fn format_user_assistant_round_trip() {
let msgs = vec![
Message::system("you are a translator"),
Message::user_text("hello"),
Message::assistant("hi"),
];
let text = format_messages_as_text(&msgs, 500);
assert!(text.contains("System: you are a translator"));
assert!(text.contains("User: hello"));
assert!(text.contains("Assistant: hi"));
}
#[test]
fn format_tool_result_includes_tool_call_id() {
let msgs = vec![Message::tool_result("call_42", "ok", false)];
let text = format_messages_as_text(&msgs, 500);
assert_eq!(text, "Tool Result [call_42]: ok");
}
#[test]
fn format_tool_result_error_label() {
let msgs = vec![Message::tool_result("call_9", "boom", true)];
let text = format_messages_as_text(&msgs, 500);
assert_eq!(text, "Tool Error [call_9]: boom");
}
#[test]
fn format_tool_use_in_assistant() {
let msgs = vec![Message::Assistant {
content: vec![
ContentBlock::Text {
text: "let me search".into(),
},
ContentBlock::ToolUse {
id: "c1".into(),
name: "search".into(),
input: serde_json::json!({"q": "rust"}),
},
],
}];
let text = format_messages_as_text(&msgs, 500);
assert_eq!(text, "Assistant: let me search [Tool: search]");
}
#[test]
fn format_truncates_long_tool_result_at_unicode_boundary() {
let long = "a".repeat(1000);
let msgs = vec![Message::tool_result("c", &long, false)];
let text = format_messages_as_text(&msgs, 100);
// 100 chars + "..."
assert!(text.contains("..."));
let truncated_part = text.split("...").next().unwrap();
// "Tool Result [c]: " is 18 chars, plus 100 a's
let a_count = truncated_part.chars().filter(|c| *c == 'a').count();
assert_eq!(a_count, 100);
}
#[test]
fn format_total_charset_truncation_keeps_recent() {
// 50 段 user 消息,每段 1000 字符 = ~50K,触发 30K 整体截断
let mut msgs = Vec::new();
for _ in 0..50 {
msgs.push(Message::user_text("x".repeat(1000)));
}
let text = format_messages_as_text(&msgs, 500);
// 总字符数 ≤ 30K + prefix "[... earlier messages truncated ...]\n"
assert!(text.chars().count() <= 30_000 + 40);
// 头部有截断标记
assert!(text.contains("[... earlier messages truncated ...]"));
// 最后一行的标记字符 (30 个 x) 应保留在末尾
assert!(text.ends_with("xxxxxxxxxx"));
}
}