1use std::sync::Arc;
11
12use async_trait::async_trait;
13use futures::StreamExt;
14
15use bamboo_compression::{HeuristicSummarizer, Summarizer};
16use bamboo_domain::ReasoningEffort;
17use bamboo_domain::{
18 ContextBlock, ContextBlockPriority, ContextBlockStability, ContextBlockType, Message, Role,
19};
20use bamboo_llm::LLMChunk;
21use bamboo_llm::{LLMProvider, LLMRequestOptions};
22
23#[derive(Debug, Clone, Default)]
25pub enum SummaryMode {
26 #[default]
28 FullRewrite,
29 IncrementalMerge,
31}
32
33pub struct LlmSummarizer {
42 llm: Arc<dyn LLMProvider>,
43 model: String,
44 existing_summary: Option<String>,
46 context_blocks: Vec<ContextBlock>,
48 custom_instructions: Option<String>,
50 summary_mode: SummaryMode,
52 heuristic_fallback_on_error: bool,
58}
59
60impl LlmSummarizer {
61 pub fn new(
62 llm: Arc<dyn LLMProvider>,
63 model: String,
64 existing_summary: Option<String>,
65 task_list_prompt: Option<String>,
66 ) -> Self {
67 let context_blocks = task_list_prompt
68 .as_deref()
69 .map(str::trim)
70 .filter(|value| !value.is_empty())
71 .map(|task_list| {
72 vec![ContextBlock::new(
73 ContextBlockType::TaskSnapshot,
74 ContextBlockPriority::High,
75 ContextBlockStability::RoundDynamic,
76 "Current Task List",
77 task_list,
78 )]
79 })
80 .unwrap_or_default();
81
82 Self {
83 llm,
84 model,
85 existing_summary,
86 context_blocks,
87 custom_instructions: None,
88 summary_mode: SummaryMode::default(),
89 heuristic_fallback_on_error: true,
90 }
91 }
92
93 pub fn with_heuristic_fallback_on_error(mut self, enabled: bool) -> Self {
98 self.heuristic_fallback_on_error = enabled;
99 self
100 }
101
102 pub fn with_context_blocks(mut self, context_blocks: Vec<ContextBlock>) -> Self {
103 self.context_blocks = context_blocks;
104 self
105 }
106
107 pub fn with_custom_instructions(mut self, instructions: Option<String>) -> Self {
108 self.custom_instructions = instructions;
109 self
110 }
111
112 pub fn with_summary_mode(mut self, mode: SummaryMode) -> Self {
113 self.summary_mode = mode;
114 self
115 }
116
117 fn build_summarization_messages(&self, messages: &[Message]) -> Vec<Message> {
119 let mut prompt_messages = Vec::new();
120
121 let system_prompt = match self.summary_mode {
122 SummaryMode::FullRewrite => {
123 r#"You are a conversation summarizer. Your task is to create a concise but reliable working-memory summary for a conversation that was removed due to context window limits.
124
125Guidelines:
126- First capture the in-flight work right before compression (what was being done, where, and with which tool/file)
127- Distinguish clearly between CURRENT ACTIVE work, COMPLETED work, and OBSOLETE or superseded work
128- Do not restate old tasks as active unless they are still unresolved
129- The provided current task list is the source of truth for active work
130- Preserve key decisions, constraints, file paths, code changes, tool findings, blockers, and important outcomes
131- Preserve error messages, test results (pass/fail counts), and function/variable names that are relevant to active work
132- If earlier plans conflict with newer messages or the current task list, mark them as obsolete or completed
133- Explicitly evaluate each clear user requirement (e.g. requirement 1, requirement 2) with a status and evidence
134- Keep the next step specific and aligned with the active work only
135- Use structured sections
136- Write in the same language as the original conversation"#
137 }
138 SummaryMode::IncrementalMerge => {
139 r#"You are updating an existing conversation summary with new information from recent messages.
140
141Guidelines:
142- Incorporate new information into the existing summary structure
143- Mark previously active work as completed if the new messages confirm completion
144- Remove or condense information that is no longer relevant
145- Preserve all key decisions, file paths, and constraints that remain active
146- If new messages conflict with the existing summary, the new messages take precedence
147- Keep the summary focused on what is currently active and relevant
148- The provided current task list is the source of truth for active work
149- Maintain the same structured sections as the existing summary
150- Write in the same language as the original conversation
151- Be concise: avoid repeating information already well-captured in the existing summary"#
152 }
153 };
154
155 prompt_messages.push(Message::system(system_prompt));
156
157 let mut user_content = String::new();
158
159 if let Some(ref existing) = self.existing_summary {
160 user_content.push_str("## Previous Summary\n\n");
161 user_content.push_str(existing);
162 user_content.push_str("\n\n---\n\n");
163 }
164
165 if !self.context_blocks.is_empty() {
166 user_content.push_str("## Compression Context Blocks\n\n");
167 for block in &self.context_blocks {
168 user_content.push_str(&format!(
169 "### {}\n- type: {}\n- priority: {}\n- stability: {}\n\n{}\n\n",
170 block.title.trim(),
171 block.block_type.as_str(),
172 block.priority.as_str(),
173 block.stability.as_str(),
174 block.content.trim(),
175 ));
176 }
177 user_content.push_str("---\n\n");
178 }
179
180 if let Some(ref instructions) = self.custom_instructions {
181 if !instructions.trim().is_empty() {
182 user_content.push_str("## Custom Compression Instructions\n\n");
183 user_content.push_str(instructions.trim());
184 user_content.push_str("\n\n---\n\n");
185 }
186 }
187
188 user_content.push_str(
189 "## Required Output Sections\n1. Pre-compression in-flight work (what was being done immediately before compression)\n2. Current active objective\n3. Requirement checklist (Requirement | Status: completed/in_progress/pending/blocked/obsolete | Evidence)\n4. Active tasks\n5. Completed tasks\n6. Obsolete or superseded tasks\n7. Important context and constraints\n8. Files, code, and tool findings\n9. Open issues and next step\n\n",
190 );
191
192 user_content.push_str("## Messages to Summarize\n\n");
193
194 for message in messages {
195 let role_label = match message.role {
196 Role::User => "User",
197 Role::Assistant => "Assistant",
198 Role::Tool => "Tool Result",
199 Role::System => continue,
200 };
201
202 if let Some(ref tool_calls) = message.tool_calls {
203 if !tool_calls.is_empty() {
204 let tool_names: Vec<&str> = tool_calls
205 .iter()
206 .map(|tc| tc.function.name.as_str())
207 .collect();
208 user_content.push_str(&format!(
209 "**{}** [called tools: {}]:\n",
210 role_label,
211 tool_names.join(", ")
212 ));
213 } else {
214 user_content.push_str(&format!("**{}**:\n", role_label));
215 }
216 } else {
217 user_content.push_str(&format!("**{}**:\n", role_label));
218 }
219
220 if let Some(ref tool_call_id) = message.tool_call_id {
221 user_content.push_str(&format!("(tool_call_id: {})\n", tool_call_id));
222 }
223
224 let content = &message.content;
225 const MAX_CONTENT_CHARS: usize = 2000;
226 if content.chars().count() > MAX_CONTENT_CHARS {
227 let truncated: String = content.chars().take(MAX_CONTENT_CHARS).collect();
228 user_content.push_str(&truncated);
229 user_content.push_str("... [truncated]\n\n");
230 } else {
231 user_content.push_str(content);
232 user_content.push_str("\n\n");
233 }
234 }
235
236 user_content.push_str(
237 "\n---\n\nReturn only the summary text. Be explicit about what is active now versus what is already completed or no longer relevant.",
238 );
239
240 prompt_messages.push(Message::user(user_content));
241
242 prompt_messages
243 }
244
245 async fn collect_stream_response(
247 &self,
248 messages: &[Message],
249 ) -> Result<String, bamboo_compression::types::BudgetError> {
250 let options = LLMRequestOptions {
253 session_id: None,
254 reasoning_effort: Some(ReasoningEffort::High),
255 parallel_tool_calls: None,
256 required_tool: None,
257 responses: None,
258 request_purpose: Some("compression".to_string()),
259 cache: None,
260 };
261 let stream = self
262 .llm
263 .chat_stream_with_options(messages, &[], Some(8192), &self.model, Some(&options))
264 .await
265 .map_err(|e| {
266 bamboo_compression::types::BudgetError::TokenCountError(format!(
267 "LLM summarization call failed: {}",
268 e
269 ))
270 })?;
271
272 let mut content = String::new();
273 let mut stream = stream;
274
275 while let Some(chunk_result) = stream.next().await {
276 match chunk_result {
277 Ok(LLMChunk::Token(text)) => content.push_str(&text),
278 Ok(LLMChunk::Done) => break,
279 Ok(_) => {} Err(e) => {
281 tracing::warn!("LLM summarization stream error: {}", e);
282 if !content.is_empty() {
283 break;
284 }
285 return Err(bamboo_compression::types::BudgetError::TokenCountError(
286 format!("LLM summarization stream failed: {}", e),
287 ));
288 }
289 }
290 }
291
292 Ok(content)
293 }
294}
295
296impl std::fmt::Debug for LlmSummarizer {
297 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
298 f.debug_struct("LlmSummarizer")
299 .field("model", &self.model)
300 .field("has_existing_summary", &self.existing_summary.is_some())
301 .field("context_block_count", &self.context_blocks.len())
302 .finish()
303 }
304}
305
306#[async_trait]
307impl Summarizer for LlmSummarizer {
308 async fn summarize(
309 &self,
310 messages: &[Message],
311 ) -> Result<String, bamboo_compression::types::BudgetError> {
312 if messages.is_empty() {
313 return Ok("No conversation history to summarize.".to_string());
314 }
315
316 let prompt_messages = self.build_summarization_messages(messages);
317
318 tracing::info!(
319 "LlmSummarizer: summarizing {} messages using model '{}' (existing_summary={})",
320 messages.len(),
321 self.model,
322 self.existing_summary.is_some()
323 );
324
325 match self.collect_stream_response(&prompt_messages).await {
326 Ok(summary) if !summary.trim().is_empty() => {
327 tracing::info!("LlmSummarizer: generated summary ({} chars)", summary.len());
328 Ok(summary)
329 }
330 Ok(_) => {
331 tracing::warn!(
332 "LlmSummarizer: LLM returned empty summary, falling back to heuristic"
333 );
334 HeuristicSummarizer::new().summarize(messages).await
335 }
336 Err(e) if self.heuristic_fallback_on_error => {
337 tracing::warn!(
338 "LlmSummarizer: LLM call failed ({}), falling back to heuristic",
339 e
340 );
341 HeuristicSummarizer::new().summarize(messages).await
342 }
343 Err(e) => {
347 tracing::warn!(
348 "LlmSummarizer: LLM call failed ({}); surfacing error (heuristic fallback disabled)",
349 e
350 );
351 Err(e)
352 }
353 }
354 }
355
356 fn estimate_summary_tokens(&self, message_count: usize) -> u32 {
357 (message_count * 80).min(2000) as u32
359 }
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365 use bamboo_domain::ReasoningEffort;
366 use bamboo_llm::{LLMChunk, LLMError, LLMRequestOptions, LLMStream};
367 use futures::stream;
368 use std::sync::Mutex;
369
370 struct DummyProvider;
371
372 #[async_trait]
373 impl LLMProvider for DummyProvider {
374 async fn chat_stream(
375 &self,
376 _messages: &[Message],
377 _tools: &[bamboo_domain::ToolSchema],
378 _max_output_tokens: Option<u32>,
379 _model: &str,
380 ) -> Result<LLMStream, LLMError> {
381 Ok(Box::pin(stream::iter(vec![
382 Ok::<LLMChunk, LLMError>(LLMChunk::Token("dummy summary".to_string())),
383 Ok::<LLMChunk, LLMError>(LLMChunk::Done),
384 ])))
385 }
386 }
387 #[test]
388 fn llm_summarizer_prompt_includes_context_blocks_and_state_sections() {
389 let summarizer = LlmSummarizer::new(
390 Arc::new(DummyProvider),
391 "gpt-4o-mini".to_string(),
392 Some("Earlier summary".to_string()),
393 Some(
394 "## Current Task List\n[/] task_1: Fix compression bounce\n[x] task_0: Analyze bug"
395 .to_string(),
396 ),
397 )
398 .with_context_blocks(vec![
399 ContextBlock::new(
400 ContextBlockType::TaskSnapshot,
401 ContextBlockPriority::High,
402 ContextBlockStability::RoundDynamic,
403 "Current Task List",
404 "[/] task_1: Fix compression bounce",
405 ),
406 ContextBlock::new(
407 ContextBlockType::ExternalMemory,
408 ContextBlockPriority::Medium,
409 ContextBlockStability::RoundDynamic,
410 "External Memory (Persistent)",
411 "Session note body",
412 ),
413 ]);
414 let messages = vec![
415 Message::user("继续做压缩修复"),
416 Message::assistant("我先检查 trigger 与 target", None),
417 ];
418
419 let prompt_messages = summarizer.build_summarization_messages(&messages);
420 assert_eq!(prompt_messages.len(), 2);
421 assert_eq!(prompt_messages[0].role, Role::System);
422 assert!(prompt_messages[1]
423 .content
424 .contains("## Compression Context Blocks"));
425 assert!(prompt_messages[1].content.contains("Current Task List"));
426 assert!(prompt_messages[1]
427 .content
428 .contains("External Memory (Persistent)"));
429 assert!(prompt_messages[1]
430 .content
431 .contains("Current active objective"));
432 assert!(prompt_messages[1].content.contains("Requirement checklist"));
433 assert!(prompt_messages[1].content.contains("Active tasks"));
434 assert!(prompt_messages[1].content.contains("Completed tasks"));
435 assert!(prompt_messages[1]
436 .content
437 .contains("Obsolete or superseded tasks"));
438 assert!(prompt_messages[1].content.contains("Earlier summary"));
439 }
440
441 #[derive(Default)]
442 struct ReasoningCaptureProvider {
443 captured_reasoning: Mutex<Vec<Option<ReasoningEffort>>>,
444 }
445
446 #[async_trait]
447 impl LLMProvider for ReasoningCaptureProvider {
448 async fn chat_stream(
449 &self,
450 _messages: &[Message],
451 _tools: &[bamboo_domain::ToolSchema],
452 _max_output_tokens: Option<u32>,
453 _model: &str,
454 ) -> Result<LLMStream, LLMError> {
455 Ok(Box::pin(stream::iter(vec![
456 Ok::<LLMChunk, LLMError>(LLMChunk::Token("captured summary".to_string())),
457 Ok::<LLMChunk, LLMError>(LLMChunk::Done),
458 ])))
459 }
460
461 async fn chat_stream_with_options(
462 &self,
463 messages: &[Message],
464 tools: &[bamboo_domain::ToolSchema],
465 max_output_tokens: Option<u32>,
466 model: &str,
467 options: Option<&LLMRequestOptions>,
468 ) -> Result<LLMStream, LLMError> {
469 self.captured_reasoning
470 .lock()
471 .expect("captured reasoning lock should not be poisoned")
472 .push(options.and_then(|o| o.reasoning_effort));
473 self.chat_stream(messages, tools, max_output_tokens, model)
474 .await
475 }
476 }
477
478 #[tokio::test]
479 async fn llm_summarizer_requests_high_reasoning_effort_for_summary_calls() {
480 let provider = Arc::new(ReasoningCaptureProvider::default());
481 let summarizer = LlmSummarizer::new(
482 provider.clone(),
483 "gpt-5-mini".to_string(),
484 None,
485 Some("task list".to_string()),
486 );
487 let messages = vec![
488 Message::user("请总结最近三轮"),
489 Message::assistant("已完成第一步并准备第二步", None),
490 ];
491
492 let summary = summarizer
493 .summarize(&messages)
494 .await
495 .expect("summary generation should succeed");
496 assert_eq!(summary, "captured summary");
497
498 let captured = provider
499 .captured_reasoning
500 .lock()
501 .expect("captured reasoning lock should not be poisoned");
502 assert_eq!(captured.as_slice(), [Some(ReasoningEffort::High)]);
503 }
504
505 #[derive(Default)]
507 struct RequestOptionsCaptureProvider {
508 captured_reasoning: Mutex<Vec<Option<ReasoningEffort>>>,
509 captured_max_tokens: Mutex<Vec<Option<u32>>>,
510 }
511
512 #[async_trait]
513 impl LLMProvider for RequestOptionsCaptureProvider {
514 async fn chat_stream(
515 &self,
516 _messages: &[Message],
517 _tools: &[bamboo_domain::ToolSchema],
518 _max_output_tokens: Option<u32>,
519 _model: &str,
520 ) -> Result<LLMStream, LLMError> {
521 Ok(Box::pin(stream::iter(vec![
522 Ok::<LLMChunk, LLMError>(LLMChunk::Token("captured summary".to_string())),
523 Ok::<LLMChunk, LLMError>(LLMChunk::Done),
524 ])))
525 }
526
527 async fn chat_stream_with_options(
528 &self,
529 messages: &[Message],
530 tools: &[bamboo_domain::ToolSchema],
531 max_output_tokens: Option<u32>,
532 model: &str,
533 options: Option<&LLMRequestOptions>,
534 ) -> Result<LLMStream, LLMError> {
535 self.captured_reasoning
536 .lock()
537 .expect("lock should not be poisoned")
538 .push(options.and_then(|o| o.reasoning_effort));
539 self.captured_max_tokens
540 .lock()
541 .expect("lock should not be poisoned")
542 .push(max_output_tokens);
543 self.chat_stream(messages, tools, max_output_tokens, model)
544 .await
545 }
546 }
547
548 #[tokio::test]
549 async fn llm_summarizer_sufficient_max_tokens_for_high_reasoning() {
550 let provider = Arc::new(RequestOptionsCaptureProvider::default());
551 let summarizer = LlmSummarizer::new(
552 provider.clone(),
553 "gpt-5-mini".to_string(),
554 None,
555 Some("task list".to_string()),
556 );
557 let messages = vec![
558 Message::user("请总结最近三轮"),
559 Message::assistant("已完成第一步并准备第二步", None),
560 ];
561
562 let summary = summarizer
563 .summarize(&messages)
564 .await
565 .expect("summary generation should succeed");
566 assert_eq!(summary, "captured summary");
567
568 let captured_reasoning = provider
569 .captured_reasoning
570 .lock()
571 .expect("lock should not be poisoned");
572 let captured_max_tokens = provider
573 .captured_max_tokens
574 .lock()
575 .expect("lock should not be poisoned");
576 assert_eq!(captured_reasoning.as_slice(), [Some(ReasoningEffort::High)]);
577 let max_tokens = captured_max_tokens[0].expect("max_output_tokens should be set");
578 assert!(
580 max_tokens > 4096,
581 "max_output_tokens ({}) must exceed thinking budget (4096) to avoid truncation",
582 max_tokens
583 );
584 }
585
586 #[test]
587 fn full_rewrite_mode_uses_default_system_prompt() {
588 let summarizer =
589 LlmSummarizer::new(Arc::new(DummyProvider), "model".to_string(), None, None)
590 .with_summary_mode(SummaryMode::FullRewrite);
591 let messages = vec![Message::user("hello"), Message::assistant("hi", None)];
592 let prompts = summarizer.build_summarization_messages(&messages);
593 let system = &prompts[0].content;
594 assert!(
595 system.contains("conversation summarizer"),
596 "FullRewrite prompt should contain 'conversation summarizer'"
597 );
598 assert!(
599 !system.contains("updating an existing"),
600 "FullRewrite prompt should not contain incremental language"
601 );
602 }
603
604 #[test]
605 fn incremental_merge_mode_uses_update_system_prompt() {
606 let summarizer = LlmSummarizer::new(
607 Arc::new(DummyProvider),
608 "model".to_string(),
609 Some("Previous summary content".to_string()),
610 None,
611 )
612 .with_summary_mode(SummaryMode::IncrementalMerge);
613 let messages = vec![Message::user("hello"), Message::assistant("hi", None)];
614 let prompts = summarizer.build_summarization_messages(&messages);
615 let system = &prompts[0].content;
616 assert!(
617 system.contains("updating an existing conversation summary"),
618 "IncrementalMerge prompt should contain 'updating an existing conversation summary'"
619 );
620 assert!(
621 system.contains("Incorporate new information"),
622 "IncrementalMerge prompt should mention incorporating new information"
623 );
624 }
625
626 #[test]
627 fn default_summary_mode_is_full_rewrite() {
628 assert!(matches!(SummaryMode::default(), SummaryMode::FullRewrite));
629 }
630
631 #[test]
632 fn incremental_merge_includes_existing_summary_in_user_content() {
633 let summarizer = LlmSummarizer::new(
634 Arc::new(DummyProvider),
635 "model".to_string(),
636 Some("Previous summary content".to_string()),
637 None,
638 )
639 .with_summary_mode(SummaryMode::IncrementalMerge);
640 let messages = vec![
641 Message::user("new work"),
642 Message::assistant("doing it", None),
643 ];
644 let prompts = summarizer.build_summarization_messages(&messages);
645 let user_content = &prompts[1].content;
646 assert!(
647 user_content.contains("Previous Summary"),
648 "IncrementalMerge user prompt should include the existing summary"
649 );
650 assert!(
651 user_content.contains("Previous summary content"),
652 "IncrementalMerge user prompt should include the actual summary text"
653 );
654 }
655
656 struct FailingProvider;
658
659 #[async_trait]
660 impl LLMProvider for FailingProvider {
661 async fn chat_stream(
662 &self,
663 _messages: &[Message],
664 _tools: &[bamboo_domain::ToolSchema],
665 _max_output_tokens: Option<u32>,
666 _model: &str,
667 ) -> Result<LLMStream, LLMError> {
668 Err(LLMError::Api("http 500 transient".to_string()))
669 }
670 }
671
672 fn summary_messages() -> Vec<Message> {
673 vec![
674 Message::user("do the work"),
675 Message::assistant("working on it", None),
676 Message::user("keep going"),
677 ]
678 }
679
680 #[tokio::test]
681 async fn summarize_falls_back_to_heuristic_on_llm_error_by_default() {
682 let summarizer =
686 LlmSummarizer::new(Arc::new(FailingProvider), "model".to_string(), None, None);
687 let out = summarizer.summarize(&summary_messages()).await;
688 assert!(
689 out.is_ok(),
690 "default heuristic fallback should recover from a transient LLM error, got {out:?}"
691 );
692 }
693
694 #[tokio::test]
695 async fn summarize_surfaces_llm_error_when_heuristic_fallback_disabled() {
696 let summarizer =
701 LlmSummarizer::new(Arc::new(FailingProvider), "model".to_string(), None, None)
702 .with_heuristic_fallback_on_error(false);
703 let out = summarizer.summarize(&summary_messages()).await;
704 assert!(
705 out.is_err(),
706 "with the heuristic fallback disabled, a transient LLM error must surface"
707 );
708 }
709}