- README 添加 feature 组合表 + 模块级 features 清单 + 升级指南 - 18 个 example 顶部添加 Required features 注释 - roadmap.md 和 roadmap-v0.3.2.md 同步 Phase 26-27 完成状态 - cargo fmt 全量格式化(修复预存格式问题,CI format job 可通过)
247 lines
9.3 KiB
Rust
247 lines
9.3 KiB
Rust
//! 摘要自动生成 —— 在长对话中自动压缩上下文。
|
|
//!
|
|
//! 通过 `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");
|
|
// 字节切安全: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"));
|
|
}
|
|
}
|