Skip to main content

wcode_harness/
compaction.rs

1//! Context compaction — deciding *when* to compact and *where* to cut.
2//!
3//! Compaction summarizes an old prefix of the conversation and keeps a recent
4//! suffix. The prefix is discarded from what the model sees, so only the kept
5//! suffix must be *valid* on the wire. The one invariant that makes a suffix
6//! invalid is an orphaned `ToolResult`: a kept tool result whose matching
7//! `ToolCall` (which lives in a preceding `Assistant` message) was summarized
8//! away. Providers reject that as an unmatched tool result.
9//!
10//! [`safe_cut`] returns the earliest index at which the conversation may be
11//! split so the kept suffix `ctx[cut..]` is self-consistent. It only ever moves
12//! the cut *earlier* than requested — summarizing less rather than breaking the
13//! pairing — and returns `0` when no boundary pairs everything.
14//!
15//! The reverse direction needs no check: the agent loop never leaves a
16//! `ToolCall` without its `ToolResult`, and a `ToolResult` always follows its
17//! `ToolCall`, so a kept call's results (later indices) are kept too.
18//!
19//! [`CompactionPolicy`] decides *when* to compact (the trigger) and *how much*
20//! recent history to keep. Retention is absolute — a token budget plus a turn
21//! floor — not a fraction of the window, so it means the same thing on an 8k
22//! and a 1M model. The *trigger* uses the provider-reported input-token count
23//! (never an estimate); only the cut boundary uses [`estimate_tokens`].
24
25use std::collections::HashSet;
26
27use futures::StreamExt as _;
28
29use crate::event::LlmStreamEvent;
30use crate::message::{AgentMessage, ContentBlock, Usage};
31use crate::session::Session;
32use crate::streamfn::{LlmOpts, StreamFn};
33
34/// Earliest index at which `ctx` may be split so the kept suffix `ctx[cut..]`
35/// has no orphaned tool result.
36///
37/// `initial_cut` is the caller's preferred split (e.g. `ctx.len() - keep`); the
38/// result is always `<= initial_cut` (clamped to `ctx.len()`). Returns `0` —
39/// keep everything — when walking all the way back still can't pair a kept
40/// result with its call.
41pub fn safe_cut(ctx: &[AgentMessage], initial_cut: usize) -> usize {
42    let cut = initial_cut.min(ctx.len());
43
44    let mut available: HashSet<&str> = HashSet::new();
45    let mut missing: HashSet<&str> = HashSet::new();
46    for msg in &ctx[cut..] {
47        absorb(msg, &mut available, &mut missing);
48    }
49    if missing.is_empty() {
50        return cut;
51    }
52
53    // Grow the kept suffix backward, one message at a time, until every kept
54    // result has its call. Adding a call removes the corresponding missing
55    // result; adding a result whose call isn't yet seen records a new one.
56    for idx in (0..cut).rev() {
57        absorb(&ctx[idx], &mut available, &mut missing);
58        if missing.is_empty() {
59            return idx;
60        }
61    }
62
63    0
64}
65
66/// Fold one message's tool calls/results into the pairing trackers.
67fn absorb<'a>(
68    msg: &'a AgentMessage,
69    available: &mut HashSet<&'a str>,
70    missing: &mut HashSet<&'a str>,
71) {
72    match msg {
73        AgentMessage::Assistant { content, .. } => {
74            for block in content {
75                if let ContentBlock::ToolCall { id, .. } = block {
76                    missing.remove(id.as_str());
77                    available.insert(id.as_str());
78                }
79            }
80        }
81        AgentMessage::ToolResult { tool_call_id, .. }
82            if !available.contains(tool_call_id.as_str()) =>
83        {
84            missing.insert(tool_call_id.as_str());
85        }
86        _ => {}
87    }
88}
89
90/// Tokens held free below the working ceiling by default: the trigger fires
91/// once fewer than this many remain.
92pub const DEFAULT_MIN_REMAINING: u64 = 16_384;
93
94/// Recent context kept verbatim by default, in tokens — an absolute budget
95/// (pi-style), independent of the model's window size.
96pub const DEFAULT_KEEP_RECENT_TOKENS: u64 = 20_000;
97
98/// Complete turns always kept by default, whatever their token size.
99pub const DEFAULT_KEEP_RECENT_TURNS: usize = 2;
100
101/// When to compact and how much recent context to retain.
102///
103/// The model window is not stored here: it comes from [`crate::limits`] (the
104/// model's advertised context) or an override, and is passed to
105/// [`CompactionPolicy::should_compact`]. `budget` is an optional working
106/// ceiling (cost control) that defaults to the window; `min_remaining` is the
107/// "minimum remaining context" trigger; the `keep_recent_*` fields are the
108/// absolute recent history retained after compacting.
109#[derive(Clone, Copy, Debug, PartialEq, Eq)]
110pub struct CompactionPolicy {
111    /// Context-window override, consulted only when [`crate::limits`] doesn't
112    /// know the model (see [`CompactionPolicy::context_window`]). `None` =
113    /// derive it from the catalog, or fall back to a default.
114    pub window: Option<u64>,
115    /// Working ceiling in tokens. `None` = the model window. Capped to the
116    /// window at trigger time, so it can never disable window safety.
117    pub budget: Option<u64>,
118    /// Compact once fewer than this many tokens remain below the ceiling
119    /// (`used >= ceiling - min_remaining`).
120    pub min_remaining: u64,
121    /// Recent context kept verbatim after compacting, in tokens.
122    pub keep_recent_tokens: u64,
123    /// Complete turns always kept, whatever their token size.
124    pub keep_recent_turns: usize,
125}
126
127impl Default for CompactionPolicy {
128    fn default() -> Self {
129        Self {
130            window: None,
131            budget: None,
132            min_remaining: DEFAULT_MIN_REMAINING,
133            keep_recent_tokens: DEFAULT_KEEP_RECENT_TOKENS,
134            keep_recent_turns: DEFAULT_KEEP_RECENT_TURNS,
135        }
136    }
137}
138
139impl CompactionPolicy {
140    /// The working ceiling: the configured budget, capped to the model window.
141    pub fn ceiling(&self, window: u64) -> u64 {
142        self.budget.map_or(window, |b| b.min(window))
143    }
144
145    /// The context window for `model` served from `base_url`: the model's
146    /// advertised context (via [`crate::limits`]) when known, else the
147    /// configured [`CompactionPolicy::window`], else
148    /// [`crate::limits::DEFAULT_CONTEXT_WINDOW`].
149    pub fn context_window(&self, base_url: Option<&str>, model: &str) -> u64 {
150        crate::limits::model_limit(base_url, model)
151            .map(|l| l.context)
152            .or(self.window)
153            .unwrap_or(crate::limits::DEFAULT_CONTEXT_WINDOW)
154    }
155
156    /// True once the provider-reported `used` input tokens leave less than
157    /// `min_remaining` free below the ceiling.
158    pub fn should_compact(&self, window: u64, used: u64) -> bool {
159        used >= self.ceiling(window).saturating_sub(self.min_remaining)
160    }
161}
162
163/// Rough per-message token estimate (~4 chars/token plus a small fixed
164/// overhead). Used only to place the cut boundary; the compaction *trigger*
165/// uses provider-reported token counts, never this.
166pub fn estimate_tokens(msg: &AgentMessage) -> u64 {
167    let bytes = match msg {
168        AgentMessage::User { content } | AgentMessage::Assistant { content, .. } => {
169            content.iter().map(block_bytes).sum()
170        }
171        AgentMessage::ToolResult {
172            output,
173            tool_call_id,
174            name,
175            ..
176        } => (output.len() + tool_call_id.len() + name.len()) as u64,
177    };
178    bytes / 4 + 4
179}
180
181fn block_bytes(block: &ContentBlock) -> u64 {
182    match block {
183        ContentBlock::Text { text } | ContentBlock::Thinking { text } => text.len() as u64,
184        ContentBlock::ToolCall {
185            name, arguments, ..
186        } => (name.len() + arguments.to_string().len()) as u64,
187    }
188}
189
190/// Oldest index to keep when retaining `keep_budget` tokens of the most recent
191/// history, adjusted by [`safe_cut`] so the kept suffix never orphans a tool
192/// result.
193#[cfg(test)]
194fn cut_for_budget(ctx: &[AgentMessage], keep_budget: u64) -> usize {
195    safe_cut(ctx, token_cut(ctx, keep_budget))
196}
197
198/// Newest-first accumulation of [`estimate_tokens`] until `budget` is met:
199/// the rough index where the retained verbatim tail begins.
200fn token_cut(ctx: &[AgentMessage], budget: u64) -> usize {
201    let mut acc = 0u64;
202    let mut idx = ctx.len();
203    while idx > 0 && acc < budget {
204        idx -= 1;
205        acc += estimate_tokens(&ctx[idx]);
206    }
207    idx
208}
209
210/// Index of the user message that begins the `turns`-th-most-recent turn, so
211/// keeping from here retains at least `turns` complete turns. `0` when the
212/// conversation has fewer than `turns` user messages; `ctx.len()` (no floor)
213/// when `turns` is 0.
214fn turn_start_cut(ctx: &[AgentMessage], turns: usize) -> usize {
215    if turns == 0 {
216        return ctx.len();
217    }
218    let mut seen = 0usize;
219    for idx in (0..ctx.len()).rev() {
220        if matches!(&ctx[idx], AgentMessage::User { .. }) {
221            seen += 1;
222            if seen == turns {
223                return idx;
224            }
225        }
226    }
227    0
228}
229
230/// Cut for `policy`: keep the newest messages up to `keep_recent_tokens` but
231/// never fewer than `keep_recent_turns` turns, made tool-safe by [`safe_cut`].
232pub fn cut_for_policy(ctx: &[AgentMessage], policy: &CompactionPolicy) -> usize {
233    let by_tokens = token_cut(ctx, policy.keep_recent_tokens);
234    let by_turns = turn_start_cut(ctx, policy.keep_recent_turns);
235    safe_cut(ctx, by_tokens.min(by_turns))
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use crate::message::StopReason;
242
243    fn user(text: &str) -> AgentMessage {
244        AgentMessage::user_text(text)
245    }
246
247    fn assistant_text(text: &str) -> AgentMessage {
248        AgentMessage::Assistant {
249            content: vec![ContentBlock::Text { text: text.into() }],
250            stop_reason: StopReason::Stop,
251            usage: None,
252            model: None,
253        }
254    }
255
256    fn assistant_calls(ids: &[&str]) -> AgentMessage {
257        AgentMessage::Assistant {
258            content: ids
259                .iter()
260                .map(|id| ContentBlock::ToolCall {
261                    id: (*id).into(),
262                    name: "t".into(),
263                    arguments: serde_json::json!({}),
264                })
265                .collect(),
266            stop_reason: StopReason::ToolUse,
267            usage: None,
268            model: None,
269        }
270    }
271
272    fn tool_result(id: &str) -> AgentMessage {
273        AgentMessage::ToolResult {
274            tool_call_id: id.into(),
275            name: "t".into(),
276            output: "o".into(),
277            is_error: false,
278        }
279    }
280
281    #[test]
282    fn empty_and_out_of_range_are_clamped() {
283        assert_eq!(safe_cut(&[], 0), 0);
284        assert_eq!(safe_cut(&[], 5), 0);
285        let ctx = vec![user("a")];
286        assert_eq!(safe_cut(&ctx, 0), 0);
287        assert_eq!(safe_cut(&ctx, 1), 1);
288        assert_eq!(safe_cut(&ctx, 9), 1, "clamps to ctx.len()");
289    }
290
291    #[test]
292    fn tool_free_context_keeps_initial_cut() {
293        let ctx = vec![user("a"), assistant_text("x"), user("b"), assistant_text("y")];
294        assert_eq!(safe_cut(&ctx, 2), 2);
295        assert_eq!(safe_cut(&ctx, 3), 3);
296    }
297
298    #[test]
299    fn cut_inside_a_tool_exchange_pulls_in_the_call() {
300        // [user, assistant(c1), result(c1), assistant]
301        let ctx = vec![
302            user("q"),
303            assistant_calls(&["c1"]),
304            tool_result("c1"),
305            assistant_text("done"),
306        ];
307        // Cutting at the result (2) would orphan it -> extend back to the call.
308        assert_eq!(safe_cut(&ctx, 2), 1);
309        assert_eq!(safe_cut(&ctx, 3), 3, "cut past the exchange drops it whole");
310        // A cut already at the call boundary is safe as-is.
311        assert_eq!(safe_cut(&ctx, 1), 1);
312        // A cut at the trailing boundary keeps only the final assistant.
313        assert_eq!(safe_cut(&ctx, 4), 4);
314    }
315
316    #[test]
317    fn multi_call_assistant_is_pulled_in_whole() {
318        // One assistant issuing two calls; results follow in order.
319        let ctx = vec![
320            user("q"),
321            assistant_calls(&["c1", "c2"]),
322            tool_result("c1"),
323            tool_result("c2"),
324            assistant_text("done"),
325        ];
326        // Cutting between the two results pulls the shared assistant back.
327        assert_eq!(safe_cut(&ctx, 3), 1);
328        assert_eq!(safe_cut(&ctx, 2), 1);
329        // Cutting at a clean turn boundary stays put.
330        assert_eq!(safe_cut(&ctx, 5), 5);
331    }
332
333    #[test]
334    fn cut_at_a_user_boundary_after_a_completed_turn_is_kept() {
335        let ctx = vec![
336            user("q1"),
337            assistant_calls(&["c1"]),
338            tool_result("c1"),
339            user("q2"),
340            assistant_text("done"),
341        ];
342        assert_eq!(safe_cut(&ctx, 3), 3, "the q2 boundary needs no adjustment");
343    }
344
345    #[test]
346    fn unpaired_result_walks_all_the_way_back() {
347        // A result whose call is absent from the whole context cannot be
348        // paired: a cut that keeps it gives up, one that drops it is fine.
349        let ctx = vec![user("q"), tool_result("ghost"), assistant_text("x")];
350        assert_eq!(safe_cut(&ctx, 1), 0);
351        assert_eq!(safe_cut(&ctx, 2), 2, "dropping the orphan is fine");
352    }
353
354    #[test]
355    fn kept_call_with_its_results_needs_no_extension() {
356        // A kept call whose results are also kept (they follow it) is valid.
357        let ctx = vec![
358            user("q"),
359            assistant_calls(&["c1"]),
360            tool_result("c1"),
361            user("next"),
362        ];
363        assert_eq!(safe_cut(&ctx, 3), 3, "keeps only the trailing user message");
364        assert_eq!(safe_cut(&ctx, 1), 1, "keeps the whole tool exchange");
365    }
366}
367
368#[cfg(test)]
369mod policy_tests {
370    use super::*;
371    use crate::message::StopReason;
372
373    fn user(text: &str) -> AgentMessage {
374        AgentMessage::user_text(text)
375    }
376
377    fn assistant_text(text: &str) -> AgentMessage {
378        AgentMessage::Assistant {
379            content: vec![ContentBlock::Text { text: text.into() }],
380            stop_reason: StopReason::Stop,
381            usage: None,
382            model: None,
383        }
384    }
385
386    fn assistant_tool_call(id: &str, args: String) -> AgentMessage {
387        AgentMessage::Assistant {
388            content: vec![ContentBlock::ToolCall {
389                id: id.into(),
390                name: "t".into(),
391                arguments: serde_json::json!({ "d": args }),
392            }],
393            stop_reason: StopReason::ToolUse,
394            usage: None,
395            model: None,
396        }
397    }
398
399    fn tool_result(id: &str, output: String) -> AgentMessage {
400        AgentMessage::ToolResult {
401            tool_call_id: id.into(),
402            name: "t".into(),
403            output,
404            is_error: false,
405        }
406    }
407
408    #[test]
409    fn trigger_uses_min_remaining_and_budget() {
410        // No budget: the ceiling is the model window.
411        let p = CompactionPolicy {
412            min_remaining: 100,
413            ..Default::default()
414        };
415        assert!(!p.should_compact(1000, 899));
416        assert!(p.should_compact(1000, 900), "window - min_remaining");
417        assert!(p.should_compact(1000, 5000), "past the window still triggers");
418
419        // A working budget below the window triggers far earlier.
420        let p = CompactionPolicy {
421            budget: Some(500),
422            min_remaining: 100,
423            ..Default::default()
424        };
425        assert!(!p.should_compact(1_000_000, 399));
426        assert!(p.should_compact(1_000_000, 400));
427
428        // A budget above the window is capped to the window.
429        let p = CompactionPolicy {
430            budget: Some(2_000_000),
431            min_remaining: 100,
432            ..Default::default()
433        };
434        assert!(!p.should_compact(1000, 899));
435        assert!(p.should_compact(1000, 900));
436    }
437
438    #[test]
439    fn ceiling_is_budget_capped_to_window() {
440        let p = CompactionPolicy {
441            budget: Some(500),
442            ..Default::default()
443        };
444        assert_eq!(p.ceiling(1_000_000), 500);
445        let p = CompactionPolicy {
446            budget: Some(2_000_000),
447            ..Default::default()
448        };
449        assert_eq!(p.ceiling(1_000_000), 1_000_000);
450        let p = CompactionPolicy::default();
451        assert_eq!(p.ceiling(1234), 1234);
452    }
453
454    #[test]
455    fn estimate_grows_with_content_and_counts_tool_output() {
456        assert!(estimate_tokens(&user("hello there")) > estimate_tokens(&user("hi")));
457        let big = AgentMessage::ToolResult {
458            tool_call_id: "c".into(),
459            name: "t".into(),
460            output: "x".repeat(400),
461            is_error: false,
462        };
463        assert!(estimate_tokens(&big) >= 100, "~400 chars -> ~100 tokens");
464    }
465
466    #[test]
467    fn cut_keeps_the_newest_within_budget() {
468        // Six ~54-token messages; a 120-token budget keeps the last three.
469        let ctx = vec![
470            user(&"a".repeat(200)),
471            assistant_text(&"b".repeat(200)),
472            user(&"c".repeat(200)),
473            assistant_text(&"d".repeat(200)),
474            user(&"e".repeat(200)),
475            assistant_text(&"f".repeat(200)),
476        ];
477        assert_eq!(cut_for_budget(&ctx, 120), 3, "keeps the newest that fit");
478    }
479
480    #[test]
481    fn cut_boundary_is_made_tool_safe() {
482        // Budget lands on a tool result, so safe_cut pulls its call in.
483        let ctx = vec![
484            user("q"),
485            assistant_tool_call("c1", "x".repeat(400)),
486            tool_result("c1", "y".repeat(400)),
487            assistant_text("done"),
488        ];
489        // A tiny budget keeps only the trailing assistant (no adjustment).
490        assert_eq!(cut_for_budget(&ctx, 1), ctx.len() - 1);
491        // A budget that reaches the result extends back to the call.
492        assert_eq!(cut_for_budget(&ctx, 120), 1, "the call joins the suffix");
493    }
494
495    #[test]
496    fn cut_for_policy_keeps_recent_tokens() {
497        let ctx = vec![
498            user(&"a".repeat(200)),
499            assistant_text(&"b".repeat(200)),
500            user(&"c".repeat(200)),
501            assistant_text(&"d".repeat(200)),
502        ];
503        // ~54 tokens each; a 100-token budget keeps the newest two.
504        let p = CompactionPolicy {
505            keep_recent_tokens: 100,
506            keep_recent_turns: 0,
507            ..Default::default()
508        };
509        assert_eq!(cut_for_policy(&ctx, &p), 2);
510    }
511
512    #[test]
513    fn cut_for_policy_keeps_at_least_the_turn_floor() {
514        // A tiny token budget must not drop below the last two turns.
515        let ctx = vec![
516            user("u1"),
517            assistant_text("a1"),
518            user("u2"),
519            assistant_text("a2"),
520            user("u3"),
521            assistant_text("a3"),
522        ];
523        let p = CompactionPolicy {
524            keep_recent_tokens: 0,
525            keep_recent_turns: 2,
526            ..Default::default()
527        };
528        assert_eq!(cut_for_policy(&ctx, &p), 2, "last two turns start at u2");
529    }
530}
531
532/// System prompt for the summarizer.
533const SUMMARY_PROMPT: &str = "\
534You are summarizing a coding-agent conversation so the agent can continue the \
535work later with this summary in place of the summarized messages. Write \
536concise plain prose under these headings, preserving concrete details (file \
537paths, names, decisions, errors) and omitting pleasantries:
538- Context: what we are working on and why.
539- What we did: key actions taken, files changed, problems solved.
540- Current state: what works, what is broken, what is next.
541- User preferences: requirements or decisions the user made. The turns you \
542summarize are not lost: they remain in this session's transcript and can be \
543searched later with the session_search tool (use scope=\"current\").";
544
545/// Per-message/-block character cap when rendering a conversation for the
546/// summarizer, so one giant tool result can't crowd out the rest.
547const MAX_BLOCK_CHARS: usize = 4_000;
548
549/// Total rendered-conversation cap (~100k tokens); the middle is elided when
550/// exceeded, keeping the start and the most recent end.
551const MAX_CONVERSATION_CHARS: usize = 400_000;
552
553/// A produced summary and the usage that generated it.
554#[derive(Debug, Clone)]
555pub struct Summary {
556    pub text: String,
557    pub usage: Option<Usage>,
558}
559
560/// What a compaction pass did.
561#[derive(Debug, Clone)]
562pub enum CompactOutcome {
563    /// Not enough history to bother summarizing.
564    NothingToDo,
565    /// Summarized `summarized` messages; `kept` were retained; a summary
566    /// message now stands in their place.
567    Done {
568        summarized: usize,
569        kept: usize,
570        usage: Option<Usage>,
571    },
572}
573
574/// Summarize the older prefix of `ctx` in place: keep the newest messages per
575/// `policy`, replace the rest with a summary message, and — when a session is
576/// open — persist the boundary so a resume rebuilds the same view.
577///
578/// Ordered so a failure leaves `ctx` untouched: the session write happens
579/// *before* the in-memory splice. `Ok(NothingToDo)` when the cut keeps
580/// everything (nothing worth summarizing).
581pub async fn compact_ctx(
582    ctx: &mut Vec<AgentMessage>,
583    policy: &CompactionPolicy,
584    stream_fn: &StreamFn,
585    opts: &LlmOpts,
586    instructions: Option<&str>,
587    session: Option<&mut Session>,
588) -> Result<CompactOutcome, String> {
589    let cut = cut_for_policy(ctx, policy);
590    if cut == 0 {
591        return Ok(CompactOutcome::NothingToDo);
592    }
593    let prefix = ctx[..cut].to_vec();
594    let summary = summarize(stream_fn, opts, &prefix, instructions).await?;
595    let kept = ctx.len() - cut;
596
597    if let Some(session) = session {
598        let tokens_before = ctx.iter().rev().find_map(|m| match m {
599            AgentMessage::Assistant { usage: Some(u), .. } => Some(u.input_tokens),
600            _ => None,
601        });
602        session
603            .record_compaction(&summary.text, kept, tokens_before, summary.usage)
604            .map_err(|e| e.to_string())?;
605    }
606
607    let mut next = Vec::with_capacity(kept + 1);
608    next.push(summary_message(&summary.text));
609    next.extend_from_slice(&ctx[cut..]);
610    *ctx = next;
611
612    Ok(CompactOutcome::Done {
613        summarized: cut,
614        kept,
615        usage: summary.usage,
616    })
617}
618
619/// The message injected in place of a summarized prefix.
620pub fn summary_message(summary: &str) -> AgentMessage {
621    AgentMessage::summary(summary)
622}
623
624/// Summarize `conversation` with the configured LLM, optionally focused by
625/// `instructions` (the `/compact <prompt>` argument). Drives the same
626/// [`StreamFn`] the agent uses, collecting the streamed text. Returns an error
627/// string on a stream error, or when the summarizer produces no text.
628pub async fn summarize(
629    stream_fn: &StreamFn,
630    opts: &LlmOpts,
631    conversation: &[AgentMessage],
632    instructions: Option<&str>,
633) -> Result<Summary, String> {
634    let mut system = String::from(SUMMARY_PROMPT);
635    if let Some(extra) = instructions.map(str::trim).filter(|s| !s.is_empty()) {
636        system.push_str("\n\nAdditional focus for this summary: ");
637        system.push_str(extra);
638    }
639    let user = format!(
640        "Summarize this conversation:\n\n{}",
641        render_conversation(conversation)
642    );
643
644    let mut stream = stream_fn(&[AgentMessage::user_text(user)], &system, &[], opts);
645    let mut text = String::new();
646    let mut usage = None;
647    while let Some(event) = stream.next().await {
648        match event {
649            LlmStreamEvent::TextDelta(delta) => text.push_str(&delta),
650            LlmStreamEvent::Done { usage: u, .. } => {
651                usage = u;
652                break;
653            }
654            LlmStreamEvent::Error { message, .. } => return Err(message),
655            _ => {}
656        }
657    }
658
659    let text = text.trim().to_string();
660    if text.is_empty() {
661        return Err("summarizer produced no text".to_string());
662    }
663    Ok(Summary { text, usage })
664}
665
666/// Render a conversation to plain text for the summarizer.
667fn render_conversation(messages: &[AgentMessage]) -> String {
668    let mut out = String::new();
669    for msg in messages {
670        match msg {
671            AgentMessage::User { content } => {
672                out.push_str("USER:\n");
673                render_blocks(&mut out, content);
674            }
675            AgentMessage::Assistant { content, .. } => {
676                out.push_str("ASSISTANT:\n");
677                render_blocks(&mut out, content);
678            }
679            AgentMessage::ToolResult {
680                tool_call_id,
681                name,
682                output,
683                is_error,
684            } => {
685                out.push_str("TOOL RESULT ");
686                out.push_str(name);
687                out.push_str(" (");
688                out.push_str(tool_call_id);
689                out.push(')');
690                if *is_error {
691                    out.push_str(" [error]");
692                }
693                out.push_str(":\n");
694                push_capped(&mut out, output, MAX_BLOCK_CHARS);
695                out.push('\n');
696            }
697        }
698        out.push('\n');
699    }
700    cap_total(out)
701}
702
703fn render_blocks(out: &mut String, content: &[ContentBlock]) {
704    for block in content {
705        match block {
706            ContentBlock::Text { text } => {
707                push_capped(out, text, MAX_BLOCK_CHARS);
708                out.push('\n');
709            }
710            ContentBlock::Thinking { text } => {
711                out.push_str("[thinking] ");
712                push_capped(out, text, MAX_BLOCK_CHARS);
713                out.push('\n');
714            }
715            ContentBlock::ToolCall {
716                id,
717                name,
718                arguments,
719            } => {
720                out.push_str("[tool call ");
721                out.push_str(name);
722                out.push_str(" (");
723                out.push_str(id);
724                out.push_str(") args: ");
725                push_capped(out, &arguments.to_string(), MAX_BLOCK_CHARS);
726                out.push_str("]\n");
727            }
728        }
729    }
730}
731
732/// Append `s`, truncated on a char boundary to `max` bytes if longer.
733fn push_capped(out: &mut String, s: &str, max: usize) {
734    if s.len() <= max {
735        out.push_str(s);
736        return;
737    }
738    let mut end = max;
739    while end > 0 && !s.is_char_boundary(end) {
740        end -= 1;
741    }
742    out.push_str(&s[..end]);
743    out.push_str(&format!("\n… [{} chars truncated]", s.len() - end));
744}
745
746/// Keep the head and tail of an over-long rendering, eliding the middle.
747fn cap_total(s: String) -> String {
748    if s.len() <= MAX_CONVERSATION_CHARS {
749        return s;
750    }
751    let half = MAX_CONVERSATION_CHARS / 2;
752    let mut head_end = half;
753    while head_end > 0 && !s.is_char_boundary(head_end) {
754        head_end -= 1;
755    }
756    let mut tail_start = s.len() - half;
757    while tail_start < s.len() && !s.is_char_boundary(tail_start) {
758        tail_start += 1;
759    }
760    format!(
761        "{}\n… [conversation truncated to fit the summarizer] …\n{}",
762        &s[..head_end],
763        &s[tail_start..]
764    )
765}
766
767#[cfg(test)]
768mod summarize_tests {
769    use super::*;
770    use crate::message::StopReason;
771    use crate::streamfn::LlmStream;
772    use std::sync::{Arc, Mutex};
773
774    /// A StreamFn that records the (system, user) prompt it was handed and
775    /// replays a fixed script of events.
776    fn scripted(events: Vec<LlmStreamEvent>, seen: Arc<Mutex<Vec<String>>>) -> StreamFn {
777        Arc::new(move |ctx, system, _tools, _opts| {
778            seen.lock()
779                .unwrap()
780                .push(format!("{system}\n---\n{}", ctx[0].as_text()));
781            Box::pin(futures::stream::iter(events.clone())) as LlmStream
782        })
783    }
784
785    fn done() -> LlmStreamEvent {
786        LlmStreamEvent::Done {
787            stop_reason: StopReason::Stop,
788            usage: Some(Usage::default()),
789        }
790    }
791
792    #[tokio::test]
793    async fn summarize_collects_text_usage_and_rendered_conversation() {
794        let seen = Arc::new(Mutex::new(Vec::new()));
795        let stream_fn = scripted(
796            vec![
797                LlmStreamEvent::TextDelta("Context: ".into()),
798                LlmStreamEvent::TextDelta("do X".into()),
799                done(),
800            ],
801            seen.clone(),
802        );
803        let convo = vec![
804            AgentMessage::user_text("please do X"),
805            AgentMessage::user_text("and Y"),
806        ];
807        let s = summarize(&stream_fn, &LlmOpts::default(), &convo, None)
808            .await
809            .unwrap();
810        assert_eq!(s.text, "Context: do X");
811        assert!(s.usage.is_some());
812        let prompt = seen.lock().unwrap()[0].clone();
813        assert!(prompt.contains("USER:"), "conversation reached the prompt");
814        assert!(prompt.contains("please do X"));
815    }
816
817    #[tokio::test]
818    async fn summarize_folds_instructions_into_the_system_prompt() {
819        let seen = Arc::new(Mutex::new(Vec::new()));
820        let stream_fn = scripted(vec![LlmStreamEvent::TextDelta("x".into()), done()], seen.clone());
821        summarize(
822            &stream_fn,
823            &LlmOpts::default(),
824            &[AgentMessage::user_text("hi")],
825            Some("focus on the API design"),
826        )
827        .await
828        .unwrap();
829        let prompt = seen.lock().unwrap()[0].clone();
830        assert!(prompt.contains("Additional focus for this summary: focus on the API design"));
831    }
832
833    #[tokio::test]
834    async fn summarize_surfaces_stream_error() {
835        let stream_fn = scripted(
836            vec![LlmStreamEvent::Error {
837                message: "boom".into(),
838                fatal: true,
839            }],
840            Arc::new(Mutex::new(Vec::new())),
841        );
842        let err = summarize(
843            &stream_fn,
844            &LlmOpts::default(),
845            &[AgentMessage::user_text("x")],
846            None,
847        )
848        .await
849        .unwrap_err();
850        assert_eq!(err, "boom");
851    }
852
853    #[tokio::test]
854    async fn summarize_rejects_empty_output() {
855        let stream_fn = scripted(vec![done()], Arc::new(Mutex::new(Vec::new())));
856        let err = summarize(
857            &stream_fn,
858            &LlmOpts::default(),
859            &[AgentMessage::user_text("x")],
860            None,
861        )
862        .await
863        .unwrap_err();
864        assert!(err.contains("no text"), "got: {err}");
865    }
866
867    #[test]
868    fn render_truncates_a_giant_tool_output() {
869        let big = AgentMessage::ToolResult {
870            tool_call_id: "c".into(),
871            name: "t".into(),
872            output: "x".repeat(10_000),
873            is_error: false,
874        };
875        let rendered = render_conversation(&[big]);
876        assert!(rendered.contains("chars truncated"));
877        assert!(rendered.len() < 5_000, "len was {}", rendered.len());
878    }
879
880    #[test]
881    fn summary_message_is_a_user_message_with_a_header() {
882        let m = summary_message("did things");
883        assert!(matches!(m, AgentMessage::User { .. }));
884        assert!(m.as_text().contains("did things"));
885        assert!(m.as_text().starts_with("Summary"));
886    }
887}
888
889#[cfg(test)]
890mod window_tests {
891    use super::*;
892
893    #[test]
894    fn context_window_prefers_catalog_then_override_then_default() {
895        // A known opencode-go model: the catalog wins over the override.
896        let p = CompactionPolicy {
897            window: Some(999),
898            ..Default::default()
899        };
900        assert_eq!(
901            p.context_window(Some("https://opencode.ai/zen/go/v1"), "deepseek-v4.1-flash"),
902            1_000_000
903        );
904
905        // An unknown model: the configured override is used.
906        let p = CompactionPolicy {
907            window: Some(999),
908            ..Default::default()
909        };
910        assert_eq!(p.context_window(Some("https://api.example.com/v1"), "who"), 999);
911
912        // Unknown model, no override: the conservative default.
913        let p = CompactionPolicy::default();
914        assert_eq!(
915            p.context_window(None, "who"),
916            crate::limits::DEFAULT_CONTEXT_WINDOW
917        );
918    }
919}