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:
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user