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:
@@ -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 应为 None(filter_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"));
|
||||
}
|
||||
|
||||
/// W7:Focused 模式摘要写入 `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 应永不触发");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user