1use 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
15pub const CALIBRATION_KEY: &str = "compactor.correction";
21
22fn 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
32pub struct DefaultCompactor {
34 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 _ => tracing::warn!(?stage, "unknown compaction stage — ignoring"),
89 }
90 Ok(())
91 }
92}
93
94pub struct ModelBackedCompactor {
109 pub model: Arc<dyn Model>,
110 pub tokens_per_char: f32,
111 pub keep_recent: usize,
113 pub summary_max_tokens: u32,
115}
116
117fn safe_split(history: &[Turn], want: usize) -> Option<usize> {
136 if want == 0 || want >= history.len() {
137 return None;
138 }
139 (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
148fn 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 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 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 Block::Image { base64, .. } => base64.len(),
342 _ => 0,
343 }
344}
345
346fn budget_reduce(ctx: &mut Context) {
360 const OLD_TRIM_BYTES: usize = 800;
361 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 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
392fn 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
420fn 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 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 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
474fn context_collapse(ctx: &mut Context) {
478 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 for turn in ctx.history.iter_mut() {
498 turn.blocks.retain(|b| !matches!(b, Block::FileRef { .. }));
499 }
500
501 ctx.history.insert(
503 0,
504 Turn {
505 role: TurnRole::System,
506 blocks: vec![Block::Text(inv)],
507 },
508 );
509}
510
511fn 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 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 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 fn assert_history_is_legal(history: &[Turn], what: &str) {
595 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 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 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 #[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 #[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 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 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 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 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); 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 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 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 let b = Budget {
871 used: 95,
872 window: 100,
873 };
874 assert_eq!(b.required_stages().len(), 4);
875 let b = Budget {
877 used: 99,
878 window: 100,
879 };
880 assert_eq!(b.required_stages().len(), 5);
881 }
882}