//! 摘要自动生成 —— 在长对话中自动压缩上下文。 //! //! 通过 `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"); // 字节切安全:dropped 由 s.chars().take(skip).collect() 构建, // 每个 char 的 UTF-8 字节序列完整保留,故 dropped.len() 恰好是 s 的某个 char 边界字节偏移。 kept.push_str(&s[dropped.len()..]); 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")); } }