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:
徐涛
2026-07-10 06:43:06 +08:00
parent 209932e3b5
commit cc1c68b69d
8 changed files with 1192 additions and 19 deletions
+428
View File
@@ -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<String, ContextSlot>,
/// Phase 10 新增:当前活跃 slot 的 id。
current_slot_id: String,
/// Phase 16 新增:上次摘要生成时的 `turn_index`(用于 `debounce_turns` 防抖)。
/// `None` 表示从未生成过摘要(首次触发不受防抖约束)。
last_summary_turn: Option<u32>,
}
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<Option<String>, 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<dyn LlmProvider>,
messages: &[Message],
prompt_template: &str,
summary_model: Option<&str>,
max_tool_result_chars: usize,
) -> Result<String, LlmError> {
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<MessageResponse>,
summary_responses: Vec<MessageResponse>,
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 应为 Nonefilter_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<MessageResponse> = 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"));
}
/// W7Focused 模式摘要写入 `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 应永不触发");
}
}