molo_agent/memory/summarize.rs
1//! Summarization trim strategy: uses an LLM to compact over-budget old
2//! messages into a single summary.
3//!
4//! [`SummarizeStrategy`] is a ready-to-use implementation of
5//! [`TrimStrategy`](crate::memory::TrimStrategy) that holds a
6//! [`Provider`](crate::Provider) to call a summarizer model: when over budget
7//! it keeps the most recent rounds verbatim (the tail of the conversation
8//! carries the strongest signal and is not compacted), textifies the earlier
9//! messages, and hands them to the summarizer; the output replaces the
10//! compacted portion as a single **System summary message** and is
11//! materialized (`replace: true`) — one compaction covers many rounds, until
12//! new messages breach the budget again.
13//!
14//! The summary message sits at the front of the result sequence and uses the
15//! System role: a summary is not "what the user said" — it is
16//! framework-injected history compaction, and the model should treat it as
17//! context background rather than conversation content. The summary message
18//! also obeys Memory's trim rules and participates as input in the next
19//! compaction when over budget again (incremental merge — the summary is never
20//! compacted repeatedly).
21//!
22//! A failed summarization (network / rate limit etc.) does not interrupt the
23//! conversation: it degrades to window dropping (projection — storage keeps
24//! the original text and summarization is retried on the next over-budget
25//! retrieval), logging a single warn.
26
27use std::fmt;
28
29use super::window::{count_message, keep_rounds, split_rounds};
30use super::{Budget, MemoryError, TokenCounter, TrimResult, TrimStrategy, WindowDrop};
31use crate::message::{ContentBlock, Message};
32use crate::provider::{ChatRequest, ModelOptions, Provider};
33
34/// Content prefix of the summary message: lets the model and downstream tools
35/// recognize "this is a historical summary" (fed as the prior-summary input on
36/// the next compaction, never re-summarized).
37const SUMMARY_PREFIX: &str = "Summary of prior context:\n";
38
39/// Default summarization prompt: instruction + requirements, sent to the
40/// summarizer model.
41const DEFAULT_SUMMARY_PROMPT: &str = "\
42You are a conversation-history compressor. Compress the provided conversation history into a concise summary so the model can recover context in subsequent conversation.
43
44Requirements:
45- Preserve the task goal, current state, decisions made, open items, and next steps;
46- Keep proper nouns such as code identifiers, file paths, URLs, and numbers verbatim;
47- The history may contain a \"Summary of prior context\" (system message): first understand the prior summary, then merge it with the new history and output a single integrated summary; do not re-summarize the prior summary itself;
48- Output only the summary body, with no explanations or surrounding text.";
49
50/// Summarization trim strategy: over-budget old messages → summarizer → one
51/// System summary message.
52///
53/// # Behavior
54///
55/// - **Keep the most recent rounds verbatim**: keep complete rounds from the
56/// tail forward until the budget is approached (the budget first deducts a
57/// reservation for the summary output, see
58/// [`with_summary_max_tokens`](SummarizeStrategy::with_summary_max_tokens));
59/// at least the most recent round is kept (kept even when a single round
60/// exceeds the budget; nothing more can be compacted);
61/// - **Incremental compaction**: when over budget again, the previous summary
62/// message goes to the summarizer as input (prior summary), producing an
63/// integrated new summary — the summary is never compacted repeatedly
64/// (semantic drift), and information already compacted is not lost;
65/// - **Materialization**: `replace: true`, the compaction result is written
66/// back to storage with zero recomputation afterwards;
67/// - **Failure degradation**: when the summarizer call fails, degrade to
68/// window dropping (projection — storage keeps the original text, retried on
69/// the next over-budget retrieval); the conversation is not interrupted.
70///
71/// The summary message uses the System role and sits at the front of the
72/// sequence (a summary is not conversation content but history compaction; the
73/// System role also avoids consecutive User messages after compaction). The
74/// summary message's tokens count toward the budget and obey the window trim
75/// rules like any other message.
76///
77/// # Comparison
78///
79/// Compared to the default [`WindowDrop`]: window dropping is a lossless
80/// projection with cheap per-turn recomputation, suited to ad-hoc inspection;
81/// this strategy trades one summarizer call for a longer compactable span,
82/// suited to long conversations that must not exceed limits, at the cost of
83/// the summarizer call (cost + latency) and information loss.
84///
85/// # Example
86///
87/// Inject the summarization strategy (using
88/// [`FakeProvider`](crate::FakeProvider) in place of a real summarizer to
89/// demonstrate the full flow):
90///
91/// ```rust
92/// # extern crate molo_agent as molo;
93/// use std::sync::Arc;
94/// use molo::memory::{Memory, SummarizeStrategy, WindowMemory};
95/// use molo::{FakeProvider, FakeReply, Message};
96///
97/// #[tokio::main]
98/// async fn main() -> Result<(), molo::memory::MemoryError> {
99/// let fake = Arc::new(FakeProvider::new([FakeReply::Text("Key points from earlier rounds".into())]));
100/// let mut memory = WindowMemory::new(30)
101/// .with_strategy(Arc::new(SummarizeStrategy::new(fake)));
102/// for i in 1..=4 {
103/// memory.record(Message::user(format!("Question from round {i}"))).await?;
104/// memory.record(Message::assistant(format!("Answer from round {i}"))).await?;
105/// }
106///
107/// // 44 tokens total > budget 30: compacted to [summary, most recent round].
108/// let context = memory.context().await?;
109/// assert_eq!(context.len(), 3);
110/// assert!(matches!(context[0], Message::System(_)));
111/// assert_eq!(context[1], Message::user("Question from round 4"));
112/// Ok(())
113/// }
114/// ```
115pub struct SummarizeStrategy {
116 provider: Box<dyn Provider>,
117 prompt: String,
118 /// Token cap for the summary output (also the budget reservation for kept
119 /// rounds).
120 summary_max_tokens: u32,
121}
122
123impl fmt::Debug for SummarizeStrategy {
124 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125 // Provider is a trait object and cannot derive Debug: print the
126 // comparable fields and mark the Provider's type.
127 f.debug_struct("SummarizeStrategy")
128 .field("provider", &"Box<dyn Provider>")
129 .field("prompt_len", &self.prompt.len())
130 .field("summary_max_tokens", &self.summary_max_tokens)
131 .finish()
132 }
133}
134
135impl SummarizeStrategy {
136 /// Creates a summarization strategy with the default prompt and a default
137 /// summary output cap (1024 tokens).
138 pub fn new(provider: impl Provider + 'static) -> Self {
139 Self {
140 provider: Box::new(provider),
141 prompt: DEFAULT_SUMMARY_PROMPT.into(),
142 summary_max_tokens: 1024,
143 }
144 }
145
146 /// Replaces the default summarization prompt (instruction + requirements;
147 /// the history text is appended by the strategy).
148 pub fn with_prompt(mut self, prompt: impl Into<String>) -> Self {
149 self.prompt = prompt.into();
150 self
151 }
152
153 /// Sets the token cap for the summary output (default 1024).
154 ///
155 /// The value doubles as the budget reservation for kept rounds: the kept
156 /// rounds' token total stays within "budget − summary cap", leaving room
157 /// for the summary output.
158 pub fn with_summary_max_tokens(mut self, max_tokens: u32) -> Self {
159 self.summary_max_tokens = max_tokens;
160 self
161 }
162
163 /// Shared implementation of the summarization call: decide the kept rounds
164 /// → send the compacted part to the summarizer → assemble
165 /// [summary message, kept rounds].
166 async fn trim_impl(
167 &self,
168 messages: &[Message],
169 counts: &[usize],
170 budget: &Budget,
171 counter: &dyn TokenCounter,
172 fallback: &WindowDrop,
173 ) -> Result<TrimResult, MemoryError> {
174 // Keep the most recent rounds: budget minus the reservation for the
175 // summary output; at least 1 round (fallback).
176 let rounds = split_rounds(messages, counts);
177 let keep = keep_rounds(&rounds, budget, self.summary_max_tokens as usize);
178 let cut = rounds[rounds.len() - keep].0;
179 if cut == 0 {
180 // Everything is kept (single round over budget, or within budget):
181 // nothing to compact.
182 return Ok(TrimResult {
183 messages: messages.to_vec(),
184 replace: false,
185 });
186 }
187
188 let request = ChatRequest {
189 messages: vec![
190 Message::system(self.prompt.clone()),
191 Message::user(messages_to_text(&messages[..cut])),
192 ],
193 tools: Vec::new(),
194 options: ModelOptions {
195 max_tokens: Some(self.summary_max_tokens),
196 ..Default::default()
197 },
198 };
199 let summary = match self.provider.chat(request).await {
200 Ok(response) => {
201 // The summarizer should reply with an Assistant message; other
202 // roles degrade defensively.
203 let Message::Assistant { content, .. } = &response.message else {
204 return fallback
205 .trim_with_counts(messages, counts, budget, counter)
206 .await;
207 };
208 if content.trim().is_empty() {
209 None
210 } else {
211 Some(content.trim().to_string())
212 }
213 }
214 Err(error) => {
215 // On failure, degrade to window dropping (projection): the
216 // conversation continues, storage keeps the original text, and
217 // summarization is retried on the next over-budget retrieval.
218 #[cfg(feature = "tracing")]
219 tracing::warn!(%error, "summarize failed, falling back to window drop");
220 #[cfg(not(feature = "tracing"))]
221 let _ = error;
222 None
223 }
224 };
225 let Some(summary) = summary else {
226 return fallback
227 .trim_with_counts(messages, counts, budget, counter)
228 .await;
229 };
230
231 let mut result = Vec::with_capacity(keep + 1);
232 result.push(Message::system(format!("{SUMMARY_PREFIX}{summary}")));
233 result.extend_from_slice(&messages[cut..]);
234 // Compaction event (observability): compacted count, kept count,
235 // summary output token count.
236 // The summary message's tokens count toward the budget and may leave
237 // the materialized result over budget (the next retrieval compacts
238 // again) — the log carries this info for diagnosis.
239 #[cfg(feature = "tracing")]
240 {
241 let summary_tokens = count_message(counter, &result[0]).await?;
242 tracing::info!(
243 compressed = cut,
244 kept = messages.len() - cut,
245 summary_tokens,
246 "context summarized by LLM"
247 );
248 }
249 Ok(TrimResult {
250 messages: result,
251 replace: true,
252 })
253 }
254}
255
256#[async_trait::async_trait]
257impl TrimStrategy for SummarizeStrategy {
258 async fn trim(
259 &self,
260 messages: &[Message],
261 budget: &Budget,
262 counter: &dyn TokenCounter,
263 ) -> Result<TrimResult, MemoryError> {
264 let mut counts = Vec::with_capacity(messages.len());
265 for m in messages {
266 counts.push(count_message(counter, m).await?);
267 }
268 self.trim_with_counts(messages, &counts, budget, counter)
269 .await
270 }
271
272 async fn trim_with_counts(
273 &self,
274 messages: &[Message],
275 counts: &[usize],
276 budget: &Budget,
277 counter: &dyn TokenCounter,
278 ) -> Result<TrimResult, MemoryError> {
279 if messages.is_empty() {
280 return Ok(TrimResult {
281 messages: Vec::new(),
282 replace: false,
283 });
284 }
285 debug_assert_eq!(messages.len(), counts.len());
286 let fallback = WindowDrop;
287 self.trim_impl(messages, counts, budget, counter, &fallback)
288 .await
289 }
290}
291
292/// Textifies a message sequence as input for the summarizer.
293///
294/// One `role: content` line per message: System verbatim, User joins content
295/// blocks, Assistant takes the body plus tool calls (name + arguments; the
296/// reasoning trace is not carried), ToolResult takes the result text.
297/// The summary message (prior summary) appears here as a `system:` line, which
298/// is how the summarizer recognizes it.
299fn messages_to_text(messages: &[Message]) -> String {
300 let mut lines = Vec::with_capacity(messages.len());
301 for message in messages {
302 match message {
303 Message::System(s) => lines.push(format!("system: {s}")),
304 Message::User(blocks) => {
305 let text: String = blocks
306 .iter()
307 .map(|block| match block {
308 ContentBlock::Text(t) => t.clone(),
309 // The summary keeps a placeholder so the context
310 // "an image was there" survives compression (the
311 // bytes themselves cannot be summarized).
312 ContentBlock::Image(image) => format!("[image: {}]", image.mime_type),
313 // Pass-through blocks are rendered by their wire
314 // type (e.g. `[audio]`); unknown shapes degrade to
315 // a generic marker.
316 ContentBlock::Wire(value) => {
317 match value.get("type").and_then(|t| t.as_str()) {
318 Some(kind) => format!("[{kind}]"),
319 None => "[content]".to_string(),
320 }
321 }
322 })
323 .collect::<Vec<_>>()
324 .join(" ");
325 lines.push(format!("user: {text}"));
326 }
327 Message::Assistant {
328 content,
329 tool_calls,
330 ..
331 } => {
332 if tool_calls.is_empty() {
333 lines.push(format!("assistant: {content}"));
334 } else {
335 let calls = tool_calls
336 .iter()
337 .map(|tc| format!("{} {}", tc.name, tc.arguments))
338 .collect::<Vec<_>>()
339 .join("; ");
340 lines.push(format!("assistant: {content}\ntool_calls: {calls}"));
341 }
342 }
343 Message::ToolResult { content, .. } => lines.push(format!("tool_result: {content}")),
344 }
345 }
346 lines.join("\n")
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352 use crate::memory::{CharTokenCounter, Memory, WindowMemory};
353 use crate::message::ToolCall;
354 use crate::provider::{FakeProvider, FakeReply, ProviderError};
355 use std::sync::Arc;
356
357 /// Builds a conversation (round `start` through round `n`; about 11 tokens
358 /// per round).
359 async fn record_rounds(memory: &mut WindowMemory, start: usize, n: usize) {
360 for i in start..=n {
361 memory
362 .record(Message::user(format!("Question from round {i}")))
363 .await
364 .unwrap();
365 memory
366 .record(Message::assistant(format!("Answer from round {i}")))
367 .await
368 .unwrap();
369 }
370 }
371
372 /// Builds the strategy with a shared instance: Arc<FakeProvider> implements
373 /// Provider, so tests can assert the request history from the original
374 /// instance.
375 fn strategy(fake: Arc<FakeProvider>) -> Arc<SummarizeStrategy> {
376 Arc::new(SummarizeStrategy::new(fake))
377 }
378
379 /// Basic compaction: over budget → [summary (System), most recent round];
380 /// the summarizer only receives the compacted part (kept rounds excluded).
381 #[tokio::test]
382 async fn summarizes_old_rounds_and_keeps_recent() {
383 let fake = Arc::new(FakeProvider::new([FakeReply::Text(
384 "Key points from earlier rounds".into(),
385 )]));
386 let mut memory = WindowMemory::new(30).with_strategy(strategy(fake.clone()));
387 record_rounds(&mut memory, 1, 4).await;
388
389 let context = memory.context().await.unwrap();
390 assert_eq!(context.len(), 3);
391 assert_eq!(
392 context[0],
393 Message::system(format!("{SUMMARY_PREFIX}Key points from earlier rounds"))
394 );
395 assert_eq!(context[1], Message::user("Question from round 4"));
396 assert_eq!(context[2], Message::assistant("Answer from round 4"));
397
398 // Request = [prompt (system), compacted-part text (user)]; kept rounds
399 // excluded.
400 let requests = fake.requests();
401 assert_eq!(requests.len(), 1);
402 let messages = &requests[0].messages;
403 assert!(
404 matches!(&messages[0], Message::System(p) if p.contains("conversation-history compressor"))
405 );
406 let Message::User(blocks) = &messages[1] else {
407 panic!("expected user message");
408 };
409 let ContentBlock::Text(text) = &blocks[0] else {
410 panic!("expected a text block");
411 };
412 assert!(text.contains("Question from round 1") && text.contains("Question from round 3"));
413 assert!(!text.contains("Question from round 4"));
414 }
415
416 /// The summary output cap doubles as the budget reservation for kept
417 /// rounds: a smaller cap → more rounds kept.
418 #[tokio::test]
419 async fn summary_budget_reserves_space_for_recent_rounds() {
420 let fake = Arc::new(FakeProvider::new([FakeReply::Text("Highlights".into())]));
421 // Budget 30, summary cap 4: 26 tokens available for kept rounds. Each
422 // round is 11 tokens ("Question/Answer from round N"): keep the recent
423 // 2 rounds (22); adding a 3rd (33 > 26) stops. Result: [summary,
424 // round 3, round 4].
425 let mut memory = WindowMemory::new(30).with_strategy(Arc::new(
426 SummarizeStrategy::new(fake.clone()).with_summary_max_tokens(4),
427 ));
428 record_rounds(&mut memory, 1, 4).await;
429
430 let context = memory.context().await.unwrap();
431 assert_eq!(context.len(), 5);
432 assert_eq!(context[1], Message::user("Question from round 3"));
433 assert_eq!(context[3], Message::user("Question from round 4"));
434 }
435
436 /// Incremental compaction: on the second over-budget event, the previous
437 /// summary is merged into the new one as input.
438 #[tokio::test]
439 async fn incremental_summary_reuses_previous_summary() {
440 let fake = Arc::new(FakeProvider::new([
441 FakeReply::Text("first segment highlights".into()),
442 FakeReply::Text("merged highlights".into()),
443 ]));
444 let mut memory = WindowMemory::new(30).with_strategy(Arc::new(
445 SummarizeStrategy::new(fake.clone()).with_summary_max_tokens(4),
446 ));
447 record_rounds(&mut memory, 1, 4).await;
448 memory.context().await.unwrap(); // first compaction → [summary, u3, a3, u4, a4]
449
450 // Append rounds 5 and 6: over budget again → second compaction.
451 record_rounds(&mut memory, 5, 6).await;
452 let context = memory.context().await.unwrap();
453 assert_eq!(
454 context[0],
455 Message::system(format!("{SUMMARY_PREFIX}merged highlights"))
456 );
457 // Budget 30 minus reserve 4 → 26: keep the 2 most recent rounds
458 // (11 + 11); the earlier messages (including the old summary) go to the
459 // second summarization. Result: [new summary, u5, a5, u6, a6].
460 assert_eq!(context.len(), 5);
461 assert_eq!(context[1], Message::user("Question from round 5"));
462
463 // The second request's input text contains the first summary message
464 // (incremental merge).
465 let requests = fake.requests();
466 assert_eq!(requests.len(), 2);
467 let Message::User(blocks) = &requests[1].messages[1] else {
468 panic!("expected user message");
469 };
470 let ContentBlock::Text(text) = &blocks[0] else {
471 panic!("expected a text block");
472 };
473 assert!(text.contains("first segment highlights"));
474 }
475
476 /// Failure degradation: summarizer call fails → window-dropped view
477 /// (projection, storage unchanged), the conversation continues; the next
478 /// over-budget retrieval retries summarization.
479 #[tokio::test]
480 async fn provider_failure_falls_back_to_window_drop() {
481 let fake = Arc::new(FakeProvider::new([
482 FakeReply::Error(ProviderError::Network("mock network error".into())),
483 FakeReply::Text("second attempt succeeded".into()),
484 ]));
485 let mut memory = WindowMemory::new(30).with_strategy(strategy(fake.clone()));
486 record_rounds(&mut memory, 1, 4).await;
487
488 // First time: failure → degrade to window dropping (budget 30 keeps the
489 // 2 most recent rounds, no summary message).
490 let context = memory.context().await.unwrap();
491 assert_eq!(
492 context,
493 vec![
494 Message::user("Question from round 3"),
495 Message::assistant("Answer from round 3"),
496 Message::user("Question from round 4"),
497 Message::assistant("Answer from round 4"),
498 ]
499 );
500
501 // Storage was not materialized: still over budget on the second call →
502 // the strategy runs again, this time succeeding.
503 let context = memory.context().await.unwrap();
504 assert_eq!(
505 context[0],
506 Message::system(format!("{SUMMARY_PREFIX}second attempt succeeded"))
507 );
508 assert_eq!(fake.requests().len(), 2);
509 }
510
511 /// Empty summary output (the model produced no text) → likewise falls back
512 /// to window dropping.
513 #[tokio::test]
514 async fn empty_summary_falls_back() {
515 let fake = Arc::new(FakeProvider::new([FakeReply::Text("".into())]));
516 let mut memory = WindowMemory::new(30).with_strategy(strategy(fake.clone()));
517 record_rounds(&mut memory, 1, 4).await;
518
519 let context = memory.context().await.unwrap();
520 assert_eq!(context.len(), 4); // window dropping: budget 30 keeps the 2 most recent rounds
521 assert!(!context.iter().any(|m| matches!(m, Message::System(_))));
522 }
523
524 /// Single-round over-budget fallback: nothing to compact, returns as-is
525 /// without calling the summarizer.
526 #[tokio::test]
527 async fn single_round_over_budget_returns_as_is() {
528 let fake = Arc::new(FakeProvider::new([FakeReply::Text("summary".into())]));
529 let mut memory = WindowMemory::new(5).with_strategy(strategy(fake.clone()));
530 memory
531 .record(Message::user(
532 "An extremely long user message, over budget in a single round",
533 ))
534 .await
535 .unwrap();
536 memory.record(Message::assistant("Reply")).await.unwrap();
537
538 let context = memory.context().await.unwrap();
539 assert_eq!(context.len(), 2);
540 assert!(
541 fake.requests().is_empty(),
542 "with nothing to compact the summarizer must not be called"
543 );
544 }
545
546 /// Defensive: an empty message list is returned as-is.
547 #[tokio::test]
548 async fn empty_input_returns_empty() {
549 let fake = Arc::new(FakeProvider::new([FakeReply::Text("summary".into())]));
550 let strategy = strategy(fake);
551 let result = strategy
552 .trim(&[], &Budget::tokens(10), &CharTokenCounter)
553 .await
554 .unwrap();
555 assert!(result.messages.is_empty());
556 assert!(!result.replace);
557 }
558
559 /// Tool-call round textification: the assistant message carries the tool
560 /// name and arguments, with the tool result on its own line.
561 #[tokio::test]
562 async fn messages_to_text_includes_tool_calls_and_results() {
563 let messages = vec![
564 Message::system("setup"),
565 Message::user("calculate"),
566 Message::Assistant {
567 content: "".into(),
568 reasoning: Some("reasoning trace is not included".into()),
569 tool_calls: vec![ToolCall {
570 id: "t1".into(),
571 name: "calc".into(),
572 arguments: r#"{"expr":"1+1"}"#.into(),
573 }],
574 },
575 Message::tool_result("t1", "2"),
576 ];
577 let text = messages_to_text(&messages);
578 assert!(text.contains("system: setup"));
579 assert!(text.contains("user: calculate"));
580 assert!(text.contains(r#"tool_calls: calc {"expr":"1+1"}"#));
581 assert!(text.contains("tool_result: 2"));
582 // The reasoning trace never enters the summary input.
583 assert!(!text.contains("reasoning trace is not included"));
584 }
585
586 /// The textified input counts against the kept-round budget (same
587 /// convention as window trimming).
588 #[tokio::test]
589 async fn trim_counts_consistent_with_window() {
590 let fake = Arc::new(FakeProvider::new([FakeReply::Text("summary".into())]));
591 let strategy = strategy(fake);
592 let messages = vec![
593 Message::user("u1"),
594 Message::assistant("a1"),
595 Message::user("u2"),
596 Message::assistant("a2"),
597 ];
598 let counter = CharTokenCounter;
599 let result = strategy
600 .trim_with_counts(&messages, &[1, 1, 1, 1], &Budget::tokens(2), &counter)
601 .await
602 .unwrap();
603 // Budget 2 − reserve 1024 → only 1 round kept: summary + u2/a2.
604 assert_eq!(result.messages.len(), 3);
605 assert!(result.replace);
606 }
607}