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!(
279                "{}{summary}",
280                with_task(ctx, &format!("[{tag}]\n"))
281            ))],
282        }];
283        new_history.extend(ctx.history.drain(split..));
284        ctx.history = new_history;
285        Ok(())
286    }
287}
288
289fn format_turn_for_summary(turn: &Turn) -> String {
290    let role = match turn.role {
291        TurnRole::User => "user",
292        TurnRole::Assistant => "assistant",
293        TurnRole::Tool => "tool",
294        TurnRole::System => "system",
295        _ => "unknown",
296    };
297    let mut s = format!("[{role}]\n");
298    for b in &turn.blocks {
299        match b {
300            Block::Text(t) => {
301                s.push_str(t);
302                s.push('\n');
303            }
304            Block::ToolCall { name, args, .. } => {
305                s.push_str(&format!("(tool-call {name} {args})\n"));
306            }
307            Block::ToolResult { call_id, content } => {
308                let preview = content.to_string();
309                let preview = preview.chars().take(160).collect::<String>();
310                s.push_str(&format!("(tool-result {call_id}: {preview}…)\n"));
311            }
312            Block::FileRef { path, .. } => {
313                s.push_str(&format!("(file-ref {path})\n"));
314            }
315            _ => {}
316        }
317    }
318    s.push('\n');
319    s
320}
321
322fn block_chars(b: &Block) -> usize {
323    match b {
324        Block::Text(s) => s.len(),
325        Block::FileRef {
326            path,
327            hash: _,
328            excerpt,
329        } => path.len() + excerpt.as_ref().map_or(0, String::len),
330        Block::Skill { name, body } => name.len() + body.len(),
331        Block::ToolCall {
332            call_id,
333            name,
334            args,
335        } => call_id.len() + name.len() + args.to_string().len(),
336        Block::ToolResult { call_id, content } => call_id.len() + content.to_string().len(),
337        Block::Feedback(signals) => signals
338            .iter()
339            .map(|s| s.message.len() + s.agent_hint.as_ref().map_or(0, String::len))
340            .sum(),
341        Block::Reasoning(s) => s.len(),
342        // Base64 image payloads are large; count them so the budget doesn't
343        // wildly under-estimate a context carrying an image.
344        Block::Image { base64, .. } => base64.len(),
345        _ => 0,
346    }
347}
348
349// ---------- Stage 1: BudgetReduce ----------
350
351/// Trim redundant content: keep the most recent N turns intact, summarise older.
352/// Conservative — only collapses big tool results, leaves text alone.
353///
354/// Recency protects a turn from being *summarised away*, but it cannot protect
355/// one from being *too large to send*. Every stage here guards on
356/// `history.len() <= keep_recent`, and a context does not blow up by turn count:
357/// an agent reads one large file and two turns later there is no room. Measured
358/// on that exact shape — three turns, one big tool result — all five stages
359/// returned immediately and the context came out 20 tokens *larger* than it went
360/// in. So a genuinely oversized result is trimmed wherever it sits, recent or
361/// not, at a much higher threshold than the one used for old turns.
362fn budget_reduce(ctx: &mut Context) {
363    const OLD_TRIM_BYTES: usize = 800;
364    /// A recent result is only touched when it is large enough to be the reason
365    /// the context does not fit — far above the threshold for stale ones.
366    const RECENT_TRIM_BYTES: usize = 16 * 1024;
367
368    let keep_recent = 8;
369    let split = ctx.history.len().saturating_sub(keep_recent);
370    for (i, turn) in ctx.history.iter_mut().enumerate() {
371        let limit = if i < split {
372            OLD_TRIM_BYTES
373        } else {
374            RECENT_TRIM_BYTES
375        };
376        for b in turn.blocks.iter_mut() {
377            if let Block::ToolResult { call_id, content } = b {
378                let body = content.to_string();
379                if body.len() > limit {
380                    // Keep more of a recent result: it is probably still the
381                    // thing being worked on, and the point is to make it fit,
382                    // not to erase it.
383                    let keep = if i < split { 200 } else { 2_000 };
384                    let preview = body.chars().take(keep).collect::<String>();
385                    *b = Block::Text(format!(
386                        "[tool-result:{call_id} (trimmed from {} bytes)] {preview}…",
387                        body.len()
388                    ));
389                }
390            }
391        }
392    }
393}
394
395// ---------- Stage 2: Snip ----------
396
397/// Replace old `Block::FileRef { excerpt }` with hash-only references.
398fn snip_file_reads(ctx: &mut Context) {
399    let keep_recent = 4;
400    if ctx.history.len() <= keep_recent {
401        return;
402    }
403    let split = ctx.history.len() - keep_recent;
404    for turn in ctx.history.iter_mut().take(split) {
405        for b in turn.blocks.iter_mut() {
406            if let Block::FileRef {
407                path,
408                hash,
409                excerpt,
410            } = b
411                && excerpt.is_some()
412            {
413                *b = Block::FileRef {
414                    path: path.clone(),
415                    hash: hash.clone(),
416                    excerpt: None,
417                };
418            }
419        }
420    }
421}
422
423// ---------- Stage 3: Microcompact ----------
424
425/// Summarise older conversation segments. In `DefaultCompactor` we just
426/// rewrite the older half of the history into a single text block tagged
427/// `[microcompact-summary]`. Real provider-backed implementations should
428/// replace this with a cheap-model summarisation call.
429fn microcompact_old(ctx: &mut Context) {
430    if ctx.history.len() < 12 {
431        return;
432    }
433    let keep_recent = 6;
434    let Some(split) = safe_split(&ctx.history, ctx.history.len() - keep_recent) else {
435        return;
436    };
437
438    // Build a textual summary of `0..split`.
439    let mut summary = with_task(ctx, "[microcompact-summary]\n");
440    for turn in ctx.history.iter().take(split) {
441        let role = match turn.role {
442            TurnRole::User => "user",
443            TurnRole::Assistant => "assistant",
444            TurnRole::Tool => "tool",
445            TurnRole::System => "system",
446            _ => "unknown",
447        };
448        summary.push_str(&format!("- {role}: "));
449        for b in &turn.blocks {
450            match b {
451                Block::Text(t) => {
452                    summary.push_str(&t.chars().take(80).collect::<String>());
453                    summary.push(' ');
454                }
455                Block::ToolCall { name, .. } => summary.push_str(&format!("(call:{name}) ")),
456                Block::ToolResult { call_id, .. } => {
457                    summary.push_str(&format!("(result:{call_id}) "))
458                }
459                Block::FileRef { path, .. } => summary.push_str(&format!("(file:{path}) ")),
460                _ => {}
461            }
462        }
463        summary.push('\n');
464    }
465
466    let mut new_history = vec![Turn {
467        // A user turn, not a system one: it is what makes the first kept turn
468        // legal when that turn requests a tool, which in an agent history it
469        // almost always does.
470        role: TurnRole::User,
471        blocks: vec![Block::Text(summary)],
472    }];
473    new_history.extend(ctx.history.drain(split..));
474    ctx.history = new_history;
475}
476
477// ---------- Stage 4: ContextCollapse ----------
478
479/// Collapse all FileRefs into a single inventory at the top, plus key excerpts.
480fn context_collapse(ctx: &mut Context) {
481    // Walk all history, collect file paths.
482    let mut files = std::collections::BTreeSet::new();
483    for turn in &ctx.history {
484        for b in &turn.blocks {
485            if let Block::FileRef { path, .. } = b {
486                files.insert(path.clone());
487            }
488        }
489    }
490    if files.is_empty() {
491        return;
492    }
493
494    let mut inv = String::from("[file-inventory]\n");
495    for f in &files {
496        inv.push_str(&format!("- {f}\n"));
497    }
498
499    // Remove file-ref blocks from history (inventory replaces them).
500    for turn in ctx.history.iter_mut() {
501        turn.blocks.retain(|b| !matches!(b, Block::FileRef { .. }));
502    }
503
504    // Insert inventory as the first system turn.
505    ctx.history.insert(
506        0,
507        Turn {
508            role: TurnRole::System,
509            blocks: vec![Block::Text(inv)],
510        },
511    );
512}
513
514/// Start a summary that replaces the head of the history with the task,
515/// verbatim. The head is where the task lives: an agent run has one user turn
516/// and it is the first one, so a summary of "the earlier turns" that counts
517/// blocks or keeps 80 characters of each throws the task away. A 9B run did
518/// exactly that and then answered "what task are we working on?".
519fn with_task(ctx: &Context, header: &str) -> String {
520    let task = ctx.task.description.trim();
521    if task.is_empty() {
522        return header.to_string();
523    }
524    // The tag stays on the first line so a summary is still recognisable as one.
525    let (tag, rest) = header.split_once('\n').unwrap_or((header, ""));
526    format!("{tag}\nThe task (verbatim):\n{task}\n\n{rest}")
527}
528
529// ---------- Stage 5: AutoCompact ----------
530
531/// Last resort: rewrite the whole history into a single condensed summary block.
532fn auto_compact(ctx: &mut Context) {
533    let keep_recent = 2;
534    if ctx.history.len() <= keep_recent {
535        return;
536    }
537    let Some(split) = safe_split(&ctx.history, ctx.history.len() - keep_recent) else {
538        return;
539    };
540    let mut combined = with_task(
541        ctx,
542        "[auto-compact-summary]\nCondensed history of earlier turns:\n",
543    );
544    let mut counts = std::collections::BTreeMap::new();
545    for turn in ctx.history.iter().take(split) {
546        for b in &turn.blocks {
547            let key = match b {
548                Block::Text(_) => "text",
549                Block::ToolCall { .. } => "tool_call",
550                Block::ToolResult { .. } => "tool_result",
551                Block::FileRef { .. } => "file_ref",
552                Block::Skill { .. } => "skill",
553                Block::Feedback(_) => "feedback",
554                Block::Reasoning(_) => "reasoning",
555                _ => "unknown",
556            };
557            *counts.entry(key).or_insert(0u32) += 1;
558        }
559    }
560    for (k, v) in counts {
561        combined.push_str(&format!("- {v} × {k} block(s)\n"));
562    }
563
564    let mut new_history = vec![Turn {
565        // A user turn, not a system one: it is what makes the first kept turn
566        // legal when that turn requests a tool, which in an agent history it
567        // almost always does.
568        role: TurnRole::User,
569        blocks: vec![Block::Text(combined)],
570    }];
571    new_history.extend(ctx.history.drain(split..));
572    ctx.history = new_history;
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578    use harness_core::{Block, Policy, Task, Turn, TurnRole};
579    use std::collections::BTreeMap;
580
581    /// A history shaped like a real agent run: user asks, assistant calls a
582    /// tool, the tool answers, repeat. Compaction must never cut between a
583    /// call and its result.
584    fn tool_using_history(exchanges: usize) -> Context {
585        let mut ctx = mk_ctx(0);
586        for i in 0..exchanges {
587            ctx.history.push(Turn {
588                role: TurnRole::User,
589                blocks: vec![Block::Text(format!("do step {i}"))],
590            });
591            ctx.history.push(Turn {
592                role: TurnRole::Assistant,
593                blocks: vec![Block::ToolCall {
594                    call_id: format!("c{i}"),
595                    name: "write_file".into(),
596                    args: serde_json::json!({ "path": format!("{i}.txt") }),
597                }],
598            });
599            ctx.history.push(Turn {
600                role: TurnRole::Tool,
601                blocks: vec![Block::ToolResult {
602                    call_id: format!("c{i}"),
603                    content: serde_json::json!({ "ok": true }),
604                }],
605            });
606        }
607        ctx
608    }
609
610    /// Every `Tool` turn must still be preceded by the assistant turn that
611    /// asked for it, and the kept history must not *open* with a tool call —
612    /// providers reject both, and Gemini rejects the second with a permanent
613    /// 400 that ends the run.
614    fn assert_history_is_legal(history: &[Turn], what: &str) {
615        // The summary leads as a user turn, so a tool *request* may follow it.
616        // A tool *result* may not: whatever asked for it is gone.
617        if let Some(first) = history.first() {
618            assert!(
619                first.role != TurnRole::Tool,
620                "{what}: history opens with a tool result whose request was dropped"
621            );
622        }
623        for (i, turn) in history.iter().enumerate() {
624            if turn.role == TurnRole::Tool {
625                let prev_is_call = i > 0
626                    && history[i - 1]
627                        .blocks
628                        .iter()
629                        .any(|b| matches!(b, Block::ToolCall { .. }));
630                assert!(
631                    prev_is_call,
632                    "{what}: tool result at {i} lost the call that made it"
633                );
634            }
635        }
636    }
637
638    #[test]
639    fn compaction_never_splits_a_tool_call_from_its_result() {
640        // Every stage that rewrites history, at every length: with 3 turns per
641        // exchange and a fixed `keep_recent`, a blind split lands mid-pair on
642        // two lengths out of three.
643        for exchanges in 4..14 {
644            for (name, stage) in [
645                ("microcompact_old", microcompact_old as fn(&mut Context)),
646                ("context_collapse", context_collapse as fn(&mut Context)),
647                ("auto_compact", auto_compact as fn(&mut Context)),
648            ] {
649                let mut ctx = tool_using_history(exchanges);
650                stage(&mut ctx);
651                assert_history_is_legal(&ctx.history, &format!("{name} @ {exchanges} exchanges"));
652            }
653        }
654    }
655
656    #[test]
657    fn a_split_with_nowhere_legal_to_land_is_declined() {
658        // Only a tool result sits after index 0, and a kept history may not
659        // begin with one. Better to skip the stage than hand the provider a
660        // conversation it will refuse for the rest of the run.
661        let history = vec![
662            Turn {
663                role: TurnRole::Assistant,
664                blocks: vec![Block::ToolCall {
665                    call_id: "c0".into(),
666                    name: "t".into(),
667                    args: serde_json::json!({}),
668                }],
669            },
670            Turn {
671                role: TurnRole::Tool,
672                blocks: vec![Block::ToolResult {
673                    call_id: "c0".into(),
674                    content: serde_json::json!({}),
675                }],
676            },
677        ];
678        assert_eq!(safe_split(&history, 1), None);
679    }
680
681    /// The shape an agent run actually has: one user turn, then nothing but
682    /// tool requests and their results. Demanding a user turn — or even an
683    /// assistant turn that only speaks — finds nowhere to land here, and
684    /// compaction silently stops working on exactly the runs that need it.
685    #[test]
686    fn a_bare_alternation_of_tool_calls_can_still_be_compacted() {
687        let mut ctx = mk_ctx(0);
688        ctx.history.push(Turn {
689            role: TurnRole::User,
690            blocks: vec![Block::Text("the task".into())],
691        });
692        for i in 0..10 {
693            ctx.history.push(Turn {
694                role: TurnRole::Assistant,
695                blocks: vec![Block::ToolCall {
696                    call_id: format!("c{i}"),
697                    name: "read_file".into(),
698                    args: serde_json::json!({ "path": format!("{i}.txt") }),
699                }],
700            });
701            ctx.history.push(Turn {
702                role: TurnRole::Tool,
703                blocks: vec![Block::ToolResult {
704                    call_id: format!("c{i}"),
705                    content: serde_json::json!({ "ok": true }),
706                }],
707            });
708        }
709        let before = ctx.history.len();
710        microcompact_old(&mut ctx);
711        assert!(
712            ctx.history.len() < before,
713            "compaction did nothing on a bare tool alternation: {before} turns in, {} out",
714            ctx.history.len()
715        );
716        assert_history_is_legal(&ctx.history, "bare tool alternation");
717    }
718
719    /// Every stage that rewrites the head of the history must carry the task
720    /// through verbatim: the head is where the task lives.
721    #[test]
722    fn the_task_survives_every_rewriting_stage() {
723        let task = "Apply policy/fraud_policy.md to every transaction and write out/scored.csv";
724        for stage in ["micro", "auto"] {
725            let mut ctx = mk_ctx(0);
726            ctx.task.description = task.into();
727            ctx.history.push(Turn {
728                role: TurnRole::User,
729                blocks: vec![Block::Text(task.into())],
730            });
731            for i in 0..10 {
732                ctx.history.push(Turn {
733                    role: TurnRole::Assistant,
734                    blocks: vec![Block::Text(format!("step {i}"))],
735                });
736            }
737            match stage {
738                "micro" => microcompact_old(&mut ctx),
739                _ => auto_compact(&mut ctx),
740            }
741            let head = match &ctx.history[0].blocks[0] {
742                Block::Text(t) => t.clone(),
743                other => panic!("{stage}: head is {other:?}"),
744            };
745            assert!(head.contains(task), "{stage} dropped the task: {head}");
746        }
747    }
748
749    /// An agent run has exactly ONE user turn — the task — and it sits at
750    /// index 0, where a cut would drop nothing. Requiring a user turn to land
751    /// on therefore made every stage decline, and compaction silently stopped
752    /// compacting on exactly the long runs it exists for. An assistant turn
753    /// that only speaks is a legal place to begin.
754    #[test]
755    fn a_run_with_one_user_turn_can_still_be_compacted() {
756        let mut ctx = mk_ctx(0);
757        ctx.history.push(Turn {
758            role: TurnRole::User,
759            blocks: vec![Block::Text("the task".into())],
760        });
761        for i in 0..8 {
762            ctx.history.push(Turn {
763                role: TurnRole::Assistant,
764                blocks: vec![Block::ToolCall {
765                    call_id: format!("c{i}"),
766                    name: "read_file".into(),
767                    args: serde_json::json!({}),
768                }],
769            });
770            ctx.history.push(Turn {
771                role: TurnRole::Tool,
772                blocks: vec![Block::ToolResult {
773                    call_id: format!("c{i}"),
774                    content: serde_json::json!({ "ok": true }),
775                }],
776            });
777            ctx.history.push(Turn {
778                role: TurnRole::Assistant,
779                blocks: vec![Block::Text(format!("step {i} done"))],
780            });
781        }
782        let before = ctx.history.len();
783        microcompact_old(&mut ctx);
784        assert!(
785            ctx.history.len() < before,
786            "compaction did nothing: {before} turns in, {} out",
787            ctx.history.len()
788        );
789        assert_history_is_legal(&ctx.history, "single-user-turn run");
790    }
791
792    fn mk_ctx(turns: usize) -> Context {
793        let mut ctx = Context {
794            system: vec![],
795            guides: vec![],
796            history: Vec::new(),
797            task: Task {
798                description: "t".into(),
799                source: None,
800                deadline: None,
801            },
802            policy: Policy::default(),
803            metadata: BTreeMap::new(),
804            tools: Vec::new(),
805            response_format: harness_core::ResponseFormat::Free,
806        };
807        for i in 0..turns {
808            ctx.history.push(Turn {
809                role: if i % 2 == 0 {
810                    TurnRole::User
811                } else {
812                    TurnRole::Assistant
813                },
814                blocks: vec![Block::Text(format!("turn {i}: {}", "x".repeat(50)))],
815            });
816        }
817        ctx
818    }
819
820    #[tokio::test]
821    async fn budget_reduce_keeps_recent() {
822        let c = DefaultCompactor::new();
823        let mut ctx = mk_ctx(20);
824        // Inject big tool results in early turns
825        ctx.history[0].blocks.push(Block::ToolResult {
826            call_id: "c1".into(),
827            content: serde_json::Value::String("y".repeat(2000)),
828        });
829        c.compact(CompactionStage::BudgetReduce, &mut ctx)
830            .await
831            .unwrap();
832        // First turn's big tool result should be trimmed.
833        let has_trim = ctx.history[0]
834            .blocks
835            .iter()
836            .any(|b| matches!(b, Block::Text(t) if t.contains("trimmed")));
837        assert!(has_trim);
838    }
839
840    #[tokio::test]
841    async fn microcompact_collapses_old_turns() {
842        let c = DefaultCompactor::new();
843        let mut ctx = mk_ctx(20);
844        c.compact(CompactionStage::Microcompact, &mut ctx)
845            .await
846            .unwrap();
847        // First turn should be the synthetic system summary.
848        // The summary leads as a user turn — see `starts_an_exchange`.
849        assert!(matches!(ctx.history[0].role, TurnRole::User));
850        let first_text = match &ctx.history[0].blocks[0] {
851            Block::Text(t) => t.clone(),
852            _ => String::new(),
853        };
854        assert!(first_text.starts_with("[microcompact-summary]"));
855    }
856
857    #[tokio::test]
858    async fn model_backed_compactor_replaces_old_turns_with_summary() {
859        use harness_models::{MockModel, MockResponse};
860
861        let model = Arc::new(MockModel::new().script(MockResponse::text("CONCISE-SUMMARY-OK")))
862            as Arc<dyn Model>;
863        let c = ModelBackedCompactor::new(model);
864
865        let mut ctx = mk_ctx(20);
866        let original_len = ctx.history.len();
867        c.compact(CompactionStage::Microcompact, &mut ctx)
868            .await
869            .unwrap();
870        // First turn now the summary, total shrinks to keep_recent (6) + 1 summary = 7
871        assert_eq!(ctx.history.len(), c.keep_recent + 1);
872        assert!(original_len > ctx.history.len());
873        let first = match &ctx.history[0].blocks[0] {
874            Block::Text(t) => t.clone(),
875            _ => String::new(),
876        };
877        assert!(first.starts_with("[microcompact-summary]"));
878        assert!(first.contains("CONCISE-SUMMARY-OK"));
879    }
880
881    #[tokio::test]
882    async fn model_backed_compactor_noop_when_history_short() {
883        use harness_models::{MockModel, MockResponse};
884        let mock = Arc::new(MockModel::new().script(MockResponse::text("never called")));
885        let c = ModelBackedCompactor::new(mock.clone() as Arc<dyn Model>);
886        let mut ctx = mk_ctx(4); // < keep_recent
887        c.compact(CompactionStage::Microcompact, &mut ctx)
888            .await
889            .unwrap();
890        assert_eq!(ctx.history.len(), 4);
891        assert_eq!(
892            mock.call_count(),
893            0,
894            "model must not be called when history is short"
895        );
896    }
897
898    #[test]
899    fn budget_applies_calibration_factor() {
900        let c = DefaultCompactor::new();
901        let mut ctx = mk_ctx(10);
902        let raw = c.budget(&ctx).used;
903        assert!(raw > 0);
904
905        // A model that reports 2× our estimate → correction 2.0 → used doubles.
906        ctx.metadata
907            .insert(CALIBRATION_KEY.into(), serde_json::json!(2.0));
908        let calibrated = c.budget(&ctx).used;
909        assert_eq!(calibrated, (raw as f64 * 2.0).round() as u32);
910
911        // Garbage / non-positive factors are ignored (fall back to raw).
912        ctx.metadata
913            .insert(CALIBRATION_KEY.into(), serde_json::json!(-1.0));
914        assert_eq!(c.budget(&ctx).used, raw);
915    }
916
917    #[tokio::test]
918    async fn budget_required_stages_escalates() {
919        // 95% triggers ALL five stages.
920        let b = Budget {
921            used: 95,
922            window: 100,
923        };
924        assert_eq!(b.required_stages().len(), 4);
925        // 99% triggers all 5
926        let b = Budget {
927            used: 99,
928            window: 100,
929        };
930        assert_eq!(b.required_stages().len(), 5);
931    }
932}