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!(
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 Block::Image { base64, .. } => base64.len(),
345 _ => 0,
346 }
347}
348
349fn budget_reduce(ctx: &mut Context) {
363 const OLD_TRIM_BYTES: usize = 800;
364 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 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
395fn 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
423fn 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 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 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
477fn context_collapse(ctx: &mut Context) {
481 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 for turn in ctx.history.iter_mut() {
501 turn.blocks.retain(|b| !matches!(b, Block::FileRef { .. }));
502 }
503
504 ctx.history.insert(
506 0,
507 Turn {
508 role: TurnRole::System,
509 blocks: vec![Block::Text(inv)],
510 },
511 );
512}
513
514fn 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 let (tag, rest) = header.split_once('\n').unwrap_or((header, ""));
526 format!("{tag}\nThe task (verbatim):\n{task}\n\n{rest}")
527}
528
529fn 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 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 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 fn assert_history_is_legal(history: &[Turn], what: &str) {
615 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 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 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 #[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 #[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 #[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 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 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 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 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); 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 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 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 let b = Budget {
921 used: 95,
922 window: 100,
923 };
924 assert_eq!(b.required_stages().len(), 4);
925 let b = Budget {
927 used: 99,
928 window: 100,
929 };
930 assert_eq!(b.required_stages().len(), 5);
931 }
932}