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:
@@ -16,6 +16,7 @@ pub mod error;
|
||||
pub mod runtime;
|
||||
pub mod session;
|
||||
pub mod session_memory;
|
||||
pub mod summary;
|
||||
pub mod task;
|
||||
|
||||
// 重导出公共 API(按使用频度排序)
|
||||
@@ -29,5 +30,6 @@ pub use error::AgentError;
|
||||
pub use runtime::{AgentConfig, RuntimeBundle};
|
||||
pub use session::AgentSession;
|
||||
pub use session_memory::SessionMemory;
|
||||
pub use summary::SummaryConfig;
|
||||
pub use task::JsonPlanParser;
|
||||
pub use task::{Plan, PlanParser, Step, StepStatus, TaskAgent};
|
||||
|
||||
@@ -11,6 +11,7 @@ use std::sync::Arc;
|
||||
|
||||
use crate::agent::error::AgentError;
|
||||
use crate::agent::runtime::{AgentConfig, RuntimeBundle};
|
||||
use crate::agent::summary::SummaryConfig;
|
||||
use crate::llm::hooks::HookExecutor;
|
||||
use crate::llm::provider::LlmProvider;
|
||||
use crate::memory::retriever::MemoryRetriever;
|
||||
@@ -86,6 +87,15 @@ impl AgentBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置摘要自动生成配置(覆盖字段,而非整体覆盖 config)。
|
||||
/// 不传则沿用现有 `config.summary_config`(默认 `None`,即关闭)。
|
||||
pub fn summary_config(mut self, cfg: SummaryConfig) -> Self {
|
||||
let mut config = self.config.take().unwrap_or_default();
|
||||
config.summary_config = Some(cfg);
|
||||
self.config = Some(config);
|
||||
self
|
||||
}
|
||||
|
||||
/// 构造 `RuntimeBundle`,校验必填字段。
|
||||
///
|
||||
/// **错误**:`provider` / `tool_registry` / `hook_executor` 任一缺失则返回
|
||||
|
||||
@@ -74,8 +74,9 @@ pub struct FocusedConfig {
|
||||
pub keep_system: bool,
|
||||
/// 保留的最近消息条数(以消息条数而非对话轮次为单位,因为一轮对话可能包含多条 tool 消息)。
|
||||
pub recent_messages: usize,
|
||||
/// 摘要覆盖(v0.2 仅消费端:手动设置则注入,不自动生成)。
|
||||
/// v0.3 将支持 Hook 驱动的自动摘要生成。
|
||||
/// 摘要覆盖(消费端:手动或自动生成的摘要会注入到消息列表末尾)。
|
||||
/// v0.3 Phase 16 起,`AgentBuilder::summary_config(cfg)` 内联检查点会
|
||||
/// 自动调用 LLM 生成摘要并写入此字段,详见 `docs/22-phase16-summary-auto-generation.md`。
|
||||
pub summary_override: Option<String>,
|
||||
}
|
||||
|
||||
@@ -105,7 +106,7 @@ pub enum DeriveStrategy {
|
||||
|
||||
/// 合并策略 —— Phase 13 新增,控制 `ContextSlot::merge` 如何将子 slot 消息合入父 slot。
|
||||
///
|
||||
/// `#[non_exhaustive]` 允许 Phase 16 加入 `Summarize` 变体而不破坏现有匹配。
|
||||
/// `#[non_exhaustive]` 预留未来扩展(如 `Summarize` 变体)。
|
||||
#[derive(Debug, Clone)]
|
||||
#[non_exhaustive]
|
||||
pub enum MergeStrategy {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::agent::summary::SummaryConfig;
|
||||
use crate::llm::compact::CompactConfig;
|
||||
use crate::llm::hooks::HookExecutor;
|
||||
use crate::llm::provider::LlmProvider;
|
||||
@@ -33,6 +34,10 @@ pub struct AgentConfig {
|
||||
pub session_ttl: Option<Duration>,
|
||||
/// 上下文压缩配置(None 表示不启用自动压缩),默认 None。
|
||||
pub compact_config: Option<CompactConfig>,
|
||||
/// 摘要自动生成配置(`None` = 不启用)。
|
||||
/// 设置后 `AgentSession` 每轮 OnTurnEnd 之后进行水位 + 防抖检查,触发时调 LLM
|
||||
/// 生成摘要并写入 `FocusedConfig.summary_override` 与 `SessionMemory["conversation_summary"]`。
|
||||
pub summary_config: Option<SummaryConfig>,
|
||||
}
|
||||
|
||||
impl Default for AgentConfig {
|
||||
@@ -42,6 +47,7 @@ impl Default for AgentConfig {
|
||||
max_tool_turns: 10,
|
||||
session_ttl: None,
|
||||
compact_config: None,
|
||||
summary_config: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 应永不触发");
|
||||
}
|
||||
}
|
||||
@@ -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