Skip to main content

harness_loop/
compactor.rs

1//! Five-stage progressive compaction (DESIGN.md §9), borrowed from Claude Code.
2//!
3//! `DefaultCompactor` is purely structural — it doesn't call a model. Stage 3
4//! (Microcompact) and Stage 5 (AutoCompact) would normally invoke a cheap LLM;
5//! here we collapse content into terse summaries so the framework can run
6//! offline. Wire a `ModelBackedCompactor` later if you want semantic summaries.
7
8use async_trait::async_trait;
9use harness_core::{
10    Block, Budget, CompactError, CompactionStage, Compactor, Context, Model, Policy, Task, Turn,
11    TurnRole,
12};
13use std::sync::Arc;
14
15/// `Context::metadata` key under which the loop stores a *calibration factor* —
16/// the ratio of the model's real reported `input_tokens` to this compactor's
17/// char-based estimate on the previous turn. `budget()` multiplies its raw
18/// estimate by it, so the trigger auto-aligns to the actual model + language
19/// (a fixed `tokens_per_char` badly under-counts CJK, over-counts nothing).
20pub const CALIBRATION_KEY: &str = "compactor.correction";
21
22/// Read the calibration factor from context metadata (defaults to 1.0 — i.e.
23/// trust the raw estimate until the first real `input_tokens` arrives).
24fn calibration(ctx: &Context) -> f64 {
25    ctx.metadata
26        .get(CALIBRATION_KEY)
27        .and_then(|v| v.as_f64())
28        .filter(|f| f.is_finite() && *f > 0.0)
29        .unwrap_or(1.0)
30}
31
32/// Heuristic compactor — operates on the structure of the context only.
33pub struct DefaultCompactor {
34    /// Approximate tokens per char. 0.30 ≈ 3.3 chars/token (a generous English
35    /// upper bound for non-Asian content). Used as the *prior*; once the loop
36    /// records a real `input_tokens`, [`CALIBRATION_KEY`] corrects it.
37    pub tokens_per_char: f32,
38}
39
40impl Default for DefaultCompactor {
41    fn default() -> Self {
42        Self {
43            tokens_per_char: 0.30,
44        }
45    }
46}
47
48impl DefaultCompactor {
49    pub fn new() -> Self {
50        Self::default()
51    }
52
53    fn estimate_tokens(&self, ctx: &Context) -> u32 {
54        let mut chars: usize = 0;
55        for b in ctx.system.iter().chain(ctx.guides.iter()) {
56            chars += block_chars(b);
57        }
58        for turn in &ctx.history {
59            for b in &turn.blocks {
60                chars += block_chars(b);
61            }
62        }
63        chars += ctx.task.description.len();
64        (chars as f32 * self.tokens_per_char) as u32
65    }
66}
67
68#[async_trait]
69impl Compactor for DefaultCompactor {
70    fn budget(&self, ctx: &Context) -> Budget {
71        let raw = self.estimate_tokens(ctx);
72        let used = (raw as f64 * calibration(ctx)).round() as u32;
73        Budget {
74            used,
75            window: ctx.policy.max_input_tokens,
76        }
77    }
78
79    async fn compact(&self, stage: CompactionStage, ctx: &mut Context) -> Result<(), CompactError> {
80        tracing::debug!(?stage, "compaction stage running");
81        match stage {
82            CompactionStage::BudgetReduce => budget_reduce(ctx),
83            CompactionStage::Snip => snip_file_reads(ctx),
84            CompactionStage::Microcompact => microcompact_old(ctx),
85            CompactionStage::ContextCollapse => context_collapse(ctx),
86            CompactionStage::AutoCompact => auto_compact(ctx),
87            // Forward-compat: ignore stages this version doesn't recognise.
88            _ => tracing::warn!(?stage, "unknown compaction stage — ignoring"),
89        }
90        Ok(())
91    }
92}
93
94// ============================================================
95// ModelBackedCompactor — uses a (typically cheap) Model to do real semantic
96// summarisation for Microcompact and AutoCompact stages.
97// ============================================================
98
99/// Compactor that calls an LLM for the inferential stages and falls back to
100/// `DefaultCompactor`'s structural strategies for the computational ones.
101///
102/// Typical wiring:
103/// ```ignore
104/// let cheap = OpenAiCompat::with_key("https://api.deepseek.com", "deepseek-v4-flash", key);
105/// let compactor = ModelBackedCompactor::new(Arc::new(cheap));
106/// let loop_ = AgentLoop::new(main_model).with_compactor(Arc::new(compactor));
107/// ```
108pub struct ModelBackedCompactor {
109    pub model: Arc<dyn Model>,
110    pub tokens_per_char: f32,
111    /// Keep the most recent N turns intact during semantic compaction.
112    pub keep_recent: usize,
113    /// Hard cap on the summary length the model is asked to produce.
114    pub summary_max_tokens: u32,
115}
116
117/// Move a proposed split to a point where cutting the history is *legal*.
118///
119/// Every compaction stage keeps `history[split..]` and replaces the prefix with
120/// a summary turn. Choosing that index by counting turns alone will eventually
121/// cut between an assistant turn that requested a tool and the turn carrying
122/// that tool's result — and the providers enforce the pairing. Gemini answers
123/// such a history with a hard 400 ("function call turn comes immediately after
124/// a user turn or after a function response turn"), which is permanent: the
125/// retry sends the same broken history and dies the same way, so the run is
126/// over. It only shows up once the context actually fills, which is to say
127/// only on the long runs that can least afford to lose their work.
128///
129/// A cut is safe directly before a `User` turn: that is where an exchange
130/// begins, so nothing behind it is owed an answer. Prefer the nearest such
131/// point at or before `want` (keeping slightly more history is harmless);
132/// failing that, take the nearest one after it. With no user turn anywhere,
133/// report `None` and let the caller skip this stage rather than corrupt the
134/// conversation.
135fn safe_split(history: &[Turn], want: usize) -> Option<usize> {
136    if want == 0 || want >= history.len() {
137        return None;
138    }
139    // Walk outward from the wanted point: back first, since keeping slightly
140    // more history costs nothing, then forward.
141    (0..=want)
142        .rev()
143        .chain(want + 1..history.len())
144        .filter(|&i| i > 0)
145        .find(|&i| starts_an_exchange(&history[i]))
146}
147
148/// Whether the kept history may *begin* at this turn.
149///
150/// After a cut the first kept turn follows the summary, which is a **user**
151/// turn — so a turn that requests a tool is fine there, exactly as it is after
152/// any user message. What is never fine is a tool *result*, because the
153/// request that earned it was just dropped.
154///
155/// Getting this wrong is easy in both directions and neither shows up except
156/// on a long run. Too loose and the provider rejects the conversation for the
157/// rest of the run; too strict and every stage declines and compaction quietly
158/// stops working. The first version cut anywhere and broke pairs; the second
159/// demanded a user turn, of which an agent run has exactly one, at index 0,
160/// where a cut drops nothing; a real agent history is a bare alternation of
161/// tool requests and results, so it offered nowhere to land at all.
162///
163/// The providers' rule is that a turn requesting a tool must follow a user
164/// turn or a tool result. After a cut the first kept turn follows only the
165/// summary, so it may not be a tool request — and it may not be a tool
166/// *result* either, since the request that earned it was just dropped.
167/// Anything else is a fresh start: a user turn, or an assistant turn that only
168/// speaks.
169///
170/// Requiring a user turn here (as this first did) is too strict to be useful:
171/// an agent run has exactly one, at index 0, and index 0 cuts nothing — so
172/// every stage declined and compaction silently stopped compacting. That is
173/// the failure this predicate exists to avoid on both sides.
174fn starts_an_exchange(turn: &Turn) -> bool {
175    turn.role != TurnRole::Tool
176}
177
178impl ModelBackedCompactor {
179    pub fn new(model: Arc<dyn Model>) -> Self {
180        Self {
181            model,
182            tokens_per_char: 0.30,
183            keep_recent: 6,
184            summary_max_tokens: 600,
185        }
186    }
187}
188
189#[async_trait]
190impl Compactor for ModelBackedCompactor {
191    fn budget(&self, ctx: &Context) -> Budget {
192        DefaultCompactor {
193            tokens_per_char: self.tokens_per_char,
194        }
195        .budget(ctx)
196    }
197
198    async fn compact(&self, stage: CompactionStage, ctx: &mut Context) -> Result<(), CompactError> {
199        match stage {
200            CompactionStage::BudgetReduce => {
201                budget_reduce(ctx);
202                Ok(())
203            }
204            CompactionStage::Snip => {
205                snip_file_reads(ctx);
206                Ok(())
207            }
208            CompactionStage::ContextCollapse => {
209                context_collapse(ctx);
210                Ok(())
211            }
212            CompactionStage::Microcompact => {
213                self.model_summarise(ctx, "microcompact-summary").await
214            }
215            CompactionStage::AutoCompact => self.model_summarise(ctx, "auto-compact-summary").await,
216            _ => Ok(()),
217        }
218    }
219}
220
221impl ModelBackedCompactor {
222    /// Ask the model to produce a tight summary of the older history; replace
223    /// `0..split` with the resulting [`Block::Text`] in a synthetic system turn.
224    async fn model_summarise(&self, ctx: &mut Context, tag: &str) -> Result<(), CompactError> {
225        if ctx.history.len() <= self.keep_recent {
226            return Ok(());
227        }
228        let Some(split) = safe_split(&ctx.history, ctx.history.len() - self.keep_recent) else {
229            return Ok(());
230        };
231        let mut dump = String::new();
232        for turn in ctx.history.iter().take(split) {
233            dump.push_str(&format_turn_for_summary(turn));
234        }
235        if dump.trim().is_empty() {
236            return Ok(());
237        }
238
239        let prompt = format!(
240            "You are compacting an in-progress agent conversation for downstream replay. \
241             Produce a terse summary (≤ 200 words) of the conversation below. Preserve: \
242             concrete file paths, decisions made, sensor outcomes, and the current goal. \
243             Drop: chit-chat, redundant tool reads, verbose stack traces.\n\n\
244             ---- TRANSCRIPT ----\n{dump}\n---- END ----\n\n\
245             Reply with the summary text only, no preamble."
246        );
247
248        let mut summary_ctx = Context::new(Task {
249            description: prompt,
250            source: None,
251            deadline: None,
252        });
253        summary_ctx.policy = Policy {
254            max_iters: 1,
255            max_input_tokens: 100_000,
256            max_output_tokens: self.summary_max_tokens,
257            self_correct_rounds: 0,
258        };
259        summary_ctx.history.push(Turn {
260            role: TurnRole::User,
261            blocks: vec![Block::Text(summary_ctx.task.description.clone())],
262        });
263
264        let out = self
265            .model
266            .complete(&summary_ctx)
267            .await
268            .map_err(|e| CompactError::Failed {
269                stage: tag.into(),
270                reason: format!("model: {e}"),
271            })?;
272
273        let summary = out.text.unwrap_or_else(|| "(empty summary)".into());
274        let mut new_history = vec![Turn {
275            // See `starts_an_exchange`: a user turn keeps a following tool
276            // request legal.
277            role: TurnRole::User,
278            blocks: vec![Block::Text(format!("[{tag}]\n{summary}"))],
279        }];
280        new_history.extend(ctx.history.drain(split..));
281        ctx.history = new_history;
282        Ok(())
283    }
284}
285
286fn format_turn_for_summary(turn: &Turn) -> String {
287    let role = match turn.role {
288        TurnRole::User => "user",
289        TurnRole::Assistant => "assistant",
290        TurnRole::Tool => "tool",
291        TurnRole::System => "system",
292        _ => "unknown",
293    };
294    let mut s = format!("[{role}]\n");
295    for b in &turn.blocks {
296        match b {
297            Block::Text(t) => {
298                s.push_str(t);
299                s.push('\n');
300            }
301            Block::ToolCall { name, args, .. } => {
302                s.push_str(&format!("(tool-call {name} {args})\n"));
303            }
304            Block::ToolResult { call_id, content } => {
305                let preview = content.to_string();
306                let preview = preview.chars().take(160).collect::<String>();
307                s.push_str(&format!("(tool-result {call_id}: {preview}…)\n"));
308            }
309            Block::FileRef { path, .. } => {
310                s.push_str(&format!("(file-ref {path})\n"));
311            }
312            _ => {}
313        }
314    }
315    s.push('\n');
316    s
317}
318
319fn block_chars(b: &Block) -> usize {
320    match b {
321        Block::Text(s) => s.len(),
322        Block::FileRef {
323            path,
324            hash: _,
325            excerpt,
326        } => path.len() + excerpt.as_ref().map_or(0, String::len),
327        Block::Skill { name, body } => name.len() + body.len(),
328        Block::ToolCall {
329            call_id,
330            name,
331            args,
332        } => call_id.len() + name.len() + args.to_string().len(),
333        Block::ToolResult { call_id, content } => call_id.len() + content.to_string().len(),
334        Block::Feedback(signals) => signals
335            .iter()
336            .map(|s| s.message.len() + s.agent_hint.as_ref().map_or(0, String::len))
337            .sum(),
338        Block::Reasoning(s) => s.len(),
339        // Base64 image payloads are large; count them so the budget doesn't
340        // wildly under-estimate a context carrying an image.
341        Block::Image { base64, .. } => base64.len(),
342        _ => 0,
343    }
344}
345
346// ---------- Stage 1: BudgetReduce ----------
347
348/// Trim redundant content: keep the most recent N turns intact, summarise older.
349/// Conservative — only collapses big tool results, leaves text alone.
350///
351/// Recency protects a turn from being *summarised away*, but it cannot protect
352/// one from being *too large to send*. Every stage here guards on
353/// `history.len() <= keep_recent`, and a context does not blow up by turn count:
354/// an agent reads one large file and two turns later there is no room. Measured
355/// on that exact shape — three turns, one big tool result — all five stages
356/// returned immediately and the context came out 20 tokens *larger* than it went
357/// in. So a genuinely oversized result is trimmed wherever it sits, recent or
358/// not, at a much higher threshold than the one used for old turns.
359fn budget_reduce(ctx: &mut Context) {
360    const OLD_TRIM_BYTES: usize = 800;
361    /// A recent result is only touched when it is large enough to be the reason
362    /// the context does not fit — far above the threshold for stale ones.
363    const RECENT_TRIM_BYTES: usize = 16 * 1024;
364
365    let keep_recent = 8;
366    let split = ctx.history.len().saturating_sub(keep_recent);
367    for (i, turn) in ctx.history.iter_mut().enumerate() {
368        let limit = if i < split {
369            OLD_TRIM_BYTES
370        } else {
371            RECENT_TRIM_BYTES
372        };
373        for b in turn.blocks.iter_mut() {
374            if let Block::ToolResult { call_id, content } = b {
375                let body = content.to_string();
376                if body.len() > limit {
377                    // Keep more of a recent result: it is probably still the
378                    // thing being worked on, and the point is to make it fit,
379                    // not to erase it.
380                    let keep = if i < split { 200 } else { 2_000 };
381                    let preview = body.chars().take(keep).collect::<String>();
382                    *b = Block::Text(format!(
383                        "[tool-result:{call_id} (trimmed from {} bytes)] {preview}…",
384                        body.len()
385                    ));
386                }
387            }
388        }
389    }
390}
391
392// ---------- Stage 2: Snip ----------
393
394/// Replace old `Block::FileRef { excerpt }` with hash-only references.
395fn snip_file_reads(ctx: &mut Context) {
396    let keep_recent = 4;
397    if ctx.history.len() <= keep_recent {
398        return;
399    }
400    let split = ctx.history.len() - keep_recent;
401    for turn in ctx.history.iter_mut().take(split) {
402        for b in turn.blocks.iter_mut() {
403            if let Block::FileRef {
404                path,
405                hash,
406                excerpt,
407            } = b
408                && excerpt.is_some()
409            {
410                *b = Block::FileRef {
411                    path: path.clone(),
412                    hash: hash.clone(),
413                    excerpt: None,
414                };
415            }
416        }
417    }
418}
419
420// ---------- Stage 3: Microcompact ----------
421
422/// Summarise older conversation segments. In `DefaultCompactor` we just
423/// rewrite the older half of the history into a single text block tagged
424/// `[microcompact-summary]`. Real provider-backed implementations should
425/// replace this with a cheap-model summarisation call.
426fn microcompact_old(ctx: &mut Context) {
427    if ctx.history.len() < 12 {
428        return;
429    }
430    let keep_recent = 6;
431    let Some(split) = safe_split(&ctx.history, ctx.history.len() - keep_recent) else {
432        return;
433    };
434
435    // Build a textual summary of `0..split`.
436    let mut summary = String::from("[microcompact-summary]\n");
437    for turn in ctx.history.iter().take(split) {
438        let role = match turn.role {
439            TurnRole::User => "user",
440            TurnRole::Assistant => "assistant",
441            TurnRole::Tool => "tool",
442            TurnRole::System => "system",
443            _ => "unknown",
444        };
445        summary.push_str(&format!("- {role}: "));
446        for b in &turn.blocks {
447            match b {
448                Block::Text(t) => {
449                    summary.push_str(&t.chars().take(80).collect::<String>());
450                    summary.push(' ');
451                }
452                Block::ToolCall { name, .. } => summary.push_str(&format!("(call:{name}) ")),
453                Block::ToolResult { call_id, .. } => {
454                    summary.push_str(&format!("(result:{call_id}) "))
455                }
456                Block::FileRef { path, .. } => summary.push_str(&format!("(file:{path}) ")),
457                _ => {}
458            }
459        }
460        summary.push('\n');
461    }
462
463    let mut new_history = vec![Turn {
464        // A user turn, not a system one: it is what makes the first kept turn
465        // legal when that turn requests a tool, which in an agent history it
466        // almost always does.
467        role: TurnRole::User,
468        blocks: vec![Block::Text(summary)],
469    }];
470    new_history.extend(ctx.history.drain(split..));
471    ctx.history = new_history;
472}
473
474// ---------- Stage 4: ContextCollapse ----------
475
476/// Collapse all FileRefs into a single inventory at the top, plus key excerpts.
477fn context_collapse(ctx: &mut Context) {
478    // Walk all history, collect file paths.
479    let mut files = std::collections::BTreeSet::new();
480    for turn in &ctx.history {
481        for b in &turn.blocks {
482            if let Block::FileRef { path, .. } = b {
483                files.insert(path.clone());
484            }
485        }
486    }
487    if files.is_empty() {
488        return;
489    }
490
491    let mut inv = String::from("[file-inventory]\n");
492    for f in &files {
493        inv.push_str(&format!("- {f}\n"));
494    }
495
496    // Remove file-ref blocks from history (inventory replaces them).
497    for turn in ctx.history.iter_mut() {
498        turn.blocks.retain(|b| !matches!(b, Block::FileRef { .. }));
499    }
500
501    // Insert inventory as the first system turn.
502    ctx.history.insert(
503        0,
504        Turn {
505            role: TurnRole::System,
506            blocks: vec![Block::Text(inv)],
507        },
508    );
509}
510
511// ---------- Stage 5: AutoCompact ----------
512
513/// Last resort: rewrite the whole history into a single condensed summary block.
514fn auto_compact(ctx: &mut Context) {
515    let keep_recent = 2;
516    if ctx.history.len() <= keep_recent {
517        return;
518    }
519    let Some(split) = safe_split(&ctx.history, ctx.history.len() - keep_recent) else {
520        return;
521    };
522    let mut combined =
523        String::from("[auto-compact-summary]\nCondensed history of earlier turns:\n");
524    let mut counts = std::collections::BTreeMap::new();
525    for turn in ctx.history.iter().take(split) {
526        for b in &turn.blocks {
527            let key = match b {
528                Block::Text(_) => "text",
529                Block::ToolCall { .. } => "tool_call",
530                Block::ToolResult { .. } => "tool_result",
531                Block::FileRef { .. } => "file_ref",
532                Block::Skill { .. } => "skill",
533                Block::Feedback(_) => "feedback",
534                Block::Reasoning(_) => "reasoning",
535                _ => "unknown",
536            };
537            *counts.entry(key).or_insert(0u32) += 1;
538        }
539    }
540    for (k, v) in counts {
541        combined.push_str(&format!("- {v} × {k} block(s)\n"));
542    }
543
544    let mut new_history = vec![Turn {
545        // A user turn, not a system one: it is what makes the first kept turn
546        // legal when that turn requests a tool, which in an agent history it
547        // almost always does.
548        role: TurnRole::User,
549        blocks: vec![Block::Text(combined)],
550    }];
551    new_history.extend(ctx.history.drain(split..));
552    ctx.history = new_history;
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558    use harness_core::{Block, Policy, Task, Turn, TurnRole};
559    use std::collections::BTreeMap;
560
561    /// A history shaped like a real agent run: user asks, assistant calls a
562    /// tool, the tool answers, repeat. Compaction must never cut between a
563    /// call and its result.
564    fn tool_using_history(exchanges: usize) -> Context {
565        let mut ctx = mk_ctx(0);
566        for i in 0..exchanges {
567            ctx.history.push(Turn {
568                role: TurnRole::User,
569                blocks: vec![Block::Text(format!("do step {i}"))],
570            });
571            ctx.history.push(Turn {
572                role: TurnRole::Assistant,
573                blocks: vec![Block::ToolCall {
574                    call_id: format!("c{i}"),
575                    name: "write_file".into(),
576                    args: serde_json::json!({ "path": format!("{i}.txt") }),
577                }],
578            });
579            ctx.history.push(Turn {
580                role: TurnRole::Tool,
581                blocks: vec![Block::ToolResult {
582                    call_id: format!("c{i}"),
583                    content: serde_json::json!({ "ok": true }),
584                }],
585            });
586        }
587        ctx
588    }
589
590    /// Every `Tool` turn must still be preceded by the assistant turn that
591    /// asked for it, and the kept history must not *open* with a tool call —
592    /// providers reject both, and Gemini rejects the second with a permanent
593    /// 400 that ends the run.
594    fn assert_history_is_legal(history: &[Turn], what: &str) {
595        // The summary leads as a user turn, so a tool *request* may follow it.
596        // A tool *result* may not: whatever asked for it is gone.
597        if let Some(first) = history.first() {
598            assert!(
599                first.role != TurnRole::Tool,
600                "{what}: history opens with a tool result whose request was dropped"
601            );
602        }
603        for (i, turn) in history.iter().enumerate() {
604            if turn.role == TurnRole::Tool {
605                let prev_is_call = i > 0
606                    && history[i - 1]
607                        .blocks
608                        .iter()
609                        .any(|b| matches!(b, Block::ToolCall { .. }));
610                assert!(
611                    prev_is_call,
612                    "{what}: tool result at {i} lost the call that made it"
613                );
614            }
615        }
616    }
617
618    #[test]
619    fn compaction_never_splits_a_tool_call_from_its_result() {
620        // Every stage that rewrites history, at every length: with 3 turns per
621        // exchange and a fixed `keep_recent`, a blind split lands mid-pair on
622        // two lengths out of three.
623        for exchanges in 4..14 {
624            for (name, stage) in [
625                ("microcompact_old", microcompact_old as fn(&mut Context)),
626                ("context_collapse", context_collapse as fn(&mut Context)),
627                ("auto_compact", auto_compact as fn(&mut Context)),
628            ] {
629                let mut ctx = tool_using_history(exchanges);
630                stage(&mut ctx);
631                assert_history_is_legal(&ctx.history, &format!("{name} @ {exchanges} exchanges"));
632            }
633        }
634    }
635
636    #[test]
637    fn a_split_with_nowhere_legal_to_land_is_declined() {
638        // Only a tool result sits after index 0, and a kept history may not
639        // begin with one. Better to skip the stage than hand the provider a
640        // conversation it will refuse for the rest of the run.
641        let history = vec![
642            Turn {
643                role: TurnRole::Assistant,
644                blocks: vec![Block::ToolCall {
645                    call_id: "c0".into(),
646                    name: "t".into(),
647                    args: serde_json::json!({}),
648                }],
649            },
650            Turn {
651                role: TurnRole::Tool,
652                blocks: vec![Block::ToolResult {
653                    call_id: "c0".into(),
654                    content: serde_json::json!({}),
655                }],
656            },
657        ];
658        assert_eq!(safe_split(&history, 1), None);
659    }
660
661    /// The shape an agent run actually has: one user turn, then nothing but
662    /// tool requests and their results. Demanding a user turn — or even an
663    /// assistant turn that only speaks — finds nowhere to land here, and
664    /// compaction silently stops working on exactly the runs that need it.
665    #[test]
666    fn a_bare_alternation_of_tool_calls_can_still_be_compacted() {
667        let mut ctx = mk_ctx(0);
668        ctx.history.push(Turn {
669            role: TurnRole::User,
670            blocks: vec![Block::Text("the task".into())],
671        });
672        for i in 0..10 {
673            ctx.history.push(Turn {
674                role: TurnRole::Assistant,
675                blocks: vec![Block::ToolCall {
676                    call_id: format!("c{i}"),
677                    name: "read_file".into(),
678                    args: serde_json::json!({ "path": format!("{i}.txt") }),
679                }],
680            });
681            ctx.history.push(Turn {
682                role: TurnRole::Tool,
683                blocks: vec![Block::ToolResult {
684                    call_id: format!("c{i}"),
685                    content: serde_json::json!({ "ok": true }),
686                }],
687            });
688        }
689        let before = ctx.history.len();
690        microcompact_old(&mut ctx);
691        assert!(
692            ctx.history.len() < before,
693            "compaction did nothing on a bare tool alternation: {before} turns in, {} out",
694            ctx.history.len()
695        );
696        assert_history_is_legal(&ctx.history, "bare tool alternation");
697    }
698
699    /// An agent run has exactly ONE user turn — the task — and it sits at
700    /// index 0, where a cut would drop nothing. Requiring a user turn to land
701    /// on therefore made every stage decline, and compaction silently stopped
702    /// compacting on exactly the long runs it exists for. An assistant turn
703    /// that only speaks is a legal place to begin.
704    #[test]
705    fn a_run_with_one_user_turn_can_still_be_compacted() {
706        let mut ctx = mk_ctx(0);
707        ctx.history.push(Turn {
708            role: TurnRole::User,
709            blocks: vec![Block::Text("the task".into())],
710        });
711        for i in 0..8 {
712            ctx.history.push(Turn {
713                role: TurnRole::Assistant,
714                blocks: vec![Block::ToolCall {
715                    call_id: format!("c{i}"),
716                    name: "read_file".into(),
717                    args: serde_json::json!({}),
718                }],
719            });
720            ctx.history.push(Turn {
721                role: TurnRole::Tool,
722                blocks: vec![Block::ToolResult {
723                    call_id: format!("c{i}"),
724                    content: serde_json::json!({ "ok": true }),
725                }],
726            });
727            ctx.history.push(Turn {
728                role: TurnRole::Assistant,
729                blocks: vec![Block::Text(format!("step {i} done"))],
730            });
731        }
732        let before = ctx.history.len();
733        microcompact_old(&mut ctx);
734        assert!(
735            ctx.history.len() < before,
736            "compaction did nothing: {before} turns in, {} out",
737            ctx.history.len()
738        );
739        assert_history_is_legal(&ctx.history, "single-user-turn run");
740    }
741
742    fn mk_ctx(turns: usize) -> Context {
743        let mut ctx = Context {
744            system: vec![],
745            guides: vec![],
746            history: Vec::new(),
747            task: Task {
748                description: "t".into(),
749                source: None,
750                deadline: None,
751            },
752            policy: Policy::default(),
753            metadata: BTreeMap::new(),
754            tools: Vec::new(),
755            response_format: harness_core::ResponseFormat::Free,
756        };
757        for i in 0..turns {
758            ctx.history.push(Turn {
759                role: if i % 2 == 0 {
760                    TurnRole::User
761                } else {
762                    TurnRole::Assistant
763                },
764                blocks: vec![Block::Text(format!("turn {i}: {}", "x".repeat(50)))],
765            });
766        }
767        ctx
768    }
769
770    #[tokio::test]
771    async fn budget_reduce_keeps_recent() {
772        let c = DefaultCompactor::new();
773        let mut ctx = mk_ctx(20);
774        // Inject big tool results in early turns
775        ctx.history[0].blocks.push(Block::ToolResult {
776            call_id: "c1".into(),
777            content: serde_json::Value::String("y".repeat(2000)),
778        });
779        c.compact(CompactionStage::BudgetReduce, &mut ctx)
780            .await
781            .unwrap();
782        // First turn's big tool result should be trimmed.
783        let has_trim = ctx.history[0]
784            .blocks
785            .iter()
786            .any(|b| matches!(b, Block::Text(t) if t.contains("trimmed")));
787        assert!(has_trim);
788    }
789
790    #[tokio::test]
791    async fn microcompact_collapses_old_turns() {
792        let c = DefaultCompactor::new();
793        let mut ctx = mk_ctx(20);
794        c.compact(CompactionStage::Microcompact, &mut ctx)
795            .await
796            .unwrap();
797        // First turn should be the synthetic system summary.
798        // The summary leads as a user turn — see `starts_an_exchange`.
799        assert!(matches!(ctx.history[0].role, TurnRole::User));
800        let first_text = match &ctx.history[0].blocks[0] {
801            Block::Text(t) => t.clone(),
802            _ => String::new(),
803        };
804        assert!(first_text.starts_with("[microcompact-summary]"));
805    }
806
807    #[tokio::test]
808    async fn model_backed_compactor_replaces_old_turns_with_summary() {
809        use harness_models::{MockModel, MockResponse};
810
811        let model = Arc::new(MockModel::new().script(MockResponse::text("CONCISE-SUMMARY-OK")))
812            as Arc<dyn Model>;
813        let c = ModelBackedCompactor::new(model);
814
815        let mut ctx = mk_ctx(20);
816        let original_len = ctx.history.len();
817        c.compact(CompactionStage::Microcompact, &mut ctx)
818            .await
819            .unwrap();
820        // First turn now the summary, total shrinks to keep_recent (6) + 1 summary = 7
821        assert_eq!(ctx.history.len(), c.keep_recent + 1);
822        assert!(original_len > ctx.history.len());
823        let first = match &ctx.history[0].blocks[0] {
824            Block::Text(t) => t.clone(),
825            _ => String::new(),
826        };
827        assert!(first.starts_with("[microcompact-summary]"));
828        assert!(first.contains("CONCISE-SUMMARY-OK"));
829    }
830
831    #[tokio::test]
832    async fn model_backed_compactor_noop_when_history_short() {
833        use harness_models::{MockModel, MockResponse};
834        let mock = Arc::new(MockModel::new().script(MockResponse::text("never called")));
835        let c = ModelBackedCompactor::new(mock.clone() as Arc<dyn Model>);
836        let mut ctx = mk_ctx(4); // < keep_recent
837        c.compact(CompactionStage::Microcompact, &mut ctx)
838            .await
839            .unwrap();
840        assert_eq!(ctx.history.len(), 4);
841        assert_eq!(
842            mock.call_count(),
843            0,
844            "model must not be called when history is short"
845        );
846    }
847
848    #[test]
849    fn budget_applies_calibration_factor() {
850        let c = DefaultCompactor::new();
851        let mut ctx = mk_ctx(10);
852        let raw = c.budget(&ctx).used;
853        assert!(raw > 0);
854
855        // A model that reports 2× our estimate → correction 2.0 → used doubles.
856        ctx.metadata
857            .insert(CALIBRATION_KEY.into(), serde_json::json!(2.0));
858        let calibrated = c.budget(&ctx).used;
859        assert_eq!(calibrated, (raw as f64 * 2.0).round() as u32);
860
861        // Garbage / non-positive factors are ignored (fall back to raw).
862        ctx.metadata
863            .insert(CALIBRATION_KEY.into(), serde_json::json!(-1.0));
864        assert_eq!(c.budget(&ctx).used, raw);
865    }
866
867    #[tokio::test]
868    async fn budget_required_stages_escalates() {
869        // 95% triggers ALL five stages.
870        let b = Budget {
871            used: 95,
872            window: 100,
873        };
874        assert_eq!(b.required_stages().len(), 4);
875        // 99% triggers all 5
876        let b = Budget {
877            used: 99,
878            window: 100,
879        };
880        assert_eq!(b.required_stages().len(), 5);
881    }
882}