diff --git a/docs/22-phase16-summary-auto-generation.md b/docs/22-phase16-summary-auto-generation.md new file mode 100644 index 0000000..ccb5ef6 --- /dev/null +++ b/docs/22-phase16-summary-auto-generation.md @@ -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` 本身已提供 opt-in/opt-out +- 不改变 Hook 系统签名 + +**缺点**: +- 摘要 LLM 调用延长了 `submit_turn` 的延迟(约 1-3s) +- 违反"Hook 哲学"(但 `Option` 配置已足够提供可插拔性) + +### 方案 B(否决):扩展 HookContext + +**做法**:在 `HookContext` 中增加 `messages: &[Message]`、`usage: &Usage`、`provider: Arc` 字段,让 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` 的 `'static` 需求**与 `HookContext<'a>` 的设计冲突 + +### 方案 C(否决):后台 spawn 异步摘要 + +**做法**:token 检测通过后,`tokio::spawn` 后台任务做摘要生成和写入。 + +**否决原因**: +1. **写入冲突**:后台任务无法获取 `&mut AgentSession` 来更新 slot config +2. **绕过方式增加复杂度**:后台任务需要直接操作 `Arc` 的原始 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` | +| `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, + /// 单个 ToolResult 在格式化时保留的最大字符数。默认 500。 + /// 超过此值从尾部截断。字符级安全(`chars().take()`)。 + pub max_tool_result_chars: usize, +} +``` + +**`generate_summary`**(`AgentSession` 关联函数): + +```rust +impl AgentSession { + async fn generate_summary( + provider: &Arc, + messages: &[Message], + prompt_template: &str, + summary_model: Option<&str>, + max_tool_result_chars: usize, + ) -> Result { ... } +} +``` + +**`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 block(Image / 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` +- `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) diff --git a/docs/roadmap.md b/docs/roadmap.md index 6aad414..7aabd18 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,13 +1,13 @@ # AG Core Roadmap > 定稿日期:2026-05-11 -> 最后更新:2026-07-09(Phase 15 完成 + M11 里程碑达成 + Phase 16 方案推演) +> 最后更新:2026-07-10(Phase 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, 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` 的 opt-in 机制已足够提供可插拔性,不改变 Hook 系统签名。 +**设计决策**:内联于 `submit_turn` 流程而非 Hook 扩展(因为 HookContext 无法携带 `&mut self` 引用更新 slot config,且流式路径的 `finalize_turn` 中 `cycle` 已销毁)。`Option` 的 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()` +- 🟡 W5:Full 模式 `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 内联检查点自动生成摘要" + +**第二轮审查门禁**:PASS(0 🔴 阻塞)。`cargo test --all-targets` **353 passed / 0 failed**,clippy 0 警告,doc 0 warning。 --- @@ -839,7 +853,7 @@ graph BT P13["Phase 13: 热身清理
旧 types 文件删除
ContextSlot fork/merge"]:::done P14["Phase 14: Document + Embedding
Document 类型
RecursiveCharacterSplitter
Embedding trait"]:::done P15["Phase 15: 向量存储持久化
VectorStore trait
PersistentVectorStore
RagPipeline
19 新测试"]:::done - P16["Phase 16: 摘要自动生成
SummaryConfig
内联检查点
FocusedConfig 自更新"]:::pending + P16["Phase 16: 摘要自动生成
SummaryConfig
内联检查点
首次防抖跳过
18 新测试"]:::done P17["Phase 17: 执行引擎
SessionManager
会话树
Time-travel Checkpointer"]:::pending P18["Phase 18: 切换与调度
Agent Switch
SubAgent Dispatch
dispatch_all 并发控制"]:::pending P19["Phase 19: 知识图谱
KnowledgeGraph trait
InMemoryGraph
双通道检索"]:::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` 等新公开 API,v0.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. **里程碑追踪**:以 M11(Phase 15)为已达成里程碑,逐 Phase 推进 M12-M15 +4. **里程碑追踪**:以 M12(Phase 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` 参数,返回 `Result<(), AgentError>`);`agent/error.rs` 新增 3 个 Slot 错误变体(`SlotReadonly` / `SlotNotFound` / `SlotAlreadyExists`);`examples/context_slot_demo.rs` 新增分支对话示例(法律咨询入口 → 两个派生方向 → 切换 → 隔离验证 → 删除保护);方案文档 `docs/17-phase10-contextslot.md`(1227 行,含 §5 推荐方案、§6 实施建议、§9 实施计划,经过 4 轮方案/计划/实施审查 + 1 轮非阻塞建议修复);全量 211 → 254(+43 新测试),clippy 0 警告,doc 0 warning,11 个离线示例全部 exit 0 - ✅ **Phase 11 测试与检索补强** — `src/memory/vector.rs` 新增 `VectorRetriever` trait(index + 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-export;9 个 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-export;9 个 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 个增量 Phase(Phase 5-12),17 个可验证 Step,覆盖 P0-P2 全部 12 项功能 + ContextSlot - ✅ **v0.3.0 Phase 13 完成** — 技术债清理(3 旧 types 文件 + ChatResponse 删除)+ ContextSlot fork/merge(9 新测试),M9 里程碑达成 - ✅ **v0.3.0 Phase 14 完成** — Document 类型(id/content/metadata/mime_type)+ `RecursiveCharacterSplitter` 两阶段算法(按 separator 优先级递归分割 + 贪心合并 overlap,全部 `chars_len()` 字符级比较)+ `Embedding` trait(async + `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` + 余弦全量扫描 + 预计算 L2 norm 缓存)+ `PersistentVectorStore`(构造时全量加载,先写持久化后写内存,持久化失败时内存不污染重启自动恢复,`remove` 幽灵数据窗口已知)+ `RagPipeline` 组合器(ingest: split→embed→store.add / retrieve: embed→store.search,`splitter: Option` 灵活切换);`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 warning;M11 里程碑达成;Phase 16-19 共 4 个增量 Phase 待实施(摘要 → 引擎 → 调度 → 知识图谱) +- ✅ **v0.3.0 Phase 15 完成** — `VectorStore` trait(`add`/`search`/`remove`/`add_one`,返回 `(Document, f32)` 消除调用方 id→Document 维护开销)+ `InMemoryVectorStore`(`Mutex` + 余弦全量扫描 + 预计算 L2 norm 缓存)+ `PersistentVectorStore`(构造时全量加载,先写持久化后写内存,持久化失败时内存不污染重启自动恢复,`remove` 幽灵数据窗口已知)+ `RagPipeline` 组合器(ingest: split→embed→store.add / retrieve: embed→store.search,`splitter: Option` 灵活切换);`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 warning;M11 里程碑达成 +- ✅ **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` 字段;`AgentSession` 新增 `last_summary_turn: Option` 字段(首次不受防抖约束,`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 里程碑达成** + 第二轮审查门禁 PASS;Phase 17-19 共 3 个增量 Phase 待实施(引擎 → 调度 → 知识图谱) --- diff --git a/src/agent.rs b/src/agent.rs index 6cc8b95..575eee3 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -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}; diff --git a/src/agent/builder.rs b/src/agent/builder.rs index 94ce451..18c6d43 100644 --- a/src/agent/builder.rs +++ b/src/agent/builder.rs @@ -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` 任一缺失则返回 diff --git a/src/agent/context.rs b/src/agent/context.rs index 96486c0..b6aa07a 100644 --- a/src/agent/context.rs +++ b/src/agent/context.rs @@ -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, } @@ -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 { diff --git a/src/agent/runtime.rs b/src/agent/runtime.rs index 0e905f2..92d8482 100644 --- a/src/agent/runtime.rs +++ b/src/agent/runtime.rs @@ -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, /// 上下文压缩配置(None 表示不启用自动压缩),默认 None。 pub compact_config: Option, + /// 摘要自动生成配置(`None` = 不启用)。 + /// 设置后 `AgentSession` 每轮 OnTurnEnd 之后进行水位 + 防抖检查,触发时调 LLM + /// 生成摘要并写入 `FocusedConfig.summary_override` 与 `SessionMemory["conversation_summary"]`。 + pub summary_config: Option, } 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, } } } diff --git a/src/agent/session.rs b/src/agent/session.rs index 6a0e422..36694a4 100644 --- a/src/agent/session.rs +++ b/src/agent/session.rs @@ -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, /// Phase 10 新增:当前活跃 slot 的 id。 current_slot_id: String, + /// Phase 16 新增:上次摘要生成时的 `turn_index`(用于 `debounce_turns` 防抖)。 + /// `None` 表示从未生成过摘要(首次触发不受防抖约束)。 + last_summary_turn: Option, } 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, 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, + messages: &[Message], + prompt_template: &str, + summary_model: Option<&str>, + max_tool_result_chars: usize, + ) -> Result { + 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, + summary_responses: Vec, + 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 应为 None(filter_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 = 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")); + } + + /// W7:Focused 模式摘要写入 `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 应永不触发"); + } } \ No newline at end of file diff --git a/src/agent/summary.rs b/src/agent/summary.rs new file mode 100644 index 0000000..85b0e7c --- /dev/null +++ b/src/agent/summary.rs @@ -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, + + /// 单个 `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")); + } +}