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 /// Request-scoped model options for the summarizer call (headers,
122 /// temperature, extra parameters). The strategy builds its own request, so
123 /// it does not see the agent's [`ModelOptions`]; anything the endpoint
124 /// needs on *every* call (an auth header for a gateway, a tenant id) has
125 /// to be set here as well, or the summarizer call fails and degrades to
126 /// window dropping.
127 options: ModelOptions,
128}
129
130impl fmt::Debug for SummarizeStrategy {
131 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132 // Provider is a trait object and cannot derive Debug: print the
133 // comparable fields and mark the Provider's type.
134 f.debug_struct("SummarizeStrategy")
135 .field("provider", &"Box<dyn Provider>")
136 .field("prompt_len", &self.prompt.len())
137 .field("summary_max_tokens", &self.summary_max_tokens)
138 .field("options", &self.options)
139 .finish()
140 }
141}
142
143impl SummarizeStrategy {
144 /// Creates a summarization strategy with the default prompt and a default
145 /// summary output cap (1024 tokens).
146 pub fn new(provider: impl Provider + 'static) -> Self {
147 Self {
148 provider: Box::new(provider),
149 prompt: DEFAULT_SUMMARY_PROMPT.into(),
150 summary_max_tokens: 1024,
151 options: ModelOptions::default(),
152 }
153 }
154
155 /// Replaces the default summarization prompt (instruction + requirements;
156 /// the history text is appended by the strategy).
157 pub fn with_prompt(mut self, prompt: impl Into<String>) -> Self {
158 self.prompt = prompt.into();
159 self
160 }
161
162 /// Sets the token cap for the summary output (default 1024).
163 ///
164 /// The value doubles as the budget reservation for kept rounds: the kept
165 /// rounds' token total stays within "budget − summary cap", leaving room
166 /// for the summary output.
167 ///
168 /// Kept authoritative over [`with_options`](Self::with_options)'s
169 /// `max_tokens`: the cap is coupled with the budget reservation, so a
170 /// different cap would make the reservation wrong.
171 pub fn with_summary_max_tokens(mut self, max_tokens: u32) -> Self {
172 self.summary_max_tokens = max_tokens;
173 self
174 }
175
176 /// Sets the model options carried by the summarizer call: request headers
177 /// ([`ModelOptions::headers`]), sampling parameters, and vendor extension
178 /// parameters.
179 ///
180 /// The strategy sends its own request (system prompt + textified history),
181 /// so it does not inherit the agent's [`ModelOptions`]. Endpoints that
182 /// require something on every call — a gateway route or auth header, a
183 /// tenant id — must have it set here too; otherwise the summarizer call is
184 /// the one that fails.
185 ///
186 /// ```rust
187 /// # extern crate molo_agent as molo;
188 /// use molo::memory::SummarizeStrategy;
189 /// use molo::provider::{FakeProvider, FakeReply, ModelOptions};
190 ///
191 /// let mut options = ModelOptions::default();
192 /// options.headers.insert("x-tenant".into(), "acme".into());
193 ///
194 /// let strategy = SummarizeStrategy::new(FakeProvider::new([FakeReply::Text(
195 /// "Earlier rounds in brief".into(),
196 /// )]))
197 /// .with_options(options);
198 /// # let _ = strategy;
199 /// ```
200 pub fn with_options(mut self, options: ModelOptions) -> Self {
201 self.options = options;
202 self
203 }
204
205 /// Shared implementation of the summarization call: decide the kept rounds
206 /// → send the compacted part to the summarizer → assemble
207 /// [summary message, kept rounds].
208 async fn trim_impl(
209 &self,
210 messages: &[Message],
211 counts: &[usize],
212 budget: &Budget,
213 counter: &dyn TokenCounter,
214 fallback: &WindowDrop,
215 ) -> Result<TrimResult, MemoryError> {
216 // Keep the most recent rounds: budget minus the reservation for the
217 // summary output; at least 1 round (fallback).
218 let rounds = split_rounds(messages, counts);
219 let keep = keep_rounds(&rounds, budget, self.summary_max_tokens as usize);
220 let cut = rounds[rounds.len() - keep].0;
221 if cut == 0 {
222 // Everything is kept (single round over budget, or within budget):
223 // nothing to compact.
224 return Ok(TrimResult {
225 messages: messages.to_vec(),
226 replace: false,
227 });
228 }
229
230 let request = ChatRequest {
231 messages: vec![
232 Message::system(self.prompt.clone()),
233 Message::user(messages_to_text(&messages[..cut])),
234 ],
235 tools: Vec::new(),
236 options: ModelOptions {
237 // The cap is the budget reservation, so it governs the request
238 // regardless of what `with_options` was given (see
239 // `with_summary_max_tokens`).
240 max_tokens: Some(self.summary_max_tokens),
241 ..self.options.clone()
242 },
243 };
244 let summary = match self.provider.chat(request).await {
245 Ok(response) => {
246 // The summarizer should reply with an Assistant message; other
247 // roles degrade defensively.
248 let Message::Assistant { content, .. } = &response.message else {
249 return fallback
250 .trim_with_counts(messages, counts, budget, counter)
251 .await;
252 };
253 if content.trim().is_empty() {
254 None
255 } else {
256 Some(content.trim().to_string())
257 }
258 }
259 Err(error) => {
260 // On failure, degrade to window dropping (projection): the
261 // conversation continues, storage keeps the original text, and
262 // summarization is retried on the next over-budget retrieval.
263 #[cfg(feature = "tracing")]
264 tracing::warn!(%error, "summarize failed, falling back to window drop");
265 #[cfg(not(feature = "tracing"))]
266 let _ = error;
267 None
268 }
269 };
270 let Some(summary) = summary else {
271 return fallback
272 .trim_with_counts(messages, counts, budget, counter)
273 .await;
274 };
275
276 let mut result = Vec::with_capacity(keep + 1);
277 result.push(Message::system(format!("{SUMMARY_PREFIX}{summary}")));
278 result.extend_from_slice(&messages[cut..]);
279 // Compaction event (observability): compacted count, kept count,
280 // summary output token count.
281 // The summary message's tokens count toward the budget and may leave
282 // the materialized result over budget (the next retrieval compacts
283 // again) — the log carries this info for diagnosis.
284 #[cfg(feature = "tracing")]
285 {
286 let summary_tokens = count_message(counter, &result[0]).await?;
287 tracing::info!(
288 compressed = cut,
289 kept = messages.len() - cut,
290 summary_tokens,
291 "context summarized by LLM"
292 );
293 }
294 Ok(TrimResult {
295 messages: result,
296 replace: true,
297 })
298 }
299}
300
301#[async_trait::async_trait]
302impl TrimStrategy for SummarizeStrategy {
303 async fn trim(
304 &self,
305 messages: &[Message],
306 budget: &Budget,
307 counter: &dyn TokenCounter,
308 ) -> Result<TrimResult, MemoryError> {
309 let mut counts = Vec::with_capacity(messages.len());
310 for m in messages {
311 counts.push(count_message(counter, m).await?);
312 }
313 self.trim_with_counts(messages, &counts, budget, counter)
314 .await
315 }
316
317 async fn trim_with_counts(
318 &self,
319 messages: &[Message],
320 counts: &[usize],
321 budget: &Budget,
322 counter: &dyn TokenCounter,
323 ) -> Result<TrimResult, MemoryError> {
324 if messages.is_empty() {
325 return Ok(TrimResult {
326 messages: Vec::new(),
327 replace: false,
328 });
329 }
330 debug_assert_eq!(messages.len(), counts.len());
331 let fallback = WindowDrop;
332 self.trim_impl(messages, counts, budget, counter, &fallback)
333 .await
334 }
335}
336
337/// Textifies a message sequence as input for the summarizer.
338///
339/// One `role: content` line per message: System verbatim, User joins content
340/// blocks, Assistant takes the body plus tool calls (name + arguments; the
341/// reasoning trace is not carried), ToolResult takes the result text.
342/// The summary message (prior summary) appears here as a `system:` line, which
343/// is how the summarizer recognizes it.
344fn messages_to_text(messages: &[Message]) -> String {
345 let mut lines = Vec::with_capacity(messages.len());
346 for message in messages {
347 match message {
348 Message::System(s) => lines.push(format!("system: {s}")),
349 Message::User(blocks) => {
350 let text: String = blocks
351 .iter()
352 .map(|block| match block {
353 ContentBlock::Text(t) => t.clone(),
354 // The summary keeps a placeholder so the context
355 // "an image was there" survives compression (the
356 // bytes themselves cannot be summarized).
357 ContentBlock::Image(image) => format!("[image: {}]", image.mime_type),
358 // Pass-through blocks are rendered by their wire
359 // type (e.g. `[audio]`); unknown shapes degrade to
360 // a generic marker.
361 ContentBlock::Wire(value) => {
362 match value.get("type").and_then(|t| t.as_str()) {
363 Some(kind) => format!("[{kind}]"),
364 None => "[content]".to_string(),
365 }
366 }
367 })
368 .collect::<Vec<_>>()
369 .join(" ");
370 lines.push(format!("user: {text}"));
371 }
372 Message::Assistant {
373 content,
374 tool_calls,
375 ..
376 } => {
377 if tool_calls.is_empty() {
378 lines.push(format!("assistant: {content}"));
379 } else {
380 let calls = tool_calls
381 .iter()
382 .map(|tc| format!("{} {}", tc.name, tc.arguments))
383 .collect::<Vec<_>>()
384 .join("; ");
385 lines.push(format!("assistant: {content}\ntool_calls: {calls}"));
386 }
387 }
388 Message::ToolResult { content, .. } => lines.push(format!("tool_result: {content}")),
389 }
390 }
391 lines.join("\n")
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397 use crate::memory::{CharTokenCounter, Memory, WindowMemory};
398 use crate::message::ToolCall;
399 use crate::provider::{FakeProvider, FakeReply, ModelOptions, ProviderError};
400 use std::sync::Arc;
401
402 /// Builds a conversation (round `start` through round `n`; about 11 tokens
403 /// per round).
404 async fn record_rounds(memory: &mut WindowMemory, start: usize, n: usize) {
405 for i in start..=n {
406 memory
407 .record(Message::user(format!("Question from round {i}")))
408 .await
409 .unwrap();
410 memory
411 .record(Message::assistant(format!("Answer from round {i}")))
412 .await
413 .unwrap();
414 }
415 }
416
417 /// Builds the strategy with a shared instance: Arc<FakeProvider> implements
418 /// Provider, so tests can assert the request history from the original
419 /// instance.
420 fn strategy(fake: Arc<FakeProvider>) -> Arc<SummarizeStrategy> {
421 Arc::new(SummarizeStrategy::new(fake))
422 }
423
424 /// Basic compaction: over budget → [summary (System), most recent round];
425 /// the summarizer only receives the compacted part (kept rounds excluded).
426 #[tokio::test]
427 async fn summarizes_old_rounds_and_keeps_recent() {
428 let fake = Arc::new(FakeProvider::new([FakeReply::Text(
429 "Key points from earlier rounds".into(),
430 )]));
431 let mut memory = WindowMemory::new(30).with_strategy(strategy(fake.clone()));
432 record_rounds(&mut memory, 1, 4).await;
433
434 let context = memory.context().await.unwrap();
435 assert_eq!(context.len(), 3);
436 assert_eq!(
437 context[0],
438 Message::system(format!("{SUMMARY_PREFIX}Key points from earlier rounds"))
439 );
440 assert_eq!(context[1], Message::user("Question from round 4"));
441 assert_eq!(context[2], Message::assistant("Answer from round 4"));
442
443 // Request = [prompt (system), compacted-part text (user)]; kept rounds
444 // excluded.
445 let requests = fake.requests();
446 assert_eq!(requests.len(), 1);
447 let messages = &requests[0].messages;
448 assert!(
449 matches!(&messages[0], Message::System(p) if p.contains("conversation-history compressor"))
450 );
451 let Message::User(blocks) = &messages[1] else {
452 panic!("expected user message");
453 };
454 let ContentBlock::Text(text) = &blocks[0] else {
455 panic!("expected a text block");
456 };
457 assert!(text.contains("Question from round 1") && text.contains("Question from round 3"));
458 assert!(!text.contains("Question from round 4"));
459 }
460
461 /// The summary output cap doubles as the budget reservation for kept
462 /// rounds: a smaller cap → more rounds kept.
463 #[tokio::test]
464 async fn summary_budget_reserves_space_for_recent_rounds() {
465 let fake = Arc::new(FakeProvider::new([FakeReply::Text("Highlights".into())]));
466 // Budget 30, summary cap 4: 26 tokens available for kept rounds. Each
467 // round is 11 tokens ("Question/Answer from round N"): keep the recent
468 // 2 rounds (22); adding a 3rd (33 > 26) stops. Result: [summary,
469 // round 3, round 4].
470 let mut memory = WindowMemory::new(30).with_strategy(Arc::new(
471 SummarizeStrategy::new(fake.clone()).with_summary_max_tokens(4),
472 ));
473 record_rounds(&mut memory, 1, 4).await;
474
475 let context = memory.context().await.unwrap();
476 assert_eq!(context.len(), 5);
477 assert_eq!(context[1], Message::user("Question from round 3"));
478 assert_eq!(context[3], Message::user("Question from round 4"));
479 }
480
481 /// Options set on the strategy are carried by the summarizer call — the
482 /// strategy builds its own request, so this is how a gateway-required
483 /// header reaches the summarizer. The summary cap stays authoritative for
484 /// `max_tokens`: it doubles as the budget reservation.
485 #[tokio::test]
486 async fn strategy_options_are_carried_by_the_summarizer_call() {
487 let fake = Arc::new(FakeProvider::new([FakeReply::Text("Highlights".into())]));
488 let mut options = ModelOptions {
489 temperature: Some(0.2),
490 max_tokens: Some(9_999),
491 ..Default::default()
492 };
493 options.headers.insert("x-tenant".into(), "acme".into());
494 let mut memory = WindowMemory::new(30).with_strategy(Arc::new(
495 SummarizeStrategy::new(fake.clone())
496 .with_summary_max_tokens(4)
497 .with_options(options),
498 ));
499 record_rounds(&mut memory, 1, 4).await;
500 memory.context().await.unwrap();
501
502 let requests = fake.requests();
503 assert_eq!(requests.len(), 1);
504 let options = &requests[0].options;
505 assert_eq!(
506 options.headers.get("x-tenant").map(String::as_str),
507 Some("acme")
508 );
509 assert_eq!(options.temperature, Some(0.2));
510 assert_eq!(options.max_tokens, Some(4));
511 }
512
513 /// Incremental compaction: on the second over-budget event, the previous
514 /// summary is merged into the new one as input.
515 #[tokio::test]
516 async fn incremental_summary_reuses_previous_summary() {
517 let fake = Arc::new(FakeProvider::new([
518 FakeReply::Text("first segment highlights".into()),
519 FakeReply::Text("merged highlights".into()),
520 ]));
521 let mut memory = WindowMemory::new(30).with_strategy(Arc::new(
522 SummarizeStrategy::new(fake.clone()).with_summary_max_tokens(4),
523 ));
524 record_rounds(&mut memory, 1, 4).await;
525 memory.context().await.unwrap(); // first compaction → [summary, u3, a3, u4, a4]
526
527 // Append rounds 5 and 6: over budget again → second compaction.
528 record_rounds(&mut memory, 5, 6).await;
529 let context = memory.context().await.unwrap();
530 assert_eq!(
531 context[0],
532 Message::system(format!("{SUMMARY_PREFIX}merged highlights"))
533 );
534 // Budget 30 minus reserve 4 → 26: keep the 2 most recent rounds
535 // (11 + 11); the earlier messages (including the old summary) go to the
536 // second summarization. Result: [new summary, u5, a5, u6, a6].
537 assert_eq!(context.len(), 5);
538 assert_eq!(context[1], Message::user("Question from round 5"));
539
540 // The second request's input text contains the first summary message
541 // (incremental merge).
542 let requests = fake.requests();
543 assert_eq!(requests.len(), 2);
544 let Message::User(blocks) = &requests[1].messages[1] else {
545 panic!("expected user message");
546 };
547 let ContentBlock::Text(text) = &blocks[0] else {
548 panic!("expected a text block");
549 };
550 assert!(text.contains("first segment highlights"));
551 }
552
553 /// Failure degradation: summarizer call fails → window-dropped view
554 /// (projection, storage unchanged), the conversation continues; the next
555 /// over-budget retrieval retries summarization.
556 #[tokio::test]
557 async fn provider_failure_falls_back_to_window_drop() {
558 let fake = Arc::new(FakeProvider::new([
559 FakeReply::Error(ProviderError::Network("mock network error".into())),
560 FakeReply::Text("second attempt succeeded".into()),
561 ]));
562 let mut memory = WindowMemory::new(30).with_strategy(strategy(fake.clone()));
563 record_rounds(&mut memory, 1, 4).await;
564
565 // First time: failure → degrade to window dropping (budget 30 keeps the
566 // 2 most recent rounds, no summary message).
567 let context = memory.context().await.unwrap();
568 assert_eq!(
569 context,
570 vec![
571 Message::user("Question from round 3"),
572 Message::assistant("Answer from round 3"),
573 Message::user("Question from round 4"),
574 Message::assistant("Answer from round 4"),
575 ]
576 );
577
578 // Storage was not materialized: still over budget on the second call →
579 // the strategy runs again, this time succeeding.
580 let context = memory.context().await.unwrap();
581 assert_eq!(
582 context[0],
583 Message::system(format!("{SUMMARY_PREFIX}second attempt succeeded"))
584 );
585 assert_eq!(fake.requests().len(), 2);
586 }
587
588 /// Empty summary output (the model produced no text) → likewise falls back
589 /// to window dropping.
590 #[tokio::test]
591 async fn empty_summary_falls_back() {
592 let fake = Arc::new(FakeProvider::new([FakeReply::Text("".into())]));
593 let mut memory = WindowMemory::new(30).with_strategy(strategy(fake.clone()));
594 record_rounds(&mut memory, 1, 4).await;
595
596 let context = memory.context().await.unwrap();
597 assert_eq!(context.len(), 4); // window dropping: budget 30 keeps the 2 most recent rounds
598 assert!(!context.iter().any(|m| matches!(m, Message::System(_))));
599 }
600
601 /// Single-round over-budget fallback: nothing to compact, returns as-is
602 /// without calling the summarizer.
603 #[tokio::test]
604 async fn single_round_over_budget_returns_as_is() {
605 let fake = Arc::new(FakeProvider::new([FakeReply::Text("summary".into())]));
606 let mut memory = WindowMemory::new(5).with_strategy(strategy(fake.clone()));
607 memory
608 .record(Message::user(
609 "An extremely long user message, over budget in a single round",
610 ))
611 .await
612 .unwrap();
613 memory.record(Message::assistant("Reply")).await.unwrap();
614
615 let context = memory.context().await.unwrap();
616 assert_eq!(context.len(), 2);
617 assert!(
618 fake.requests().is_empty(),
619 "with nothing to compact the summarizer must not be called"
620 );
621 }
622
623 /// Defensive: an empty message list is returned as-is.
624 #[tokio::test]
625 async fn empty_input_returns_empty() {
626 let fake = Arc::new(FakeProvider::new([FakeReply::Text("summary".into())]));
627 let strategy = strategy(fake);
628 let result = strategy
629 .trim(&[], &Budget::tokens(10), &CharTokenCounter)
630 .await
631 .unwrap();
632 assert!(result.messages.is_empty());
633 assert!(!result.replace);
634 }
635
636 /// Tool-call round textification: the assistant message carries the tool
637 /// name and arguments, with the tool result on its own line.
638 #[tokio::test]
639 async fn messages_to_text_includes_tool_calls_and_results() {
640 let messages = vec![
641 Message::system("setup"),
642 Message::user("calculate"),
643 Message::Assistant {
644 content: "".into(),
645 reasoning: Some("reasoning trace is not included".into()),
646 tool_calls: vec![ToolCall {
647 id: "t1".into(),
648 name: "calc".into(),
649 arguments: r#"{"expr":"1+1"}"#.into(),
650 }],
651 },
652 Message::tool_result("t1", "2"),
653 ];
654 let text = messages_to_text(&messages);
655 assert!(text.contains("system: setup"));
656 assert!(text.contains("user: calculate"));
657 assert!(text.contains(r#"tool_calls: calc {"expr":"1+1"}"#));
658 assert!(text.contains("tool_result: 2"));
659 // The reasoning trace never enters the summary input.
660 assert!(!text.contains("reasoning trace is not included"));
661 }
662
663 /// The textified input counts against the kept-round budget (same
664 /// convention as window trimming).
665 #[tokio::test]
666 async fn trim_counts_consistent_with_window() {
667 let fake = Arc::new(FakeProvider::new([FakeReply::Text("summary".into())]));
668 let strategy = strategy(fake);
669 let messages = vec![
670 Message::user("u1"),
671 Message::assistant("a1"),
672 Message::user("u2"),
673 Message::assistant("a2"),
674 ];
675 let counter = CharTokenCounter;
676 let result = strategy
677 .trim_with_counts(&messages, &[1, 1, 1, 1], &Budget::tokens(2), &counter)
678 .await
679 .unwrap();
680 // Budget 2 − reserve 1024 → only 1 round kept: summary + u2/a2.
681 assert_eq!(result.messages.len(), 3);
682 assert!(result.replace);
683 }
684}