Files
agcore/src/agent/summary.rs
T
徐涛 5baa170508 docs: 更新 README feature 表 + 升级指南 + 示例注释 + roadmap 同步
- README 添加 feature 组合表 + 模块级 features 清单 + 升级指南
- 18 个 example 顶部添加 Required features 注释
- roadmap.md 和 roadmap-v0.3.2.md 同步 Phase 26-27 完成状态
- cargo fmt 全量格式化(修复预存格式问题,CI format job 可通过)
2026-07-19 08:18:04 +08:00

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"));
}
}